More Shipstation integrations(warehouses)
This commit is contained in:
+26
-13
@@ -14,8 +14,17 @@ JIRA_URL=
|
|||||||
JIRA_EMAIL=
|
JIRA_EMAIL=
|
||||||
# Create one at https://id.atlassian.com/manage-profile/security/api-tokens
|
# Create one at https://id.atlassian.com/manage-profile/security/api-tokens
|
||||||
JIRA_API_TOKEN=
|
JIRA_API_TOKEN=
|
||||||
# JQL used to pull "today's orders". Adjust to match how your team tags order tickets.
|
# JQL used to pull orders. This is your real filter (23753)'s exact query,
|
||||||
JIRA_JQL=project = AR AND created >= startOfDay() ORDER BY created ASC
|
# 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)
|
# Deliverable/SKU-bearing custom fields, per company (comma-separated field IDs)
|
||||||
JIRA_SIGNIFY_SKU_FIELDS=customfield_10573,customfield_10570
|
JIRA_SIGNIFY_SKU_FIELDS=customfield_10573,customfield_10570
|
||||||
JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
|
JIRA_OAKSTREET_SKU_FIELDS=customfield_12790,customfield_13021
|
||||||
@@ -25,9 +34,6 @@ JIRA_FIELD_NAME=customfield_10662
|
|||||||
JIRA_FIELD_PHONE=customfield_10424
|
JIRA_FIELD_PHONE=customfield_10424
|
||||||
JIRA_FIELD_EMAIL=customfield_10544
|
JIRA_FIELD_EMAIL=customfield_10544
|
||||||
JIRA_FIELD_ADDRESS1=customfield_10654
|
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_ADDRESS2=customfield_10655
|
||||||
JIRA_FIELD_CITY=customfield_10480
|
JIRA_FIELD_CITY=customfield_10480
|
||||||
JIRA_FIELD_STATE=customfield_10560
|
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).
|
# needs to know which store to put it in; pulling tracking numbers doesn't).
|
||||||
SHIPSTATION_SIGNIFY_STORE_ID=se-221889
|
SHIPSTATION_SIGNIFY_STORE_ID=se-221889
|
||||||
SHIPSTATION_OAKSTREET_STORE_ID=se-367672
|
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 ---
|
# --- Companies ---
|
||||||
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
|
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies.
|
||||||
@@ -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
|
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
|
||||||
|
|
||||||
# --- Status Tracking ---
|
# --- Status Tracking ---
|
||||||
# Statuses where we stop re-checking a ticket on future imports (comma-separated).
|
# Statuses that count as real active work - shown on the Active Orders tab
|
||||||
# Defaults to Cancelled + both fulfilled statuses below, since none of those
|
# and re-checked on every import (comma-separated). Anything NOT in this
|
||||||
# need to be re-checked once reached. Add more if your workflow gets other
|
# list and NOT in CANCELLED_STATUSES below is treated as done and shown on
|
||||||
# terminal statuses later.
|
# the Done tab - including JIRA-side automation statuses this app was never
|
||||||
JIRA_TERMINAL_STATUSES=Cancelled,Waiting For Return,Device Return Not Needed
|
# explicitly told about (e.g. "1st Contact Attempt"), since they aren't
|
||||||
# Statuses that get highlighted red in the table and trigger a popup when a
|
# "Created" or "Cancelled" either.
|
||||||
# ticket newly transitions into one (comma-separated).
|
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
|
CANCELLED_STATUSES=Cancelled
|
||||||
# The two statuses your team uses at end-of-day close-out, depending on
|
# The two statuses your team uses at end-of-day close-out, depending on
|
||||||
# whether ShipStation's automation also generated a return label. Reaching
|
# 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_WITH_RETURN=Waiting For Return
|
||||||
FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
|
FULFILLED_STATUS_WITHOUT_RETURN=Device Return Not Needed
|
||||||
# Daily intake cutoff (24h HH:MM). Tickets created after this time on their
|
# Daily intake cutoff (24h HH:MM). Tickets created after this time on their
|
||||||
|
|||||||
@@ -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:[email protected]: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='[email protected]'
|
||||||
|
# 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=''
|
||||||
@@ -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
|
Orders are cached locally in SQLite (`orders.db`). Re-importing updates
|
||||||
existing tickets rather than duplicating them.
|
existing tickets rather than duplicating them.
|
||||||
|
|
||||||
## Status tracking, matched to your actual workflow
|
## Three tabs: Active, Cancelled, Done
|
||||||
|
|
||||||
- **Cancellation** (`CANCELLED_STATUSES`, default `Cancelled`): you
|
The tab a ticket shows up on is decided by an **allowlist**, not a list
|
||||||
cancel a ticket yourself when the SKU is wrong or the address doesn't
|
of every "finished" status:
|
||||||
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 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
|
This is a filter, not a physical move - it's the same `orders` table,
|
||||||
**Active Orders** tab and onto the **Done** tab automatically - the
|
split by current status every time the view refreshes, so nothing to
|
||||||
active view stays focused on what's still being worked. This is a
|
reconcile if a status ever changes back.
|
||||||
filter, not a physical move: everything's still the same `orders`
|
|
||||||
table, split by current status each time the view refreshes, so if a
|
Within Done, the two fulfilled statuses (`FULFILLED_STATUS_WITH_RETURN`
|
||||||
status ever changed back there'd be nothing to reconcile. Cancelled
|
/ `FULFILLED_STATUS_WITHOUT_RETURN`, defaulting to `Waiting For Return`
|
||||||
tickets stay on Active Orders (still need eyes on them) - only the two
|
/ `Device Return Not Needed`) still get **highlighted green** and drive
|
||||||
fulfilled statuses trigger the move. The Dashboard's "Active Orders"
|
the "Fulfilled Today" dashboard count and `fulfilled_at` timestamp -
|
||||||
count and company breakdown only reflect what's still active;
|
they're the two statuses this app actually knows the meaning of, versus
|
||||||
"Fulfilled Today" and "Arrived Past Cutoff Today" look at all of
|
other done-statuses (like `1st Contact Attempt`) which land on Done but
|
||||||
today's activity regardless of which tab something ended up on.
|
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
|
## 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
|
order the way a normal one would before relying on this in an actual
|
||||||
emergency.
|
emergency.
|
||||||
|
|
||||||
A couple of things worth confirming once you've seen real data:
|
One thing worth knowing: **Quantity** is always sent as `1` per line
|
||||||
- **Address 2**: two JIRA fields were both labeled "Address 1" when you
|
item today, since JIRA doesn't currently give a per-deliverable
|
||||||
listed them (`customfield_10654` and `customfield_10655`) - this
|
quantity. Say the word if that's ever not right.
|
||||||
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.
|
## Keeping .env in sync as new settings get added
|
||||||
- **Quantity**: always sent as `1` per line item today, since JIRA
|
|
||||||
doesn't currently give a per-deliverable quantity. Say the word if
|
`.env` is gitignored on purpose (it holds real credentials and field
|
||||||
that's ever not right.
|
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
|
## How company/SKU extraction works
|
||||||
|
|
||||||
|
|||||||
+40
-6
@@ -64,6 +64,16 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
|||||||
"ShipStation",
|
"ShipStation",
|
||||||
False,
|
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": (
|
"COMPANY_SKU_MAP": (
|
||||||
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
"SKU Prefix -> Company (e.g. SH:Signify Health,OK:Oak Street Health)",
|
||||||
@@ -76,13 +86,14 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
|||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
|
|
||||||
"JIRA_TERMINAL_STATUSES": (
|
"ACTIVE_STATUSES": (
|
||||||
"Statuses where we stop re-checking a ticket (comma-separated)",
|
"Statuses that count as real active work (comma-separated) - "
|
||||||
|
"everything else is treated as done",
|
||||||
"Status Tracking",
|
"Status Tracking",
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
"CANCELLED_STATUSES": (
|
"CANCELLED_STATUSES": (
|
||||||
"Statuses treated as cancelled - highlighted + notified (comma-separated)",
|
"Statuses treated as cancelled - shown in their own tab (comma-separated)",
|
||||||
"Status Tracking",
|
"Status Tracking",
|
||||||
False,
|
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_WITH_RETURN = "Waiting For Return"
|
||||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
||||||
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
||||||
DEFAULT_TERMINAL_STATUSES = (
|
DEFAULT_ACTIVE_STATUSES = "Created"
|
||||||
f"Cancelled,{DEFAULT_FULFILLED_WITH_RETURN},{DEFAULT_FULFILLED_WITHOUT_RETURN}"
|
|
||||||
)
|
|
||||||
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +135,31 @@ def ensure_env_file_exists() -> None:
|
|||||||
ENV_PATH.touch()
|
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]:
|
def load_settings() -> Dict[str, str]:
|
||||||
"""Read current values from .env (does not touch os.environ)."""
|
"""Read current values from .env (does not touch os.environ)."""
|
||||||
ensure_env_file_exists()
|
ensure_env_file_exists()
|
||||||
|
|||||||
+7
-5
@@ -17,11 +17,13 @@ from app.models import Order
|
|||||||
from app.status_rules import status_in
|
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
|
Ticket numbers for a source that are still in an active status - i.e.
|
||||||
yet - i.e. still worth re-fetching to catch status changes (like a
|
still worth re-fetching to catch a status change (a cancellation, a
|
||||||
cancellation) even if the ticket wasn't created today.
|
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()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -37,5 +39,5 @@ def get_open_ticket_numbers(source: str, terminal_statuses: set[str]) -> List[st
|
|||||||
return [
|
return [
|
||||||
ticket_number
|
ticket_number
|
||||||
for ticket_number, status in rows
|
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)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app import config
|
|||||||
from app.companies import parse_mapping, resolve_company_for_skus
|
from app.companies import parse_mapping, resolve_company_for_skus
|
||||||
from app.queries import get_open_ticket_numbers
|
from app.queries import get_open_ticket_numbers
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
from app.services.base import OrderService, NormalizedOrder
|
||||||
from app.status_rules import parse_status_list
|
from app.status_rules import get_active_statuses
|
||||||
|
|
||||||
SEARCH_PAGE_SIZE = 50
|
SEARCH_PAGE_SIZE = 50
|
||||||
REQUEST_TIMEOUT_SECONDS = 30
|
REQUEST_TIMEOUT_SECONDS = 30
|
||||||
@@ -75,11 +75,9 @@ class JiraService(OrderService):
|
|||||||
"npi": settings["JIRA_FIELD_NPI"].strip(),
|
"npi": settings["JIRA_FIELD_NPI"].strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statuses at which we stop re-checking a ticket for changes -
|
# The one (or few) statuses that mean "still needs work" - see
|
||||||
# see fetch_orders() for why we re-check at all.
|
# fetch_orders() for why we re-check tickets that are still active.
|
||||||
self.terminal_statuses = parse_status_list(
|
self.active_statuses = get_active_statuses()
|
||||||
settings["JIRA_TERMINAL_STATUSES"] or config.DEFAULT_TERMINAL_STATUSES
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_field_ids(raw: str) -> List[str]:
|
def _split_field_ids(raw: str) -> List[str]:
|
||||||
@@ -97,13 +95,13 @@ class JiraService(OrderService):
|
|||||||
|
|
||||||
# Your JQL (e.g. "created >= startOfDay()") only catches NEW
|
# Your JQL (e.g. "created >= startOfDay()") only catches NEW
|
||||||
# tickets. On its own, that would miss a ticket that gets
|
# tickets. On its own, that would miss a ticket that gets
|
||||||
# cancelled a day or two after it was created, since it no
|
# cancelled - or reaches any other status - a day or two after
|
||||||
# longer matches "created today". So on top of your JQL, we
|
# it was created, since it no longer matches "created today". So
|
||||||
# also re-check every previously-imported JIRA ticket that
|
# on top of your JQL, we also re-check every previously-imported
|
||||||
# hasn't reached a terminal status yet (JIRA_TERMINAL_STATUSES,
|
# JIRA ticket that's still in an active status (ACTIVE_STATUSES,
|
||||||
# default just "Cancelled") - that's how a later cancellation
|
# default just "Created") - that's how a later status change
|
||||||
# gets picked up.
|
# gets picked up, no matter what it changes to.
|
||||||
recheck_keys = get_open_ticket_numbers("jira", self.terminal_statuses)
|
recheck_keys = get_open_ticket_numbers("jira", self.active_statuses)
|
||||||
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
|
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
|
||||||
|
|
||||||
issues = self._search_all_issues(effective_jql)
|
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
|
want to re-check, being careful to keep any ORDER BY clause at
|
||||||
the very end (JQL requires it there).
|
the very end (JQL requires it there).
|
||||||
|
|
||||||
Note: as the number of still-open tracked tickets grows, this
|
Note: as the number of still-active tracked tickets grows, this
|
||||||
"key in (...)" list grows too. If that ever gets unwieldy, add
|
"key in (...)" list grows too. Since ACTIVE_STATUSES defaults to
|
||||||
more statuses to JIRA_TERMINAL_STATUSES (e.g. "Done", once you
|
just "Created", a ticket drops out of this list the moment it
|
||||||
know your workflow's real terminal status names) so fulfilled
|
moves to anything else, so this naturally stays bounded to
|
||||||
tickets stop being re-checked and drop out of this list.
|
what's genuinely still unprocessed.
|
||||||
"""
|
"""
|
||||||
if not recheck_keys:
|
if not recheck_keys:
|
||||||
return base_jql
|
return base_jql
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ still have an option for CSV uploads"):
|
|||||||
row per line item, customer/address info repeated on each row.
|
row per line item, customer/address info repeated on each row.
|
||||||
- send_order_to_shipstation_api(): calls ShipStation's V2 API
|
- send_order_to_shipstation_api(): calls ShipStation's V2 API
|
||||||
directly (POST /v2/shipments with create_sales_order: true) to
|
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
|
IMPORTANT - please verify the first real send: ShipStation's docs say
|
||||||
automation rules apply tags to orders "when they import based on any
|
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 ""
|
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:
|
def send_order_to_shipstation_api(order: Order) -> dict:
|
||||||
"""
|
"""
|
||||||
Creates the order directly in ShipStation via POST /v2/shipments with
|
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."
|
"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 {}
|
info = order.shipping_info or {}
|
||||||
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
|
if not (info.get("address1") and info.get("city") and info.get("state") and info.get("zip")):
|
||||||
raise ShipStationSendError(
|
raise ShipStationSendError(
|
||||||
@@ -172,6 +198,7 @@ def send_order_to_shipstation_api(order: Order) -> dict:
|
|||||||
{
|
{
|
||||||
"create_sales_order": True,
|
"create_sales_order": True,
|
||||||
"store_id": store_id,
|
"store_id": store_id,
|
||||||
|
"warehouse_id": warehouse_id,
|
||||||
"external_shipment_id": ticket_number,
|
"external_shipment_id": ticket_number,
|
||||||
"shipment_number": ticket_number,
|
"shipment_number": ticket_number,
|
||||||
"ship_to": {
|
"ship_to": {
|
||||||
|
|||||||
+24
-3
@@ -2,15 +2,27 @@
|
|||||||
Status matching helpers.
|
Status matching helpers.
|
||||||
|
|
||||||
Kept separate from companies.py because this is about order lifecycle
|
Kept separate from companies.py because this is about order lifecycle
|
||||||
state (cancelled, terminal-for-recheck-purposes, and later probably
|
state rather than company identity - a different axis of classification
|
||||||
"fulfilled"/"shipped"/etc.) rather than company identity - a different
|
that grows independently.
|
||||||
axis of classification that will grow 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
|
Matching is case-insensitive since JIRA and ShipStation don't
|
||||||
necessarily agree on casing (e.g. "Cancelled" vs "cancelled").
|
necessarily agree on casing (e.g. "Cancelled" vs "cancelled").
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
|
||||||
def parse_status_list(raw: str) -> set[str]:
|
def parse_status_list(raw: str) -> set[str]:
|
||||||
return {s.strip().lower() for s in (raw or "").split(",") if s.strip()}
|
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
|
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]:
|
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
|
From a batch of status changes, return only the ones that just BECAME
|
||||||
|
|||||||
+15
-13
@@ -25,11 +25,10 @@ from PyQt6.QtWidgets import (
|
|||||||
QFileDialog,
|
QFileDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app import config
|
|
||||||
from app.services import SERVICE_REGISTRY
|
from app.services import SERVICE_REGISTRY
|
||||||
from app.services.odoo_export import export_orders_to_csv
|
from app.services.odoo_export import export_orders_to_csv
|
||||||
from app.services.shipstation_send import export_order_to_shipstation_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.settings_dialog import SettingsDialog
|
||||||
from app.ui.widgets.orders_table import OrdersTableView
|
from app.ui.widgets.orders_table import OrdersTableView
|
||||||
from app.ui.widgets.dashboard import DashboardWidget
|
from app.ui.widgets.dashboard import DashboardWidget
|
||||||
@@ -37,7 +36,7 @@ from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
|||||||
from app.workers import (
|
from app.workers import (
|
||||||
FetchOrdersWorker,
|
FetchOrdersWorker,
|
||||||
SendToShipStationWorker,
|
SendToShipStationWorker,
|
||||||
load_active_and_done_orders,
|
load_orders_by_view,
|
||||||
get_dashboard_stats,
|
get_dashboard_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,10 +64,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.dashboard = DashboardWidget()
|
self.dashboard = DashboardWidget()
|
||||||
self.orders_table = OrdersTableView()
|
self.orders_table = OrdersTableView()
|
||||||
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
|
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 = OrdersTableView()
|
||||||
self.done_table.order_double_clicked.connect(self._on_order_double_clicked)
|
self.done_table.order_double_clicked.connect(self._on_order_double_clicked)
|
||||||
self.tabs.addTab(self.dashboard, "Dashboard")
|
self.tabs.addTab(self.dashboard, "Dashboard")
|
||||||
self.tabs.addTab(self.orders_table, "Active Orders")
|
self.tabs.addTab(self.orders_table, "Active Orders")
|
||||||
|
self.tabs.addTab(self.cancelled_table, "Cancelled")
|
||||||
self.tabs.addTab(self.done_table, "Done")
|
self.tabs.addTab(self.done_table, "Done")
|
||||||
layout.addWidget(self.tabs)
|
layout.addWidget(self.tabs)
|
||||||
|
|
||||||
@@ -91,13 +93,13 @@ class MainWindow(QMainWindow):
|
|||||||
toolbar.addSeparator()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
export_action = QAction("Export Visible Orders to Odoo CSV", self)
|
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)
|
export_action.triggered.connect(self._on_export_clicked)
|
||||||
toolbar.addAction(export_action)
|
toolbar.addAction(export_action)
|
||||||
|
|
||||||
toolbar.addSeparator()
|
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.setToolTip("Select a ticket in Active Orders first")
|
||||||
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
||||||
toolbar.addAction(emergency_action)
|
toolbar.addAction(emergency_action)
|
||||||
@@ -186,10 +188,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _notify_new_cancellations(self, status_changes: list) -> None:
|
def _notify_new_cancellations(self, status_changes: list) -> None:
|
||||||
cancelled_statuses = parse_status_list(
|
newly_cancelled = find_new_cancellations(status_changes, get_cancelled_statuses())
|
||||||
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
|
|
||||||
)
|
|
||||||
newly_cancelled = find_new_cancellations(status_changes, cancelled_statuses)
|
|
||||||
if not newly_cancelled:
|
if not newly_cancelled:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -217,7 +216,7 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"Nothing to export",
|
"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
|
return
|
||||||
|
|
||||||
@@ -333,13 +332,15 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _refresh_everything(self) -> None:
|
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.orders_table.set_orders(active_orders)
|
||||||
|
self.cancelled_table.set_orders(cancelled_orders)
|
||||||
self.done_table.set_orders(done_orders)
|
self.done_table.set_orders(done_orders)
|
||||||
self.dashboard.update_stats(get_dashboard_stats())
|
self.dashboard.update_stats(get_dashboard_stats())
|
||||||
if not hasattr(self, "_suppress_ready_status"):
|
if not hasattr(self, "_suppress_ready_status"):
|
||||||
self.status_label.setText(
|
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.
|
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
|
||||||
# - Emailed-label SKU: these tickets won't get tracking numbers pulled via
|
# - Emailed-label SKU: these tickets won't get tracking numbers pulled via
|
||||||
# the normal ShipStation flow - once its handling is defined, it likely
|
# 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
|
# - Pipeline actions ("mark fulfilled manually", "void a label"): add
|
||||||
# toolbar actions gated on table selection.
|
# toolbar actions gated on table selection.
|
||||||
# - If the window grows too much, split each tab's toolbar into its own
|
# - If the window grows too much, split each tab's toolbar into its own
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ Settings dialog.
|
|||||||
Built dynamically from app.config.SETTINGS_SCHEMA, grouped by section
|
Built dynamically from app.config.SETTINGS_SCHEMA, grouped by section
|
||||||
(Database, JIRA, ...). When a new service adds settings to that
|
(Database, JIRA, ...). When a new service adds settings to that
|
||||||
schema, they show up here automatically - no UI changes needed.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,6 +25,9 @@ from PyQt6.QtWidgets import (
|
|||||||
QGroupBox,
|
QGroupBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QScrollArea,
|
||||||
|
QWidget,
|
||||||
|
QApplication,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
@@ -28,7 +37,6 @@ class SettingsDialog(QDialog):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Settings")
|
self.setWindowTitle("Settings")
|
||||||
self.setMinimumWidth(480)
|
|
||||||
|
|
||||||
self._fields: dict[str, QLineEdit] = {}
|
self._fields: dict[str, QLineEdit] = {}
|
||||||
current_values = config.load_settings()
|
current_values = config.load_settings()
|
||||||
@@ -38,11 +46,15 @@ class SettingsDialog(QDialog):
|
|||||||
for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items():
|
for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items():
|
||||||
groups[group].append((key, label, is_secret))
|
groups[group].append((key, label, is_secret))
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
outer_layout = QVBoxLayout(self)
|
||||||
layout.addWidget(
|
outer_layout.addWidget(
|
||||||
QLabel("Changes are saved to your .env file and applied immediately.")
|
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():
|
for group_name, entries in groups.items():
|
||||||
box = QGroupBox(group_name)
|
box = QGroupBox(group_name)
|
||||||
form = QFormLayout(box)
|
form = QFormLayout(box)
|
||||||
@@ -52,7 +64,13 @@ class SettingsDialog(QDialog):
|
|||||||
field.setEchoMode(QLineEdit.EchoMode.Password)
|
field.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
form.addRow(label, field)
|
form.addRow(label, field)
|
||||||
self._fields[key] = 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()
|
button_row = QHBoxLayout()
|
||||||
save_button = QPushButton("Save")
|
save_button = QPushButton("Save")
|
||||||
@@ -62,7 +80,24 @@ class SettingsDialog(QDialog):
|
|||||||
button_row.addStretch()
|
button_row.addStretch()
|
||||||
button_row.addWidget(cancel_button)
|
button_row.addWidget(cancel_button)
|
||||||
button_row.addWidget(save_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:
|
def _on_save(self) -> None:
|
||||||
values = {key: field.text().strip() for key, field in self._fields.items()}
|
values = {key: field.text().strip() for key, field in self._fields.items()}
|
||||||
|
|||||||
+36
-32
@@ -14,12 +14,11 @@ from typing import List, Tuple, TypedDict
|
|||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import config
|
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Order
|
from app.models import Order
|
||||||
from app.schedule import get_cutoff_time, is_past_cutoff
|
from app.schedule import get_cutoff_time, is_past_cutoff
|
||||||
from app.services.base import OrderService, NormalizedOrder
|
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
|
from app.tracking import get_fulfilled_statuses
|
||||||
|
|
||||||
|
|
||||||
@@ -209,63 +208,68 @@ def load_all_orders() -> List[Order]:
|
|||||||
session.close()
|
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
|
Three tabs, allowlist-driven:
|
||||||
eyes on them). Done = fulfilled (Waiting For Return / Device Return
|
- Active: status is in ACTIVE_STATUSES (just "Created" by default) -
|
||||||
Not Needed) - archived out of the working view on purpose, per how
|
the only tickets that represent real work still to do.
|
||||||
the team wants to keep the active list focused on what's at hand.
|
- 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_statuses = get_active_statuses()
|
||||||
active, done = [], []
|
cancelled_statuses = get_cancelled_statuses()
|
||||||
|
active, cancelled, done = [], [], []
|
||||||
for order in orders:
|
for order in orders:
|
||||||
(done if status_in(order.status, fulfilled_statuses) else active).append(order)
|
if status_in(order.status, active_statuses):
|
||||||
return active, done
|
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]]:
|
def load_orders_by_view() -> Tuple[List[Order], List[Order], List[Order]]:
|
||||||
return split_active_and_done(load_all_orders())
|
return split_orders_by_view(load_all_orders())
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard_stats() -> dict:
|
def get_dashboard_stats() -> dict:
|
||||||
"""
|
"""
|
||||||
Counts for the dashboard. "Active Orders" and the company breakdown
|
Counts for the dashboard. "Active Orders" and the company breakdown
|
||||||
reflect only the active workload (fulfilled tickets have moved to
|
reflect only the Active tab (status in ACTIVE_STATUSES) - the actual
|
||||||
the Done pile and don't clutter this). Fulfilled-today and
|
at-hand workload. Cancelled counts the Cancelled tab. Fulfilled-today
|
||||||
past-cutoff-today look across ALL of today's tickets regardless of
|
and past-cutoff-today look across ALL of today's tickets regardless
|
||||||
which pile they're in now, since both are about what happened today.
|
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
|
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
|
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()
|
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()
|
cutoff = get_cutoff_time()
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
|
|
||||||
by_company: dict[str, int] = {}
|
by_company: dict[str, int] = {}
|
||||||
cancelled_count = 0
|
|
||||||
carryover_count = 0
|
carryover_count = 0
|
||||||
tracking_received_count = 0
|
tracking_received_count = 0
|
||||||
|
|
||||||
for order in active_orders:
|
for order in active_orders:
|
||||||
by_company[order.company] = by_company.get(order.company, 0) + 1
|
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:
|
if order.tracking_numbers:
|
||||||
tracking_received_count += 1
|
tracking_received_count += 1
|
||||||
|
|
||||||
still_open = not status_in(order.status, terminal_statuses)
|
|
||||||
created_before_today = bool(
|
created_before_today = bool(
|
||||||
order.source_created_at and order.source_created_at.date() < today
|
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 order.source_created_at.date() == today
|
||||||
and is_past_cutoff(order.source_created_at, cutoff)
|
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
|
carryover_count += 1
|
||||||
|
|
||||||
fulfilled_today_count = sum(
|
fulfilled_today_count = sum(
|
||||||
@@ -294,7 +298,7 @@ def get_dashboard_stats() -> dict:
|
|||||||
return {
|
return {
|
||||||
"total": len(active_orders),
|
"total": len(active_orders),
|
||||||
"by_company": by_company,
|
"by_company": by_company,
|
||||||
"cancelled_count": cancelled_count,
|
"cancelled_count": len(cancelled_orders),
|
||||||
"carryover_count": carryover_count,
|
"carryover_count": carryover_count,
|
||||||
"tracking_received_count": tracking_received_count,
|
"tracking_received_count": tracking_received_count,
|
||||||
"fulfilled_today_count": fulfilled_today_count,
|
"fulfilled_today_count": fulfilled_today_count,
|
||||||
|
|||||||
Reference in New Issue
Block a user