EMailed label return generation (alpha)
This commit is contained in:
@@ -25,6 +25,7 @@ from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -33,9 +34,11 @@ 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.workers import (
|
||||
FetchOrdersWorker,
|
||||
SendToShipStationWorker,
|
||||
CreateReturnLabelWorker,
|
||||
load_orders_by_view,
|
||||
get_dashboard_stats,
|
||||
mark_shipstation_sent,
|
||||
@@ -52,6 +55,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._build_ui()
|
||||
self._refresh_everything()
|
||||
@@ -106,6 +110,13 @@ class MainWindow(QMainWindow):
|
||||
emergency_action.triggered.connect(self._on_emergency_send_clicked)
|
||||
toolbar.addAction(emergency_action)
|
||||
|
||||
return_label_action = QAction("Create Return Label", self)
|
||||
return_label_action.setToolTip(
|
||||
"For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first"
|
||||
)
|
||||
return_label_action.triggered.connect(self._on_create_return_label_clicked)
|
||||
toolbar.addAction(return_label_action)
|
||||
|
||||
toolbar.addSeparator()
|
||||
|
||||
settings_action = QAction("Settings", self)
|
||||
@@ -339,6 +350,66 @@ class MainWindow(QMainWindow):
|
||||
"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
|
||||
|
||||
dialog = ReturnLabelDialog(order, self)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
def _on_return_label_ok(self, ticket_number: str, result: dict) -> None:
|
||||
mark_shipstation_sent(ticket_number)
|
||||
self._refresh_everything()
|
||||
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.",
|
||||
)
|
||||
|
||||
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_reset_database_clicked(self) -> None:
|
||||
confirm = QMessageBox.warning(
|
||||
self,
|
||||
|
||||
@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
|
||||
from app.models import Order
|
||||
from app.return_labels import is_emailed_label_order
|
||||
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
||||
from app.status_rules import get_cancelled_statuses, status_in
|
||||
from app.tracking import get_fulfilled_statuses
|
||||
@@ -42,6 +43,7 @@ COLUMNS = [
|
||||
("status", "Status"),
|
||||
("skus_display", "SKUs"),
|
||||
("summary", "Summary"),
|
||||
("return_label_display", "Return Label"),
|
||||
("outgoing_tracking_display", "Outgoing Tracking"),
|
||||
("return_tracking_display", "Return Tracking"),
|
||||
("uploaded_display", "Uploaded"),
|
||||
@@ -122,6 +124,8 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
|
||||
if field_name == "skus_display":
|
||||
return ", ".join(order.skus or [])
|
||||
if field_name == "return_label_display":
|
||||
return CHECK_MARK if is_emailed_label_order(order.skus or []) else ""
|
||||
if field_name == "outgoing_tracking_display":
|
||||
return _format_tracking_numbers(order.tracking_numbers, is_return=False)
|
||||
if field_name == "return_tracking_display":
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
After a successful create, the app does NOT email anything - per the
|
||||
team's workflow, that happens from ShipStation itself so it goes out
|
||||
through their branded return-email template.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QTextEdit,
|
||||
QTableWidget,
|
||||
QDoubleSpinBox,
|
||||
QPushButton,
|
||||
QComboBox,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
|
||||
from app.models import Order
|
||||
|
||||
CHARGE_EVENT_LABELS = {
|
||||
"carrier_default": "Carrier default",
|
||||
"on_creation": "On label creation",
|
||||
"on_carrier_acceptance": "On carrier acceptance (label may go unused free)",
|
||||
}
|
||||
|
||||
COLUMNS = ["Weight (oz)", "Length (in)", "Width (in)", "Height (in)"]
|
||||
DEFAULT_WEIGHT_OZ = 1.0
|
||||
DEFAULT_DIMENSION_IN = 1.0
|
||||
|
||||
|
||||
class ReturnLabelDialog(QDialog):
|
||||
def __init__(self, order: Order, parent=None):
|
||||
super().__init__(parent)
|
||||
self.order = order
|
||||
self.setWindowTitle(f"Create Return Label - {order.ticket_number or order.external_id}")
|
||||
self.resize(560, 560)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
info = order.shipping_info or {}
|
||||
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
||||
summary_lines = [
|
||||
f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}",
|
||||
f"Customer: {info.get('name') or '(missing)'}",
|
||||
f"Address: {info.get('address1') or '(missing)'}{address_line2}, "
|
||||
f"{info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}",
|
||||
]
|
||||
layout.addWidget(QLabel("\n".join(summary_lines)))
|
||||
|
||||
layout.addWidget(QLabel("Description (box requirements from JIRA):"))
|
||||
description_box = QTextEdit()
|
||||
description_box.setReadOnly(True)
|
||||
description_box.setPlainText(order.description or "(no description on this ticket)")
|
||||
description_box.setMaximumHeight(100)
|
||||
layout.addWidget(description_box)
|
||||
|
||||
layout.addWidget(QLabel("Packages - one row per box:"))
|
||||
self.table = QTableWidget(0, len(COLUMNS))
|
||||
self.table.setHorizontalHeaderLabels(COLUMNS)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
package_buttons = QHBoxLayout()
|
||||
add_button = QPushButton("Add Package")
|
||||
add_button.clicked.connect(self._add_package_row)
|
||||
remove_button = QPushButton("Remove Selected")
|
||||
remove_button.clicked.connect(self._remove_selected_row)
|
||||
package_buttons.addWidget(add_button)
|
||||
package_buttons.addWidget(remove_button)
|
||||
package_buttons.addStretch()
|
||||
layout.addLayout(package_buttons)
|
||||
|
||||
form = QFormLayout()
|
||||
self.charge_event_combo = QComboBox()
|
||||
for value, label in CHARGE_EVENT_LABELS.items():
|
||||
self.charge_event_combo.addItem(label, userData=value)
|
||||
form.addRow("Charge event:", self.charge_event_combo)
|
||||
layout.addLayout(form)
|
||||
|
||||
button_box = QDialogButtonBox()
|
||||
self.create_button = button_box.addButton(
|
||||
"Create Return Label", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Start with one package pre-filled at your current standard
|
||||
# (1x1x1, 1oz) - fully editable, just a familiar starting point.
|
||||
self._add_package_row()
|
||||
|
||||
def _add_package_row(self) -> None:
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
defaults = [DEFAULT_WEIGHT_OZ, DEFAULT_DIMENSION_IN, DEFAULT_DIMENSION_IN, DEFAULT_DIMENSION_IN]
|
||||
for col, default_value in enumerate(defaults):
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0, 9999)
|
||||
spin.setDecimals(2)
|
||||
spin.setValue(default_value)
|
||||
self.table.setCellWidget(row, col, spin)
|
||||
|
||||
def _remove_selected_row(self) -> None:
|
||||
rows = {index.row() for index in self.table.selectedIndexes()}
|
||||
for row in sorted(rows, reverse=True):
|
||||
self.table.removeRow(row)
|
||||
|
||||
def get_packages(self) -> List[dict]:
|
||||
packages = []
|
||||
for row in range(self.table.rowCount()):
|
||||
weight_spin = self.table.cellWidget(row, 0)
|
||||
length_spin = self.table.cellWidget(row, 1)
|
||||
width_spin = self.table.cellWidget(row, 2)
|
||||
height_spin = self.table.cellWidget(row, 3)
|
||||
packages.append(
|
||||
{
|
||||
"weight_oz": weight_spin.value(),
|
||||
"length": length_spin.value(),
|
||||
"width": width_spin.value(),
|
||||
"height": height_spin.value(),
|
||||
}
|
||||
)
|
||||
return packages
|
||||
|
||||
def get_charge_event(self) -> str:
|
||||
return self.charge_event_combo.currentData()
|
||||
Reference in New Issue
Block a user