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:
+23
-2
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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)}"
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user