More Shipstation integrations(warehouses)

This commit is contained in:
2026-08-31 15:00:37 -05:00
parent 4c172c1fd5
commit 9651b82415
12 changed files with 337 additions and 139 deletions
+26 -13
View File
@@ -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
+48
View File
@@ -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=''
+56 -42
View File
@@ -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
View File
@@ -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
View File
@@ -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)
] ]
+16 -18
View File
@@ -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
+28 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+40 -5
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -11,7 +11,7 @@ from app.ui.main_window import MainWindow
def main() -> int: def main() -> int:
config.ensure_env_file_exists() config.sync_env_with_example()
init_db() init_db()
app = QApplication(sys.argv) app = QApplication(sys.argv)