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 ---
|
# --- Companies ---
|
||||||
# SKU prefix -> company. Add more "PREFIX:Company" pairs as you add 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
|
# Fallback pattern for recognizing the AR-###### ticket number if it's ever
|
||||||
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
|
# not found in ShipStation's usual shipment_number/external_shipment_id fields.
|
||||||
TICKET_NUMBER_REGEX=\b[A-Z]{2,6}-\d{3,}\b
|
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_STATE=TX
|
||||||
OAKSTREET_RETURN_ZIP=75067
|
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 ---
|
# --- Packing ---
|
||||||
# Serial-number fields to suggest per device keyword found in a ticket's line
|
# 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
|
# items, as a starting point in the Pack Ticket dialog (staff can always add
|
||||||
# a custom field for anything not matched here). Format:
|
# a custom field for anything not matched here). Format:
|
||||||
# keyword:Field One|Field Two,keyword2:Field Three
|
# 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
|
# Maps a ShipStation service code (from the outbound label) to your team's
|
||||||
# term for the Shipping Method column - confirmed against your real usage.
|
# 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
|
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",
|
"Packing",
|
||||||
False,
|
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_CARRIER_ID": ("Signify Return: ShipStation Carrier ID", "Return Labels", False),
|
||||||
"SIGNIFY_RETURN_SERVICE_CODE": (
|
"SIGNIFY_RETURN_SERVICE_CODE": (
|
||||||
"Signify Return: Service code (e.g. ups_ground)",
|
"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_STATE": ("Oak Street Return Address: State", "Return Labels", False),
|
||||||
"OAKSTREET_RETURN_ZIP": ("Oak Street Return Address: Zip", "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": (
|
"ACTIVE_STATUSES": (
|
||||||
"Statuses that count as real active work (comma-separated) - "
|
"Statuses that count as real active work (comma-separated) - "
|
||||||
"everything else is treated as done",
|
"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_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_TICKET_NUMBER_REGEX = r"\b[A-Z]{2,6}-\d{3,}\b"
|
||||||
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
DEFAULT_FULFILLED_WITH_RETURN = "Waiting For Return"
|
||||||
DEFAULT_FULFILLED_WITHOUT_RETURN = "Device Return Not Needed"
|
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,"
|
"phone:Phone IMEI|Phone Serial Number|Phone ICCID|Phone Asset Tag,"
|
||||||
"ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,"
|
"ipad:iPad IMEI|iPad Serial Number|iPad ICCID|iPad Asset Tag,"
|
||||||
"spiro:Spiro Serial Number|Spiro 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.
|
# Confirmed against ShipStation's own UPS service code reference, not guessed.
|
||||||
DEFAULT_SHIPPING_METHOD_LABELS = (
|
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"}
|
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()
|
settings = config.load_settings()
|
||||||
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
prefix = _return_config_prefix(order)
|
||||||
return {
|
return {
|
||||||
"name": settings.get(f"{prefix}NAME", ""),
|
"name": settings.get(f"{prefix}NAME", ""),
|
||||||
"phone": settings.get(f"{prefix}PHONE", ""),
|
"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]:
|
def _return_carrier_for_order(order: Order) -> tuple[str, str]:
|
||||||
"""Each company has its own UPS account (different carrier_id) even
|
"""Each shipping account (Signify, Oak Street, and RubiconMD as its own
|
||||||
though they share a warehouse - matches how store/warehouse IDs are
|
subsidiary account) has its own UPS carrier_id, even where the physical
|
||||||
already split per company elsewhere in this app."""
|
warehouse is shared."""
|
||||||
settings = config.load_settings()
|
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", "")
|
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:
|
if not api_key:
|
||||||
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
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:
|
if not carrier_id or not service_code:
|
||||||
raise ShipStationSendError(
|
raise ShipStationSendError(
|
||||||
f"No return-label Carrier ID/Service Code configured for '{order.company}'. "
|
f"No return-label Carrier ID/Service Code configured for '{order.company}'. "
|
||||||
"Add them in Settings under Return Labels."
|
"Add them in Settings under Return Labels."
|
||||||
)
|
)
|
||||||
|
|
||||||
return_address = _return_address_for_company(order.company)
|
return_address = _return_address_for_order(order)
|
||||||
if not (
|
if not (
|
||||||
return_address["address_line1"]
|
return_address["address_line1"]
|
||||||
and return_address["city_locality"]
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
@@ -26,6 +28,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.external_links import jira_ticket_url, google_maps_search_url
|
||||||
from app.return_labels import is_emailed_label_order
|
from app.return_labels import is_emailed_label_order
|
||||||
from app.services import SERVICE_REGISTRY
|
from app.services import SERVICE_REGISTRY
|
||||||
from app.services.odoo_export import export_orders_to_csv
|
from app.services.odoo_export import export_orders_to_csv
|
||||||
@@ -91,62 +94,92 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
|
|
||||||
toolbar = QToolBar("Main")
|
menu_bar = self.menuBar()
|
||||||
toolbar.setMovable(False)
|
|
||||||
self.addToolBar(toolbar)
|
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 = QAction("Import from JIRA", self)
|
||||||
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
|
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
|
self._import_actions["jira"] = jira_action
|
||||||
|
|
||||||
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self)
|
||||||
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
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
|
self._import_actions["shipstation"] = shipstation_action
|
||||||
|
|
||||||
toolbar.addSeparator()
|
data_menu.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)
|
|
||||||
|
|
||||||
refresh_action = QAction("Refresh from Local DB", self)
|
refresh_action = QAction("Refresh from Local DB", self)
|
||||||
refresh_action.triggered.connect(self._refresh_everything)
|
refresh_action.triggered.connect(self._refresh_everything)
|
||||||
toolbar.addAction(refresh_action)
|
data_menu.addAction(refresh_action)
|
||||||
|
|
||||||
reset_action = QAction("Reset Local Database", self)
|
export_action = QAction("Export Visible Orders to Odoo CSV...", self)
|
||||||
reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward")
|
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)
|
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.status_bar = QStatusBar()
|
||||||
self.setStatusBar(self.status_bar)
|
self.setStatusBar(self.status_bar)
|
||||||
@@ -481,6 +514,42 @@ class MainWindow(QMainWindow):
|
|||||||
self._refresh_everything()
|
self._refresh_everything()
|
||||||
self.status_label.setText(f"Saved pack data for {ticket_number}.")
|
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:
|
def _on_reset_database_clicked(self) -> None:
|
||||||
confirm = QMessageBox.warning(
|
confirm = QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ Order detail dialog.
|
|||||||
Mainly a debugging aid: shows exactly what JIRA sent back for this
|
Mainly a debugging aid: shows exactly what JIRA sent back for this
|
||||||
ticket (raw payload), plus the tracking numbers ShipStation supplied
|
ticket (raw payload), plus the tracking numbers ShipStation supplied
|
||||||
and the JIRA status they suggest - handy for the end-of-day close-out
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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.models import Order
|
||||||
|
from app.ticket_validation import validate_ticket
|
||||||
from app.tracking import suggest_jira_status
|
from app.tracking import suggest_jira_status
|
||||||
|
|
||||||
|
|
||||||
@@ -38,12 +43,21 @@ class OrderDetailDialog(QDialog):
|
|||||||
info = order.shipping_info or {}
|
info = order.shipping_info or {}
|
||||||
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
address_line2 = f" {info.get('address2')}" if info.get("address2") else ""
|
||||||
|
|
||||||
|
issues = validate_ticket(order.skus, order.line_items)
|
||||||
|
|
||||||
summary_lines = [
|
summary_lines = [
|
||||||
f"Ticket #: {order.ticket_number or '(none found)'}",
|
f"Ticket #: {order.ticket_number or '(none found)'}",
|
||||||
f"Company: {order.company}",
|
f"Company: {order.company}",
|
||||||
f"Created by: {order.creator or '(unknown)'}",
|
f"Created by: {order.creator or '(unknown)'}",
|
||||||
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
||||||
f"Status: {order.status}",
|
f"Status: {order.status}",
|
||||||
|
]
|
||||||
|
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):",
|
"Shipping info (used by the emergency Send to ShipStation action):",
|
||||||
f" Name: {info.get('name') or '(missing)'}",
|
f" Name: {info.get('name') or '(missing)'}",
|
||||||
@@ -53,6 +67,7 @@ class OrderDetailDialog(QDialog):
|
|||||||
"",
|
"",
|
||||||
"Tracking numbers:",
|
"Tracking numbers:",
|
||||||
]
|
]
|
||||||
|
)
|
||||||
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
|
summary_lines.extend(tracking_lines or [" (none pulled yet)"])
|
||||||
if suggested:
|
if suggested:
|
||||||
summary_lines.append("")
|
summary_lines.append("")
|
||||||
@@ -61,6 +76,16 @@ class OrderDetailDialog(QDialog):
|
|||||||
summary_label = QLabel("\n".join(summary_lines))
|
summary_label = QLabel("\n".join(summary_lines))
|
||||||
layout.addWidget(summary_label)
|
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:"))
|
layout.addWidget(QLabel("Raw payload from source system:"))
|
||||||
text = QTextEdit()
|
text = QTextEdit()
|
||||||
text.setReadOnly(True)
|
text.setReadOnly(True)
|
||||||
@@ -71,3 +96,7 @@ class OrderDetailDialog(QDialog):
|
|||||||
close_button = QPushButton("Close")
|
close_button = QPushButton("Close")
|
||||||
close_button.clicked.connect(self.accept)
|
close_button.clicked.connect(self.accept)
|
||||||
layout.addWidget(close_button)
|
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.return_labels import is_emailed_label_order
|
||||||
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
from app.schedule import get_cutoff_time, is_past_cutoff_today
|
||||||
from app.status_rules import get_cancelled_statuses, status_in
|
from app.status_rules import get_cancelled_statuses, status_in
|
||||||
|
from app.ticket_validation import validate_ticket
|
||||||
from app.tracking import get_fulfilled_statuses
|
from app.tracking import get_fulfilled_statuses
|
||||||
|
|
||||||
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
|
CANCELLED_TEXT_COLOR = QColor(180, 0, 0)
|
||||||
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
|
||||||
|
ISSUE_TEXT_COLOR = QColor(170, 100, 0)
|
||||||
CHECK_MARK = "\u2713"
|
CHECK_MARK = "\u2713"
|
||||||
|
WARNING_MARK = "\u26a0"
|
||||||
|
|
||||||
# Order here is display order, left to right.
|
# Order here is display order, left to right.
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
("company", "Company"),
|
("company", "Company"),
|
||||||
("ticket_number", "Ticket #"),
|
("ticket_number", "Ticket #"),
|
||||||
("status", "Status"),
|
("status", "Status"),
|
||||||
|
("issues_display", "Issues"),
|
||||||
("packed_display", "Done"),
|
("packed_display", "Done"),
|
||||||
("skus_display", "SKUs"),
|
("skus_display", "SKUs"),
|
||||||
("summary", "Summary"),
|
("summary", "Summary"),
|
||||||
@@ -108,9 +112,14 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
order = self._orders[index.row()]
|
order = self._orders[index.row()]
|
||||||
is_cancelled = status_in(order.status, self._cancelled_statuses)
|
is_cancelled = status_in(order.status, self._cancelled_statuses)
|
||||||
|
field_name, _ = COLUMNS[index.column()]
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.ForegroundRole:
|
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:
|
if role == Qt.ItemDataRole.BackgroundRole:
|
||||||
# Cancelled rows use red TEXT (above) instead of a background,
|
# Cancelled rows use red TEXT (above) instead of a background,
|
||||||
@@ -121,11 +130,16 @@ class OrdersTableModel(QAbstractTableModel):
|
|||||||
return FULFILLED_ROW_COLOR
|
return FULFILLED_ROW_COLOR
|
||||||
return None
|
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:
|
if role != Qt.ItemDataRole.DisplayRole:
|
||||||
return None
|
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":
|
if field_name == "skus_display":
|
||||||
return ", ".join(order.skus or [])
|
return ", ".join(order.skus or [])
|
||||||
if field_name == "packed_display":
|
if field_name == "packed_display":
|
||||||
|
|||||||
Reference in New Issue
Block a user