From ffaaa0c8c8d56f267af56782cd203e1182323a58 Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Wed, 26 Aug 2026 09:15:35 -0500 Subject: [PATCH] Initial commit --- .env.example | 32 ++++ .gitignore | 22 +++ README.md | 124 ++++++++++++++ app/__init__.py | 0 app/companies.py | 59 +++++++ app/config.py | 103 ++++++++++++ app/database.py | 50 ++++++ app/models.py | 71 ++++++++ app/services/__init__.py | 15 ++ app/services/base.py | 44 +++++ app/services/jira_service.py | 228 ++++++++++++++++++++++++++ app/services/odoo_export.py | 44 +++++ app/services/shipstation_service.py | 167 +++++++++++++++++++ app/ui/__init__.py | 0 app/ui/main_window.py | 191 +++++++++++++++++++++ app/ui/settings_dialog.py | 74 +++++++++ app/ui/widgets/__init__.py | 0 app/ui/widgets/dashboard.py | 124 ++++++++++++++ app/ui/widgets/order_detail_dialog.py | 46 ++++++ app/ui/widgets/orders_table.py | 217 ++++++++++++++++++++++++ app/workers.py | 133 +++++++++++++++ main.py | 27 +++ requirements.txt | 4 + 23 files changed, 1775 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/companies.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/models.py create mode 100644 app/services/__init__.py create mode 100644 app/services/base.py create mode 100644 app/services/jira_service.py create mode 100644 app/services/odoo_export.py create mode 100644 app/services/shipstation_service.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/settings_dialog.py create mode 100644 app/ui/widgets/__init__.py create mode 100644 app/ui/widgets/dashboard.py create mode 100644 app/ui/widgets/order_detail_dialog.py create mode 100644 app/ui/widgets/orders_table.py create mode 100644 app/workers.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79d36d0 --- /dev/null +++ b/.env.example @@ -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:password@192.168.1.50: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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..681fa52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c81d41 --- /dev/null +++ b/README.md @@ -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@: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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/companies.py b/app/companies.py new file mode 100644 index 0000000..f829a3c --- /dev/null +++ b/app/companies.py @@ -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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..806d40c --- /dev/null +++ b/app/config.py @@ -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 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..5178f22 --- /dev/null +++ b/app/database.py @@ -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:password@192.168.1.50: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()() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4aa2228 --- /dev/null +++ b/app/models.py @@ -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"" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..ffb8cf2 --- /dev/null +++ b/app/services/__init__.py @@ -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 +} diff --git a/app/services/base.py b/app/services/base.py new file mode 100644 index 0000000..7b7acbf --- /dev/null +++ b/app/services/base.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 diff --git a/app/services/jira_service.py b/app/services/jira_service.py new file mode 100644 index 0000000..f41ae4a --- /dev/null +++ b/app/services/jira_service.py @@ -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, + ) diff --git a/app/services/odoo_export.py b/app/services/odoo_export.py new file mode 100644 index 0000000..5a23c09 --- /dev/null +++ b/app/services/odoo_export.py @@ -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) diff --git a/app/services/shipstation_service.py b/app/services/shipstation_service.py new file mode 100644 index 0000000..5b2e646 --- /dev/null +++ b/app/services/shipstation_service.py @@ -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, + ) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..87aa25f --- /dev/null +++ b/app/ui/main_window.py @@ -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. +# --------------------------------------------------------------------------- diff --git a/app/ui/settings_dialog.py b/app/ui/settings_dialog.py new file mode 100644 index 0000000..420aa1e --- /dev/null +++ b/app/ui/settings_dialog.py @@ -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() diff --git a/app/ui/widgets/__init__.py b/app/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/widgets/dashboard.py b/app/ui/widgets/dashboard.py new file mode 100644 index 0000000..f3bbe6e --- /dev/null +++ b/app/ui/widgets/dashboard.py @@ -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) diff --git a/app/ui/widgets/order_detail_dialog.py b/app/ui/widgets/order_detail_dialog.py new file mode 100644 index 0000000..1d051c7 --- /dev/null +++ b/app/ui/widgets/order_detail_dialog.py @@ -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) diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py new file mode 100644 index 0000000..6c3e01a --- /dev/null +++ b/app/ui/widgets/orders_table.py @@ -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 diff --git a/app/workers.py b/app/workers.py new file mode 100644 index 0000000..b4a7e7d --- /dev/null +++ b/app/workers.py @@ -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), + } diff --git a/main.py b/main.py new file mode 100644 index 0000000..723129a --- /dev/null +++ b/main.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6194be6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyQt6>=6.6.0 +SQLAlchemy>=2.0 +python-dotenv>=1.0 +requests>=2.31