More email SKU shenanigans, now with more TEST labels!

This commit is contained in:
2026-09-14 11:41:27 -05:00
parent a6f60408ff
commit b5570d39f0
12 changed files with 815 additions and 139 deletions
+192 -22
View File
@@ -16,6 +16,7 @@ import webbrowser
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
@@ -28,6 +29,7 @@ from PyQt6.QtWidgets import (
QDialog,
)
from app import config
from app.external_links import jira_ticket_url, google_maps_search_url
from app.return_labels import is_emailed_label_order
from app.services import SERVICE_REGISTRY
@@ -43,10 +45,12 @@ from app.ui.widgets.pack_ticket_dialog import PackTicketDialog
from app.workers import (
FetchOrdersWorker,
SendToShipStationWorker,
CreateDummyShipmentWorker,
CreateReturnLabelWorker,
load_orders_by_view,
get_dashboard_stats,
mark_shipstation_sent,
save_dummy_outbound_label_id,
save_pack_data,
reset_local_database,
)
@@ -61,7 +65,7 @@ class MainWindow(QMainWindow):
self._workers: dict[str, FetchOrdersWorker] = {}
self._import_actions: dict[str, QAction] = {}
self._send_worker: SendToShipStationWorker | None = None
self._return_label_worker: CreateReturnLabelWorker | None = None
self._return_label_worker: CreateDummyShipmentWorker | CreateReturnLabelWorker | None = None
# Reused, never-destroyed dialog instances (see the WORKAROUND NOTE
# in each dialog's module docstring) - created lazily via
@@ -135,6 +139,34 @@ class MainWindow(QMainWindow):
reset_action.triggered.connect(self._on_reset_database_clicked)
data_menu.addAction(reset_action)
data_menu.addSeparator()
self._test_mode_action = QAction("ShipStation Test Mode", self)
self._test_mode_action.setCheckable(True)
self._test_mode_action.setChecked(config.is_shipstation_test_mode())
self._test_mode_action.setStatusTip(
"Uses your ShipStation test/sandbox API key for every ShipStation action - "
"no real charges, nothing appears in your production account"
)
self._test_mode_action.toggled.connect(self._on_test_mode_toggled)
data_menu.addAction(self._test_mode_action)
list_carriers_action = QAction("List ShipStation Carriers...", self)
list_carriers_action.setStatusTip(
"Shows the carrier IDs actually valid for whichever API key is currently active "
"(test or production) - useful for finding the right carrier_id directly"
)
list_carriers_action.triggered.connect(self._on_list_carriers_clicked)
data_menu.addAction(list_carriers_action)
list_stores_action = QAction("List ShipStation Stores...", self)
list_stores_action.setStatusTip(
"Shows the store IDs actually valid for whichever API key is currently active "
"(test or production)"
)
list_stores_action.triggered.connect(self._on_list_stores_clicked)
data_menu.addAction(list_stores_action)
orders_menu = menu_bar.addMenu("&Orders")
self._pack_ticket_action = QAction("Pack Ticket", self)
@@ -186,11 +218,92 @@ class MainWindow(QMainWindow):
self.status_label = QLabel("Ready.")
self.status_bar.addWidget(self.status_label)
# Permanent (right-aligned) so it's always visible regardless of
# whatever status_label currently says - the whole point is that
# it should be hard to miss whether real charges/labels are in
# play right now.
self._test_mode_indicator = QLabel()
self._test_mode_indicator.setStyleSheet(
"background-color: #b35c00; color: white; padding: 2px 8px; font-weight: bold;"
)
self.status_bar.addPermanentWidget(self._test_mode_indicator)
self._update_test_mode_indicator()
# -- actions -----------------------------------------------------------
def _on_settings_clicked(self) -> None:
dialog = SettingsDialog(self)
dialog.exec()
# Settings could have been edited directly (SHIPSTATION_TEST_MODE
# as raw text) rather than via the menu checkbox - keep both in sync.
self._test_mode_action.blockSignals(True)
self._test_mode_action.setChecked(config.is_shipstation_test_mode())
self._test_mode_action.blockSignals(False)
self._update_test_mode_indicator()
def _on_test_mode_toggled(self, checked: bool) -> None:
config.save_settings({"SHIPSTATION_TEST_MODE": "true" if checked else "false"})
self._update_test_mode_indicator()
def _update_test_mode_indicator(self) -> None:
if config.is_shipstation_test_mode():
self._test_mode_indicator.setText("SHIPSTATION TEST MODE")
self._test_mode_indicator.show()
else:
self._test_mode_indicator.hide()
def _on_list_carriers_clicked(self) -> None:
from app.services.shipstation_send import list_carriers, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
carriers = list_carriers()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list carriers", str(exc))
return
if not carriers:
QMessageBox.information(
self,
f"ShipStation Carriers ({mode})",
f"No carriers are connected to this {mode.lower()} ShipStation account.",
)
return
lines = [f"Carriers visible to your current {mode} API key:", ""]
for carrier in carriers:
nickname = carrier.get("nickname") or "(no nickname)"
lines.append(
f" carrier_id: {carrier.get('carrier_id', '?')} "
f"{carrier.get('friendly_name', '?')} - {nickname}"
)
QMessageBox.information(self, f"ShipStation Carriers ({mode})", "\n".join(lines))
def _on_list_stores_clicked(self) -> None:
from app.services.shipstation_send import list_stores, ShipStationSendError
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
try:
stores = list_stores()
except ShipStationSendError as exc:
QMessageBox.critical(self, "Could not list stores", str(exc))
return
if not stores:
QMessageBox.information(
self,
f"ShipStation Stores ({mode})",
f"No stores are set up in this {mode.lower()} ShipStation account.",
)
return
lines = [f"Stores visible to your current {mode} API key:", ""]
for store in stores:
lines.append(
f" store_id: {store.get('store_id', '?')} "
f"{store.get('store_name', '?')}"
)
QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines))
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
@@ -443,35 +556,92 @@ class MainWindow(QMainWindow):
dialog = self._return_label_dialog
order = dialog.order
packages = dialog.get_packages()
charge_event = dialog.get_charge_event()
if not packages:
QMessageBox.warning(self, "No packages", "Add at least one package first.")
return
ticket_number = order.ticket_number or order.external_id
self.status_label.setText(f"Creating return label for {ticket_number}...")
worker = CreateReturnLabelWorker(order, packages, charge_event)
worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result))
worker.failed.connect(self._on_return_label_failed)
self._return_label_worker = worker # keep a reference so it isn't garbage collected
worker.start()
if order.dummy_outbound_label_id:
# Step 2: a dummy already exists for this ticket - create the
# real return label from it.
packages = dialog.get_packages()
charge_event = dialog.get_charge_event()
if not packages:
QMessageBox.warning(self, "No packages", "Add at least one package first.")
return
self.status_label.setText(f"Creating return label for {ticket_number}...")
worker = CreateReturnLabelWorker(order, order.dummy_outbound_label_id, packages, charge_event)
worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result))
worker.failed.connect(self._on_return_label_failed)
self._return_label_worker = worker # keep a reference so it isn't garbage collected
worker.start()
else:
# Step 1: no dummy yet - create it, then stop and let the user
# verify it in ShipStation before running this again for step 2.
self.status_label.setText(f"Creating dummy shipment for {ticket_number}...")
worker = CreateDummyShipmentWorker(order)
worker.finished_ok.connect(lambda result: self._on_dummy_shipment_ok(ticket_number, result))
worker.failed.connect(self._on_return_label_failed)
self._return_label_worker = worker
worker.start()
def _on_dummy_shipment_ok(self, ticket_number: str, result: dict) -> None:
label_id = result.get("label_id", "?")
save_dummy_outbound_label_id(ticket_number, label_id)
self._refresh_everything()
# Refreshing resets the table model, which clears whatever row was
# selected - re-select the same ticket so clicking Create Return
# Label again immediately proceeds to step 2 rather than hitting
# "no ticket selected".
self.orders_table.select_ticket(ticket_number)
self.status_label.setText(f"Dummy shipment created - {label_id}")
QMessageBox.information(
self,
"Step 1 Complete",
f"Dummy shipment {label_id} created for {ticket_number}.\n\n"
"Go check it in ShipStation now - confirm Order #, Ship From/Store, and everything "
"else looks right. Once you've verified it, click Create Return Label again on this "
"ticket to run step 2 (the actual return label).",
)
def _on_return_label_ok(self, ticket_number: str, result: dict) -> None:
mark_shipstation_sent(ticket_number)
self._refresh_everything()
self.orders_table.select_ticket(ticket_number)
label_id = result.get("label_id", "?")
tracking = result.get("tracking_number", "?")
self.status_label.setText(f"Return label created - {label_id}")
QMessageBox.information(
self,
"Return Label Created",
f"Label {label_id} created (tracking {tracking}).\n\n"
"Next: go to ShipStation's Returns tab, find this label, and use "
"Other Actions -> Send Return Label to email it to the customer through "
"your branded template.",
)
label_status = result.get("status", "unknown")
dummy_id = result.get("_dummy_outbound_label_id", "?")
self.status_label.setText(f"Return label created - {label_id} (status: {label_status})")
if label_status == "processing":
body = (
f"Label {label_id} was accepted but is still processing (tracking {tracking}).\n\n"
"ShipStation says this can take a few minutes to fully complete - if you can't "
"find it yet, wait a bit and check again before assuming something's wrong.\n\n"
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
)
else:
body = (
f"Label {label_id} created, status: {label_status} (tracking {tracking}).\n\n"
"Find it in ShipStation and use Other Actions -> Send Return Label to email "
"it to the customer through your branded template.\n\n"
f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)"
)
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle("Return Label Created")
box.setText(body)
copy_button = None
if tracking and tracking != "?":
copy_button = box.addButton("Copy Tracking Number", QMessageBox.ButtonRole.ActionRole)
box.addButton(QMessageBox.StandardButton.Ok)
box.exec()
# Only ever touches the clipboard if explicitly asked - never
# overwrites it automatically, since it's used constantly for other
# things and clobbering it silently would lose whatever was there.
if copy_button is not None and box.clickedButton() is copy_button:
QApplication.clipboard().setText(tracking)
self.status_label.setText(f"Tracking number {tracking} copied to clipboard.")
def _on_return_label_failed(self, message: str) -> None:
self.status_label.setText("Return label creation failed.")
+39 -6
View File
@@ -27,10 +27,10 @@ from PyQt6.QtWidgets import (
)
from app.models import Order
from app.return_labels import is_emailed_label_order
from app.return_labels import is_emailed_label_order, get_emailed_label_skus
from app.schedule import get_cutoff_time, is_past_cutoff_today
from app.status_rules import get_cancelled_statuses, status_in
from app.ticket_validation import validate_ticket
from app.ticket_validation import validate_ticket, make_validation_context
from app.tracking import get_fulfilled_statuses
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
@@ -83,12 +83,31 @@ class OrdersTableModel(QAbstractTableModel):
self._cancelled_statuses: set[str] = set()
self._fulfilled_statuses: set[str] = set()
self._cutoff_time = None
self._issues_by_id: dict[int, list] = {}
self._is_emailed_label_by_id: dict[int, bool] = {}
def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists and cutoff time each refresh, in case Settings changed.
self._cancelled_statuses = get_cancelled_statuses()
self._fulfilled_statuses = get_fulfilled_statuses()
self._cutoff_time = get_cutoff_time()
# Computed ONCE per refresh here, not per cell paint - both of
# these read Settings from disk internally, and Qt calls data()
# extremely frequently (every cell, every repaint, constantly
# during scrolling) - doing this per-cell instead of per-refresh
# was a severe, real performance bug, not just a minor slowdown.
# validation_context is ALSO fetched once here rather than once
# per order inside validate_ticket() - same fix, applied to the
# cost of computing the cache itself, not just using it.
validation_context = make_validation_context()
self._issues_by_id = {
order.id: validate_ticket(order.skus, order.line_items, **validation_context)
for order in orders
}
emailed_skus = get_emailed_label_skus()
self._is_emailed_label_by_id = {
order.id: is_emailed_label_order(order.skus or [], emailed_skus) for order in orders
}
self.beginResetModel()
self._orders = orders
self.endResetModel()
@@ -117,7 +136,7 @@ class OrdersTableModel(QAbstractTableModel):
if role == Qt.ItemDataRole.ForegroundRole:
if is_cancelled:
return CANCELLED_TEXT_COLOR
if field_name == "issues_display" and validate_ticket(order.skus, order.line_items):
if field_name == "issues_display" and self._issues_by_id.get(order.id):
return ISSUE_TEXT_COLOR
return None
@@ -131,14 +150,14 @@ class OrdersTableModel(QAbstractTableModel):
return None
if role == Qt.ItemDataRole.ToolTipRole and field_name == "issues_display":
issues = validate_ticket(order.skus, order.line_items)
issues = self._issues_by_id.get(order.id, [])
return "\n".join(i.message for i in issues) if issues else None
if role != Qt.ItemDataRole.DisplayRole:
return None
if field_name == "issues_display":
issues = validate_ticket(order.skus, order.line_items)
issues = self._issues_by_id.get(order.id, [])
return f"{WARNING_MARK} ({len(issues)})" if issues else ""
if field_name == "skus_display":
return ", ".join(order.skus or [])
@@ -149,7 +168,7 @@ class OrdersTableModel(QAbstractTableModel):
filled = sum(1 for v in serials.values() if (v or "").strip())
return f"{filled} entered" if filled else ""
if field_name == "return_label_display":
return CHECK_MARK if is_emailed_label_order(order.skus or []) else ""
return CHECK_MARK if self._is_emailed_label_by_id.get(order.id) else ""
if field_name == "outgoing_tracking_display":
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
if field_name == "return_tracking_display":
@@ -281,6 +300,20 @@ class OrdersTableView(QWidget):
source_index = self._proxy_model.mapToSource(indexes[0])
return self._source_model.order_at(source_index.row())
def select_ticket(self, ticket_number: str) -> bool:
"""Re-selects a row by ticket number - a table refresh (set_orders)
clears whatever was selected, since the underlying model resets.
Used after a step in a multi-step action (like the return-label
dummy-then-return flow) so the next step doesn't silently find
nothing selected. Returns whether the ticket was found/selected."""
for row in range(self._proxy_model.rowCount()):
proxy_index = self._proxy_model.index(row, 0)
source_index = self._proxy_model.mapToSource(proxy_index)
if self._source_model.order_at(source_index.row()).ticket_number == ticket_number:
self.table.selectRow(proxy_index.row())
return True
return False
def visible_orders(self) -> List[Order]:
"""Orders currently passing the active filters - used for export."""
result = []
+50 -8
View File
@@ -2,8 +2,17 @@
Return label creation dialog - for the emailed-return-label workflow
(SH007 / OK012). Shows the ticket's description (where staff note what
boxes are needed) and lets them specify however many packages, each
with its own weight/dimensions, replacing the old "always 1x1x1, 1oz"
placeholder with real per-request control.
with its own weight/dimensions.
These packages genuinely reach ShipStation now: a standalone return
label (no linked outbound shipment) turned out to report success while
being invisible in ShipStation's UI - confirmed against the team's own
working manual process, not just the API docs. The fix (in
shipstation_send.py) creates a minimal, cheap dummy outbound label
first and links the real return to it via outbound_label_id, matching
what ShipStation's GUI does automatically when creating a return from
an existing shipment. The dialog itself doesn't need to know about
that - it just collects real packages, same as before.
WORKAROUND NOTE: this dialog crashed the whole process on close,
confirmed via two full crash dumps (identical fault offset both times)
@@ -54,6 +63,7 @@ CHARGE_EVENT_LABELS = {
"on_carrier_acceptance": "On carrier acceptance (label may go unused free)",
}
DEFAULT_WEIGHT_LB = "0.00"
DEFAULT_WEIGHT_OZ = "1.00"
DEFAULT_DIMENSION_IN = "1.00"
@@ -87,6 +97,11 @@ class ReturnLabelDialog(QDialog):
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
self.step_status_label = QLabel()
self.step_status_label.setWordWrap(True)
self.step_status_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.step_status_label)
layout.addWidget(QLabel("Description (box requirements from JIRA):"))
self.description_label = QLabel()
self.description_label.setWordWrap(True)
@@ -120,7 +135,7 @@ class ReturnLabelDialog(QDialog):
button_box = QDialogButtonBox()
self.create_button = button_box.addButton(
"Create Return Label", QDialogButtonBox.ButtonRole.AcceptRole
"Step 1: Create Dummy Shipment", QDialogButtonBox.ButtonRole.AcceptRole
)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
@@ -139,7 +154,7 @@ class ReturnLabelDialog(QDialog):
"""
self.order = order
self.setWindowTitle(f"Create Return Label - {order.ticket_number or order.external_id}")
self.resize(560, 480)
self.resize(560, 520)
info = order.shipping_info or {}
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
@@ -152,6 +167,27 @@ class ReturnLabelDialog(QDialog):
self.summary_label.setText("\n".join(summary_lines))
self.description_label.setText(order.description or "(no description on this ticket)")
# This dialog is used for BOTH steps of the workflow - the button
# (and what clicking it actually does, wired up in main_window.py)
# depends on whether a dummy shipment already exists for this
# ticket. Splitting these apart on purpose, per your request: a
# problem in the dummy step shouldn't be masked by immediately
# attempting the return step too.
if order.dummy_outbound_label_id:
self.step_status_label.setText(
f"Step 1 done - dummy shipment {order.dummy_outbound_label_id} already exists. "
"If you've verified it looks right in ShipStation (Order #, Ship From/Store all "
"populated), click below to create the actual return label from it."
)
self.create_button.setText("Step 2: Create Return Label")
else:
self.step_status_label.setText(
"Step 1: this creates a cheap dummy outbound shipment first (1x1x1in, 1oz). "
"Go verify it in ShipStation before running this again to create the actual "
"return label - that way a problem in either step is easy to isolate."
)
self.create_button.setText("Step 1: Create Dummy Shipment")
# Clear out any package rows left over from a previous ticket.
# setParent(None) + deleteLater() rather than an immediate delete -
# deferred deletion here is deliberate, letting Qt clean these up
@@ -169,13 +205,15 @@ class ReturnLabelDialog(QDialog):
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
weight_field = _make_number_field(DEFAULT_WEIGHT_OZ)
weight_lb_field = _make_number_field(DEFAULT_WEIGHT_LB)
weight_oz_field = _make_number_field(DEFAULT_WEIGHT_OZ)
length_field = _make_number_field(DEFAULT_DIMENSION_IN)
width_field = _make_number_field(DEFAULT_DIMENSION_IN)
height_field = _make_number_field(DEFAULT_DIMENSION_IN)
for label_text, widget in [
("Weight (oz):", weight_field),
("Weight (lb):", weight_lb_field),
("+ (oz):", weight_oz_field),
("L (in):", length_field),
("W (in):", width_field),
("H (in):", height_field),
@@ -186,7 +224,8 @@ class ReturnLabelDialog(QDialog):
entry = {
"widget": row_widget,
"weight": weight_field,
"weight_lb": weight_lb_field,
"weight_oz": weight_oz_field,
"length": length_field,
"width": width_field,
"height": height_field,
@@ -204,7 +243,10 @@ class ReturnLabelDialog(QDialog):
def get_packages(self) -> List[dict]:
return [
{
"weight_oz": _parse_number(entry["weight"].text()),
"weight_oz": (
_parse_number(entry["weight_lb"].text()) * 16
+ _parse_number(entry["weight_oz"].text())
),
"length": _parse_number(entry["length"].text()),
"width": _parse_number(entry["width"].text()),
"height": _parse_number(entry["height"].text()),