775 lines
33 KiB
Python
775 lines
33 KiB
Python
"""
|
|
Main window.
|
|
|
|
Deliberately thin: it wires the toolbar, tabs, and dialogs together,
|
|
and delegates real work to app.services (fetching), app.workers
|
|
(saving/loading/stats), and app.services.odoo_export (file export).
|
|
|
|
JIRA import creates/updates ticket rows. "Pull Tracking Numbers"
|
|
(ShipStation) doesn't create anything of its own - it merges tracking
|
|
numbers onto the matching JIRA row by ticket number, since ShipStation
|
|
here is purely a label-generation step for JIRA tickets.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import webbrowser
|
|
|
|
from PyQt6.QtGui import QAction
|
|
from PyQt6.QtWidgets import (
|
|
QApplication,
|
|
QMainWindow,
|
|
QWidget,
|
|
QVBoxLayout,
|
|
QToolBar,
|
|
QStatusBar,
|
|
QMessageBox,
|
|
QLabel,
|
|
QTabWidget,
|
|
QFileDialog,
|
|
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
|
|
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 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
|
|
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
|
from app.ui.widgets.return_label_dialog import ReturnLabelDialog
|
|
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,
|
|
)
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("Order Manager")
|
|
self.resize(1150, 700)
|
|
|
|
self._workers: dict[str, FetchOrdersWorker] = {}
|
|
self._import_actions: dict[str, QAction] = {}
|
|
self._send_worker: SendToShipStationWorker | 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
|
|
# set_order() rather than a fresh instance per ticket.
|
|
self._pack_ticket_dialog: PackTicketDialog | None = None
|
|
self._return_label_dialog: ReturnLabelDialog | None = None
|
|
|
|
self._build_ui()
|
|
self._refresh_everything()
|
|
|
|
# -- UI construction -------------------------------------------------
|
|
|
|
def _build_ui(self) -> None:
|
|
central = QWidget()
|
|
layout = QVBoxLayout(central)
|
|
|
|
self.tabs = QTabWidget()
|
|
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)
|
|
|
|
self.setCentralWidget(central)
|
|
|
|
menu_bar = self.menuBar()
|
|
|
|
file_menu = menu_bar.addMenu("&File")
|
|
settings_action = QAction("Settings...", self)
|
|
settings_action.triggered.connect(self._on_settings_clicked)
|
|
file_menu.addAction(settings_action)
|
|
file_menu.addSeparator()
|
|
exit_action = QAction("Exit", self)
|
|
exit_action.triggered.connect(self.close)
|
|
file_menu.addAction(exit_action)
|
|
|
|
data_menu = menu_bar.addMenu("&Data")
|
|
|
|
jira_action = QAction("Import from JIRA", self)
|
|
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
|
|
data_menu.addAction(jira_action)
|
|
self._import_actions["jira"] = jira_action
|
|
|
|
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
|
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
|
data_menu.addAction(shipstation_action)
|
|
self._import_actions["shipstation"] = shipstation_action
|
|
|
|
data_menu.addSeparator()
|
|
|
|
refresh_action = QAction("Refresh from Local DB", self)
|
|
refresh_action.triggered.connect(self._refresh_everything)
|
|
data_menu.addAction(refresh_action)
|
|
|
|
export_action = QAction("Export Visible Orders to Odoo CSV...", self)
|
|
export_action.setStatusTip("Exports from whichever tab is currently open")
|
|
export_action.triggered.connect(self._on_export_clicked)
|
|
data_menu.addAction(export_action)
|
|
|
|
data_menu.addSeparator()
|
|
|
|
reset_action = QAction("Reset Local Database...", self)
|
|
reset_action.setStatusTip("Wipes the local cache - re-import from JIRA afterward")
|
|
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)
|
|
self._pack_ticket_action.setStatusTip(
|
|
"Enter serial numbers and mark packed - select a ticket first"
|
|
)
|
|
self._pack_ticket_action.triggered.connect(self._on_pack_ticket_clicked)
|
|
orders_menu.addAction(self._pack_ticket_action)
|
|
|
|
emergency_action = QAction("Send to ShipStation", self)
|
|
emergency_action.setStatusTip("Select a ticket in Active Orders first")
|
|
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
|
orders_menu.addAction(emergency_action)
|
|
|
|
self._return_label_action = QAction("Create Return Label", self)
|
|
self._return_label_action.setStatusTip(
|
|
"For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first"
|
|
)
|
|
self._return_label_action.triggered.connect(self._on_create_return_label_clicked)
|
|
orders_menu.addAction(self._return_label_action)
|
|
|
|
orders_menu.addSeparator()
|
|
|
|
view_jira_action = QAction("View in JIRA", self)
|
|
view_jira_action.setStatusTip("Opens the selected ticket in JIRA")
|
|
view_jira_action.triggered.connect(self._on_view_in_jira_clicked)
|
|
orders_menu.addAction(view_jira_action)
|
|
|
|
lookup_address_action = QAction("Look Up Address on Google Maps", self)
|
|
lookup_address_action.setStatusTip(
|
|
"Opens the selected ticket's shipping address in Google Maps"
|
|
)
|
|
lookup_address_action.triggered.connect(self._on_lookup_address_clicked)
|
|
orders_menu.addAction(lookup_address_action)
|
|
|
|
# Quick-access toolbar for the highest-frequency actions - everything
|
|
# here is also in the menus above (same QAction objects, so there's
|
|
# nothing to keep in sync); this is just a shortcut for the few used
|
|
# constantly enough to want one click instead of a menu dropdown.
|
|
quick_toolbar = QToolBar("Quick Actions")
|
|
quick_toolbar.setMovable(False)
|
|
self.addToolBar(quick_toolbar)
|
|
quick_toolbar.addAction(self._import_actions["jira"])
|
|
quick_toolbar.addAction(self._pack_ticket_action)
|
|
quick_toolbar.addAction(self._return_label_action)
|
|
|
|
self.status_bar = QStatusBar()
|
|
self.setStatusBar(self.status_bar)
|
|
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)
|
|
dialog.exec()
|
|
|
|
def _on_import_clicked(self, service_name: str) -> None:
|
|
service_cls = SERVICE_REGISTRY[service_name]
|
|
service = service_cls()
|
|
|
|
if not service.is_configured():
|
|
QMessageBox.warning(
|
|
self,
|
|
f"{service_name.title()} not configured",
|
|
f"Fill in the {service_name.title()} settings first, then try again.",
|
|
)
|
|
return
|
|
|
|
action = self._import_actions[service_name]
|
|
action.setEnabled(False)
|
|
if service_name == "jira":
|
|
self.status_label.setText("Importing orders from JIRA...")
|
|
else:
|
|
self.status_label.setText("Pulling tracking numbers from ShipStation...")
|
|
|
|
worker = FetchOrdersWorker(service)
|
|
worker.finished_ok.connect(
|
|
lambda result: self._on_import_finished(service_name, result)
|
|
)
|
|
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
|
|
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
|
|
worker.start()
|
|
|
|
def _on_import_finished(self, service_name: str, result: dict) -> None:
|
|
self._import_actions[service_name].setEnabled(True)
|
|
self._refresh_everything()
|
|
|
|
if service_name == "jira":
|
|
self.status_label.setText(
|
|
f"JIRA import complete: {result['new_count']} new, "
|
|
f"{result['updated_count']} updated."
|
|
)
|
|
self._notify_new_cancellations(result["status_changes"])
|
|
else:
|
|
self.status_label.setText(
|
|
f"Tracking pull complete: {result['enriched_count']} ticket(s) updated with "
|
|
f"tracking numbers."
|
|
)
|
|
self._notify_unmatched_tracking(result["unmatched_tracking_tickets"])
|
|
|
|
def _notify_unmatched_tracking(self, unmatched_tickets: list) -> None:
|
|
if not unmatched_tickets:
|
|
return
|
|
lines = unmatched_tickets[:15]
|
|
if len(unmatched_tickets) > 15:
|
|
lines.append(f"...and {len(unmatched_tickets) - 15} more")
|
|
QMessageBox.information(
|
|
self,
|
|
"Some tracking numbers didn't match a known ticket",
|
|
"ShipStation had tracking numbers for these ticket numbers, but no matching "
|
|
"JIRA ticket was found locally (maybe it hasn't been imported yet):\n\n"
|
|
+ "\n".join(lines),
|
|
)
|
|
|
|
def _notify_new_cancellations(self, status_changes: list) -> None:
|
|
newly_cancelled = find_new_cancellations(status_changes, get_cancelled_statuses())
|
|
if not newly_cancelled:
|
|
return
|
|
|
|
lines = [
|
|
f"{c['ticket_number']}: {c['old_status']} -> {c['new_status']}"
|
|
for c in newly_cancelled[:15]
|
|
]
|
|
if len(newly_cancelled) > 15:
|
|
lines.append(f"...and {len(newly_cancelled) - 15} more")
|
|
|
|
QMessageBox.warning(
|
|
self,
|
|
"Ticket(s) cancelled",
|
|
f"{len(newly_cancelled)} ticket(s) were just cancelled:\n\n" + "\n".join(lines),
|
|
)
|
|
|
|
def _on_import_failed(self, service_name: str, message: str) -> None:
|
|
self._import_actions[service_name].setEnabled(True)
|
|
self.status_label.setText(f"{service_name.title()} import failed.")
|
|
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
|
|
|
|
def _on_export_clicked(self) -> None:
|
|
current_tab = self.tabs.currentWidget()
|
|
if not isinstance(current_tab, OrdersTableView):
|
|
QMessageBox.information(
|
|
self,
|
|
"Nothing to export",
|
|
"Switch to Active Orders, Cancelled, or Done first, then export.",
|
|
)
|
|
return
|
|
|
|
orders = current_tab.visible_orders()
|
|
if not orders:
|
|
QMessageBox.information(
|
|
self, "Nothing to export", "No orders match the current filters."
|
|
)
|
|
return
|
|
|
|
filepath, _ = QFileDialog.getSaveFileName(
|
|
self, "Export to Odoo CSV", "odoo_orders_export.csv", "CSV Files (*.csv)"
|
|
)
|
|
if not filepath:
|
|
return
|
|
|
|
try:
|
|
count = export_orders_to_csv(orders, filepath)
|
|
except OSError as exc:
|
|
QMessageBox.critical(self, "Export failed", str(exc))
|
|
return
|
|
|
|
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
|
|
|
|
def _on_emergency_send_clicked(self) -> None:
|
|
order = self.orders_table.selected_order()
|
|
if order is None:
|
|
QMessageBox.information(
|
|
self,
|
|
"No ticket selected",
|
|
"Select a ticket in Active Orders first, then click Send to ShipStation.",
|
|
)
|
|
return
|
|
|
|
info = order.shipping_info or {}
|
|
line_items = order.line_items or []
|
|
skus_display = ", ".join(item.get("sku", "") for item in line_items) or (
|
|
", ".join(order.skus or []) or "(none)"
|
|
)
|
|
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
|
|
|
summary_lines = [
|
|
f"Ticket: {order.ticket_number or order.external_id}",
|
|
f"Company: {order.company}",
|
|
f"Name: {info.get('name') or '(missing)'}",
|
|
f"Address: {info.get('address1') or '(missing)'}{address_line2}",
|
|
f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}",
|
|
f"SKUs: {skus_display}",
|
|
]
|
|
|
|
box = QMessageBox(self)
|
|
box.setIcon(QMessageBox.Icon.Warning)
|
|
box.setWindowTitle("Send to ShipStation")
|
|
box.setText("Review before sending - this creates a real order in ShipStation.")
|
|
box.setInformativeText("\n".join(summary_lines))
|
|
api_button = box.addButton("Send via API Now", QMessageBox.ButtonRole.AcceptRole)
|
|
csv_button = box.addButton("Export CSV Row...", QMessageBox.ButtonRole.ActionRole)
|
|
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
|
|
box.exec()
|
|
|
|
clicked = box.clickedButton()
|
|
if clicked is api_button:
|
|
self._send_via_api(order)
|
|
elif clicked is csv_button:
|
|
self._send_via_csv(order)
|
|
|
|
def _send_via_api(self, order) -> None:
|
|
ticket_number = order.ticket_number or order.external_id
|
|
self.status_label.setText(f"Sending {ticket_number} to ShipStation...")
|
|
worker = SendToShipStationWorker(order)
|
|
worker.finished_ok.connect(lambda result: self._on_emergency_send_ok(ticket_number, result))
|
|
worker.failed.connect(self._on_emergency_send_failed)
|
|
self._send_worker = worker # keep a reference so it isn't garbage collected
|
|
worker.start()
|
|
|
|
def _on_emergency_send_ok(self, ticket_number: str, result: dict) -> None:
|
|
shipment_id = result.get("shipment_id", "?")
|
|
mark_shipstation_sent(ticket_number)
|
|
self._refresh_everything()
|
|
self.status_label.setText(f"Sent to ShipStation - shipment {shipment_id} created.")
|
|
QMessageBox.information(
|
|
self,
|
|
"Sent to ShipStation",
|
|
f"Order created in ShipStation (shipment {shipment_id}).\n\n"
|
|
"This is a live order - please double-check it in ShipStation to confirm "
|
|
"automation packed and priced it correctly, especially the first few times "
|
|
"you use this.",
|
|
)
|
|
|
|
def _on_emergency_send_failed(self, message: str) -> None:
|
|
self.status_label.setText("Send to ShipStation failed.")
|
|
QMessageBox.critical(self, "Send to ShipStation failed", message)
|
|
|
|
def _send_via_csv(self, order) -> None:
|
|
default_name = f"shipstation_{order.ticket_number or order.external_id}.csv"
|
|
filepath, _ = QFileDialog.getSaveFileName(
|
|
self, "Export Ticket to ShipStation CSV", default_name, "CSV Files (*.csv)"
|
|
)
|
|
if not filepath:
|
|
return
|
|
|
|
try:
|
|
count = export_order_to_shipstation_csv([order], filepath)
|
|
except OSError as exc:
|
|
QMessageBox.critical(self, "Export failed", str(exc))
|
|
return
|
|
|
|
self.status_label.setText(f"Exported {count} row(s) to {filepath}")
|
|
QMessageBox.information(
|
|
self,
|
|
"Exported",
|
|
f"Wrote {count} row(s) to:\n{filepath}\n\n"
|
|
"Upload this into ShipStation's Import Orders wizard.",
|
|
)
|
|
|
|
def _on_create_return_label_clicked(self) -> None:
|
|
order = self.orders_table.selected_order()
|
|
if order is None:
|
|
QMessageBox.information(
|
|
self,
|
|
"No ticket selected",
|
|
"Select a ticket in Active Orders first, then click Create Return Label.",
|
|
)
|
|
return
|
|
|
|
if not is_emailed_label_order(order.skus or []):
|
|
proceed = QMessageBox.question(
|
|
self,
|
|
"Not a return-label ticket",
|
|
f"This ticket's SKU(s) ({', '.join(order.skus or []) or 'none'}) don't match "
|
|
"the configured emailed-label SKUs. Continue anyway?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No,
|
|
)
|
|
if proceed != QMessageBox.StandardButton.Yes:
|
|
return
|
|
|
|
if self._return_label_dialog is None:
|
|
self._return_label_dialog = ReturnLabelDialog(order, self)
|
|
self._return_label_dialog.finished.connect(self._on_return_label_dialog_finished)
|
|
else:
|
|
self._return_label_dialog.set_order(order)
|
|
|
|
# Non-modal on purpose - see the module docstring in
|
|
# return_label_dialog.py. Disable the action while it's open so a
|
|
# second click can't call set_order() on it mid-edit and silently
|
|
# overwrite whatever the user is in the middle of entering.
|
|
self._return_label_action.setEnabled(False)
|
|
self._return_label_dialog.show()
|
|
self._return_label_dialog.raise_()
|
|
self._return_label_dialog.activateWindow()
|
|
|
|
def _on_return_label_dialog_finished(self, result: int) -> None:
|
|
self._return_label_action.setEnabled(True)
|
|
if result != QDialog.DialogCode.Accepted:
|
|
return
|
|
|
|
dialog = self._return_label_dialog
|
|
order = dialog.order
|
|
ticket_number = order.ticket_number or order.external_id
|
|
|
|
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", "?")
|
|
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.")
|
|
QMessageBox.critical(self, "Return label creation failed", message)
|
|
|
|
def _on_pack_ticket_clicked(self) -> None:
|
|
order = self.orders_table.selected_order()
|
|
if order is None:
|
|
QMessageBox.information(
|
|
self,
|
|
"No ticket selected",
|
|
"Select a ticket in Active Orders first, then click Pack Ticket.",
|
|
)
|
|
return
|
|
|
|
if self._pack_ticket_dialog is None:
|
|
self._pack_ticket_dialog = PackTicketDialog(order, self)
|
|
self._pack_ticket_dialog.finished.connect(self._on_pack_ticket_dialog_finished)
|
|
else:
|
|
self._pack_ticket_dialog.set_order(order)
|
|
|
|
# Non-modal on purpose - see the module docstring in
|
|
# pack_ticket_dialog.py. Disable the action while it's open so a
|
|
# second click can't call set_order() on it mid-edit and silently
|
|
# overwrite whatever the user is in the middle of entering/scanning.
|
|
self._pack_ticket_action.setEnabled(False)
|
|
self._pack_ticket_dialog.show()
|
|
self._pack_ticket_dialog.raise_()
|
|
self._pack_ticket_dialog.activateWindow()
|
|
|
|
def _on_pack_ticket_dialog_finished(self, result: int) -> None:
|
|
self._pack_ticket_action.setEnabled(True)
|
|
if result != QDialog.DialogCode.Accepted:
|
|
return
|
|
|
|
dialog = self._pack_ticket_dialog
|
|
order = dialog.order
|
|
ticket_number = order.ticket_number or order.external_id
|
|
save_pack_data(ticket_number, dialog.get_serial_numbers(), dialog.get_packed())
|
|
self._refresh_everything()
|
|
self.status_label.setText(f"Saved pack data for {ticket_number}.")
|
|
|
|
def _currently_selected_order(self):
|
|
"""Unlike orders_table.selected_order() (Active Orders specifically),
|
|
this checks whichever tab is actually showing - View in JIRA and
|
|
Look Up Address are read-only lookups that make just as much sense
|
|
for a Cancelled or Done ticket as an Active one."""
|
|
current_widget = self.tabs.currentWidget()
|
|
if isinstance(current_widget, OrdersTableView):
|
|
return current_widget.selected_order()
|
|
return None
|
|
|
|
def _on_view_in_jira_clicked(self) -> None:
|
|
order = self._currently_selected_order()
|
|
if order is None:
|
|
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
|
|
return
|
|
url = jira_ticket_url(order.ticket_number)
|
|
if url is None:
|
|
QMessageBox.warning(
|
|
self,
|
|
"JIRA URL not set",
|
|
"Add your JIRA URL in Settings first (or this ticket has no ticket number).",
|
|
)
|
|
return
|
|
webbrowser.open(url)
|
|
|
|
def _on_lookup_address_clicked(self) -> None:
|
|
order = self._currently_selected_order()
|
|
if order is None:
|
|
QMessageBox.information(self, "No ticket selected", "Select a ticket first.")
|
|
return
|
|
url = google_maps_search_url(order.shipping_info)
|
|
if url is None:
|
|
QMessageBox.information(self, "No address", "This ticket has no address on file.")
|
|
return
|
|
webbrowser.open(url)
|
|
|
|
def _on_reset_database_clicked(self) -> None:
|
|
confirm = QMessageBox.warning(
|
|
self,
|
|
"Reset Local Database",
|
|
"This clears every locally cached order, INCLUDING any serial numbers "
|
|
"and packed status your team has already entered on the Pack Ticket "
|
|
"dialog - that data doesn't come from JIRA, so a reset does not bring "
|
|
"it back. JIRA itself is unaffected either way.\n\n"
|
|
"You'll need to click Import from JIRA afterward to repopulate, and "
|
|
"Pull Tracking Numbers again if you rely on today's already-pulled "
|
|
"tracking data.\n\n"
|
|
"Continue?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No,
|
|
)
|
|
if confirm != QMessageBox.StandardButton.Yes:
|
|
return
|
|
|
|
count = reset_local_database()
|
|
self._refresh_everything()
|
|
self.status_label.setText(
|
|
f"Local database reset - {count} order(s) cleared. Run Import from JIRA to repopulate."
|
|
)
|
|
|
|
def _refresh_everything(self) -> None:
|
|
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, {len(cancelled_orders)} cancelled, "
|
|
f"{len(done_orders)} done."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Future growth notes:
|
|
#
|
|
# - Odoo push API (once ready): add app/services/odoo_service.py with a
|
|
# push_orders(orders) method, wire a new toolbar action similarly to
|
|
# _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 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
|
|
# QToolBar shown only while that tab is active.
|
|
# ---------------------------------------------------------------------------
|