diff --git a/.env.example b/.env.example index 9318b8b..43147b3 100644 --- a/.env.example +++ b/.env.example @@ -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 + diff --git a/README.md b/README.md index 01fdf32..f332788 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/config.py b/app/config.py index cefbef7..c7a4199 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/models.py b/app/models.py index 5a29f43..809509f 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/serial_suggestions.py b/app/serial_suggestions.py new file mode 100644 index 0000000..734c443 --- /dev/null +++ b/app/serial_suggestions.py @@ -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 diff --git a/app/services/base.py b/app/services/base.py index cd7bcae..2f0e587 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -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] diff --git a/app/services/jira_service.py b/app/services/jira_service.py index 2fe6e0e..0ea9281 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -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, diff --git a/app/services/shipstation_service.py b/app/services/shipstation_service.py index a0ad415..28ea7ac 100644 --- a/app/services/shipstation_service.py +++ b/app/services/shipstation_service.py @@ -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, diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 355625a..cdd6788 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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, diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index eac93ba..8f935da 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -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": diff --git a/app/ui/widgets/pack_ticket_dialog.py b/app/ui/widgets/pack_ticket_dialog.py new file mode 100644 index 0000000..665002f --- /dev/null +++ b/app/ui/widgets/pack_ticket_dialog.py @@ -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() diff --git a/app/workers.py b/app/workers.py index 584374b..1b994f7 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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