Files
Order-Manager/app/ui/main_window.py
T

362 lines
14 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
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QToolBar,
QStatusBar,
QMessageBox,
QLabel,
QTabWidget,
QFileDialog,
)
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.workers import (
FetchOrdersWorker,
SendToShipStationWorker,
load_orders_by_view,
get_dashboard_stats,
)
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._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)
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
jira_action = QAction("Import from JIRA", self)
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
toolbar.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"))
toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action
toolbar.addSeparator()
export_action = QAction("Export Visible Orders to Odoo CSV", self)
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", self)
emergency_action.setToolTip("Select a ticket in Active Orders first")
emergency_action.triggered.connect(self._on_emergency_send_clicked)
toolbar.addAction(emergency_action)
toolbar.addSeparator()
settings_action = QAction("Settings", self)
settings_action.triggered.connect(self._on_settings_clicked)
toolbar.addAction(settings_action)
refresh_action = QAction("Refresh from Local DB", self)
refresh_action.triggered.connect(self._refresh_everything)
toolbar.addAction(refresh_action)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.")
self.status_bar.addWidget(self.status_label)
# -- actions -----------------------------------------------------------
def _on_settings_clicked(self) -> None:
dialog = SettingsDialog(self)
dialog.exec()
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:
self.status_label.setText(
f"Sending {order.ticket_number or order.external_id} to ShipStation..."
)
worker = SendToShipStationWorker(order)
worker.finished_ok.connect(self._on_emergency_send_ok)
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, result: dict) -> None:
shipment_id = result.get("shipment_id", "?")
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 _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.
# ---------------------------------------------------------------------------