Files
Order-Manager/app/ticket_validation.py
T

114 lines
4.2 KiB
Python

"""
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