Initial commit

This commit is contained in:
2026-08-26 09:15:35 -05:00
commit ffaaa0c8c8
23 changed files with 1775 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Copy this file to .env and fill in your values.
# You can also edit these from within the app: Settings menu -> Edit Settings.
# --- Database ---
# Local (default): sqlite:///orders.db
# Later, point at your MariaDB LXC, e.g.:
# DB_URL=mysql+pymysql://user:[email protected]:3306/order_manager
DB_URL=sqlite:///orders.db
# --- JIRA ---
# Your Atlassian site, e.g. https://yourcompany.atlassian.net
JIRA_URL=
# The email address tied to your JIRA API token
JIRA_EMAIL=
# Create one at https://id.atlassian.com/manage-profile/security/api-tokens
JIRA_API_TOKEN=
# JQL used to pull "today's orders". Adjust to match how your team tags order tickets.
JIRA_JQL=project = AR AND created >= startOfDay() ORDER BY created ASC
# Deliverable/SKU-bearing custom fields, per company (comma-separated field IDs)
JIRA_SIGNIFY_SKU_FIELDS=customfield_10573,customfield_10570
JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
# --- ShipStation (API V2) ---
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
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
+22
View File
@@ -0,0 +1,22 @@
# Secrets - never commit this. Everyone gets .env.example instead and
# fills in their own real values locally.
.env
# Local cache database (rebuilt from JIRA/ShipStation on import)
orders.db
*.db
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.venv/
venv/
# PyCharm
.idea/
# Exports you generate for Odoo - these are output, not source
odoo_orders_export.csv
*.csv
+124
View File
@@ -0,0 +1,124 @@
# 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.
## Setup
1. `pip install -r requirements.txt`
2. Run it once: `python main.py` (this creates `.env` from `.env.example`)
3. Click **Settings** and fill in:
- **JIRA**: Site URL, Email, API Token, JQL (defaults to tickets
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.
- **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.
Orders are cached locally in SQLite (`orders.db`). Re-importing updates
existing orders rather than duplicating them (matched on source +
source ID).
## How company/ticket matching works
- **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.
## A note on the ShipStation V2 API
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.
## Moving storage to your MariaDB LXC later
In Settings (or directly in `.env`), change:
```
DB_URL=mysql+pymysql://user:password@<lxc-ip>:3306/order_manager
```
and `pip install pymysql`. No code changes needed. Note: this is a
local cache rebuilt from JIRA/ShipStation, so if you change `DB_URL`
or the schema changes in a future update, it's safe to just delete
`orders.db` and re-import rather than migrate it.
## Project layout
```
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
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
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
settings_dialog.py auto-built from config.SETTINGS_SCHEMA
widgets/
dashboard.py summary stat cards
orders_table.py sortable/filterable Qt table for orders
```
## 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.
Since everything funnels through the same `Order` table, the table
view, dashboard, and settings UI don't need to change as sources are
added.
View File
+59
View File
@@ -0,0 +1,59 @@
"""
Company resolution.
Two ways an order tells us which company it belongs to:
- JIRA orders: the SKU prefix (SH... -> Signify Health, OK... -> Oak Street Health)
- ShipStation orders: which store the order lives in (each company has
its own store because they have separate UPS accounts)
Both mappings are configurable in Settings so adding a third company
later doesn't require touching code - just add another "PREFIX:Company
Name" or "storeId:Company Name" entry.
"""
from __future__ import annotations
UNKNOWN_COMPANY = "Unknown"
def parse_mapping(raw: str) -> dict[str, str]:
"""Parse a 'KEY:Value,KEY2:Value2' settings string into a dict."""
mapping: dict[str, str] = {}
if not raw:
return mapping
for part in raw.split(","):
part = part.strip()
if not part or ":" not in part:
continue
key, _, value = part.partition(":")
key, value = key.strip(), value.strip()
if key and value:
mapping[key] = value
return mapping
def resolve_company_by_sku(sku: str, sku_map: dict[str, str]) -> str:
"""Match a SKU against configured prefixes, e.g. 'SH-1029' -> 'Signify Health'."""
if not sku:
return UNKNOWN_COMPANY
sku_upper = sku.strip().upper()
for prefix, company in sku_map.items():
if sku_upper.startswith(prefix.upper()):
return company
return UNKNOWN_COMPANY
def resolve_company_for_skus(skus: list[str], sku_map: dict[str, str]) -> str:
"""
Resolve the company for a set of SKUs on one ticket. Per business rule,
companies never mix on a single ticket, so the first recognizable SKU
decides it.
"""
for sku in skus:
company = resolve_company_by_sku(sku, sku_map)
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)
+103
View File
@@ -0,0 +1,103 @@
"""
Central place for all configuration.
Settings live in a .env file at the project root. This module wraps
python-dotenv so the rest of the app never touches the file directly -
that keeps the settings dialog, the services, and the database layer
all agreeing on where config comes from.
As we add ShipStation / Odoo / anything else, add the new keys to
DEFAULTS below and to .env.example - everything else (loading, saving,
the settings dialog) will pick them up automatically because the
dialog is built from this list.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Dict
from dotenv import dotenv_values, set_key
# Project root = the folder containing this app/ package
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ENV_PATH = PROJECT_ROOT / ".env"
# Every setting the app knows about, grouped for the settings UI.
# key -> (label, group, is_secret)
SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
"DB_URL": ("Database URL", "Database", False),
"JIRA_URL": ("JIRA Site URL", "JIRA", False),
"JIRA_EMAIL": ("JIRA Account Email", "JIRA", False),
"JIRA_API_TOKEN": ("JIRA API Token", "JIRA", True),
"JIRA_JQL": ("JIRA Query (JQL)", "JIRA", False),
"JIRA_SIGNIFY_SKU_FIELDS": (
"Signify Health JIRA Deliverable Field IDs (comma-separated)",
"JIRA",
False,
),
"JIRA_OAKSTREET_SKU_FIELDS": (
"Oak Street Health JIRA Deliverable Field IDs (comma-separated)",
"JIRA",
False,
),
"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)",
"Companies",
False,
),
"TICKET_NUMBER_REGEX": (
"Ticket Number Pattern (regex, e.g. AR-######)",
"Companies",
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"
def ensure_env_file_exists() -> None:
"""Create a .env from .env.example on first run if one doesn't exist yet."""
if ENV_PATH.exists():
return
example = PROJECT_ROOT / ".env.example"
if example.exists():
ENV_PATH.write_text(example.read_text())
else:
ENV_PATH.touch()
def load_settings() -> Dict[str, str]:
"""Read current values from .env (does not touch os.environ)."""
ensure_env_file_exists()
values = dotenv_values(ENV_PATH)
# Fill in blanks for any known key that's missing from the file
return {key: values.get(key) or "" for key in SETTINGS_SCHEMA}
def save_settings(values: Dict[str, str]) -> None:
"""Write settings back to .env, one key at a time (preserves the file)."""
ensure_env_file_exists()
for key, value in values.items():
if key in SETTINGS_SCHEMA:
set_key(str(ENV_PATH), key, value or "")
# Refresh the current process's environment too, so a running app
# picks up the change without needing a restart for most settings.
for key, value in values.items():
os.environ[key] = value or ""
def get(key: str, default: str = "") -> str:
"""Convenience getter, e.g. config.get('DB_URL')."""
return load_settings().get(key, default) or default
+50
View File
@@ -0,0 +1,50 @@
"""
Database engine/session management.
Uses SQLAlchemy so the storage backend is a config change, not a code
change. Today DB_URL points at a local SQLite file. When you're ready
to move to the MariaDB LXC, set DB_URL in .env to something like:
mysql+pymysql://user:[email protected]:3306/order_manager
and install PyMySQL (pip install pymysql). Nothing else in the app
needs to change.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from app import config
from app.models import Base
_engine = None
_SessionLocal = None
def get_engine():
global _engine
if _engine is None:
db_url = config.get("DB_URL", config.DEFAULT_DB_URL)
connect_args = {}
if db_url.startswith("sqlite"):
# allow use across the QThread worker and the UI thread
connect_args = {"check_same_thread": False}
_engine = create_engine(db_url, connect_args=connect_args, future=True)
return _engine
def get_session_factory():
global _SessionLocal
if _SessionLocal is None:
_SessionLocal = sessionmaker(bind=get_engine(), future=True, expire_on_commit=False)
return _SessionLocal
def init_db() -> None:
"""Create tables that don't exist yet. Safe to call every startup."""
Base.metadata.create_all(get_engine())
def get_session() -> Session:
return get_session_factory()()
+71
View File
@@ -0,0 +1,71 @@
"""
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.
"""
from __future__ import annotations
import datetime as dt
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
JSON,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Order(Base):
__tablename__ = "orders"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_source_external_id"),
)
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 = 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.
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.
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)
summary = Column(String(500), nullable=False, default="")
status = Column(String(100), nullable=False, default="")
# When the order/ticket was created in the source system
source_created_at = Column(DateTime, nullable=True)
# 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
fulfilled = Column(Boolean, nullable=False, default=False)
# Full original payload from the source system, for anything not
# modeled explicitly above.
raw_data = Column(JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Order {self.source}:{self.external_id} '{self.summary[:30]}'>"
+15
View File
@@ -0,0 +1,15 @@
"""
Registry of available order sources.
Add new services here as they're built (ShipStation, Odoo, ...).
The UI reads this registry rather than importing specific services
directly, so adding a source doesn't require touching main_window.py.
"""
from app.services.jira_service import JiraService
from app.services.shipstation_service import ShipStationService
SERVICE_REGISTRY = {
"jira": JiraService,
"shipstation": ShipStationService,
# "odoo": OdooService, # Odoo push/pull API coming later - file export exists today, see app/services/odoo_export.py
}
+44
View File
@@ -0,0 +1,44 @@
"""
Base interface for anything that can fetch orders.
To add a new source later (ShipStation, Odoo, ...):
1. Create app/services/shipstation_service.py
2. Subclass OrderService, implement fetch_orders()
3. Register it in app/services/__init__.py's SERVICE_REGISTRY
4. Add its settings to app/config.py's SETTINGS_SCHEMA
The UI and database layers don't need to change at all.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, TypedDict, Optional
import datetime as dt
class NormalizedOrder(TypedDict):
"""The common shape every service must translate its data into."""
source: str
external_id: str
ticket_number: Optional[str]
company: str
skus: List[str]
summary: str
status: str
source_created_at: Optional[dt.datetime]
raw_data: dict
class OrderService(ABC):
"""Common interface for every order source."""
#: short machine-friendly name, e.g. "jira", "shipstation", "odoo"
name: str = "base"
@abstractmethod
def fetch_orders(self) -> List[NormalizedOrder]:
"""Fetch and return today's orders, normalized to NormalizedOrder."""
raise NotImplementedError
def is_configured(self) -> bool:
"""Override to check that required settings are present."""
return True
+228
View File
@@ -0,0 +1,228 @@
"""
JIRA order source.
Pulls tickets matching a JQL query (configured in .env as JIRA_JQL,
editable from the Settings dialog) and normalizes them into orders.
Auth: JIRA Cloud API token (https://id.atlassian.com/manage-profile/security/api-tokens)
using HTTP Basic auth with your account email + the token.
"""
from __future__ import annotations
import datetime as dt
import re
from typing import List, Tuple
import requests
from app import config
from app.companies import parse_mapping, resolve_company_for_skus
from app.services.base import OrderService, NormalizedOrder
SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30
# Deliverable values come back like "SH011: Shipping - Return Label iPad -
# Physical in Box" - the part before the colon is the actual SKU code;
# everything after it is a human-readable description.
DELIVERABLE_CODE_PATTERN = re.compile(r"^\s*([A-Za-z]{2,6}\d{1,6})\s*:\s*(.*)$")
class JiraServiceError(Exception):
"""Raised for any JIRA fetch failure, with a message safe to show in the UI."""
class JiraService(OrderService):
name = "jira"
def __init__(self) -> None:
settings = config.load_settings()
self.base_url = settings["JIRA_URL"].rstrip("/")
self.email = settings["JIRA_EMAIL"]
self.api_token = settings["JIRA_API_TOKEN"]
self.jql = settings["JIRA_JQL"]
# Each company has its own set of "deliverable" custom fields in
# JIRA (e.g. "Deliverables" + "Hardware Needed" for Signify;
# "Oak Street Deliverables" + "Oak Street Hardware Needed" for
# Oak Street). We scan all of them and let the SKU code prefix
# (SH.../OK...) decide the company, same as before - this just
# widens where we look for those codes.
self.signify_field_ids = self._split_field_ids(settings["JIRA_SIGNIFY_SKU_FIELDS"])
self.oakstreet_field_ids = self._split_field_ids(settings["JIRA_OAKSTREET_SKU_FIELDS"])
self.all_sku_field_ids = list(
dict.fromkeys(self.signify_field_ids + self.oakstreet_field_ids)
)
self.sku_map = parse_mapping(
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP
)
@staticmethod
def _split_field_ids(raw: str) -> List[str]:
return [f.strip() for f in (raw or "").split(",") if f.strip()]
def is_configured(self) -> bool:
return bool(self.base_url and self.email and self.api_token and self.jql)
def fetch_orders(self) -> List[NormalizedOrder]:
if not self.is_configured():
raise JiraServiceError(
"JIRA is not fully configured yet. Open Settings and fill in "
"JIRA Site URL, Email, API Token, and JQL."
)
issues = self._search_all_issues()
return [self._to_normalized_order(issue) for issue in issues]
# -- internals -----------------------------------------------------
def _search_all_issues(self) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search"
auth = (self.email, self.api_token)
headers = {"Accept": "application/json"}
# Always pull summary/status/created, plus every configured deliverable field.
fields = "summary,status,created"
if self.all_sku_field_ids:
fields += "," + ",".join(self.all_sku_field_ids)
all_issues: List[dict] = []
start_at = 0
while True:
params = {
"jql": self.jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE,
"fields": fields,
}
try:
response = requests.get(
url,
params=params,
auth=auth,
headers=headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise JiraServiceError(f"Could not reach JIRA: {exc}") from exc
if response.status_code == 401:
raise JiraServiceError(
"JIRA rejected the credentials (401). Check your email and API token."
)
if response.status_code == 400:
raise JiraServiceError(
f"JIRA rejected the query (400). Check your JQL or SKU field ID. "
f"Details: {response.text[:300]}"
)
if not response.ok:
raise JiraServiceError(
f"JIRA returned an error ({response.status_code}): {response.text[:300]}"
)
try:
data = response.json()
except ValueError as exc:
raise JiraServiceError("JIRA returned a response that wasn't valid JSON.") from exc
batch = data.get("issues", [])
all_issues.extend(batch)
total = data.get("total", len(all_issues))
start_at += len(batch)
if start_at >= total or not batch:
break
return all_issues
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
"""
Pull raw string values out of one custom field. JIRA custom fields
can come back several different ways depending on the field type:
- plain string (text fields)
- a single {"value": ...} object (single-select dropdown fields -
this is the shape "Hardware Needed" uses, and it was previously
being silently dropped since it isn't a list)
- a list of strings or {"value": ...} objects (multi-select fields,
e.g. "Deliverables" which can hold several kit items)
Handle all of these.
"""
raw_value = fields.get(field_id)
if raw_value is None:
return []
if isinstance(raw_value, str):
parts = re.split(r"[\n;]+", raw_value)
return [p.strip() for p in parts if p.strip()]
if isinstance(raw_value, dict):
value = raw_value.get("value") or raw_value.get("name") or raw_value.get("id")
return [str(value).strip()] if value else []
if isinstance(raw_value, list):
values = []
for item in raw_value:
if isinstance(item, str):
values.append(item.strip())
elif isinstance(item, dict):
value = item.get("value") or item.get("name") or item.get("id")
if value:
values.append(str(value).strip())
return [v for v in values if v]
return []
def _extract_skus_and_texts(self, fields: dict) -> Tuple[List[str], List[str]]:
"""
Scan every configured deliverable field and return:
- skus: just the code part (e.g. "SH011"), deduped, order preserved
- texts: the full "CODE: description" strings, for use as a
fallback summary when the ticket's actual Summary is blank
(which is the normal case for these order tickets)
"""
skus: List[str] = []
texts: List[str] = []
for field_id in self.all_sku_field_ids:
for raw_value in self._raw_values_from_field(fields, field_id):
texts.append(raw_value)
match = DELIVERABLE_CODE_PATTERN.match(raw_value)
code = match.group(1) if match else raw_value
if code not in skus:
skus.append(code)
return skus, texts
def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
fields = issue.get("fields", {})
created_raw = fields.get("created")
created_at = None
if created_raw:
try:
# JIRA timestamps look like 2024-01-15T09:30:00.000+0000
created_at = dt.datetime.strptime(created_raw[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
created_at = None
ticket_number = issue.get("key", "")
skus, deliverable_texts = self._extract_skus_and_texts(fields)
company = resolve_company_for_skus(skus, self.sku_map)
# These order tickets typically leave the JIRA Summary field
# blank - fall back to the deliverables so there's still
# something readable in the dashboard/table.
summary = fields.get("summary") or "; ".join(deliverable_texts)
return NormalizedOrder(
source="jira",
external_id=ticket_number,
ticket_number=ticket_number,
company=company,
skus=skus,
summary=summary,
status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at,
raw_data=issue,
)
+44
View File
@@ -0,0 +1,44 @@
"""
Odoo file export.
Odoo integration is still in development on your end, so for now we
generate a CSV of orders that fits your import template. This is a
placeholder column layout - once you know the exact columns your Odoo
import template expects, update ODOO_EXPORT_COLUMNS below (or tell me
and I'll adjust it) and the export will follow automatically.
When live Odoo API access is ready, this file is where an
OdooService(OrderService)-style push would go instead/alongside.
"""
from __future__ import annotations
import csv
from pathlib import Path
from typing import List
from app.models import Order
# (header label, function to get the value from an Order)
ODOO_EXPORT_COLUMNS = [
("Order Reference", lambda o: o.ticket_number or o.external_id),
("Company", lambda o: o.company),
("Source", lambda o: o.source),
("SKUs", lambda o: ", ".join(o.skus or [])),
("Status", lambda o: o.status),
("Summary", lambda o: o.summary),
("Source Created At", lambda o: o.source_created_at.isoformat() if o.source_created_at else ""),
]
def export_orders_to_csv(orders: List[Order], filepath: str) -> int:
"""Write orders to a CSV file. Returns the number of rows written."""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([label for label, _ in ODOO_EXPORT_COLUMNS])
for order in orders:
writer.writerow([getter(order) for _, getter in ODOO_EXPORT_COLUMNS])
return len(orders)
+167
View File
@@ -0,0 +1,167 @@
"""
ShipStation order source (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.
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.
"""
from __future__ import annotations
import datetime as dt
import json
import re
from typing import 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
API_BASE = "https://api.shipstation.com/v2"
PAGE_SIZE = 100
REQUEST_TIMEOUT_SECONDS = 30
class ShipStationServiceError(Exception):
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
class ShipStationService(OrderService):
name = "shipstation"
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
)
def is_configured(self) -> bool:
return bool(self.api_key and self.store_map)
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."
)
shipments = self._fetch_todays_shipments()
# 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]
return [self._to_normalized_order(s) for s in relevant]
# -- internals -----------------------------------------------------
def _fetch_todays_shipments(self) -> List[dict]:
headers = {"API-Key": self.api_key, "Accept": "application/json"}
today_start = dt.datetime.combine(dt.date.today(), dt.time.min)
today_end = today_start + dt.timedelta(days=1)
all_shipments: List[dict] = []
page = 1
while True:
params = {
"created_at_start": today_start.isoformat() + "Z",
"created_at_end": today_end.isoformat() + "Z",
"page": page,
"page_size": PAGE_SIZE,
"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)
total_pages = data.get("pages", 1)
if page >= total_pages or not batch:
break
page += 1
return all_shipments
def _extract_ticket_number(self, shipment: dict) -> Optional[str]:
try:
blob = json.dumps(shipment)
except (TypeError, ValueError):
return None
match = self.ticket_pattern.search(blob)
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
return NormalizedOrder(
source="shipstation",
external_id=external_id,
ticket_number=ticket_number,
company=company,
skus=skus,
summary=summary,
status=shipment.get("shipment_status", ""),
source_created_at=created_at,
raw_data=shipment,
)
View File
+191
View File
@@ -0,0 +1,191 @@
"""
Main window.
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.
"""
from __future__ import annotations
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QToolBar,
QStatusBar,
QMessageBox,
QLabel,
QTabWidget,
QFileDialog,
)
from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv
from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
from app.workers import FetchOrdersWorker, load_all_orders, get_dashboard_stats
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Order Manager")
self.resize(1150, 700)
self._workers: dict[str, FetchOrdersWorker] = {}
self._import_actions: dict[str, QAction] = {}
self._build_ui()
self._refresh_everything()
# -- UI construction -------------------------------------------------
def _build_ui(self) -> None:
central = QWidget()
layout = QVBoxLayout(central)
self.tabs = QTabWidget()
self.dashboard = DashboardWidget()
self.orders_table = OrdersTableView()
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
self.tabs.addTab(self.dashboard, "Dashboard")
self.tabs.addTab(self.orders_table, "All Orders")
layout.addWidget(self.tabs)
self.setCentralWidget(central)
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
jira_action = QAction("Import from JIRA", self)
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
toolbar.addAction(jira_action)
self._import_actions["jira"] = jira_action
shipstation_action = QAction("Import from ShipStation", self)
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action
toolbar.addSeparator()
export_action = QAction("Export Visible Orders to Odoo CSV", self)
export_action.triggered.connect(self._on_export_clicked)
toolbar.addAction(export_action)
toolbar.addSeparator()
settings_action = QAction("Settings", self)
settings_action.triggered.connect(self._on_settings_clicked)
toolbar.addAction(settings_action)
refresh_action = QAction("Refresh from Local DB", self)
refresh_action.triggered.connect(self._refresh_everything)
toolbar.addAction(refresh_action)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.")
self.status_bar.addWidget(self.status_label)
# -- actions -----------------------------------------------------------
def _on_settings_clicked(self) -> None:
dialog = SettingsDialog(self)
dialog.exec()
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
dialog.exec()
def _on_import_clicked(self, service_name: str) -> None:
service_cls = SERVICE_REGISTRY[service_name]
service = service_cls()
if not service.is_configured():
QMessageBox.warning(
self,
f"{service_name.title()} not configured",
f"Fill in the {service_name.title()} settings first, then try again.",
)
return
action = self._import_actions[service_name]
action.setEnabled(False)
self.status_label.setText(f"Importing orders from {service_name.title()}...")
worker = FetchOrdersWorker(service)
worker.finished_ok.connect(
lambda new_count, updated_count: self._on_import_finished(
service_name, new_count, updated_count
)
)
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:
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()
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.")
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
def _on_export_clicked(self) -> None:
orders = self.orders_table.visible_orders()
if not orders:
QMessageBox.information(
self, "Nothing to export", "No orders match the current filters."
)
return
filepath, _ = QFileDialog.getSaveFileName(
self, "Export to Odoo CSV", "odoo_orders_export.csv", "CSV Files (*.csv)"
)
if not filepath:
return
try:
count = export_orders_to_csv(orders, filepath)
except OSError as exc:
QMessageBox.critical(self, "Export failed", str(exc))
return
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
def _refresh_everything(self) -> None:
orders = load_all_orders()
self.orders_table.set_orders(orders)
self.dashboard.update_stats(get_dashboard_stats())
if not hasattr(self, "_suppress_ready_status"):
self.status_label.setText(f"{len(orders)} order(s) in local database.")
# ---------------------------------------------------------------------------
# Future growth notes:
#
# - 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
# 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.
# ---------------------------------------------------------------------------
+74
View File
@@ -0,0 +1,74 @@
"""
Settings dialog.
Built dynamically from app.config.SETTINGS_SCHEMA, grouped by section
(Database, JIRA, ...). When a new service adds settings to that
schema, they show up here automatically - no UI changes needed.
"""
from __future__ import annotations
from collections import defaultdict
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QFormLayout,
QLineEdit,
QPushButton,
QHBoxLayout,
QGroupBox,
QLabel,
QMessageBox,
)
from app import config
class SettingsDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self.setMinimumWidth(480)
self._fields: dict[str, QLineEdit] = {}
current_values = config.load_settings()
# Group schema entries by their group label, preserving order
groups: dict[str, list[tuple[str, str, bool]]] = defaultdict(list)
for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items():
groups[group].append((key, label, is_secret))
layout = QVBoxLayout(self)
layout.addWidget(
QLabel("Changes are saved to your .env file and applied immediately.")
)
for group_name, entries in groups.items():
box = QGroupBox(group_name)
form = QFormLayout(box)
for key, label, is_secret in entries:
field = QLineEdit(current_values.get(key, ""))
if is_secret:
field.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow(label, field)
self._fields[key] = field
layout.addWidget(box)
button_row = QHBoxLayout()
save_button = QPushButton("Save")
cancel_button = QPushButton("Cancel")
save_button.clicked.connect(self._on_save)
cancel_button.clicked.connect(self.reject)
button_row.addStretch()
button_row.addWidget(cancel_button)
button_row.addWidget(save_button)
layout.addLayout(button_row)
def _on_save(self) -> None:
values = {key: field.text().strip() for key, field in self._fields.items()}
try:
config.save_settings(values)
except OSError as exc:
QMessageBox.critical(self, "Couldn't save settings", str(exc))
return
self.accept()
View File
+124
View File
@@ -0,0 +1,124 @@
"""
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
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.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QWidget,
QGridLayout,
QVBoxLayout,
QHBoxLayout,
QLabel,
QFrame,
QSizePolicy,
)
class StatCard(QFrame):
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
layout = QVBoxLayout(self)
self.value_label = QLabel("0")
value_font = self.value_label.font()
value_font.setPointSize(28)
value_font.setBold(True)
self.value_label.setFont(value_font)
self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label = QLabel(title)
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label.setWordWrap(True)
layout.addWidget(self.value_label)
layout.addWidget(title_label)
def set_value(self, value) -> None:
self.value_label.setText(str(value))
class DashboardWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
heading = QLabel("Today's Orders at a Glance")
heading_font = heading.font()
heading_font.setPointSize(16)
heading_font.setBold(True)
heading.setFont(heading_font)
outer.addWidget(heading)
# Top row: overall + per-source totals
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):
top_row.addWidget(card)
outer.addLayout(top_row)
# Second 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
def _section_label(text: str) -> QLabel:
label = QLabel(text)
font = label.font()
font.setBold(True)
label.setFont(font)
return label
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))
by_company = stats.get("by_company", {})
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added)
if set(by_company.keys()) != set(self.company_cards.keys()):
self._rebuild_company_cards(by_company.keys())
for company, count in by_company.items():
self.company_cards[company].set_value(count)
def _rebuild_company_cards(self, companies) -> None:
while self.company_grid.count():
item = self.company_grid.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self.company_cards.clear()
for i, company in enumerate(sorted(companies)):
card = StatCard(company)
self.company_cards[company] = card
self.company_grid.addWidget(card, i // 3, i % 3)
+46
View File
@@ -0,0 +1,46 @@
"""
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.
"""
from __future__ import annotations
import json
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
from app.models import Order
class OrderDetailDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.setWindowTitle(f"Order Detail - {order.ticket_number or order.external_id}")
self.resize(700, 600)
layout = QVBoxLayout(self)
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}",
]
summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label)
layout.addWidget(QLabel("Raw payload from source system:"))
text = QTextEdit()
text.setReadOnly(True)
text.setFontFamily("Courier New")
text.setPlainText(json.dumps(order.raw_data, indent=2, default=str))
layout.addWidget(text)
close_button = QPushButton("Close")
close_button.clicked.connect(self.accept)
layout.addWidget(close_button)
+217
View File
@@ -0,0 +1,217 @@
"""
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.
"""
from __future__ import annotations
from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtWidgets import (
QTableView,
QAbstractItemView,
QHeaderView,
QWidget,
QVBoxLayout,
QHBoxLayout,
QComboBox,
QLineEdit,
QLabel,
)
from app.models import Order
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"),
("status", "Status"),
("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
]
ALL_COMPANIES = "All Companies"
ALL_SOURCES = "All Sources"
class OrdersTableModel(QAbstractTableModel):
def __init__(self, orders: List[Order] | None = None, parent=None):
super().__init__(parent)
self._orders: List[Order] = orders or []
def set_orders(self, orders: List[Order]) -> None:
self.beginResetModel()
self._orders = orders
self.endResetModel()
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._orders)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(COLUMNS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return COLUMNS[section][1]
return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
return None
order = self._orders[index.row()]
field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display":
return ", ".join(order.skus or [])
value = getattr(order, field_name)
if value is None:
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return 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."""
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()
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool:
model: OrdersTableModel = self.sourceModel()
order = model.order_at(source_row)
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 "",
" ".join(order.skus or []),
]
).lower()
if self.search_text not in haystack:
return False
return True
class OrdersTableView(QWidget):
"""Filter bar + sortable, read-only, full-row-selection table."""
order_double_clicked = pyqtSignal(object) # emits the Order that was double-clicked
def __init__(self, parent=None):
super().__init__(parent)
self._source_model = OrdersTableModel()
self._proxy_model = OrdersFilterProxyModel()
self._proxy_model.setSourceModel(self._source_model)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
filter_bar = QHBoxLayout()
filter_bar.addWidget(QLabel("Company:"))
self.company_combo = QComboBox()
self.company_combo.addItem(ALL_COMPANIES)
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.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1)
layout.addLayout(filter_bar)
self.table = QTableView()
self.table.setModel(self._proxy_model)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSortingEnabled(True)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.doubleClicked.connect(self._on_row_double_clicked)
layout.addWidget(self.table)
def _on_row_double_clicked(self, index) -> None:
source_index = self._proxy_model.mapToSource(index)
order = self._source_model.order_at(source_index.row())
self.order_double_clicked.emit(order)
def set_orders(self, orders: List[Order]) -> None:
self._source_model.set_orders(orders)
self.table.resizeColumnsToContents()
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)
def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows()
if not indexes:
return None
source_index = self._proxy_model.mapToSource(indexes[0])
return self._source_model.order_at(source_index.row())
def visible_orders(self) -> List[Order]:
"""Orders currently passing the active filters - used for export."""
result = []
for row in range(self._proxy_model.rowCount()):
source_index = self._proxy_model.mapToSource(self._proxy_model.index(row, 0))
result.append(self._source_model.order_at(source_index.row()))
return result
+133
View File
@@ -0,0 +1,133 @@
"""
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.
"""
from __future__ import annotations
from typing import List
from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select
from app.database import get_session
from app.models import Order
from app.services.base import OrderService, NormalizedOrder
class FetchOrdersWorker(QThread):
"""Fetches orders from a given service and saves new/updated ones to the DB."""
finished_ok = pyqtSignal(int, int) # (new_count, updated_count)
failed = pyqtSignal(str)
def __init__(self, service: OrderService, parent=None):
super().__init__(parent)
self.service = service
def run(self) -> None:
try:
orders = self.service.fetch_orders()
except Exception as exc: # noqa: BLE001 - surface any failure to the UI
self.failed.emit(str(exc))
return
try:
new_count, updated_count = 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)
def save_orders(orders: List[NormalizedOrder]) -> tuple[int, int]:
"""Insert new orders / update existing ones (matched by source + external_id)."""
session = get_session()
new_count = 0
updated_count = 0
try:
for order in orders:
existing = session.execute(
select(Order).where(
Order.source == order["source"],
Order.external_id == order["external_id"],
)
).scalar_one_or_none()
if existing is None:
session.add(
Order(
source=order["source"],
external_id=order["external_id"],
ticket_number=order.get("ticket_number"),
company=order.get("company", "Unknown"),
skus=order.get("skus", []),
summary=order["summary"],
status=order["status"],
source_created_at=order["source_created_at"],
raw_data=order["raw_data"],
)
)
new_count += 1
else:
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.source_created_at = order["source_created_at"]
existing.raw_data = order["raw_data"]
updated_count += 1
session.commit()
finally:
session.close()
return new_count, updated_count
def load_all_orders() -> List[Order]:
session = get_session()
try:
return list(
session.execute(select(Order).order_by(Order.source_created_at.desc())).scalars()
)
finally:
session.close()
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.
"""
orders = load_all_orders()
by_company: dict[str, int] = {}
by_source: dict[str, int] = {}
tickets_by_source: dict[str, set] = {}
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())
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),
}
+27
View File
@@ -0,0 +1,27 @@
"""
Entry point. Run with: python main.py
"""
import sys
from PyQt6.QtWidgets import QApplication
from app import config
from app.database import init_db
from app.ui.main_window import MainWindow
def main() -> int:
config.ensure_env_file_exists()
init_db()
app = QApplication(sys.argv)
app.setApplicationName("Order Manager")
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -0,0 +1,4 @@
PyQt6>=6.6.0
SQLAlchemy>=2.0
python-dotenv>=1.0
requests>=2.31