Rework of workflow, added the dock back. This is now the beginning of the SKU mismatch issues. FKN FUN TIMES AHEAD

This commit is contained in:
2026-09-04 12:09:49 -05:00
parent a1b438a61f
commit 52bd6f5da2
8 changed files with 395 additions and 68 deletions
+23 -2
View File
@@ -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
+27 -2
View File
@@ -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 = (
+40
View File
@@ -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)}"
+25 -9
View File
@@ -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"]
+113
View File
@@ -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
+111 -42
View File
@@ -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,
+39 -10
View File
@@ -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)
+17 -3
View File
@@ -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":