Email procedure (beta), WorkFlow update
This commit is contained in:
@@ -120,3 +120,14 @@ OAKSTREET_RETURN_CITY=Lewisville
|
||||
OAKSTREET_RETURN_STATE=TX
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
For the rare case a ticket needs to skip the normal daily batch. Select
|
||||
|
||||
@@ -91,6 +91,17 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = {
|
||||
"Return Labels",
|
||||
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_SERVICE_CODE": (
|
||||
"Signify Return: Service code (e.g. ups_ground)",
|
||||
@@ -160,6 +171,21 @@ DEFAULT_INTAKE_CUTOFF_TIME = "15:30"
|
||||
DEFAULT_ACTIVE_STATUSES = "Created"
|
||||
DEFAULT_CANCELLED_STATUSES = "Cancelled"
|
||||
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"
|
||||
|
||||
|
||||
|
||||
+27
-1
@@ -65,6 +65,9 @@ class Order(Base):
|
||||
|
||||
# Display name of whoever created the JIRA ticket.
|
||||
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
|
||||
# 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"
|
||||
# action, separately from the JIRA import.
|
||||
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="")
|
||||
status = Column(String(100), nullable=False, default="")
|
||||
@@ -101,7 +124,10 @@ class Order(Base):
|
||||
# way to confirm that manual step happened.
|
||||
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)
|
||||
|
||||
# Full original payload from JIRA, for anything not modeled explicitly
|
||||
|
||||
@@ -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
|
||||
@@ -25,8 +25,10 @@ class NormalizedOrder(TypedDict):
|
||||
line_items: List[dict]
|
||||
shipping_info: dict
|
||||
creator: Optional[str]
|
||||
assignee: Optional[str]
|
||||
description: Optional[str]
|
||||
tracking_numbers: List[dict]
|
||||
shipping_method: Optional[str]
|
||||
summary: str
|
||||
status: str
|
||||
source_created_at: Optional[dt.datetime]
|
||||
|
||||
@@ -165,7 +165,7 @@ class JiraService(OrderService):
|
||||
|
||||
# Always pull summary/status/created/creator, plus every configured
|
||||
# deliverable field and contact field.
|
||||
fields = "summary,status,created,creator,description"
|
||||
fields = "summary,status,created,creator,assignee,description"
|
||||
if 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]
|
||||
@@ -323,6 +323,11 @@ class JiraService(OrderService):
|
||||
creator = fields.get("creator") 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:
|
||||
fields = issue.get("fields", {})
|
||||
created_raw = fields.get("created")
|
||||
@@ -339,6 +344,7 @@ class JiraService(OrderService):
|
||||
company = resolve_company_for_skus(skus, self.sku_map)
|
||||
shipping_info = self._extract_shipping_info(fields)
|
||||
creator = self._extract_creator(fields)
|
||||
assignee = self._extract_assignee(fields)
|
||||
description = adf_to_text(fields.get("description"))
|
||||
|
||||
# These order tickets typically leave the JIRA Summary field
|
||||
@@ -355,8 +361,10 @@ class JiraService(OrderService):
|
||||
line_items=line_items,
|
||||
shipping_info=shipping_info,
|
||||
creator=creator,
|
||||
assignee=assignee,
|
||||
description=description,
|
||||
tracking_numbers=[],
|
||||
shipping_method=None, # comes from ShipStation, not JIRA - see shipstation_service.py
|
||||
summary=summary,
|
||||
status=(fields.get("status") or {}).get("name", ""),
|
||||
source_created_at=created_at,
|
||||
|
||||
@@ -45,6 +45,7 @@ import requests
|
||||
from PyQt6.QtCore import QRunnable, QThreadPool
|
||||
|
||||
from app import config
|
||||
from app.companies import parse_mapping
|
||||
from app.services.base import OrderService, NormalizedOrder
|
||||
from app.tracking import suggest_jira_status
|
||||
|
||||
@@ -64,6 +65,22 @@ SHIPMENT_LOOKBACK_DAYS = 7
|
||||
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):
|
||||
"""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 ---
|
||||
tracking_by_ticket: Dict[str, List[dict]] = {}
|
||||
raw_by_ticket: Dict[str, dict] = {}
|
||||
shipping_method_by_ticket: Dict[str, str] = {}
|
||||
self.unmatched_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[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 [
|
||||
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
|
||||
]
|
||||
@@ -280,7 +309,7 @@ class ShipStationService(OrderService):
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
suggested_status = suggest_jira_status(tracking_numbers) or "Tracking Pulled"
|
||||
numbers_display = ", ".join(
|
||||
@@ -294,7 +323,13 @@ class ShipStationService(OrderService):
|
||||
ticket_number=ticket_number,
|
||||
company="", # not used - the JIRA row this merges onto already has one
|
||||
skus=[],
|
||||
line_items=[],
|
||||
shipping_info={},
|
||||
creator=None,
|
||||
assignee=None,
|
||||
description=None,
|
||||
tracking_numbers=tracking_numbers,
|
||||
shipping_method=shipping_method,
|
||||
summary=numbers_display,
|
||||
status=suggested_status,
|
||||
source_created_at=None,
|
||||
|
||||
+33
-4
@@ -35,6 +35,7 @@ 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.ui.widgets.pack_ticket_dialog import PackTicketDialog
|
||||
from app.workers import (
|
||||
FetchOrdersWorker,
|
||||
SendToShipStationWorker,
|
||||
@@ -42,6 +43,7 @@ from app.workers import (
|
||||
load_orders_by_view,
|
||||
get_dashboard_stats,
|
||||
mark_shipstation_sent,
|
||||
save_pack_data,
|
||||
reset_local_database,
|
||||
)
|
||||
|
||||
@@ -103,6 +105,11 @@ class MainWindow(QMainWindow):
|
||||
export_action.triggered.connect(self._on_export_clicked)
|
||||
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()
|
||||
|
||||
emergency_action = QAction("Send to ShipStation", self)
|
||||
@@ -410,14 +417,36 @@ class MainWindow(QMainWindow):
|
||||
self.status_label.setText("Return label creation failed.")
|
||||
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:
|
||||
confirm = QMessageBox.warning(
|
||||
self,
|
||||
"Reset Local Database",
|
||||
"This clears every locally cached order (JIRA is still the real source of "
|
||||
"truth, so nothing there is affected). 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"
|
||||
"This clears every locally cached order, INCLUDING any serial numbers "
|
||||
"and packed status your team has already entered on the Pack Ticket "
|
||||
"dialog - that data doesn't come from JIRA, so a reset does not bring "
|
||||
"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?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
|
||||
@@ -41,11 +41,15 @@ COLUMNS = [
|
||||
("company", "Company"),
|
||||
("ticket_number", "Ticket #"),
|
||||
("status", "Status"),
|
||||
("packed_display", "Done"),
|
||||
("skus_display", "SKUs"),
|
||||
("summary", "Summary"),
|
||||
("assignee", "Assignee"),
|
||||
("serials_display", "Serial #s"),
|
||||
("return_label_display", "Return Label"),
|
||||
("outgoing_tracking_display", "Outgoing Tracking"),
|
||||
("return_tracking_display", "Return Tracking"),
|
||||
("shipping_method", "Shipping Method"),
|
||||
("uploaded_display", "Uploaded"),
|
||||
("past_cutoff_display", "Past Cutoff"),
|
||||
("created_display", "Created"),
|
||||
@@ -124,6 +128,12 @@ class OrdersTableModel(QAbstractTableModel):
|
||||
|
||||
if field_name == "skus_display":
|
||||
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":
|
||||
return CHECK_MARK if is_emailed_label_order(order.skus or []) else ""
|
||||
if field_name == "outgoing_tracking_display":
|
||||
|
||||
@@ -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()
|
||||
@@ -161,6 +161,8 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
continue
|
||||
|
||||
jira_row.tracking_numbers = order.get("tracking_numbers", [])
|
||||
if order.get("shipping_method"):
|
||||
jira_row.shipping_method = order["shipping_method"]
|
||||
enriched_count += 1
|
||||
continue
|
||||
|
||||
@@ -185,6 +187,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
line_items=order.get("line_items", []),
|
||||
shipping_info=order.get("shipping_info", {}),
|
||||
creator=order.get("creator"),
|
||||
assignee=order.get("assignee"),
|
||||
description=order.get("description"),
|
||||
tracking_numbers=order.get("tracking_numbers", []),
|
||||
summary=order["summary"],
|
||||
@@ -225,6 +228,7 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
|
||||
existing.line_items = order.get("line_items", [])
|
||||
existing.shipping_info = order.get("shipping_info", {})
|
||||
existing.creator = order.get("creator")
|
||||
existing.assignee = order.get("assignee")
|
||||
existing.description = order.get("description")
|
||||
existing.summary = order["summary"]
|
||||
existing.status = new_status
|
||||
@@ -330,6 +334,32 @@ def mark_shipstation_sent(ticket_number: str) -> None:
|
||||
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:
|
||||
"""
|
||||
Counts for the dashboard. "Active Orders" and the company breakdown
|
||||
|
||||
Reference in New Issue
Block a user