From 9651b824152df4bbc9aeb352571e7905c423209f Mon Sep 17 00:00:00 2001 From: Sanjay Padole Date: Mon, 31 Aug 2026 15:00:37 -0500 Subject: [PATCH] More Shipstation integrations(warehouses) --- .env.example | 39 ++++++++----- .env.old | 48 ++++++++++++++++ README.md | 98 ++++++++++++++++++-------------- app/config.py | 46 +++++++++++++-- app/queries.py | 12 ++-- app/services/jira_service.py | 34 ++++++----- app/services/shipstation_send.py | 29 +++++++++- app/status_rules.py | 27 ++++++++- app/ui/main_window.py | 28 ++++----- app/ui/settings_dialog.py | 45 +++++++++++++-- app/workers.py | 68 +++++++++++----------- main.py | 2 +- 12 files changed, 337 insertions(+), 139 deletions(-) create mode 100644 .env.old diff --git a/.env.example b/.env.example index 8daff31..2a05fe4 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,17 @@ JIRA_URL= 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 +# JQL used to pull orders. This is your real filter (23753)'s exact query, +# used directly rather than "filter = 23753" so it doesn't depend on that +# filter being shared/visible to whichever account the API token belongs +# to. The 4-day lookback is your holiday/weekend carryover buffer; the +# status restriction matches this app's ACTIVE_STATUSES/CANCELLED_STATUSES +# below. Tickets that move to a status outside Created/Cancelled (e.g. +# fulfilled) stop matching this query on their own - the app's re-check +# mechanism (see README) is what still catches that transition by ticket +# key regardless of status, so update this JQL and the status settings +# together if either one ever changes. +JIRA_JQL=project = "AR" AND created >= -4d AND (status = Created or status = Cancelled) ORDER BY created DESC, cf[10349] 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 @@ -25,9 +34,6 @@ JIRA_FIELD_NAME=customfield_10662 JIRA_FIELD_PHONE=customfield_10424 JIRA_FIELD_EMAIL=customfield_10544 JIRA_FIELD_ADDRESS1=customfield_10654 -# Best guess pending confirmation - both "Address 1" and "Address 2" were -# listed with the same label, this assumes the second one is Address 2. -# Verify via double-click -> raw payload on a ticket that has both filled in. JIRA_FIELD_ADDRESS2=customfield_10655 JIRA_FIELD_CITY=customfield_10480 JIRA_FIELD_STATE=customfield_10560 @@ -43,6 +49,10 @@ SHIPSTATION_API_KEY= # needs to know which store to put it in; pulling tracking numbers doesn't). SHIPSTATION_SIGNIFY_STORE_ID=se-221889 SHIPSTATION_OAKSTREET_STORE_ID=se-367672 +# ShipStation needs to know where a package ships FROM. Find these in +# ShipStation under Settings > Shipping > Warehouses/Ship From Locations. +SHIPSTATION_SIGNIFY_WAREHOUSE_ID=se-180473 +SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417 # --- Companies --- # SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies. @@ -52,17 +62,20 @@ COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b # --- Status Tracking --- -# Statuses where we stop re-checking a ticket on future imports (comma-separated). -# Defaults to Cancelled + both fulfilled statuses below, since none of those -# need to be re-checked once reached. Add more if your workflow gets other -# terminal statuses later. -JIRA_TERMINAL_STATUSES=Cancelled,Waiting For Return,Device Return Not Needed -# Statuses that get highlighted red in the table and trigger a popup when a -# ticket newly transitions into one (comma-separated). +# Statuses that count as real active work - shown on the Active Orders tab +# and re-checked on every import (comma-separated). Anything NOT in this +# list and NOT in CANCELLED_STATUSES below is treated as done and shown on +# the Done tab - including JIRA-side automation statuses this app was never +# explicitly told about (e.g. "1st Contact Attempt"), since they aren't +# "Created" or "Cancelled" either. +ACTIVE_STATUSES=Created +# Statuses that get their own Cancelled tab, highlighted red, and trigger a +# popup when a ticket newly transitions into one (comma-separated). CANCELLED_STATUSES=Cancelled # The two statuses your team uses at end-of-day close-out, depending on # whether ShipStation's automation also generated a return label. Reaching -# either one moves a ticket to the Done tab and out of the active view. +# either one moves a ticket off Active Orders and into Done (since neither +# is in ACTIVE_STATUSES or CANCELLED_STATUSES above). FULFILLED_STATUS_WITH_RETURN=Waiting For Return FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed # Daily intake cutoff (24h HH:MM). Tickets created after this time on their diff --git a/.env.old b/.env.old new file mode 100644 index 0000000..9b27578 --- /dev/null +++ b/.env.old @@ -0,0 +1,48 @@ +# 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='https://cvs-hcd.atlassian.net/rest/api/3/search/jql?' +# The email address tied to your JIRA API token +JIRA_EMAIL='sanjay.padole@signifyhealth.com' +# Create one at https://id.atlassian.com/manage-profile/security/api-tokens +JIRA_API_TOKEN='ATATT3xFfGF0baKZJ4Zxj9BTPKzRSwn8HLnxSlpi9JPSPPL-dnDQOvCX_mMn23LtQJE4NCUAGG2IpOYQXdCgApHIFjc0m0lIzfSXgOzkY9NIy6v9OdxazihZXy1EKf9elSSmR52_YWIdGzsaZMoRf36zsXTDUb5GtWnZGVGxmx1mpx5UsK_wIyc=FE2DA56D' +# 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='xxuRcKlQi+BFla5fHbW0jDc6yqkFiZp/s1n2RKSRvVU' +# 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' +JIRA_TERMINAL_STATUSES='' +CANCELLED_STATUSES='' +FULFILLED_STATUS_WITH_RETURN='' +FULFILLED_STATUS_WITHOUT_RETURN='' +JIRA_FIELD_NAME='' +JIRA_FIELD_PHONE='' +JIRA_FIELD_EMAIL='' +JIRA_FIELD_ADDRESS1='' +JIRA_FIELD_ADDRESS2='' +JIRA_FIELD_CITY='' +JIRA_FIELD_STATE='' +JIRA_FIELD_ZIP='' +JIRA_FIELD_NPI='' +SHIPSTATION_SIGNIFY_STORE_ID='se-221889' +SHIPSTATION_OAKSTREET_STORE_ID='se-367672' +INTAKE_CUTOFF_TIME='' diff --git a/README.md b/README.md index ce80607..08624db 100644 --- a/README.md +++ b/README.md @@ -41,43 +41,47 @@ There's no "source" filter because there's effectively one source. Orders are cached locally in SQLite (`orders.db`). Re-importing updates existing tickets rather than duplicating them. -## Status tracking, matched to your actual workflow +## Three tabs: Active, Cancelled, Done -- **Cancellation** (`CANCELLED_STATUSES`, default `Cancelled`): you - cancel a ticket yourself when the SKU is wrong or the address doesn't - validate. Any order in this status is **highlighted red** in the - table, and a **popup fires** right after an import if a ticket just - transitioned into it (not one that was already cancelled). -- **Fulfilled** (`FULFILLED_STATUS_WITH_RETURN` / - `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return` - / `Device Return Not Needed`): these are **highlighted green**. The - "Pull Tracking Numbers" action figures out which one applies per - ticket automatically, from whether ShipStation's automation also - generated a return label (see below) - so you know which status to - set without checking each one by hand. -- **Carryover**: tickets you didn't close out same-day (rare, per what - you described) don't need anything special - they're just tickets - that haven't hit a terminal status yet (`JIRA_TERMINAL_STATUSES`, - default `Cancelled,Waiting For Return,Device Return Not Needed`). - Every JIRA import re-checks any such ticket regardless of when it was - created, so a carryover ticket stays "alive" and gets its status - change picked up whenever it happens, however many days later. The - Dashboard's Carryover count is just these tickets filtered to "created - before today." +The tab a ticket shows up on is decided by an **allowlist**, not a list +of every "finished" status: -## The Done pile +- **Active Orders**: status is in `ACTIVE_STATUSES` (default just + `Created`) - the only tickets that represent real work still to do. +- **Cancelled**: status is in `CANCELLED_STATUSES` (default `Cancelled`) + - its own tab so it doesn't clutter Active, but still reviewable + anytime. Any transition into this status also triggers a **popup** + right after the import that caused it (not one that was already + cancelled). +- **Done**: everything else, automatically. This is deliberate - rather + than maintaining a list of every status that means "finished" + (`Waiting For Return`, `Device Return Not Needed`, and whatever your + JIRA automation adds next, like `1st Contact Attempt`), only the + couple of statuses that mean "not done yet" are named. Anything that + isn't Created or Cancelled lands on Done with zero config changes + needed when your workflow adds another downstream status later. -Once a ticket reaches either fulfilled status, it moves off the -**Active Orders** tab and onto the **Done** tab automatically - the -active view stays focused on what's still being worked. This is a -filter, not a physical move: everything's still the same `orders` -table, split by current status each time the view refreshes, so if a -status ever changed back there'd be nothing to reconcile. Cancelled -tickets stay on Active Orders (still need eyes on them) - only the two -fulfilled statuses trigger the move. The Dashboard's "Active Orders" -count and company breakdown only reflect what's still active; -"Fulfilled Today" and "Arrived Past Cutoff Today" look at all of -today's activity regardless of which tab something ended up on. +This is a filter, not a physical move - it's the same `orders` table, +split by current status every time the view refreshes, so nothing to +reconcile if a status ever changes back. + +Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN` +/ `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return` +/ `Device Return Not Needed`) still get **highlighted green** and drive +the "Fulfilled Today" dashboard count and `fulfilled_at` timestamp - +they're the two statuses this app actually knows the meaning of, versus +other done-statuses (like `1st Contact Attempt`) which land on Done but +aren't otherwise tracked, per "it is considered done...unnecessary to +be tracked for us." + +**Carryover**: tickets that didn't get closed out same-day don't need +anything special - since `ACTIVE_STATUSES` defaults to just `Created`, +every JIRA import re-checks any ticket still in that status regardless +of when it was created, so it stays "alive" and its eventual status +change gets picked up whenever it happens. The Dashboard's Carryover +count is Active tickets created before today, plus today's tickets that +arrived past the cutoff (see below) - both cases are known not to get +done today, just at different points in the day. ## Intake cutoff tracking @@ -153,14 +157,24 @@ account - check that ShipStation packed and priced an emergency-sent order the way a normal one would before relying on this in an actual emergency. -A couple of things worth confirming once you've seen real data: -- **Address 2**: two JIRA fields were both labeled "Address 1" when you - listed them (`customfield_10654` and `customfield_10655`) - this - assumes the second one is actually Address 2. Double-click a ticket - that has both filled in and check the Shipping Info section to confirm. -- **Quantity**: always sent as `1` per line item today, since JIRA - doesn't currently give a per-deliverable quantity. Say the word if - that's ever not right. +One thing worth knowing: **Quantity** is always sent as `1` per line +item today, since JIRA doesn't currently give a per-deliverable +quantity. Say the word if that's ever not right. + +## Keeping .env in sync as new settings get added + +`.env` is gitignored on purpose (it holds real credentials and field +IDs), which means pulling new code never updates it automatically - +new settings would otherwise sit silently blank until someone noticed +a feature wasn't working (this is what caused an early version of the +emergency-send feature to have no address data - the field IDs existed +in `.env.example` but never made it into the real `.env`). Every +startup now calls `config.sync_env_with_example()`, which adds any key +present in `.env.example` but missing from `.env`, using the example's +value as the default - without touching anything you've already set. +Existing tickets in the local database still need a fresh **Import +from JIRA** to pick up newly-added fields, though, since extraction +only runs when a ticket is actually re-fetched. ## How company/SKU extraction works diff --git a/app/config.py b/app/config.py index 833ba0a..0156c22 100644 --- a/app/config.py +++ b/app/config.py @@ -64,6 +64,16 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { "ShipStation", False, ), + "SHIPSTATION_SIGNIFY_WAREHOUSE_ID": ( + "ShipStation Warehouse ID: Signify Health (ship-from location)", + "ShipStation", + False, + ), + "SHIPSTATION_OAKSTREET_WAREHOUSE_ID": ( + "ShipStation Warehouse ID: Oak Street Health (ship-from location)", + "ShipStation", + False, + ), "COMPANY_SKU_MAP": ( "SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)", @@ -76,13 +86,14 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { False, ), - "JIRA_TERMINAL_STATUSES": ( - "Statuses where we stop re-checking a ticket (comma-separated)", + "ACTIVE_STATUSES": ( + "Statuses that count as real active work (comma-separated) - " + "everything else is treated as done", "Status Tracking", False, ), "CANCELLED_STATUSES": ( - "Statuses treated as cancelled - highlighted + notified (comma-separated)", + "Statuses treated as cancelled - shown in their own tab (comma-separated)", "Status Tracking", False, ), @@ -109,9 +120,7 @@ DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b" DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return" DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" DEFAULT_INTAKE_CUTOFF_TIME = "15:30" -DEFAULT_TERMINAL_STATUSES = ( - f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}" -) +DEFAULT_ACTIVE_STATUSES = "Created" DEFAULT_CANCELLED_STATUSES = "Cancelled" @@ -126,6 +135,31 @@ def ensure_env_file_exists() -> None: ENV_PATH.touch() +def sync_env_with_example() -> None: + """ + Add any key present in .env.example but entirely missing from an + already-existing .env, using .env.example's value as the default - + without touching anything the user already has. .env is gitignored + on purpose (it holds real credentials/field IDs), which means it + never gets updated just by pulling new code - as new settings get + added over time, this is what keeps them from silently sitting blank + until someone notices a feature isn't working. Safe to call every + startup; a no-op once everything's already present. + """ + ensure_env_file_exists() + example_path = PROJECT_ROOT / ".env.example" + if not example_path.exists(): + return + + example_values = dotenv_values(example_path) + current_values = dotenv_values(ENV_PATH) + + for key, example_value in example_values.items(): + if key not in current_values: + set_key(str(ENV_PATH), key, example_value or "") + os.environ[key] = example_value or "" + + def load_settings() -> Dict[str, str]: """Read current values from .env (does not touch os.environ).""" ensure_env_file_exists() diff --git a/app/queries.py b/app/queries.py index ac5605c..55b2411 100644 --- a/app/queries.py +++ b/app/queries.py @@ -17,11 +17,13 @@ from app.models import Order from app.status_rules import status_in -def get_open_ticket_numbers(source: str, terminal_statuses: set[str]) -> List[str]: +def get_open_ticket_numbers(source: str, active_statuses: set[str]) -> List[str]: """ - Ticket numbers for a source that haven't reached a terminal status - yet - i.e. still worth re-fetching to catch status changes (like a - cancellation) even if the ticket wasn't created today. + Ticket numbers for a source that are still in an active status - i.e. + still worth re-fetching to catch a status change (a cancellation, a + fulfillment, or any other transition) even if the ticket wasn't + created today. Once a ticket leaves the active set, we stop + re-checking it - whatever it became, it's no longer "not done yet". """ session = get_session() try: @@ -37,5 +39,5 @@ def get_open_ticket_numbers(source: str, terminal_statuses: set[str]) -> List[st return [ ticket_number for ticket_number, status in rows - if ticket_number and not status_in(status, terminal_statuses) + if ticket_number and status_in(status, active_statuses) ] diff --git a/app/services/jira_service.py b/app/services/jira_service.py index ea64020..e18398b 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -19,7 +19,7 @@ from app import config from app.companies import parse_mapping, resolve_company_for_skus from app.queries import get_open_ticket_numbers from app.services.base import OrderService, NormalizedOrder -from app.status_rules import parse_status_list +from app.status_rules import get_active_statuses SEARCH_PAGE_SIZE = 50 REQUEST_TIMEOUT_SECONDS = 30 @@ -75,11 +75,9 @@ class JiraService(OrderService): "npi": settings["JIRA_FIELD_NPI"].strip(), } - # Statuses at which we stop re-checking a ticket for changes - - # see fetch_orders() for why we re-check at all. - self.terminal_statuses = parse_status_list( - settings["JIRA_TERMINAL_STATUSES"] or config.DEFAULT_TERMINAL_STATUSES - ) + # The one (or few) statuses that mean "still needs work" - see + # fetch_orders() for why we re-check tickets that are still active. + self.active_statuses = get_active_statuses() @staticmethod def _split_field_ids(raw: str) -> List[str]: @@ -97,13 +95,13 @@ class JiraService(OrderService): # Your JQL (e.g. "created >= startOfDay()") only catches NEW # tickets. On its own, that would miss a ticket that gets - # cancelled a day or two after it was created, since it no - # longer matches "created today". So on top of your JQL, we - # also re-check every previously-imported JIRA ticket that - # hasn't reached a terminal status yet (JIRA_TERMINAL_STATUSES, - # default just "Cancelled") - that's how a later cancellation - # gets picked up. - recheck_keys = get_open_ticket_numbers("jira", self.terminal_statuses) + # cancelled - or reaches any other status - a day or two after + # it was created, since it no longer matches "created today". So + # on top of your JQL, we also re-check every previously-imported + # JIRA ticket that's still in an active status (ACTIVE_STATUSES, + # default just "Created") - that's how a later status change + # gets picked up, no matter what it changes to. + recheck_keys = get_open_ticket_numbers("jira", self.active_statuses) effective_jql = self._build_effective_jql(self.jql, recheck_keys) issues = self._search_all_issues(effective_jql) @@ -118,11 +116,11 @@ class JiraService(OrderService): want to re-check, being careful to keep any ORDER BY clause at the very end (JQL requires it there). - Note: as the number of still-open tracked tickets grows, this - "key in (...)" list grows too. If that ever gets unwieldy, add - more statuses to JIRA_TERMINAL_STATUSES (e.g. "Done", once you - know your workflow's real terminal status names) so fulfilled - tickets stop being re-checked and drop out of this list. + Note: as the number of still-active tracked tickets grows, this + "key in (...)" list grows too. Since ACTIVE_STATUSES defaults to + just "Created", a ticket drops out of this list the moment it + moves to anything else, so this naturally stays bounded to + what's genuinely still unprocessed. """ if not recheck_keys: return base_jql diff --git a/app/services/shipstation_send.py b/app/services/shipstation_send.py index 7ddd83b..515954b 100644 --- a/app/services/shipstation_send.py +++ b/app/services/shipstation_send.py @@ -10,7 +10,11 @@ still have an option for CSV uploads"): row per line item, customer/address info repeated on each row. - send_order_to_shipstation_api(): calls ShipStation's V2 API directly (POST /v2/shipments with create_sales_order: true) to - create the order without leaving the app. + create the order without leaving the app. Needs a Warehouse ID per + company (SHIPSTATION_SIGNIFY_WAREHOUSE_ID / + SHIPSTATION_OAKSTREET_WAREHOUSE_ID) - ShipStation requires knowing + where the package ships FROM, either via a warehouse or an explicit + ship_from address; only the warehouse path is wired up today. IMPORTANT - please verify the first real send: ShipStation's docs say automation rules apply tags to orders "when they import based on any @@ -134,6 +138,15 @@ def _store_id_for_company(company: str) -> str: return "" +def _warehouse_id_for_company(company: str) -> str: + settings = config.load_settings() + if company == "Signify Health": + return settings.get("SHIPSTATION_SIGNIFY_WAREHOUSE_ID", "") + if company == "Oak Street Health": + return settings.get("SHIPSTATION_OAKSTREET_WAREHOUSE_ID", "") + return "" + + def send_order_to_shipstation_api(order: Order) -> dict: """ Creates the order directly in ShipStation via POST /v2/shipments with @@ -154,6 +167,19 @@ def send_order_to_shipstation_api(order: Order) -> dict: "Add it in Settings under ShipStation." ) + # ShipStation needs to know where the package ships FROM - either a + # configured warehouse, or an explicit ship_from address. Only the + # warehouse path is wired up today; fail clearly here rather than + # sending an incomplete request and getting ShipStation's less + # actionable "ship_from is required when warehouse_id is not present". + warehouse_id = _warehouse_id_for_company(order.company) + if not warehouse_id: + raise ShipStationSendError( + f"No ShipStation Warehouse ID configured for '{order.company}'. " + "Add it in Settings under ShipStation - find it in ShipStation under " + "Settings > Shipping > Warehouses/Ship From Locations." + ) + info = order.shipping_info or {} if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")): raise ShipStationSendError( @@ -172,6 +198,7 @@ def send_order_to_shipstation_api(order: Order) -> dict: { "create_sales_order": True, "store_id": store_id, + "warehouse_id": warehouse_id, "external_shipment_id": ticket_number, "shipment_number": ticket_number, "ship_to": { diff --git a/app/status_rules.py b/app/status_rules.py index d0052e2..7613b7c 100644 --- a/app/status_rules.py +++ b/app/status_rules.py @@ -2,15 +2,27 @@ Status matching helpers. Kept separate from companies.py because this is about order lifecycle -state (cancelled, terminal-for-recheck-purposes, and later probably -"fulfilled"/"shipped"/etc.) rather than company identity - a different -axis of classification that will grow independently. +state rather than company identity - a different axis of classification +that grows independently. + +The model is deliberately an allowlist, not a denylist: ACTIVE_STATUSES +is the small set of statuses that represent real work still to do +(just "Created" by default). Anything NOT in ACTIVE_STATUSES or +CANCELLED_STATUSES is treated as done, automatically - including +statuses this app has never been told about by name (like a JIRA +automation status such as "1st Contact Attempt" that fires after a +ticket is already handled). That's on purpose: enumerating every +possible "this means it's done" status would be a losing game as more +JIRA-side automation gets added; only naming the couple of statuses +that mean "not done yet" is far more robust. Matching is case-insensitive since JIRA and ShipStation don't necessarily agree on casing (e.g. "Cancelled" vs "cancelled"). """ from __future__ import annotations +from app import config + def parse_status_list(raw: str) -> set[str]: return {s.strip().lower() for s in (raw or "").split(",") if s.strip()} @@ -20,6 +32,15 @@ def status_in(status: str | None, status_set: set[str]) -> bool: return (status or "").strip().lower() in status_set +def get_active_statuses() -> set[str]: + """Statuses that mean real, unfinished work - default just 'Created'.""" + return parse_status_list(config.get("ACTIVE_STATUSES", config.DEFAULT_ACTIVE_STATUSES)) + + +def get_cancelled_statuses() -> set[str]: + return parse_status_list(config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)) + + def find_new_cancellations(status_changes: list[dict], cancelled_statuses: set[str]) -> list[dict]: """ From a batch of status changes, return only the ones that just BECAME diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 5278659..d35e31a 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -25,11 +25,10 @@ from PyQt6.QtWidgets import ( QFileDialog, ) -from app import config from app.services import SERVICE_REGISTRY from app.services.odoo_export import export_orders_to_csv from app.services.shipstation_send import export_order_to_shipstation_csv -from app.status_rules import parse_status_list, find_new_cancellations +from app.status_rules import get_cancelled_statuses, find_new_cancellations from app.ui.settings_dialog import SettingsDialog from app.ui.widgets.orders_table import OrdersTableView from app.ui.widgets.dashboard import DashboardWidget @@ -37,7 +36,7 @@ from app.ui.widgets.order_detail_dialog import OrderDetailDialog from app.workers import ( FetchOrdersWorker, SendToShipStationWorker, - load_active_and_done_orders, + load_orders_by_view, get_dashboard_stats, ) @@ -65,10 +64,13 @@ class MainWindow(QMainWindow): self.dashboard = DashboardWidget() self.orders_table = OrdersTableView() self.orders_table.order_double_clicked.connect(self._on_order_double_clicked) + self.cancelled_table = OrdersTableView() + self.cancelled_table.order_double_clicked.connect(self._on_order_double_clicked) self.done_table = OrdersTableView() self.done_table.order_double_clicked.connect(self._on_order_double_clicked) self.tabs.addTab(self.dashboard, "Dashboard") self.tabs.addTab(self.orders_table, "Active Orders") + self.tabs.addTab(self.cancelled_table, "Cancelled") self.tabs.addTab(self.done_table, "Done") layout.addWidget(self.tabs) @@ -91,13 +93,13 @@ class MainWindow(QMainWindow): toolbar.addSeparator() export_action = QAction("Export Visible Orders to Odoo CSV", self) - export_action.setToolTip("Exports from whichever of Active Orders / Done is open") + export_action.setToolTip("Exports from whichever tab is currently open") export_action.triggered.connect(self._on_export_clicked) toolbar.addAction(export_action) toolbar.addSeparator() - emergency_action = QAction("Send to ShipStation (Emergency)", self) + emergency_action = QAction("Send to ShipStation", self) emergency_action.setToolTip("Select a ticket in Active Orders first") emergency_action.triggered.connect(self._on_emergency_send_clicked) toolbar.addAction(emergency_action) @@ -186,10 +188,7 @@ class MainWindow(QMainWindow): ) def _notify_new_cancellations(self, status_changes: list) -> None: - cancelled_statuses = parse_status_list( - config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES) - ) - newly_cancelled = find_new_cancellations(status_changes, cancelled_statuses) + newly_cancelled = find_new_cancellations(status_changes, get_cancelled_statuses()) if not newly_cancelled: return @@ -217,7 +216,7 @@ class MainWindow(QMainWindow): QMessageBox.information( self, "Nothing to export", - "Switch to the Active Orders or Done tab first, then export.", + "Switch to Active Orders, Cancelled, or Done first, then export.", ) return @@ -333,13 +332,15 @@ class MainWindow(QMainWindow): ) def _refresh_everything(self) -> None: - active_orders, done_orders = load_active_and_done_orders() + active_orders, cancelled_orders, done_orders = load_orders_by_view() self.orders_table.set_orders(active_orders) + self.cancelled_table.set_orders(cancelled_orders) self.done_table.set_orders(done_orders) self.dashboard.update_stats(get_dashboard_stats()) if not hasattr(self, "_suppress_ready_status"): self.status_label.setText( - f"{len(active_orders)} active order(s), {len(done_orders)} done." + f"{len(active_orders)} active, {len(cancelled_orders)} cancelled, " + f"{len(done_orders)} done." ) @@ -351,7 +352,8 @@ class MainWindow(QMainWindow): # _on_import_clicked, and it can eventually replace/augment odoo_export.py. # - Emailed-label SKU: these tickets won't get tracking numbers pulled via # the normal ShipStation flow - once its handling is defined, it likely -# needs its own terminal status and its own "mark as sent" action here. +# needs its own status/action here so it doesn't get stuck in Active +# forever with nothing to trigger its move to Done. # - Pipeline actions ("mark fulfilled manually", "void a label"): add # toolbar actions gated on table selection. # - If the window grows too much, split each tab's toolbar into its own diff --git a/app/ui/settings_dialog.py b/app/ui/settings_dialog.py index 420aa1e..0dcc268 100644 --- a/app/ui/settings_dialog.py +++ b/app/ui/settings_dialog.py @@ -4,6 +4,12 @@ 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. + +The number of settings has grown a lot, so the group content lives in +a QScrollArea and the dialog's height is capped to the actual screen's +available space - Save/Cancel stay outside the scroll area, pinned at +the bottom, so they're always reachable no matter how tall the content +gets or how small the screen is. """ from __future__ import annotations @@ -19,6 +25,9 @@ from PyQt6.QtWidgets import ( QGroupBox, QLabel, QMessageBox, + QScrollArea, + QWidget, + QApplication, ) from app import config @@ -28,7 +37,6 @@ 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() @@ -38,11 +46,15 @@ class SettingsDialog(QDialog): for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items(): groups[group].append((key, label, is_secret)) - layout = QVBoxLayout(self) - layout.addWidget( + outer_layout = QVBoxLayout(self) + outer_layout.addWidget( QLabel("Changes are saved to your .env file and applied immediately.") ) + # All the group boxes live inside a scrollable area, since the + # full list no longer reliably fits on smaller screens. + scroll_content = QWidget() + content_layout = QVBoxLayout(scroll_content) for group_name, entries in groups.items(): box = QGroupBox(group_name) form = QFormLayout(box) @@ -52,7 +64,13 @@ class SettingsDialog(QDialog): field.setEchoMode(QLineEdit.EchoMode.Password) form.addRow(label, field) self._fields[key] = field - layout.addWidget(box) + content_layout.addWidget(box) + content_layout.addStretch() + + scroll_area = QScrollArea() + scroll_area.setWidget(scroll_content) + scroll_area.setWidgetResizable(True) + outer_layout.addWidget(scroll_area, stretch=1) button_row = QHBoxLayout() save_button = QPushButton("Save") @@ -62,7 +80,24 @@ class SettingsDialog(QDialog): button_row.addStretch() button_row.addWidget(cancel_button) button_row.addWidget(save_button) - layout.addLayout(button_row) + outer_layout.addLayout(button_row) + + self._size_to_fit_screen() + + def _size_to_fit_screen(self) -> None: + """Start at a comfortable size, but never taller/wider than the + actual screen has room for - whatever's left just scrolls.""" + width, height = 560, 720 + + screen = self.screen() or QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + # Leave a little breathing room so window chrome/taskbars don't + # push Save off-screen either. + height = min(height, available.height() - 80) + width = min(width, available.width() - 80) + + self.resize(max(width, 400), max(height, 300)) def _on_save(self) -> None: values = {key: field.text().strip() for key, field in self._fields.items()} diff --git a/app/workers.py b/app/workers.py index 8db59c6..9269b82 100644 --- a/app/workers.py +++ b/app/workers.py @@ -14,12 +14,11 @@ from typing import List, Tuple, TypedDict from PyQt6.QtCore import QThread, pyqtSignal from sqlalchemy import select -from app import config from app.database import get_session from app.models import Order from app.schedule import get_cutoff_time, is_past_cutoff from app.services.base import OrderService, NormalizedOrder -from app.status_rules import parse_status_list, status_in +from app.status_rules import get_active_statuses, get_cancelled_statuses, status_in from app.tracking import get_fulfilled_statuses @@ -209,63 +208,68 @@ def load_all_orders() -> List[Order]: session.close() -def split_active_and_done(orders: List[Order]) -> Tuple[List[Order], List[Order]]: +def split_orders_by_view(orders: List[Order]) -> Tuple[List[Order], List[Order], List[Order]]: """ - Active = still being worked (includes cancelled - those still need - eyes on them). Done = fulfilled (Waiting For Return / Device Return - Not Needed) - archived out of the working view on purpose, per how - the team wants to keep the active list focused on what's at hand. + Three tabs, allowlist-driven: + - Active: status is in ACTIVE_STATUSES (just "Created" by default) - + the only tickets that represent real work still to do. + - Cancelled: status is in CANCELLED_STATUSES - its own tab so it + doesn't clutter Active, but still reviewable on demand. + - Done: everything else. This deliberately doesn't enumerate every + "finished" status by name - a JIRA-side automation status like + "1st Contact Attempt" falls in here automatically just by not + being Created or Cancelled, with no code change needed when your + JIRA workflow adds another downstream status later. """ - fulfilled_statuses = get_fulfilled_statuses() - active, done = [], [] + active_statuses = get_active_statuses() + cancelled_statuses = get_cancelled_statuses() + active, cancelled, done = [], [], [] for order in orders: - (done if status_in(order.status, fulfilled_statuses) else active).append(order) - return active, done + if status_in(order.status, active_statuses): + active.append(order) + elif status_in(order.status, cancelled_statuses): + cancelled.append(order) + else: + done.append(order) + return active, cancelled, done -def load_active_and_done_orders() -> Tuple[List[Order], List[Order]]: - return split_active_and_done(load_all_orders()) +def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]: + return split_orders_by_view(load_all_orders()) def get_dashboard_stats() -> dict: """ Counts for the dashboard. "Active Orders" and the company breakdown - reflect only the active workload (fulfilled tickets have moved to - the Done pile and don't clutter this). Fulfilled-today and - past-cutoff-today look across ALL of today's tickets regardless of - which pile they're in now, since both are about what happened today. + reflect only the Active tab (status in ACTIVE_STATUSES) - the actual + at-hand workload. Cancelled counts the Cancelled tab. Fulfilled-today + and past-cutoff-today look across ALL of today's tickets regardless + of which tab they ended up in, since both are about what happened + today specifically. - Carryover counts any still-open ticket that's already known to spill + Carryover counts any Active ticket that's already known to spill into tomorrow - either it's genuinely left over from a prior day, or it arrived today but after the cutoff (same effect, just known a day - earlier). + earlier). Since we're only looking at the Active bucket, every + ticket here is by definition still unresolved - no separate "is it + still open" check needed. """ orders = load_all_orders() - active_orders, _done_orders = split_active_and_done(orders) + active_orders, cancelled_orders, _done_orders = split_orders_by_view(orders) - cancelled_statuses = parse_status_list( - config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES) - ) - terminal_statuses = parse_status_list( - config.get("JIRA_TERMINAL_STATUSES", config.DEFAULT_TERMINAL_STATUSES) - ) cutoff = get_cutoff_time() today = dt.date.today() by_company: dict[str, int] = {} - cancelled_count = 0 carryover_count = 0 tracking_received_count = 0 for order in active_orders: by_company[order.company] = by_company.get(order.company, 0) + 1 - if status_in(order.status, cancelled_statuses): - cancelled_count += 1 if order.tracking_numbers: tracking_received_count += 1 - still_open = not status_in(order.status, terminal_statuses) created_before_today = bool( order.source_created_at and order.source_created_at.date() < today ) @@ -277,7 +281,7 @@ def get_dashboard_stats() -> dict: and order.source_created_at.date() == today and is_past_cutoff(order.source_created_at, cutoff) ) - if still_open and (created_before_today or arrived_past_cutoff_today): + if created_before_today or arrived_past_cutoff_today: carryover_count += 1 fulfilled_today_count = sum( @@ -294,7 +298,7 @@ def get_dashboard_stats() -> dict: return { "total": len(active_orders), "by_company": by_company, - "cancelled_count": cancelled_count, + "cancelled_count": len(cancelled_orders), "carryover_count": carryover_count, "tracking_received_count": tracking_received_count, "fulfilled_today_count": fulfilled_today_count, diff --git a/main.py b/main.py index 723129a..50b4845 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from app.ui.main_window import MainWindow def main() -> int: - config.ensure_env_file_exists() + config.sync_env_with_example() init_db() app = QApplication(sys.argv)