Files
Order-Manager/app/ui/widgets/pack_ticket_dialog.py
T

190 lines
7.7 KiB
Python

"""
Pack Ticket dialog - where staff enter serial numbers (mostly via
barcode scanner) and mark a ticket packed/ready to ship.
Barcode scanners act as a keyboard: they type the scanned value and
then send an Enter keystroke automatically. So every field here
connects its Enter/returnPressed signal to jump focus to the next
field - staff scan device after device without touching the mouse or
keyboard in between. This is the actual point of this dialog; get this
wrong and it defeats the "minimal interactions" requirement entirely.
Suggested fields come from app.serial_suggestions, based on keywords in
the ticket's line items - a starting point, not a fixed schema. Staff
can add any custom field the suggestions miss.
WORKAROUND NOTE: this dialog (along with Return Label) crashed the
whole process on close, confirmed via two full crash dumps (identical
fault offset both times) to be caused by Bitdefender Endpoint
Security's Advanced Threat Control corrupting a stack frame inside
Qt6Core.dll - not a bug in this code. Reusing a persistent instance
instead of destroying/recreating it per ticket did NOT resolve it -
the crash recurred at the same offset regardless, ruling out object
destruction timing as the cause. The current mitigation is in
main_window.py: this dialog is shown via show() (non-modal) instead of
exec() (modal), since exec() runs a nested event loop that disables
and re-enables the parent window - a different, more involved Windows
API sequence than a plain show/hide. This dialog still supports being
reused via set_order() regardless, since avoiding unnecessary
construction/destruction is sound practice either way.
"""
from __future__ import annotations
from typing import Optional
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QCheckBox,
QScrollArea,
QWidget,
QDialogButtonBox,
)
from app.models import Order
from app.serial_suggestions import suggest_serial_fields
class PackTicketDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.order: Optional[Order] = None
self._field_rows: list[tuple[QLineEdit, QLineEdit]] = [] # (label_edit, value_edit)
layout = QVBoxLayout(self)
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
layout.addWidget(QLabel("Serial numbers (scan or type; Enter moves to the next field):"))
self._scroll_area = QScrollArea()
self._scroll_area.setWidgetResizable(True)
layout.addWidget(self._scroll_area, stretch=1)
add_field_row = QHBoxLayout()
self.new_field_label_input = QLineEdit()
self.new_field_label_input.setPlaceholderText("Custom field name...")
add_button = QPushButton("Add Field")
add_button.clicked.connect(self._on_add_custom_field_clicked)
self.new_field_label_input.returnPressed.connect(self._on_add_custom_field_clicked)
add_field_row.addWidget(self.new_field_label_input, stretch=1)
add_field_row.addWidget(add_button)
layout.addLayout(add_field_row)
self.packed_checkbox = QCheckBox("Packed / ready to ship")
layout.addWidget(self.packed_checkbox)
button_box = QDialogButtonBox()
button_box.addButton("Save", 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)
self.set_order(order)
def set_order(self, order: Order) -> None:
"""
Re-initializes this dialog for a different ticket, in place -
this is what lets main_window.py reuse a single persistent
instance instead of constructing (and eventually destroying) a
new one per ticket. See the module docstring for why that
matters here specifically.
"""
self.order = order
self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}")
self.resize(520, 600)
info = order.shipping_info or {}
kit_text = ", ".join(
f"{item.get('sku', '')}: {item.get('item_name', '')}" for item in (order.line_items or [])
) or ", ".join(order.skus or [])
summary_lines = [
f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}",
f"Customer: {info.get('name') or '(missing)'}",
f"Kit: {kit_text or '(none)'}",
]
self.summary_label.setText("\n".join(summary_lines))
# Swap in a fresh fields widget rather than trying to clear rows
# out of the existing QFormLayout - simpler, and the old one is
# only deleteLater()'d, not force-destroyed immediately.
old_fields_widget = self._scroll_area.takeWidget()
if old_fields_widget is not None:
old_fields_widget.deleteLater()
self._fields_widget = QWidget()
self._fields_layout = QFormLayout(self._fields_widget)
self._scroll_area.setWidget(self._fields_widget)
self._field_rows = []
self.packed_checkbox.setChecked(bool(order.packed))
self._populate_initial_fields()
def _populate_initial_fields(self) -> None:
existing = dict(self.order.serial_numbers or {})
suggested = suggest_serial_fields(self.order.line_items or [])
# Suggested fields first (in suggestion order), pre-filled with any
# already-saved value so re-opening a partially-packed ticket
# doesn't lose earlier scans. Then any existing fields that aren't
# part of the current suggestion set (e.g. a custom field added
# last time, or a suggestion rule that's since changed).
added_labels: set[str] = set()
for label in suggested:
self._add_field_row(label, existing.get(label, ""))
added_labels.add(label)
for label, value in existing.items():
if label not in added_labels:
self._add_field_row(label, value)
if self._field_rows:
self._field_rows[0][1].setFocus()
def _add_field_row(self, label: str, value: str = "") -> None:
label_edit = QLineEdit(label)
label_edit.setReadOnly(True)
label_edit.setStyleSheet("border: none; background: transparent;")
value_edit = QLineEdit(value)
row_index = len(self._field_rows)
value_edit.returnPressed.connect(lambda: self._focus_next(row_index))
self._fields_layout.addRow(label_edit, value_edit)
self._field_rows.append((label_edit, value_edit))
def _focus_next(self, current_index: int) -> None:
next_index = current_index + 1
if next_index < len(self._field_rows):
self._field_rows[next_index][1].setFocus()
self._field_rows[next_index][1].selectAll()
else:
# Last known field - hand off to the checkbox rather than
# silently submitting, so finishing still takes one deliberate
# action instead of an accidental extra scan closing the dialog.
self.packed_checkbox.setFocus()
def _on_add_custom_field_clicked(self) -> None:
label = self.new_field_label_input.text().strip()
if not label:
return
self._add_field_row(label, "")
self.new_field_label_input.clear()
self._field_rows[-1][1].setFocus()
def get_serial_numbers(self) -> dict[str, str]:
return {
label_edit.text(): value_edit.text()
for label_edit, value_edit in self._field_rows
if value_edit.text().strip()
}
def get_packed(self) -> bool:
return self.packed_checkbox.isChecked()