Email procedure (beta), WorkFlow update

This commit is contained in:
2026-09-02 11:44:54 -05:00
parent 826a70bdf6
commit edd5f2a886
12 changed files with 450 additions and 8 deletions
+11
View File
@@ -120,3 +120,14 @@ OAKSTREET_RETURN_CITY=Lewisville
OAKSTREET_RETURN_STATE=TX OAKSTREET_RETURN_STATE=TX
OAKSTREET_RETURN_ZIP=75067 OAKSTREET_RETURN_ZIP=75067
# --- Packing ---
# Serial-number fields to suggest per device keyword found in a ticket's line
# items, as a starting point in the Pack Ticket dialog (staff can always add
# a custom field for anything not matched here). Format:
# keyword:Field One|Field Two,keyword2:Field Three
DEVICE_FIELD_SUGGESTIONS=laptop:Laptop Serial Number|Laptop Asset Tag,optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,desktop:Desktop Serial Number|Desktop Asset Tag,phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,spiro:Spiro Serial Number|Spiro Asset Tag,apc:UPS/APC Serial Number|UPS/APC Asset Tag
# Maps a ShipStation service code (from the outbound label) to your team's
# term for the Shipping Method column - confirmed against your real usage.
SHIPPING_METHOD_LABELS=ups_next_day_air:Priority Overnight,ups_2nd_day_air:Two Day,ups_ground:Ground
+46
View File
@@ -150,6 +150,52 @@ that doesn't go through this shipment/label flow at all - those
tickets just won't have tracking numbers pulled, which is expected for tickets just won't have tracking numbers pulled, which is expected for
now, not a bug. Flag it when you're ready to handle it. now, not a bug. Flag it when you're ready to handle it.
## Packing: serial numbers and marking Done
Reflects your actual workflow: staff enter serial numbers (mostly via
**barcode scanner**) and mark a ticket packed as devices go into the
box - this is the beginning of what eventually becomes the Ship Sheet,
built directly into the app instead of a separate spreadsheet.
Select a ticket and click **Pack Ticket**:
- **Suggested fields, not a fixed schema.** Device types and their
field sets vary a lot by company and by kit (confirmed against your
real Ship Sheet - Oak Street tracks OptiPlex/Laptop/Phone, Signify
tracks iPad/Spiro/Laptop/Phone x2), so hard-coding columns per device
type would fight the "versatile" requirement. Instead, fields are
suggested from keywords in the ticket's line items
(`DEVICE_FIELD_SUGGESTIONS`) - a starting point staff can always
extend with a custom field. A "Shipping - Return Label X" line item
is deliberately excluded from matching (tested against this - it
describes a label deliverable, not an actual second device).
- **Built for the scanner, not around it.** Each field's Enter key
(which a scanner sends automatically after scanning) jumps focus to
the next field - scan straight through a device list with zero mouse
clicks. After the last field, focus lands on the **Packed** checkbox
rather than auto-submitting, so finishing still takes one deliberate
action.
- **Reopening a partially-packed ticket** preserves whatever was
already scanned and still shows the current suggestions for what's
left - tested explicitly.
- This data is **staff-entered, not sourced from JIRA**, and a JIRA
re-import never touches it - confirmed the update path doesn't
reference `serial_numbers`/`packed` at all. It's exactly the data
the eventual end-of-day JIRA push will need, whenever that gets
built.
**Assignee** (who's working the ticket, from JIRA) and **Shipping
Method** (the outbound label's service, from the ShipStation tracking
pull - `ups_ground` -> "Ground", etc. via `SHIPPING_METHOD_LABELS`,
confirmed against ShipStation's real UPS service codes) are also
pulled in now, matching two more Ship Sheet columns.
**One consequence worth knowing:** since packing data doesn't come
from JIRA, **Reset Local Database now also clears it** - the warning
dialog says so explicitly. Before this update, a reset was always
harmless (just a rebuildable cache); now it isn't, for this one kind
of data.
## Emergency: Send to ShipStation ## Emergency: Send to ShipStation
For the rare case a ticket needs to skip the normal daily batch. Select For the rare case a ticket needs to skip the normal daily batch. Select
+26
View File
@@ -91,6 +91,17 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
"Return Labels", "Return Labels",
False, False,
), ),
"DEVICE_FIELD_SUGGESTIONS": (
"Serial-number fields to suggest per device keyword "
"(keyword:Field One|Field Two,keyword2:Field Three)",
"Packing",
False,
),
"SHIPPING_METHOD_LABELS": (
"ShipStation service code -> your term (e.g. ups_ground:Ground)",
"Packing",
False,
),
"SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False), "SIGNIFY_RETURN_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False),
"SIGNIFY_RETURN_SERVICE_CODE": ( "SIGNIFY_RETURN_SERVICE_CODE": (
"Signify Return: Service code (e.g. ups_ground)", "Signify Return: Service code (e.g. ups_ground)",
@@ -160,6 +171,21 @@ DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
DEFAULT_ACTIVE_STATUSES = "Created" DEFAULT_ACTIVE_STATUSES = "Created"
DEFAULT_CANCELLED_STATUSES = "Cancelled" DEFAULT_CANCELLED_STATUSES = "Cancelled"
DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012" DEFAULT_EMAILED_LABEL_SKUS = "SH007,OK012"
DEFAULT_DEVICE_FIELD_SUGGESTIONS = (
"laptop:Laptop Serial Number|Laptop Asset Tag,"
"optiplex:OptiPlex Serial Number|OptiPlex Asset Tag,"
"desktop:Desktop Serial Number|Desktop Asset Tag,"
"phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,"
"ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,"
"spiro:Spiro Serial Number|Spiro Asset Tag,"
"apc:UPS/APC Serial Number|UPS/APC Asset Tag"
)
# Confirmed against ShipStation's own UPS service code reference, not guessed.
DEFAULT_SHIPPING_METHOD_LABELS = (
"ups_next_day_air:Priority Overnight,"
"ups_2nd_day_air:Two Day,"
"ups_ground:Ground"
)
DEFAULT_RETURN_CHARGE_EVENT = "carrier_default" DEFAULT_RETURN_CHARGE_EVENT = "carrier_default"
+27 -1
View File
@@ -65,6 +65,9 @@ class Order(Base):
# Display name of whoever created the JIRA ticket. # Display name of whoever created the JIRA ticket.
creator = Column(String(200), nullable=True) creator = Column(String(200), nullable=True)
# Display name of whoever the ticket is assigned to (who's working it) -
# distinct from creator (who opened it).
assignee = Column(String(200), nullable=True)
# Plain-text version of the JIRA ticket's Description field (parsed # Plain-text version of the JIRA ticket's Description field (parsed
# from Atlassian Document Format - see app/adf.py). Mainly useful for # from Atlassian Document Format - see app/adf.py). Mainly useful for
@@ -77,6 +80,26 @@ class Order(Base):
# "is_return": false}]. Populated by the "Pull Tracking Numbers" # "is_return": false}]. Populated by the "Pull Tracking Numbers"
# action, separately from the JIRA import. # action, separately from the JIRA import.
tracking_numbers = Column(JSON, nullable=True) tracking_numbers = Column(JSON, nullable=True)
# Service level used for the outbound label (e.g. "Priority Overnight",
# "Ground") - read off the ShipStation label during the same pull that
# gets tracking numbers, not something JIRA knows about.
shipping_method = Column(String(100), nullable=True)
# Freeform {label: value} pairs, e.g. {"Laptop Serial Number": "6NJLP54",
# "Laptop Asset Tag": "30882"} - entered by staff (often via barcode
# scanner) as devices are packed. Deliberately not fixed columns per
# device type: which fields are relevant varies by company and by kit,
# and hard-coding that would fight the "versatile" requirement. See
# app/serial_suggestions.py for how likely fields get suggested from
# the ticket's line items.
serial_numbers = Column(JSON, nullable=True)
# Staff-set "packed and ready to ship" flag - the Ship Sheet's "Done"
# column. Deliberately separate from JIRA's own status: a ticket can be
# packed=True while still sitting in JIRA as "Created", waiting on the
# (currently manual, eventually automated) end-of-day push that sets
# the real JIRA status.
packed = Column(Boolean, nullable=False, default=False)
packed_at = Column(DateTime, nullable=True)
summary = Column(String(500), nullable=False, default="") summary = Column(String(500), nullable=False, default="")
status = Column(String(100), nullable=False, default="") status = Column(String(100), nullable=False, default="")
@@ -101,7 +124,10 @@ class Order(Base):
# way to confirm that manual step happened. # way to confirm that manual step happened.
shipstation_sent_at = Column(DateTime, nullable=True) shipstation_sent_at = Column(DateTime, nullable=True)
# Downstream pipeline flags - useful once Odoo export is fully wired up # Legacy/unused - kept only because SQLite doesn't make dropping a
# column free and nothing reads this. Don't confuse with `packed`
# above (the real "Done" flag) or the JIRA-status-based fulfilled
# concept used for the Done tab/dashboard - this column predates both.
fulfilled = Column(Boolean, nullable=False, default=False) fulfilled = Column(Boolean, nullable=False, default=False)
# Full original payload from JIRA, for anything not modeled explicitly # Full original payload from JIRA, for anything not modeled explicitly
+70
View File
@@ -0,0 +1,70 @@
"""
Suggests which serial-number fields are probably relevant for a ticket,
based on keywords in its line items (e.g. an item name containing
"Laptop" suggests Laptop Serial Number + Laptop Asset Tag). This is a
starting point staff can edit, not a hard schema - device types and
their field sets vary by company and grow over time, so the mapping
lives in a setting (DEVICE_FIELD_SUGGESTIONS), not code.
If a ticket has two line items that both match "phone" (e.g. a
termination processing two lines), the second gets numbered - "Phone
IMEI (2)" - rather than colliding with the first.
"""
from __future__ import annotations
from typing import List
from app import config
def parse_device_field_suggestions(raw: str) -> dict[str, list[str]]:
"""'keyword:Field One|Field Two,keyword2:Field Three' -> {keyword: [fields]}"""
mapping: dict[str, list[str]] = {}
for group in (raw or "").split(","):
group = group.strip()
if not group or ":" not in group:
continue
keyword, _, fields_str = group.partition(":")
keyword = keyword.strip().lower()
fields = [f.strip() for f in fields_str.split("|") if f.strip()]
if keyword and fields:
mapping[keyword] = fields
return mapping
def get_device_field_suggestions() -> dict[str, list[str]]:
return parse_device_field_suggestions(
config.get("DEVICE_FIELD_SUGGESTIONS", config.DEFAULT_DEVICE_FIELD_SUGGESTIONS)
)
def suggest_serial_fields(line_items: List[dict]) -> List[str]:
"""
line_items: [{"sku": "OK401", "item_name": "Laptop - Dell Latitude..."}]
Returns suggested field labels, in the order their matching keyword
was found, numbered on repeat matches (e.g. two phone line items ->
"Phone IMEI (1)", "Phone IMEI (2)", ...).
Line items describing a shipping/return-label deliverable (e.g.
"Shipping - Return Label Laptop") are skipped - they name a device
type in passing but aren't an actual physical device to serialize,
and would otherwise double-count against the real device line item.
"""
keyword_map = get_device_field_suggestions()
suggestions: List[str] = []
match_counts: dict[str, int] = {}
for item in line_items or []:
item_name = item.get("item_name", "") or ""
if item_name.strip().lower().startswith("shipping"):
continue
haystack = f"{item_name} {item.get('sku', '')}".lower()
for keyword, fields in keyword_map.items():
if keyword in haystack:
match_counts[keyword] = match_counts.get(keyword, 0) + 1
occurrence = match_counts[keyword]
suffix = f" ({occurrence})" if occurrence > 1 else ""
suggestions.extend(f"{field}{suffix}" for field in fields)
return suggestions
+2
View File
@@ -25,8 +25,10 @@ class NormalizedOrder(TypedDict):
line_items: List[dict] line_items: List[dict]
shipping_info: dict shipping_info: dict
creator: Optional[str] creator: Optional[str]
assignee: Optional[str]
description: Optional[str] description: Optional[str]
tracking_numbers: List[dict] tracking_numbers: List[dict]
shipping_method: Optional[str]
summary: str summary: str
status: str status: str
source_created_at: Optional[dt.datetime] source_created_at: Optional[dt.datetime]
+9 -1
View File
@@ -165,7 +165,7 @@ class JiraService(OrderService):
# Always pull summary/status/created/creator, plus every configured # Always pull summary/status/created/creator, plus every configured
# deliverable field and contact field. # deliverable field and contact field.
fields = "summary,status,created,creator,description" fields = "summary,status,created,creator,assignee,description"
if self.all_sku_field_ids: if self.all_sku_field_ids:
fields += "," + ",".join(self.all_sku_field_ids) fields += "," + ",".join(self.all_sku_field_ids)
contact_field_ids = [v for v in self.contact_field_ids.values() if v] contact_field_ids = [v for v in self.contact_field_ids.values() if v]
@@ -323,6 +323,11 @@ class JiraService(OrderService):
creator = fields.get("creator") or {} creator = fields.get("creator") or {}
return creator.get("displayName") or creator.get("emailAddress") or "" return creator.get("displayName") or creator.get("emailAddress") or ""
@staticmethod
def _extract_assignee(fields: dict) -> str:
assignee = fields.get("assignee") or {}
return assignee.get("displayName") or assignee.get("emailAddress") or ""
def _to_normalized_order(self, issue: dict) -> NormalizedOrder: def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
fields = issue.get("fields", {}) fields = issue.get("fields", {})
created_raw = fields.get("created") created_raw = fields.get("created")
@@ -339,6 +344,7 @@ class JiraService(OrderService):
company = resolve_company_for_skus(skus, self.sku_map) company = resolve_company_for_skus(skus, self.sku_map)
shipping_info = self._extract_shipping_info(fields) shipping_info = self._extract_shipping_info(fields)
creator = self._extract_creator(fields) creator = self._extract_creator(fields)
assignee = self._extract_assignee(fields)
description = adf_to_text(fields.get("description")) description = adf_to_text(fields.get("description"))
# These order tickets typically leave the JIRA Summary field # These order tickets typically leave the JIRA Summary field
@@ -355,8 +361,10 @@ class JiraService(OrderService):
line_items=line_items, line_items=line_items,
shipping_info=shipping_info, shipping_info=shipping_info,
creator=creator, creator=creator,
assignee=assignee,
description=description, description=description,
tracking_numbers=[], tracking_numbers=[],
shipping_method=None, # comes from ShipStation, not JIRA - see shipstation_service.py
summary=summary, summary=summary,
status=(fields.get("status") or {}).get("name", ""), status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at, source_created_at=created_at,
+37 -2
View File
@@ -45,6 +45,7 @@ import requests
from PyQt6.QtCore import QRunnable, QThreadPool from PyQt6.QtCore import QRunnable, QThreadPool
from app import config from app import config
from app.companies import parse_mapping
from app.services.base import OrderService, NormalizedOrder from app.services.base import OrderService, NormalizedOrder
from app.tracking import suggest_jira_status from app.tracking import suggest_jira_status
@@ -64,6 +65,22 @@ SHIPMENT_LOOKBACK_DAYS = 7
MAX_CONCURRENT_REQUESTS = 5 MAX_CONCURRENT_REQUESTS = 5
def _resolve_shipping_method(service_code: str) -> str:
"""Maps a ShipStation service_code (e.g. 'ups_ground') to your team's
JIRA-facing term (e.g. 'Ground') via SHIPPING_METHOD_LABELS. Falls back
to a prettified version of the raw code for anything not mapped, so an
unmapped service still shows something readable rather than nothing."""
if not service_code:
return ""
settings = config.load_settings()
mapping = parse_mapping(
settings.get("SHIPPING_METHOD_LABELS", "") or config.DEFAULT_SHIPPING_METHOD_LABELS
)
if service_code in mapping:
return mapping[service_code]
return service_code.replace("_", " ").title()
class ShipStationServiceError(Exception): class ShipStationServiceError(Exception):
"""Raised for any ShipStation fetch failure, with a message safe to show in the UI.""" """Raised for any ShipStation fetch failure, with a message safe to show in the UI."""
@@ -124,6 +141,7 @@ class ShipStationService(OrderService):
# --- Sort/correlate now, entirely in memory, after both batches landed --- # --- Sort/correlate now, entirely in memory, after both batches landed ---
tracking_by_ticket: Dict[str, List[dict]] = {} tracking_by_ticket: Dict[str, List[dict]] = {}
raw_by_ticket: Dict[str, dict] = {} raw_by_ticket: Dict[str, dict] = {}
shipping_method_by_ticket: Dict[str, str] = {}
self.unmatched_labels = [] self.unmatched_labels = []
for label in usable_labels: for label in usable_labels:
@@ -144,9 +162,20 @@ class ShipStationService(OrderService):
raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment}) raw_by_ticket.setdefault(ticket_number, {"labels": [], "shipment": shipment})
raw_by_ticket[ticket_number]["labels"].append(label) raw_by_ticket[ticket_number]["labels"].append(label)
# Shipping Method reflects the OUTBOUND label specifically (matches
# the Ship Sheet's usage) - only set from the first non-return label
# seen per ticket, so a return label's service doesn't overwrite it.
if not label.get("is_return_label") and ticket_number not in shipping_method_by_ticket:
shipping_method_by_ticket[ticket_number] = _resolve_shipping_method(
label.get("service_code", "")
)
return [ return [
self._to_normalized_order( self._to_normalized_order(
ticket_number, tracking_by_ticket[ticket_number], raw_by_ticket[ticket_number] ticket_number,
tracking_by_ticket[ticket_number],
raw_by_ticket[ticket_number],
shipping_method_by_ticket.get(ticket_number, ""),
) )
for ticket_number in tracking_by_ticket for ticket_number in tracking_by_ticket
] ]
@@ -280,7 +309,7 @@ class ShipStationService(OrderService):
@staticmethod @staticmethod
def _to_normalized_order( def _to_normalized_order(
ticket_number: str, tracking_numbers: List[dict], raw: dict ticket_number: str, tracking_numbers: List[dict], raw: dict, shipping_method: str
) -> NormalizedOrder: ) -> NormalizedOrder:
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled" suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
numbers_display = ", ".join( numbers_display = ", ".join(
@@ -294,7 +323,13 @@ class ShipStationService(OrderService):
ticket_number=ticket_number, ticket_number=ticket_number,
company="", # not used - the JIRA row this merges onto already has one company="", # not used - the JIRA row this merges onto already has one
skus=[], skus=[],
line_items=[],
shipping_info={},
creator=None,
assignee=None,
description=None,
tracking_numbers=tracking_numbers, tracking_numbers=tracking_numbers,
shipping_method=shipping_method,
summary=numbers_display, summary=numbers_display,
status=suggested_status, status=suggested_status,
source_created_at=None, source_created_at=None,
+33 -4
View File
@@ -35,6 +35,7 @@ from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget from app.ui.widgets.dashboard import DashboardWidget
from app.ui.widgets.order_detail_dialog import OrderDetailDialog from app.ui.widgets.order_detail_dialog import OrderDetailDialog
from app.ui.widgets.return_label_dialog import ReturnLabelDialog from app.ui.widgets.return_label_dialog import ReturnLabelDialog
from app.ui.widgets.pack_ticket_dialog import PackTicketDialog
from app.workers import ( from app.workers import (
FetchOrdersWorker, FetchOrdersWorker,
SendToShipStationWorker, SendToShipStationWorker,
@@ -42,6 +43,7 @@ from app.workers import (
load_orders_by_view, load_orders_by_view,
get_dashboard_stats, get_dashboard_stats,
mark_shipstation_sent, mark_shipstation_sent,
save_pack_data,
reset_local_database, reset_local_database,
) )
@@ -103,6 +105,11 @@ class MainWindow(QMainWindow):
export_action.triggered.connect(self._on_export_clicked) export_action.triggered.connect(self._on_export_clicked)
toolbar.addAction(export_action) toolbar.addAction(export_action)
pack_action = QAction("Pack Ticket", self)
pack_action.setToolTip("Enter serial numbers and mark packed - select a ticket first")
pack_action.triggered.connect(self._on_pack_ticket_clicked)
toolbar.addAction(pack_action)
toolbar.addSeparator() toolbar.addSeparator()
emergency_action = QAction("Send to ShipStation", self) emergency_action = QAction("Send to ShipStation", self)
@@ -410,14 +417,36 @@ class MainWindow(QMainWindow):
self.status_label.setText("Return label creation failed.") self.status_label.setText("Return label creation failed.")
QMessageBox.critical(self, "Return label creation failed", message) 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
dialog = PackTicketDialog(order, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
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 _on_reset_database_clicked(self) -> None: def _on_reset_database_clicked(self) -> None:
confirm = QMessageBox.warning( confirm = QMessageBox.warning(
self, self,
"Reset Local Database", "Reset Local Database",
"This clears every locally cached order (JIRA is still the real source of " "This clears every locally cached order, INCLUDING any serial numbers "
"truth, so nothing there is affected). You'll need to click Import from JIRA " "and packed status your team has already entered on the Pack Ticket "
"afterward to repopulate, and Pull Tracking Numbers again if you rely on " "dialog - that data doesn't come from JIRA, so a reset does not bring "
"today's already-pulled tracking data.\n\n" "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?", "Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
+10
View File
@@ -41,11 +41,15 @@ COLUMNS = [
("company", "Company"), ("company", "Company"),
("ticket_number", "Ticket #"), ("ticket_number", "Ticket #"),
("status", "Status"), ("status", "Status"),
("packed_display", "Done"),
("skus_display", "SKUs"), ("skus_display", "SKUs"),
("summary", "Summary"), ("summary", "Summary"),
("assignee", "Assignee"),
("serials_display", "Serial #s"),
("return_label_display", "Return Label"), ("return_label_display", "Return Label"),
("outgoing_tracking_display", "Outgoing Tracking"), ("outgoing_tracking_display", "Outgoing Tracking"),
("return_tracking_display", "Return Tracking"), ("return_tracking_display", "Return Tracking"),
("shipping_method", "Shipping Method"),
("uploaded_display", "Uploaded"), ("uploaded_display", "Uploaded"),
("past_cutoff_display", "Past Cutoff"), ("past_cutoff_display", "Past Cutoff"),
("created_display", "Created"), ("created_display", "Created"),
@@ -124,6 +128,12 @@ class OrdersTableModel(QAbstractTableModel):
if field_name == "skus_display": if field_name == "skus_display":
return ", ".join(order.skus or []) return ", ".join(order.skus or [])
if field_name == "packed_display":
return CHECK_MARK if order.packed else ""
if field_name == "serials_display":
serials = order.serial_numbers or {}
filled = sum(1 for v in serials.values() if (v or "").strip())
return f"{filled} entered" if filled else ""
if field_name == "return_label_display": if field_name == "return_label_display":
return CHECK_MARK if is_emailed_label_order(order.skus or []) else "" return CHECK_MARK if is_emailed_label_order(order.skus or []) else ""
if field_name == "outgoing_tracking_display": if field_name == "outgoing_tracking_display":
+149
View File
@@ -0,0 +1,149 @@
"""
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.
"""
from __future__ import annotations
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 = order
self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}")
self.resize(520, 600)
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)))
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)
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")
self.packed_checkbox.setChecked(bool(order.packed))
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._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()
+30
View File
@@ -161,6 +161,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
continue continue
jira_row.tracking_numbers = order.get("tracking_numbers", []) jira_row.tracking_numbers = order.get("tracking_numbers", [])
if order.get("shipping_method"):
jira_row.shipping_method = order["shipping_method"]
enriched_count += 1 enriched_count += 1
continue continue
@@ -185,6 +187,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
line_items=order.get("line_items", []), line_items=order.get("line_items", []),
shipping_info=order.get("shipping_info", {}), shipping_info=order.get("shipping_info", {}),
creator=order.get("creator"), creator=order.get("creator"),
assignee=order.get("assignee"),
description=order.get("description"), description=order.get("description"),
tracking_numbers=order.get("tracking_numbers", []), tracking_numbers=order.get("tracking_numbers", []),
summary=order["summary"], summary=order["summary"],
@@ -225,6 +228,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
existing.line_items = order.get("line_items", []) existing.line_items = order.get("line_items", [])
existing.shipping_info = order.get("shipping_info", {}) existing.shipping_info = order.get("shipping_info", {})
existing.creator = order.get("creator") existing.creator = order.get("creator")
existing.assignee = order.get("assignee")
existing.description = order.get("description") existing.description = order.get("description")
existing.summary = order["summary"] existing.summary = order["summary"]
existing.status = new_status existing.status = new_status
@@ -330,6 +334,32 @@ def mark_shipstation_sent(ticket_number: str) -> None:
session.close() session.close()
def save_pack_data(ticket_number: str, serial_numbers: dict, packed: bool) -> None:
"""
Saves serial numbers and the packed/done flag from the Pack Ticket
dialog. Staff-entered data, not sourced from JIRA - this is the
beginning of the eventual end-of-day push back to JIRA (deferred for
now), so nothing here gets overwritten by a JIRA re-import.
"""
session = get_session()
try:
order = session.execute(
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
).scalar_one_or_none()
if order is None:
return
order.serial_numbers = serial_numbers
was_packed = order.packed
order.packed = packed
if packed and not was_packed:
order.packed_at = dt.datetime.now()
elif not packed:
order.packed_at = None
session.commit()
finally:
session.close()
def get_dashboard_stats() -> dict: def get_dashboard_stats() -> dict:
""" """
Counts for the dashboard. "Active Orders" and the company breakdown Counts for the dashboard. "Active Orders" and the company breakdown