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
+40 -6
View File
@@ -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
View File
@@ -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)
]
+16 -18
View File
@@ -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
+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.
- 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
View File
@@ -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
View File
@@ -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
+40 -5
View File
@@ -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
View File
@@ -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,