diff --git a/.env.example b/.env.example index 43147b3..375e14e 100644 --- a/.env.example +++ b/.env.example @@ -56,7 +56,7 @@ SHIPSTATION_OAKSTREET_WAREHOUSE_ID=se-437417 # --- Companies --- # SKU prefix -> company. Add more "PREFIX:Company" pairs as you add companies. -COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health +COMPANY_SKU_MAP=SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health # Fallback pattern for recognizing the AR-###### ticket number if it's ever # not found in ShipStation's usual shipment_number/external_shipment_id fields. TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b @@ -120,14 +120,35 @@ OAKSTREET_RETURN_CITY=Lewisville OAKSTREET_RETURN_STATE=TX OAKSTREET_RETURN_ZIP=75067 +# RubiconMD - Oak Street subsidiary (RMD-prefixed SKUs). Still needs its +# real Carrier ID/Service Code (its own UPS account, not Oak Street's) - +# address defaults to the same shared warehouse, just under its own name, +# since that's the only difference confirmed so far. Change the address if +# it actually ships from somewhere else. +RUBICONMD_RETURN_CARRIER_ID= +RUBICONMD_RETURN_SERVICE_CODE= +RUBICONMD_RETURN_NAME=Rubicon MD +RUBICONMD_RETURN_PHONE= +RUBICONMD_RETURN_ADDRESS1=1000 Spinks Road Suite 100 +RUBICONMD_RETURN_ADDRESS2= +RUBICONMD_RETURN_CITY=Lewisville +RUBICONMD_RETURN_STATE=TX +RUBICONMD_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 +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,monitor:Monitor Serial Number|Monitor Asset Tag,accessor:Accessory Notes,camera:Camera Serial Number|Camera 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 +# --- Ticket validation --- +# Return types with no matching device expected - exempt from the +# return/device mismatch check (emailed labels, DPS Device, scheduled +# pickups don't have a corresponding device line item by design). +RETURN_DEVICE_EXEMPT_KEYWORDS=emailed,dps,scheduled pickup,padded envelope + diff --git a/app/config.py b/app/config.py index c7a4199..4623991 100644 --- a/app/config.py +++ b/app/config.py @@ -102,6 +102,11 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { "Packing", False, ), + "RETURN_DEVICE_EXEMPT_KEYWORDS": ( + "Return types with no matching device expected (comma-separated keywords)", + "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)", @@ -134,6 +139,23 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { "OAKSTREET_RETURN_STATE": ("Oak Street Return Address: State", "Return Labels", False), "OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "Return Labels", False), + # RubiconMD - an Oak Street subsidiary (RMD-prefixed SKUs). Classified as + # Oak Street Health everywhere else, but return labels use its own UPS + # account and its own name on the return address. + "RUBICONMD_RETURN_CARRIER_ID": ("RubiconMD Return: ShipStation Carrier ID", "Return Labels", False), + "RUBICONMD_RETURN_SERVICE_CODE": ( + "RubiconMD Return: Service code (e.g. ups_ground)", + "Return Labels", + False, + ), + "RUBICONMD_RETURN_NAME": ("RubiconMD Return Address: Name", "Return Labels", False), + "RUBICONMD_RETURN_PHONE": ("RubiconMD Return Address: Phone", "Return Labels", False), + "RUBICONMD_RETURN_ADDRESS1": ("RubiconMD Return Address: Address 1", "Return Labels", False), + "RUBICONMD_RETURN_ADDRESS2": ("RubiconMD Return Address: Address 2", "Return Labels", False), + "RUBICONMD_RETURN_CITY": ("RubiconMD Return Address: City", "Return Labels", False), + "RUBICONMD_RETURN_STATE": ("RubiconMD Return Address: State", "Return Labels", False), + "RUBICONMD_RETURN_ZIP": ("RubiconMD Return Address: Zip", "Return Labels", False), + "ACTIVE_STATUSES": ( "Statuses that count as real active work (comma-separated) - " "everything else is treated as done", @@ -163,7 +185,7 @@ SETTINGS_SCHEMA: Dict[str, tuple[str, str, bool]] = { } DEFAULT_DB_URL = "sqlite:///orders.db" -DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health" +DEFAULT_COMPANY_SKU_MAP = "SH:Signify Health,OK:Oak Street Health,RMD:Oak Street Health" DEFAULT_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b" DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return" DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed" @@ -178,7 +200,10 @@ DEFAULT_DEVICE_FIELD_SUGGESTIONS = ( "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" + "apc:UPS/APC Serial Number|UPS/APC Asset Tag," + "monitor:Monitor Serial Number|Monitor Asset Tag," + "accessor:Accessory Notes," + "camera:Camera Serial Number|Camera Asset Tag" ) # Confirmed against ShipStation's own UPS service code reference, not guessed. DEFAULT_SHIPPING_METHOD_LABELS = ( diff --git a/app/external_links.py b/app/external_links.py new file mode 100644 index 0000000..4b9cc4f --- /dev/null +++ b/app/external_links.py @@ -0,0 +1,40 @@ +""" +Builds URLs for the two "jump out to somewhere else" actions: viewing a +ticket directly in JIRA, and looking up a shipping address on Google +Maps (matching the existing manual workflow of pasting an address into +Google when investigating a validation issue). + +Deliberately just URL-building, no browser-launching here - that stays +in the UI layer (main_window.py / order_detail_dialog.py) via +webbrowser.open(), so this module has no GUI dependency and is easy to +test in isolation. +""" +from __future__ import annotations + +from urllib.parse import quote + +from app import config + + +def jira_ticket_url(ticket_number: str | None) -> str | None: + if not ticket_number: + return None + jira_url = config.get("JIRA_URL", "").strip() + if not jira_url: + return None + return f"{jira_url.rstrip('/')}/browse/{ticket_number}" + + +def google_maps_search_url(shipping_info: dict | None) -> str | None: + info = shipping_info or {} + parts = [ + info.get("address1", ""), + info.get("address2", ""), + info.get("city", ""), + info.get("state", ""), + info.get("zip", ""), + ] + address = " ".join(p for p in parts if p).strip() + if not address: + return None + return f"https://www.google.com/maps/search/?api=1&query={quote(address)}" diff --git a/app/services/shipstation_send.py b/app/services/shipstation_send.py index 9f540fd..e679f7b 100644 --- a/app/services/shipstation_send.py +++ b/app/services/shipstation_send.py @@ -273,9 +273,25 @@ def send_order_to_shipstation_api(order: Order) -> dict: VALID_CHARGE_EVENTS = {"on_creation", "on_carrier_acceptance", "carrier_default"} -def _return_address_for_company(company: str) -> dict: +def _return_config_prefix(order: Order) -> str: + """ + Normally keyed off company, but RMD-prefixed SKUs (RubiconMD, an Oak + Street subsidiary) use their own UPS account and return address even + though the ticket is still classified as Oak Street Health for every + other purpose - so this checks the ticket's actual SKUs, not just + order.company. (The IE prefix seen in Signify's catalog is deprecated + and deliberately not handled here - not expected on current tickets.) + """ + if any((sku or "").strip().upper().startswith("RMD") for sku in (order.skus or [])): + return "RUBICONMD_RETURN_" + if order.company == "Signify Health": + return "SIGNIFY_RETURN_" + return "OAKSTREET_RETURN_" + + +def _return_address_for_order(order: Order) -> dict: settings = config.load_settings() - prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" + prefix = _return_config_prefix(order) return { "name": settings.get(f"{prefix}NAME", ""), "phone": settings.get(f"{prefix}PHONE", ""), @@ -288,12 +304,12 @@ def _return_address_for_company(company: str) -> dict: } -def _return_carrier_for_company(company: str) -> tuple[str, str]: - """Each company has its own UPS account (different carrier_id) even - though they share a warehouse - matches how store/warehouse IDs are - already split per company elsewhere in this app.""" +def _return_carrier_for_order(order: Order) -> tuple[str, str]: + """Each shipping account (Signify, Oak Street, and RubiconMD as its own + subsidiary account) has its own UPS carrier_id, even where the physical + warehouse is shared.""" settings = config.load_settings() - prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_" + prefix = _return_config_prefix(order) return settings.get(f"{prefix}CARRIER_ID", ""), settings.get(f"{prefix}SERVICE_CODE", "") @@ -321,14 +337,14 @@ def create_return_label(order: Order, packages: List[dict], charge_event: str | if not api_key: raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.") - carrier_id, service_code = _return_carrier_for_company(order.company) + carrier_id, service_code = _return_carrier_for_order(order) if not carrier_id or not service_code: raise ShipStationSendError( f"No return-label Carrier ID/Service Code configured for '{order.company}'. " "Add them in Settings under Return Labels." ) - return_address = _return_address_for_company(order.company) + return_address = _return_address_for_order(order) if not ( return_address["address_line1"] and return_address["city_locality"] diff --git a/app/ticket_validation.py b/app/ticket_validation.py new file mode 100644 index 0000000..f5a00ef --- /dev/null +++ b/app/ticket_validation.py @@ -0,0 +1,113 @@ +""" +Ticket validation - flags problems for staff to investigate and +(manually, in JIRA, with a reason) cancel if confirmed. This module +only ever FLAGS; it never writes anything back to JIRA or changes a +ticket's status - that stays a deliberate human step. + +Two rules right now, per the actual business logic: + +1. Company mismatch: a ticket's SKUs resolve to more than one company + (SH + OK present together). Simple and unambiguous - every SKU on a + ticket should belong to the same company. + +2. Return/device mismatch: a "Shipping - Return Label/Box X" SKU's + implied device type doesn't match any other device SKU on the same + ticket - covers both asset-recovery (box + return label, same + device) and break-fix (asset + matching-type return label) flows. + Some return types are exempt (see RETURN_DEVICE_EXEMPT_KEYWORDS) - + emailed labels, DPS Device, and scheduled pickups don't have (or + need) a matching device line item at all. + +Device-type keywords are shared with app.serial_suggestions +(DEVICE_FIELD_SUGGESTIONS) rather than duplicated - same vocabulary, +different use. +""" +from __future__ import annotations + +from typing import List, NamedTuple, Optional + +from app import config +from app.companies import parse_mapping, resolve_company_by_sku +from app.serial_suggestions import get_device_field_suggestions + +DEFAULT_RETURN_DEVICE_EXEMPT_KEYWORDS = "emailed,dps,scheduled pickup,padded envelope" + + +class TicketIssue(NamedTuple): + code: str + message: str + + +def get_return_device_exempt_keywords() -> list[str]: + raw = config.get( + "RETURN_DEVICE_EXEMPT_KEYWORDS", DEFAULT_RETURN_DEVICE_EXEMPT_KEYWORDS + ) + return [k.strip().lower() for k in raw.split(",") if k.strip()] + + +def _is_shipping_item(item_name: str) -> bool: + return item_name.strip().lower().startswith("shipping") + + +def check_company_mismatch(skus: List[str]) -> Optional[TicketIssue]: + settings = config.load_settings() + sku_map = parse_mapping(settings.get("COMPANY_SKU_MAP", "")) + + companies = set() + for sku in skus or []: + company = resolve_company_by_sku(sku, sku_map) + if company != "Unknown": + companies.add(company) + + if len(companies) > 1: + return TicketIssue( + code="company_mismatch", + message=f"SKUs from multiple companies on one ticket: {', '.join(sorted(companies))}", + ) + return None + + +def check_return_device_mismatch(line_items: List[dict]) -> List[TicketIssue]: + keyword_map = get_device_field_suggestions() # {keyword: [serial fields]} - keys only, here + exempt_keywords = get_return_device_exempt_keywords() + issues: List[TicketIssue] = [] + + device_items = [item for item in (line_items or []) if not _is_shipping_item(item.get("item_name", ""))] + return_items = [item for item in (line_items or []) if _is_shipping_item(item.get("item_name", ""))] + + for return_item in return_items: + return_text = return_item.get("item_name", "").lower() + + if any(exempt in return_text for exempt in exempt_keywords): + continue + + matched_keyword = next((kw for kw in keyword_map if kw in return_text), None) + if matched_keyword is None: + # The return SKU doesn't mention any known device type at all - + # nothing to check it against, so nothing to flag here either. + continue + + has_matching_device = any( + matched_keyword in item.get("item_name", "").lower() for item in device_items + ) + if not has_matching_device: + issues.append( + TicketIssue( + code="return_device_mismatch", + message=( + f"'{return_item.get('sku', '')}' ({return_item.get('item_name', '')}) " + f"expects a matching '{matched_keyword}' device, but none is on this ticket" + ), + ) + ) + + return issues + + +def validate_ticket(skus: List[str], line_items: List[dict]) -> List[TicketIssue]: + issues: List[TicketIssue] = [] + company_issue = check_company_mismatch(skus) + if company_issue: + issues.append(company_issue) + issues.extend(check_return_device_mismatch(line_items)) + return issues diff --git a/app/ui/main_window.py b/app/ui/main_window.py index c0b8aee..f724e93 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -12,6 +12,8 @@ here is purely a label-generation step for JIRA tickets. """ from __future__ import annotations +import webbrowser + from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QMainWindow, @@ -26,6 +28,7 @@ from PyQt6.QtWidgets import ( QDialog, ) +from app.external_links import jira_ticket_url, google_maps_search_url from app.return_labels import is_emailed_label_order from app.services import SERVICE_REGISTRY from app.services.odoo_export import export_orders_to_csv @@ -91,62 +94,92 @@ class MainWindow(QMainWindow): self.setCentralWidget(central) - toolbar = QToolBar("Main") - toolbar.setMovable(False) - self.addToolBar(toolbar) + menu_bar = self.menuBar() + + file_menu = menu_bar.addMenu("&File") + settings_action = QAction("Settings...", self) + settings_action.triggered.connect(self._on_settings_clicked) + file_menu.addAction(settings_action) + file_menu.addSeparator() + exit_action = QAction("Exit", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + data_menu = menu_bar.addMenu("&Data") jira_action = QAction("Import from JIRA", self) jira_action.triggered.connect(lambda: self._on_import_clicked("jira")) - toolbar.addAction(jira_action) + data_menu.addAction(jira_action) self._import_actions["jira"] = jira_action shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self) shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation")) - toolbar.addAction(shipstation_action) + data_menu.addAction(shipstation_action) self._import_actions["shipstation"] = shipstation_action - toolbar.addSeparator() - - export_action = QAction("Export Visible Orders to Odoo CSV", self) - export_action.setToolTip("Exports from whichever tab is currently open") - export_action.triggered.connect(self._on_export_clicked) - toolbar.addAction(export_action) - - self._pack_ticket_action = QAction("Pack Ticket", self) - self._pack_ticket_action.setToolTip( - "Enter serial numbers and mark packed - select a ticket first" - ) - self._pack_ticket_action.triggered.connect(self._on_pack_ticket_clicked) - toolbar.addAction(self._pack_ticket_action) - - toolbar.addSeparator() - - emergency_action = QAction("Send to ShipStation", self) - emergency_action.setToolTip("Select a ticket in Active Orders first") - emergency_action.triggered.connect(self._on_emergency_send_clicked) - toolbar.addAction(emergency_action) - - self._return_label_action = QAction("Create Return Label", self) - self._return_label_action.setToolTip( - "For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first" - ) - self._return_label_action.triggered.connect(self._on_create_return_label_clicked) - toolbar.addAction(self._return_label_action) - - toolbar.addSeparator() - - settings_action = QAction("Settings", self) - settings_action.triggered.connect(self._on_settings_clicked) - toolbar.addAction(settings_action) + data_menu.addSeparator() refresh_action = QAction("Refresh from Local DB", self) refresh_action.triggered.connect(self._refresh_everything) - toolbar.addAction(refresh_action) + data_menu.addAction(refresh_action) - reset_action = QAction("Reset Local Database", self) - reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward") + export_action = QAction("Export Visible Orders to Odoo CSV...", self) + export_action.setStatusTip("Exports from whichever tab is currently open") + export_action.triggered.connect(self._on_export_clicked) + data_menu.addAction(export_action) + + data_menu.addSeparator() + + reset_action = QAction("Reset Local Database...", self) + reset_action.setStatusTip("Wipes the local cache - re-import from JIRA afterward") reset_action.triggered.connect(self._on_reset_database_clicked) - toolbar.addAction(reset_action) + data_menu.addAction(reset_action) + + orders_menu = menu_bar.addMenu("&Orders") + + self._pack_ticket_action = QAction("Pack Ticket", self) + self._pack_ticket_action.setStatusTip( + "Enter serial numbers and mark packed - select a ticket first" + ) + self._pack_ticket_action.triggered.connect(self._on_pack_ticket_clicked) + orders_menu.addAction(self._pack_ticket_action) + + emergency_action = QAction("Send to ShipStation", self) + emergency_action.setStatusTip("Select a ticket in Active Orders first") + emergency_action.triggered.connect(self._on_emergency_send_clicked) + orders_menu.addAction(emergency_action) + + self._return_label_action = QAction("Create Return Label", self) + self._return_label_action.setStatusTip( + "For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first" + ) + self._return_label_action.triggered.connect(self._on_create_return_label_clicked) + orders_menu.addAction(self._return_label_action) + + orders_menu.addSeparator() + + view_jira_action = QAction("View in JIRA", self) + view_jira_action.setStatusTip("Opens the selected ticket in JIRA") + view_jira_action.triggered.connect(self._on_view_in_jira_clicked) + orders_menu.addAction(view_jira_action) + + lookup_address_action = QAction("Look Up Address on Google Maps", self) + lookup_address_action.setStatusTip( + "Opens the selected ticket's shipping address in Google Maps" + ) + lookup_address_action.triggered.connect(self._on_lookup_address_clicked) + orders_menu.addAction(lookup_address_action) + + # Quick-access toolbar for the highest-frequency actions - everything + # here is also in the menus above (same QAction objects, so there's + # nothing to keep in sync); this is just a shortcut for the few used + # constantly enough to want one click instead of a menu dropdown. + quick_toolbar = QToolBar("Quick Actions") + quick_toolbar.setMovable(False) + self.addToolBar(quick_toolbar) + quick_toolbar.addAction(self._import_actions["jira"]) + quick_toolbar.addAction(self._pack_ticket_action) + quick_toolbar.addAction(self._return_label_action) self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) @@ -481,6 +514,42 @@ class MainWindow(QMainWindow): self._refresh_everything() self.status_label.setText(f"Saved pack data for {ticket_number}.") + def _currently_selected_order(self): + """Unlike orders_table.selected_order() (Active Orders specifically), + this checks whichever tab is actually showing - View in JIRA and + Look Up Address are read-only lookups that make just as much sense + for a Cancelled or Done ticket as an Active one.""" + current_widget = self.tabs.currentWidget() + if isinstance(current_widget, OrdersTableView): + return current_widget.selected_order() + return None + + def _on_view_in_jira_clicked(self) -> None: + order = self._currently_selected_order() + if order is None: + QMessageBox.information(self, "No ticket selected", "Select a ticket first.") + return + url = jira_ticket_url(order.ticket_number) + if url is None: + QMessageBox.warning( + self, + "JIRA URL not set", + "Add your JIRA URL in Settings first (or this ticket has no ticket number).", + ) + return + webbrowser.open(url) + + def _on_lookup_address_clicked(self) -> None: + order = self._currently_selected_order() + if order is None: + QMessageBox.information(self, "No ticket selected", "Select a ticket first.") + return + url = google_maps_search_url(order.shipping_info) + if url is None: + QMessageBox.information(self, "No address", "This ticket has no address on file.") + return + webbrowser.open(url) + def _on_reset_database_clicked(self) -> None: confirm = QMessageBox.warning( self, diff --git a/app/ui/widgets/order_detail_dialog.py b/app/ui/widgets/order_detail_dialog.py index 1b06e75..026447d 100644 --- a/app/ui/widgets/order_detail_dialog.py +++ b/app/ui/widgets/order_detail_dialog.py @@ -4,15 +4,20 @@ Order detail dialog. Mainly a debugging aid: shows exactly what JIRA sent back for this ticket (raw payload), plus the tracking numbers ShipStation supplied and the JIRA status they suggest - handy for the end-of-day close-out -without having to piece it together by hand. +without having to piece it together by hand. Also offers quick jumps +out to JIRA and Google Maps, since those are common next steps when +something about a ticket needs a closer look. """ from __future__ import annotations import json +import webbrowser -from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, QLabel, QPushButton +from app.external_links import jira_ticket_url, google_maps_search_url from app.models import Order +from app.ticket_validation import validate_ticket from app.tracking import suggest_jira_status @@ -38,21 +43,31 @@ class OrderDetailDialog(QDialog): info = order.shipping_info or {} address_line2 = f" {info.get('address2')}" if info.get("address2") else "" + issues = validate_ticket(order.skus, order.line_items) + summary_lines = [ f"Ticket #: {order.ticket_number or '(none found)'}", f"Company: {order.company}", f"Created by: {order.creator or '(unknown)'}", f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}", f"Status: {order.status}", - "", - "Shipping info (used by the emergency Send to ShipStation action):", - f" Name: {info.get('name') or '(missing)'}", - f" Address: {info.get('address1') or '(missing)'}{address_line2}", - f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}".rstrip(), - f" Phone: {info.get('phone') or '(none)'} Email: {info.get('email') or '(none)'}", - "", - "Tracking numbers:", ] + if issues: + summary_lines.append("") + summary_lines.append("\u26a0 Flagged for review:") + summary_lines.extend(f" - {issue.message}" for issue in issues) + summary_lines.extend( + [ + "", + "Shipping info (used by the emergency Send to ShipStation action):", + f" Name: {info.get('name') or '(missing)'}", + f" Address: {info.get('address1') or '(missing)'}{address_line2}", + f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}".rstrip(), + f" Phone: {info.get('phone') or '(none)'} Email: {info.get('email') or '(none)'}", + "", + "Tracking numbers:", + ] + ) summary_lines.extend(tracking_lines or [" (none pulled yet)"]) if suggested: summary_lines.append("") @@ -61,6 +76,16 @@ class OrderDetailDialog(QDialog): summary_label = QLabel("\n".join(summary_lines)) layout.addWidget(summary_label) + link_buttons = QHBoxLayout() + jira_button = QPushButton("View in JIRA") + jira_button.clicked.connect(lambda: self._open_url(jira_ticket_url(order.ticket_number))) + link_buttons.addWidget(jira_button) + maps_button = QPushButton("Look Up Address on Google Maps") + maps_button.clicked.connect(lambda: self._open_url(google_maps_search_url(order.shipping_info))) + link_buttons.addWidget(maps_button) + link_buttons.addStretch() + layout.addLayout(link_buttons) + layout.addWidget(QLabel("Raw payload from source system:")) text = QTextEdit() text.setReadOnly(True) @@ -71,3 +96,7 @@ class OrderDetailDialog(QDialog): close_button = QPushButton("Close") close_button.clicked.connect(self.accept) layout.addWidget(close_button) + + def _open_url(self, url: str | None) -> None: + if url: + webbrowser.open(url) diff --git a/app/ui/widgets/orders_table.py b/app/ui/widgets/orders_table.py index 8f935da..28080ec 100644 --- a/app/ui/widgets/orders_table.py +++ b/app/ui/widgets/orders_table.py @@ -30,17 +30,21 @@ from app.models import Order from app.return_labels import is_emailed_label_order from app.schedule import get_cutoff_time, is_past_cutoff_today from app.status_rules import get_cancelled_statuses, status_in +from app.ticket_validation import validate_ticket from app.tracking import get_fulfilled_statuses CANCELLED_TEXT_COLOR = QColor(180, 0, 0) FULFILLED_ROW_COLOR = QColor(210, 240, 210) +ISSUE_TEXT_COLOR = QColor(170, 100, 0) CHECK_MARK = "\u2713" +WARNING_MARK = "\u26a0" # Order here is display order, left to right. COLUMNS = [ ("company", "Company"), ("ticket_number", "Ticket #"), ("status", "Status"), + ("issues_display", "Issues"), ("packed_display", "Done"), ("skus_display", "SKUs"), ("summary", "Summary"), @@ -108,9 +112,14 @@ class OrdersTableModel(QAbstractTableModel): order = self._orders[index.row()] is_cancelled = status_in(order.status, self._cancelled_statuses) + field_name, _ = COLUMNS[index.column()] if role == Qt.ItemDataRole.ForegroundRole: - return CANCELLED_TEXT_COLOR if is_cancelled else None + if is_cancelled: + return CANCELLED_TEXT_COLOR + if field_name == "issues_display" and validate_ticket(order.skus, order.line_items): + return ISSUE_TEXT_COLOR + return None if role == Qt.ItemDataRole.BackgroundRole: # Cancelled rows use red TEXT (above) instead of a background, @@ -121,11 +130,16 @@ class OrdersTableModel(QAbstractTableModel): return FULFILLED_ROW_COLOR return None + if role == Qt.ItemDataRole.ToolTipRole and field_name == "issues_display": + issues = validate_ticket(order.skus, order.line_items) + return "\n".join(i.message for i in issues) if issues else None + if role != Qt.ItemDataRole.DisplayRole: return None - field_name, _ = COLUMNS[index.column()] - + if field_name == "issues_display": + issues = validate_ticket(order.skus, order.line_items) + return f"{WARNING_MARK} ({len(issues)})" if issues else "" if field_name == "skus_display": return ", ".join(order.skus or []) if field_name == "packed_display":