More email SKU shenanigans, now with more TEST labels!
This commit is contained in:
+192
-22
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user