Major crashing bug fixed, program returned to normal operations

This commit is contained in:
2026-09-03 10:03:50 -05:00
parent edd5f2a886
commit a1b438a61f
5 changed files with 432 additions and 94 deletions
+61 -21
View File
@@ -12,9 +12,26 @@ 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,
@@ -36,33 +53,19 @@ from app.serial_suggestions import suggest_serial_fields
class PackTicketDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.order = order
self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}")
self.resize(520, 600)
self.order: Optional[Order] = None
self._field_rows: list[tuple[QLineEdit, QLineEdit]] = [] # (label_edit, value_edit)
layout = QVBoxLayout(self)
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)'}",
]
layout.addWidget(QLabel("\n".join(summary_lines)))
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
layout.addWidget(QLabel("Serial numbers (scan or type; Enter moves to the next field):"))
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
self._fields_widget = QWidget()
self._fields_layout = QFormLayout(self._fields_widget)
scroll_area.setWidget(self._fields_widget)
layout.addWidget(scroll_area, stretch=1)
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()
@@ -75,7 +78,6 @@ class PackTicketDialog(QDialog):
layout.addLayout(add_field_row)
self.packed_checkbox = QCheckBox("Packed / ready to ship")
self.packed_checkbox.setChecked(bool(order.packed))
layout.addWidget(self.packed_checkbox)
button_box = QDialogButtonBox()
@@ -85,6 +87,44 @@ class PackTicketDialog(QDialog):
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:
+139 -61
View File
@@ -5,25 +5,44 @@ 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.
WORKAROUND NOTE: this dialog 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 (atcuf64.dll) corrupting a stack frame inside Qt6Core.dll -
not a bug in this code. Six rewrites of this dialog's contents made no
difference, including one with almost no widgets at all, and neither
did reusing a single persistent instance instead of creating a new one
per ticket - the crash recurred at the exact same offset regardless.
That rules out both "which widgets" and "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/re-enables the parent window -
a different, more involved Windows API sequence than a plain show/hide,
and one more plausible avenue for Bitdefender's hook to misfire on.
This dialog still supports being reused via set_order() regardless,
since keeping construction/destruction out of the hot path is sound
practice independent of whether it turns out to be the actual fix.
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 typing import List, Optional
from PyQt6.QtCore import QLocale
from PyQt6.QtGui import QDoubleValidator
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QTextEdit,
QTableWidget,
QDoubleSpinBox,
QLineEdit,
QPushButton,
QComboBox,
QWidget,
QDialogButtonBox,
)
@@ -35,47 +54,58 @@ CHARGE_EVENT_LABELS = {
"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
DEFAULT_WEIGHT_OZ = "1.00"
DEFAULT_DIMENSION_IN = "1.00"
def _make_number_field(default_text: str) -> QLineEdit:
field = QLineEdit(default_text)
validator = QDoubleValidator(0.0, 9999.0, 2, field)
validator.setLocale(QLocale(QLocale.Language.English, QLocale.Country.UnitedStates))
validator.setNotation(QDoubleValidator.Notation.StandardNotation)
field.setValidator(validator)
field.setMaximumWidth(70)
return field
def _parse_number(text: str) -> float:
try:
return float(text)
except (TypeError, ValueError):
return 0.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)
self.order: Optional[Order] = None
self._package_rows: list[dict] = [] # [{widget, weight, length, width, height}]
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)))
self.summary_label = QLabel()
layout.addWidget(self.summary_label)
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)
self.description_label = QLabel()
self.description_label.setWordWrap(True)
self.description_label.setStyleSheet(
"border: 1px solid palette(mid); padding: 4px; background: palette(base);"
)
layout.addWidget(self.description_label)
layout.addWidget(QLabel("Packages - one row per box:"))
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
layout.addWidget(self.table)
layout.addWidget(QLabel("Packages - one row per box (weight in oz, dimensions in inches):"))
self._packages_container = QWidget()
self._packages_layout = QVBoxLayout(self._packages_container)
self._packages_layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._packages_container)
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)
remove_button = QPushButton("Remove Last Package")
remove_button.clicked.connect(self._remove_last_package_row)
package_buttons.addWidget(add_button)
package_buttons.addWidget(remove_button)
package_buttons.addStretch()
@@ -97,42 +127,90 @@ class ReturnLabelDialog(QDialog):
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.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"Create Return Label - {order.ticket_number or order.external_id}")
self.resize(560, 480)
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', '')}",
]
self.summary_label.setText("\n".join(summary_lines))
self.description_label.setText(order.description or "(no description on this ticket)")
# Clear out any package rows left over from a previous ticket.
# setParent(None) + deleteLater() rather than an immediate delete -
# deferred deletion here is deliberate, letting Qt clean these up
# on its own schedule rather than forcing it synchronously.
for entry in self._package_rows:
entry["widget"].setParent(None)
entry["widget"].deleteLater()
self._package_rows = []
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)
self.charge_event_combo.setCurrentIndex(0)
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 _add_package_row(self) -> None:
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
weight_field = _make_number_field(DEFAULT_WEIGHT_OZ)
length_field = _make_number_field(DEFAULT_DIMENSION_IN)
width_field = _make_number_field(DEFAULT_DIMENSION_IN)
height_field = _make_number_field(DEFAULT_DIMENSION_IN)
for label_text, widget in [
("Weight (oz):", weight_field),
("L (in):", length_field),
("W (in):", width_field),
("H (in):", height_field),
]:
row_layout.addWidget(QLabel(label_text))
row_layout.addWidget(widget)
row_layout.addStretch()
entry = {
"widget": row_widget,
"weight": weight_field,
"length": length_field,
"width": width_field,
"height": height_field,
}
self._package_rows.append(entry)
self._packages_layout.addWidget(row_widget)
def _remove_last_package_row(self) -> None:
if len(self._package_rows) <= 1:
return
entry = self._package_rows.pop()
entry["widget"].setParent(None)
entry["widget"].deleteLater()
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
return [
{
"weight_oz": _parse_number(entry["weight"].text()),
"length": _parse_number(entry["length"].text()),
"width": _parse_number(entry["width"].text()),
"height": _parse_number(entry["height"].text()),
}
for entry in self._package_rows
]
def get_charge_event(self) -> str:
return self.charge_event_combo.currentData()