Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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()()
|
||||
@@ -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]}'>"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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())
|
||||
@@ -0,0 +1,4 @@
|
||||
PyQt6>=6.6.0
|
||||
SQLAlchemy>=2.0
|
||||
python-dotenv>=1.0
|
||||
requests>=2.31
|
||||
Reference in New Issue
Block a user