More Shipstation integrations(warehouses)
This commit is contained in:
+26
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
+40
-6
@@ -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()
|
||||
|
||||
+7
-5
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+24
-3
@@ -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
|
||||
|
||||
+15
-13
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
+36
-32
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user