Reuses the MetLife eligibility login (persistent profile, email MFA, OTP from app or browser), then Submit Claims -> ClaimConnect -> DentalXChange Single Claim: billing/rendering provider, member ID, last name, payer Metlife 65978, patient picked by DOB/name, signature on file Yes, one claim item per CDT code (date, code, tooth, surfaces, fee; Add Claim Item past 3), Finish, Submit Claim, then Back to Claim to read the Claim ID and save the page as PDF. Waits out Wicket ajax/veil before each click. MetLife now has its own fee schedule (seeded from DDMA) instead of falling back to MassHealth prices. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
679 lines
34 KiB
Python
679 lines
34 KiB
Python
from selenium.common.exceptions import (
|
|
TimeoutException, StaleElementReferenceException, ElementClickInterceptedException,
|
|
)
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.common.keys import Keys
|
|
from selenium.webdriver.support.ui import WebDriverWait, Select
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
import base64
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
from selenium_MetLife_eligibilityCheckWorker import AutomationMetLifeEligibilityCheck, METLIFE_HOME_URL
|
|
|
|
# Home page card link: <a id="document-forms-link" href="javascript:;">Submit Claims</a>
|
|
SUBMIT_CLAIMS_LINK_XPATH = "//a[@id='document-forms-link']"
|
|
# Submit Claims page: <button id="claims-submission-button">ClaimConnect</button>
|
|
CLAIMCONNECT_BUTTON_XPATH = "//button[@id='claims-submission-button']"
|
|
# "Leaving MetLife Website" modal: <button id="confirmation-modal-primary-button">Continue</button>
|
|
LEAVING_CONTINUE_BUTTON_XPATH = "//button[@id='confirmation-modal-primary-button']"
|
|
# DentalXChange dashboard (register.dentalxchange.com/reg/dashboard), Claims card:
|
|
# <span class="quickLinkContent"><span>Single Claim</span>…</span> — the click bubbles to its link
|
|
SINGLE_CLAIM_LINK_XPATH = "//span[contains(@class,'quickLinkContent')][span[normalize-space()='Single Claim']]"
|
|
|
|
# DentalXChange "Start New Claim" (Wicket app — ids like id11/id12 change, so match on
|
|
# placeholder text / labels / names). Dropdowns are Select2 v3.
|
|
def _select2_container_xpath(placeholder):
|
|
return (
|
|
"//div[contains(@class,'select2-container')]"
|
|
f"[.//span[contains(@class,'select2-chosen')][normalize-space()='{placeholder}']]"
|
|
)
|
|
BILLING_SELECT_XPATH = _select2_container_xpath("Select Billing Provider")
|
|
RENDERING_SELECT_XPATH = _select2_container_xpath("Select Rendering Provider")
|
|
PAYER_SELECT_XPATH = _select2_container_xpath("Type Payer Name")
|
|
# Open Select2 dropdown (only one is open at a time)
|
|
SELECT2_DROP_XPATH = "//div[@id='select2-drop' and not(contains(@style,'display: none'))]"
|
|
SELECT2_RESULT_XPATH = SELECT2_DROP_XPATH + "//li[contains(@class,'select2-result-selectable')]"
|
|
SELECT2_SEARCH_INPUT_XPATH = SELECT2_DROP_XPATH + "//input[contains(@class,'select2-input')]"
|
|
MEMBER_ID_INPUT_XPATH = "//*[contains(normalize-space(text()),'Member ID or SSN')]/following::input[@type='text'][1]"
|
|
LAST_NAME_INPUT_XPATH = "//input[contains(@name,'enterPatientContainer:patient.address.lastName')]"
|
|
NEXT_BUTTON_XPATH = "//button[@name='buttons:next']"
|
|
# MetLife payer in the DentalXChange payer list ("Metlife - 65978"; not "Metlife Tricare - 89070")
|
|
METLIFE_PAYER_SEARCH = "metlife"
|
|
METLIFE_PAYER_ID = "65978"
|
|
# "Select Patient" page: one row per covered person, Name cell is a link
|
|
# (Name | Relationship | Date of Birth | Group/Policy # | Status)
|
|
PATIENT_ROW_XPATH = "//table//tr[td and .//a]"
|
|
# "Eligible Patient Review" page: <span>Create Claim</span> inside the action button
|
|
CREATE_CLAIM_BUTTON_XPATH = "//*[self::button or self::a][normalize-space(.)='Create Claim']"
|
|
# "Claim Detail" page — Signature on file radios (each click fires a Wicket ajax call)
|
|
RELEASE_YES_RADIO_ID = "releaseYesButton"
|
|
ASSIGNMENT_YES_RADIO_ID = "assignmentYesButton"
|
|
# "Claim Item Detail" page — one panel per procedure line (3 shown by default).
|
|
# Field names are stable apart from the line index; every field posts a Wicket ajax
|
|
# call on change/blur, which can re-render the line, so always re-find by name.
|
|
ITEM_PREFIX = "view:formPanel:claimForm:claimItemListContainer:claimItemList:{i}:claimItemPanel:claimItemForm:"
|
|
ITEM_DATE_NAME = ITEM_PREFIX + "startDateInput"
|
|
ITEM_PROC_NAME = ITEM_PREFIX + "procCode"
|
|
ITEM_FEE_NAME = ITEM_PREFIX + "feeInput"
|
|
ITEM_TOOTH_NAME = ITEM_PREFIX + "claimToothListViewContainer:claimToothListView:0:tooth"
|
|
ADD_CLAIM_ITEM_BUTTON_XPATH = "//button[@name='view:formPanel:claimForm:add']"
|
|
FINISH_BUTTON_XPATH = "//button[@name='buttons:finish']"
|
|
# The 5 Surfaces dropdowns sit right after the Tooth box; options are
|
|
# Buccal, Distal, Facial, Incisal, Lingual, Mesial, Occlusal (first letter = code)
|
|
MAX_SURFACES = 5
|
|
# Review page after Finish: <button type="button" id="id116">Submit Claim</button> (ajax;
|
|
# id changes). While any ajax call runs, Wicket shows the full-page #ajax-indicator-veil.
|
|
SUBMIT_CLAIM_BUTTON_XPATH = "//button[normalize-space(.)='Submit Claim']"
|
|
AJAX_VEIL_ID = "ajax-indicator-veil"
|
|
# Confirmation page: "Submitted to MetLife" banner + <button>Back to Claim</button>
|
|
BACK_TO_CLAIM_BUTTON_XPATH = "//button[normalize-space(.)='Back to Claim']"
|
|
# agent.py serves this folder at /downloads so the backend can fetch the PDF
|
|
SHARED_DOWNLOADS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "downloads")
|
|
# True when no Wicket ajax request is in flight (Wicket 1.4/1.5 channel manager)
|
|
WICKET_IDLE_JS = r"""
|
|
try {
|
|
if (typeof Wicket === 'undefined' || !Wicket.channelManager) return true;
|
|
var ch = Wicket.channelManager.channels || {};
|
|
for (var k in ch) { if (ch[k] && ch[k].busy) return false; }
|
|
return true;
|
|
} catch (e) { return true; }
|
|
"""
|
|
|
|
|
|
class AutomationMetLifeClaimSubmit(AutomationMetLifeEligibilityCheck):
|
|
"""
|
|
MetLife Dental claim submission on dentalprovider.metlife.com.
|
|
|
|
Login (persistent Chrome profile, PingOne + email MFA) is inherited from the
|
|
eligibility worker so both flows share one session.
|
|
"""
|
|
|
|
def __init__(self, data):
|
|
claim = data.get("claim", {}) if isinstance(data, dict) else {}
|
|
super().__init__({"data": claim})
|
|
|
|
self.claim = claim
|
|
self.serviceDate = claim.get("serviceDate", "")
|
|
self.serviceLines = claim.get("serviceLines", []) or []
|
|
self.claimFiles = claim.get("claimFiles", []) or []
|
|
self.providerName = claim.get("providerName", "")
|
|
self.remarks = claim.get("remarks", "")
|
|
|
|
# Claim payload carries the patient name as one field
|
|
if not (self.firstName or self.lastName):
|
|
parts = (claim.get("patientName", "") or "").split()
|
|
if parts:
|
|
self.firstName = parts[0]
|
|
self.lastName = " ".join(parts[1:])
|
|
|
|
def _click(self, xpath, label, timeout=20):
|
|
"""Click the first visible element matching `xpath`."""
|
|
WebDriverWait(self.driver, timeout).until(
|
|
lambda d: any(e.is_displayed() for e in d.find_elements(By.XPATH, xpath))
|
|
)
|
|
el = next(e for e in self.driver.find_elements(By.XPATH, xpath) if e.is_displayed())
|
|
try:
|
|
el.click()
|
|
except Exception:
|
|
# href="javascript:;" / overlay — the React onClick does the navigation
|
|
self.driver.execute_script("arguments[0].click();", el)
|
|
print(f"[MetLife Claim] Clicked '{label}'")
|
|
|
|
def _click_and_follow(self, xpath, label, timeout=20):
|
|
"""
|
|
Click the first visible element matching `xpath`, then wait for the page to
|
|
change. If the click opened a new tab, switch to it.
|
|
"""
|
|
start_url = self.driver.current_url
|
|
start_handles = set(self.driver.window_handles)
|
|
self._click(xpath, label, timeout)
|
|
|
|
# The click may route inside the SPA or open a new tab
|
|
try:
|
|
WebDriverWait(self.driver, 15).until(
|
|
lambda d: d.current_url != start_url or len(d.window_handles) > len(start_handles)
|
|
)
|
|
except TimeoutException:
|
|
print(f"[MetLife Claim] No navigation after '{label}'; still on {self.driver.current_url}")
|
|
new_handles = [h for h in self.driver.window_handles if h not in start_handles]
|
|
if new_handles:
|
|
self.driver.switch_to.window(new_handles[-1])
|
|
print(f"[MetLife Claim] '{label}' opened a new tab — switched to it")
|
|
time.sleep(2)
|
|
print(f"[MetLife Claim] After '{label}': {self.driver.current_url}")
|
|
|
|
def step1_open_submit_claims(self):
|
|
"""From the home page, click "Submit Claims"."""
|
|
try:
|
|
if "/home" not in self.driver.current_url:
|
|
self.driver.get(METLIFE_HOME_URL)
|
|
self._click_and_follow(SUBMIT_CLAIMS_LINK_XPATH, "Submit Claims")
|
|
return "Success"
|
|
except TimeoutException:
|
|
return "ERROR: 'Submit Claims' link not found on MetLife home page"
|
|
except Exception as e:
|
|
return f"ERROR: Could not open Submit Claims: {e}"
|
|
|
|
def step2_open_claimconnect(self):
|
|
"""
|
|
On the Submit Claims page, click "ClaimConnect", then "Continue" on the
|
|
"Leaving MetLife Website" modal.
|
|
"""
|
|
try:
|
|
self._click(CLAIMCONNECT_BUTTON_XPATH, "ClaimConnect")
|
|
except TimeoutException:
|
|
return "ERROR: 'ClaimConnect' button not found on MetLife Submit Claims page"
|
|
try:
|
|
self._click_and_follow(LEAVING_CONTINUE_BUTTON_XPATH, "Continue (Leaving MetLife)", timeout=10)
|
|
return "Success"
|
|
except TimeoutException:
|
|
return "ERROR: 'Leaving MetLife Website' Continue button not found"
|
|
except Exception as e:
|
|
return f"ERROR: Could not open ClaimConnect: {e}"
|
|
|
|
def step3_open_single_claim(self):
|
|
"""On the DentalXChange dashboard (new tab), click "Single Claim"."""
|
|
try:
|
|
self._click_and_follow(SINGLE_CLAIM_LINK_XPATH, "Single Claim", timeout=30)
|
|
return "Success"
|
|
except TimeoutException:
|
|
return f"ERROR: 'Single Claim' link not found on DentalXChange dashboard ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: Could not open Single Claim: {e}"
|
|
|
|
# ── DentalXChange Select2 helpers ─────────────────────────────────────────
|
|
|
|
def _select2_open(self, container_xpath, label, timeout=20):
|
|
container = WebDriverWait(self.driver, timeout).until(
|
|
EC.presence_of_element_located((By.XPATH, container_xpath))
|
|
)
|
|
choice = container.find_element(By.XPATH, ".//a[contains(@class,'select2-choice')]")
|
|
try:
|
|
choice.click()
|
|
except Exception:
|
|
self.driver.execute_script(
|
|
"arguments[0].dispatchEvent(new MouseEvent('mousedown', {bubbles: true}));", choice
|
|
)
|
|
WebDriverWait(self.driver, 10).until(EC.visibility_of_element_located((By.XPATH, SELECT2_DROP_XPATH)))
|
|
print(f"[MetLife Claim] Opened '{label}' dropdown")
|
|
|
|
def _select2_pick(self, label, match=None, timeout=15):
|
|
"""Click the first open-dropdown result (or the first whose text contains `match`)."""
|
|
def find_result(d):
|
|
for li in d.find_elements(By.XPATH, SELECT2_RESULT_XPATH):
|
|
if li.is_displayed() and (match is None or match.lower() in li.text.lower()):
|
|
return li
|
|
return False
|
|
li = WebDriverWait(self.driver, timeout).until(find_result)
|
|
text = li.text.strip()
|
|
li.click()
|
|
print(f"[MetLife Claim] {label}: picked '{text}'")
|
|
# Wicket re-renders parts of the form after a selection
|
|
time.sleep(1.5)
|
|
return text
|
|
|
|
def _select2_choose(self, container_xpath, label, search=None, match=None):
|
|
self._select2_open(container_xpath, label)
|
|
if search:
|
|
box = WebDriverWait(self.driver, 10).until(
|
|
EC.visibility_of_element_located((By.XPATH, SELECT2_SEARCH_INPUT_XPATH))
|
|
)
|
|
box.send_keys(search)
|
|
return self._select2_pick(label, match)
|
|
|
|
def _fill_text(self, xpath, value, label):
|
|
el = WebDriverWait(self.driver, 15).until(EC.visibility_of_element_located((By.XPATH, xpath)))
|
|
el.clear()
|
|
el.send_keys(value)
|
|
el.send_keys(Keys.TAB)
|
|
print(f"[MetLife Claim] Filled {label}")
|
|
time.sleep(0.5)
|
|
|
|
def _click_next(self, page, xpath=NEXT_BUTTON_XPATH, label="Next"):
|
|
"""
|
|
Click a wizard button ("Next" by default) and wait for the next page.
|
|
Returns "Success", or "ERROR: …" with the page's validation messages.
|
|
"""
|
|
# The last field's ajax call (e.g. Fee) may still be in flight and overlay the page
|
|
self._wait_wicket_idle()
|
|
try:
|
|
self._wait_veil_gone(timeout=30)
|
|
except TimeoutException:
|
|
print(f"[MetLife Claim] Ajax veil still showing before '{label}'")
|
|
next_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, xpath))
|
|
)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", next_btn)
|
|
time.sleep(0.5)
|
|
try:
|
|
next_btn.click()
|
|
except ElementClickInterceptedException:
|
|
# Something overlays the button (e.g. date picker / page footer) — the
|
|
# native click never landed, so a script click submits exactly once
|
|
print(f"[MetLife Claim] '{label}' click intercepted — clicking via script")
|
|
self.driver.execute_script("arguments[0].click();", next_btn)
|
|
print(f"[MetLife Claim] Clicked '{label}' on {page}")
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(EC.staleness_of(next_btn))
|
|
except TimeoutException:
|
|
# Still on the page — surface any validation message (e.g. "Member ID is required.")
|
|
errors = [e.text.strip() for e in self.driver.find_elements(
|
|
By.XPATH, "//span[contains(@class,'feedbacklabel') and contains(@class,'ERROR')]"
|
|
) if e.text.strip()]
|
|
if errors:
|
|
return f"ERROR: DentalXChange {page}: {'; '.join(errors)}"
|
|
time.sleep(2)
|
|
print(f"[MetLife Claim] After {page}: {self.driver.current_url}")
|
|
return "Success"
|
|
|
|
def step4_start_new_claim(self):
|
|
"""
|
|
Start New Claim page: Billing + Rendering provider (first entry), Member ID,
|
|
patient last name, payer "Metlife - 65978", then Next.
|
|
"""
|
|
member_id = re.sub(r"[\s-]", "", self.memberId or "")
|
|
if not member_id:
|
|
return "ERROR: Member ID is required for MetLife claim"
|
|
try:
|
|
self._select2_choose(BILLING_SELECT_XPATH, "Billing provider")
|
|
self._select2_choose(RENDERING_SELECT_XPATH, "Rendering provider")
|
|
|
|
self._fill_text(MEMBER_ID_INPUT_XPATH, member_id, "Member ID")
|
|
if self.lastName:
|
|
self._fill_text(LAST_NAME_INPUT_XPATH, self.lastName, "Last Name")
|
|
|
|
self._select2_choose(PAYER_SELECT_XPATH, "Payer", search=METLIFE_PAYER_SEARCH, match=METLIFE_PAYER_ID)
|
|
|
|
return self._click_next("Start New Claim")
|
|
except TimeoutException as e:
|
|
return f"ERROR: DentalXChange Start New Claim — element not found ({e.msg or 'timeout'})"
|
|
except Exception as e:
|
|
return f"ERROR: DentalXChange Start New Claim failed: {e}"
|
|
|
|
@staticmethod
|
|
def _mmddyyyy(value):
|
|
"""YYYY-MM-DD[...] or MM/DD/YYYY as MM/DD/YYYY ("" if unparseable)."""
|
|
v = str(value or "").strip()
|
|
m = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", v)
|
|
if m:
|
|
return f"{m.group(2).zfill(2)}/{m.group(3).zfill(2)}/{m.group(1)}"
|
|
m = re.match(r"(\d{1,2})/(\d{1,2})/(\d{4})", v)
|
|
if m:
|
|
return f"{m.group(1).zfill(2)}/{m.group(2).zfill(2)}/{m.group(3)}"
|
|
return ""
|
|
|
|
def _expected_dob(self):
|
|
return self._mmddyyyy(self.dateOfBirth)
|
|
|
|
def step5_select_patient(self):
|
|
"""
|
|
"Select Patient" page: click the covered person matching the claim's patient
|
|
(DOB first, then name). Then "Create Claim" on "Eligible Patient Review".
|
|
"""
|
|
try:
|
|
# Either the patient list, or straight to the review page (single match)
|
|
WebDriverWait(self.driver, 30).until(
|
|
lambda d: d.find_elements(By.XPATH, PATIENT_ROW_XPATH)
|
|
or d.find_elements(By.XPATH, CREATE_CLAIM_BUTTON_XPATH)
|
|
)
|
|
rows = [r for r in self.driver.find_elements(By.XPATH, PATIENT_ROW_XPATH) if r.is_displayed()]
|
|
if rows and not self.driver.find_elements(By.XPATH, CREATE_CLAIM_BUTTON_XPATH):
|
|
expected_dob = self._expected_dob()
|
|
first = (self.firstName or "").strip().upper()
|
|
last = (self.lastName or "").strip().upper()
|
|
|
|
parsed = []
|
|
for r in rows:
|
|
cells = [c.text.strip() for c in r.find_elements(By.TAG_NAME, "td")]
|
|
name = cells[0].upper() if cells else ""
|
|
dob = next((c for c in cells if re.fullmatch(r"\d{2}/\d{2}/\d{4}", c)), "")
|
|
parsed.append((r, name, dob))
|
|
print(f"[MetLife Claim step5] Row: {cells}")
|
|
|
|
def name_match(name):
|
|
return (not first or first in name) and (not last or last in name)
|
|
|
|
matches = [p for p in parsed if expected_dob and p[2] == expected_dob]
|
|
if len(matches) > 1:
|
|
matches = [p for p in matches if name_match(p[1])] or matches
|
|
if not matches:
|
|
matches = [p for p in parsed if name_match(p[1])]
|
|
if not matches:
|
|
listed = ", ".join(f"{p[1]} ({p[2]})" for p in parsed)
|
|
return (f"ERROR: Patient {first} {last} ({expected_dob}) not listed by MetLife. "
|
|
f"Covered: {listed}")
|
|
|
|
row, name, dob = matches[0]
|
|
link = row.find_element(By.XPATH, ".//a")
|
|
try:
|
|
link.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", link)
|
|
print(f"[MetLife Claim step5] Selected patient {name} ({dob})")
|
|
|
|
create_btn = WebDriverWait(self.driver, 30).until(
|
|
EC.element_to_be_clickable((By.XPATH, CREATE_CLAIM_BUTTON_XPATH))
|
|
)
|
|
create_btn.click()
|
|
print("[MetLife Claim step5] Clicked 'Create Claim'")
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(EC.staleness_of(create_btn))
|
|
except TimeoutException:
|
|
print("[MetLife Claim step5] Page did not change after 'Create Claim'")
|
|
time.sleep(2)
|
|
print(f"[MetLife Claim step5] After Create Claim: {self.driver.current_url}")
|
|
return "Success"
|
|
except TimeoutException:
|
|
return f"ERROR: DentalXChange Select Patient / Create Claim not found ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: DentalXChange Select Patient failed: {e}"
|
|
|
|
def _check_radio(self, radio_id, label):
|
|
"""Select a radio by id and confirm it stuck (Wicket ajax may re-render it)."""
|
|
for attempt in range(3):
|
|
radio = WebDriverWait(self.driver, 20).until(
|
|
EC.presence_of_element_located((By.ID, radio_id))
|
|
)
|
|
if radio.is_selected():
|
|
print(f"[MetLife Claim] {label}: Yes")
|
|
return True
|
|
try:
|
|
radio.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", radio)
|
|
time.sleep(1.5) # let the ajax round-trip finish
|
|
radio = self.driver.find_element(By.ID, radio_id)
|
|
if radio.is_selected():
|
|
print(f"[MetLife Claim] {label}: Yes")
|
|
return True
|
|
return False
|
|
|
|
def step6_claim_detail(self):
|
|
"""
|
|
Claim Detail page: Signature on file — Release of information Yes,
|
|
Assignment of payment Yes — then Next.
|
|
"""
|
|
try:
|
|
if not self._check_radio(RELEASE_YES_RADIO_ID, "Release of information by provider"):
|
|
return "ERROR: Could not select 'Yes' for Release of information by provider"
|
|
if not self._check_radio(ASSIGNMENT_YES_RADIO_ID, "Assignment of payment to provider"):
|
|
return "ERROR: Could not select 'Yes' for Assignment of payment to provider"
|
|
return self._click_next("Claim Detail")
|
|
except TimeoutException:
|
|
return f"ERROR: DentalXChange Claim Detail page not found ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: DentalXChange Claim Detail failed: {e}"
|
|
|
|
# ── Claim Item Detail ─────────────────────────────────────────────────────
|
|
|
|
def _by_name(self, name, timeout=15):
|
|
return WebDriverWait(self.driver, timeout).until(
|
|
EC.presence_of_element_located((By.NAME, name))
|
|
)
|
|
|
|
def _wait_wicket_idle(self, timeout=15):
|
|
"""Wait for in-flight Wicket ajax calls (they re-render the line) to finish."""
|
|
time.sleep(0.3)
|
|
try:
|
|
WebDriverWait(self.driver, timeout).until(lambda d: d.execute_script(WICKET_IDLE_JS))
|
|
except TimeoutException:
|
|
print("[MetLife Claim step7] Wicket ajax still busy — continuing")
|
|
time.sleep(0.3)
|
|
|
|
@staticmethod
|
|
def _same_value(actual, expected):
|
|
"""Field value matches what was typed (fees compared as numbers, "$90.00" == "90")."""
|
|
try:
|
|
return abs(float(re.sub(r"[$,\s]", "", actual)) - float(expected)) < 0.005
|
|
except ValueError:
|
|
return re.sub(r"[^A-Z0-9]", "", actual.upper()) == re.sub(r"[^A-Z0-9]", "", expected.upper())
|
|
|
|
def _set_item_field(self, name, value, label):
|
|
"""
|
|
Type into a line field and tab out so its Wicket onchange/onblur fires.
|
|
The previous field's ajax call can re-render the line mid-typing, so wait
|
|
for it, retry on a stale element, and verify the value stuck.
|
|
"""
|
|
for attempt in range(1, 4):
|
|
self._wait_wicket_idle()
|
|
try:
|
|
el = self._by_name(name)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", el)
|
|
try:
|
|
el.click()
|
|
el.send_keys(Keys.CONTROL, "a")
|
|
el.send_keys(Keys.BACKSPACE)
|
|
el.send_keys(value)
|
|
el.send_keys(Keys.TAB)
|
|
except StaleElementReferenceException:
|
|
raise
|
|
except Exception as type_err:
|
|
# Not interactable — set it directly and fire the inline onchange/onblur
|
|
print(f"[MetLife Claim step7] {label}: typing failed ({type_err}); setting via JS")
|
|
self.driver.execute_script(
|
|
"arguments[0].value = arguments[1];"
|
|
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));"
|
|
"arguments[0].dispatchEvent(new Event('blur'));"
|
|
"if (arguments[0].onchange) arguments[0].onchange();"
|
|
"if (arguments[0].onblur) arguments[0].onblur();",
|
|
el, value,
|
|
)
|
|
self._wait_wicket_idle()
|
|
actual = (self._by_name(name).get_attribute("value") or "").strip()
|
|
if self._same_value(actual, value):
|
|
print(f"[MetLife Claim step7] {label} = {value}")
|
|
return
|
|
print(f"[MetLife Claim step7] {label}: expected {value!r}, field shows {actual!r} (attempt {attempt})")
|
|
except StaleElementReferenceException:
|
|
print(f"[MetLife Claim step7] {label}: field re-rendered (attempt {attempt}) — retrying")
|
|
raise RuntimeError(f"{label}: could not set {value!r}")
|
|
|
|
def _set_item_surfaces(self, i, surfaces):
|
|
tooth = self._by_name(ITEM_TOOTH_NAME.format(i=i))
|
|
selects = tooth.find_elements(By.XPATH, f"./following::select[position()<={MAX_SURFACES}]")
|
|
for n, code in enumerate(surfaces[:MAX_SURFACES]):
|
|
if n >= len(selects):
|
|
return f"ERROR: Line {i + 1}: only {len(selects)} surface dropdowns found"
|
|
sel = Select(selects[n])
|
|
option = next((o for o in sel.options
|
|
if (o.get_attribute("value") or "").upper() == code
|
|
or (o.text or "").strip().upper().startswith(code)), None)
|
|
if option is None:
|
|
return f"ERROR: Line {i + 1}: surface '{code}' not in dropdown"
|
|
sel.select_by_visible_text(option.text)
|
|
print(f"[MetLife Claim step7] Line {i + 1} surface{n + 1} = {option.text.strip()}")
|
|
time.sleep(0.5)
|
|
# Re-find after a possible re-render
|
|
tooth = self._by_name(ITEM_TOOTH_NAME.format(i=i))
|
|
selects = tooth.find_elements(By.XPATH, f"./following::select[position()<={MAX_SURFACES}]")
|
|
return None
|
|
|
|
def _ensure_item(self, i):
|
|
"""Click "Add Claim Item" until line index `i` exists."""
|
|
for _ in range(3):
|
|
if self.driver.find_elements(By.NAME, ITEM_DATE_NAME.format(i=i)):
|
|
return True
|
|
btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, ADD_CLAIM_ITEM_BUTTON_XPATH))
|
|
)
|
|
btn.click()
|
|
print(f"[MetLife Claim step7] Clicked 'Add Claim Item' for line {i + 1}")
|
|
try:
|
|
self._by_name(ITEM_DATE_NAME.format(i=i), timeout=10)
|
|
return True
|
|
except TimeoutException:
|
|
continue
|
|
return False
|
|
|
|
def step7_fill_claim_items(self):
|
|
"""Claim Item Detail page: one line per procedure code, then Finish."""
|
|
lines = [l for l in self.serviceLines if str(l.get("procedureCode") or "").strip()]
|
|
if not lines:
|
|
return "ERROR: No procedure codes on the claim"
|
|
try:
|
|
self._by_name(ITEM_DATE_NAME.format(i=0), timeout=30)
|
|
for i, line in enumerate(lines):
|
|
code = str(line.get("procedureCode") or "").strip().upper()
|
|
date = self._mmddyyyy(line.get("procedureDate")) or self._mmddyyyy(self.serviceDate)
|
|
# Tooth box takes 2 characters (1-32, A-T); drop "#", spaces, etc.
|
|
tooth = re.sub(r"[^A-Za-z0-9]", "", str(line.get("toothNumber") or "")).upper()
|
|
surfaces = [c for c in str(line.get("toothSurface") or "").upper() if c.isalpha()]
|
|
billed = line.get("totalBilled") or line.get("billedAmount") or line.get("fee")
|
|
try:
|
|
fee = f"{float(billed):.2f}" if billed not in (None, "") else ""
|
|
except (TypeError, ValueError):
|
|
fee = ""
|
|
print(f"[MetLife Claim step7] Line {i + 1}: code={code} date={date} "
|
|
f"tooth={tooth!r} surf={surfaces} fee={fee}")
|
|
|
|
if not date:
|
|
return f"ERROR: Line {i + 1} ({code}): no service date"
|
|
if not self._ensure_item(i):
|
|
return f"ERROR: Could not add claim item line {i + 1}"
|
|
|
|
self._set_item_field(ITEM_DATE_NAME.format(i=i), date, f"Line {i + 1} date")
|
|
self._set_item_field(ITEM_PROC_NAME.format(i=i), code, f"Line {i + 1} procedure")
|
|
if tooth:
|
|
self._set_item_field(ITEM_TOOTH_NAME.format(i=i), tooth, f"Line {i + 1} tooth")
|
|
if surfaces:
|
|
err = self._set_item_surfaces(i, surfaces)
|
|
if err:
|
|
return err
|
|
if fee:
|
|
self._set_item_field(ITEM_FEE_NAME.format(i=i), fee, f"Line {i + 1} fee")
|
|
|
|
return self._click_next("Claim Item Detail", FINISH_BUTTON_XPATH, "Finish")
|
|
except TimeoutException:
|
|
return f"ERROR: DentalXChange Claim Item Detail field not found ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: DentalXChange Claim Item Detail failed: {e}"
|
|
|
|
def _wait_veil_gone(self, timeout=60):
|
|
"""Wait until Wicket's full-page ajax veil is hidden (it blocks clicks)."""
|
|
def gone(d):
|
|
veils = d.find_elements(By.ID, AJAX_VEIL_ID)
|
|
return not veils or not veils[0].is_displayed()
|
|
WebDriverWait(self.driver, timeout).until(gone)
|
|
|
|
def step8_submit_claim(self):
|
|
"""Review page: click "Submit Claim" once and wait for the portal's response."""
|
|
try:
|
|
self._wait_wicket_idle()
|
|
self._wait_veil_gone()
|
|
btn = WebDriverWait(self.driver, 30).until(
|
|
EC.element_to_be_clickable((By.XPATH, SUBMIT_CLAIM_BUTTON_XPATH))
|
|
)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn)
|
|
time.sleep(0.5)
|
|
try:
|
|
btn.click()
|
|
except ElementClickInterceptedException:
|
|
# The native click never landed, so a script click submits exactly once
|
|
print("[MetLife Claim step8] 'Submit Claim' click intercepted — clicking via script")
|
|
self.driver.execute_script("arguments[0].click();", btn)
|
|
print("[MetLife Claim step8] Clicked 'Submit Claim'")
|
|
|
|
# Submission is an ajax call: wait for the veil to clear and the button to go away
|
|
time.sleep(1)
|
|
self._wait_veil_gone(timeout=90)
|
|
self._wait_wicket_idle(timeout=60)
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(
|
|
lambda d: not any(b.is_displayed() for b in d.find_elements(By.XPATH, SUBMIT_CLAIM_BUTTON_XPATH))
|
|
)
|
|
except TimeoutException:
|
|
errors = [e.text.strip() for e in self.driver.find_elements(
|
|
By.XPATH, "//span[contains(@class,'feedbacklabel') and contains(@class,'ERROR')]"
|
|
" | //*[contains(@class,'feedbackPanelERROR') or contains(@class,'alert-error')]"
|
|
) if e.text.strip()]
|
|
if errors:
|
|
return f"ERROR: DentalXChange Submit Claim: {'; '.join(errors)}"
|
|
return "ERROR: 'Submit Claim' did not complete — check the browser window before resubmitting"
|
|
time.sleep(2)
|
|
print(f"[MetLife Claim step8] After Submit Claim: {self.driver.current_url}")
|
|
return "Success"
|
|
except TimeoutException:
|
|
return f"ERROR: DentalXChange review page — 'Submit Claim' not found ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: DentalXChange Submit Claim failed: {e}"
|
|
|
|
def _extract_claim_id(self):
|
|
"""Claim ID from the claim page ("Claim ID 736537870"), else from the URL path."""
|
|
try:
|
|
text = self.driver.execute_script("return document.body.innerText;") or ""
|
|
except Exception:
|
|
text = ""
|
|
m = re.search(r"Claim\s*ID\s*[:#]?\s*(\d{6,})", text, re.IGNORECASE)
|
|
if m:
|
|
return m.group(1)
|
|
# Claim pages live at …/claim/<claimId>?<n>-<n>
|
|
m = re.search(r"/(\d{6,})(?:\?|$)", self.driver.current_url)
|
|
return m.group(1) if m else None
|
|
|
|
def _save_page_pdf(self, claim_id):
|
|
os.makedirs(SHARED_DOWNLOADS_DIR, exist_ok=True)
|
|
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
|
|
safe_claim = f"_{claim_id}" if claim_id else ""
|
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
pdf_path = os.path.join(SHARED_DOWNLOADS_DIR, f"metlife_claim_{safe_member}{safe_claim}_{timestamp}.pdf")
|
|
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {
|
|
"printBackground": True,
|
|
"paperWidth": 8.5,
|
|
"paperHeight": 11,
|
|
"marginTop": 0.4,
|
|
"marginBottom": 0.4,
|
|
"marginLeft": 0.4,
|
|
"marginRight": 0.4,
|
|
})
|
|
with open(pdf_path, "wb") as f:
|
|
f.write(base64.b64decode(pdf_data["data"]))
|
|
print(f"[MetLife Claim step9] PDF saved: {pdf_path}")
|
|
return pdf_path
|
|
|
|
def step9_save_claim(self):
|
|
"""
|
|
Confirmation page ("Submitted to MetLife"): click "Back to Claim", read the
|
|
Claim ID and save the claim page as a PDF.
|
|
Returns {"claimNumber", "pdf_path"} or "ERROR: …".
|
|
"""
|
|
try:
|
|
self._wait_veil_gone(timeout=30)
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(
|
|
lambda d: "Submitted to MetLife" in (d.execute_script("return document.body.innerText;") or "")
|
|
)
|
|
print("[MetLife Claim step9] 'Submitted to MetLife' confirmed")
|
|
except TimeoutException:
|
|
print("[MetLife Claim step9] 'Submitted to MetLife' banner not seen — continuing")
|
|
|
|
old_page = self.driver.find_element(By.TAG_NAME, "body")
|
|
self._click(BACK_TO_CLAIM_BUTTON_XPATH, "Back to Claim", timeout=30)
|
|
# Plain navigation (window.location.href)
|
|
try:
|
|
WebDriverWait(self.driver, 30).until(EC.staleness_of(old_page))
|
|
except TimeoutException:
|
|
print("[MetLife Claim step9] Page did not reload after 'Back to Claim'")
|
|
WebDriverWait(self.driver, 30).until(
|
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
)
|
|
self._wait_wicket_idle()
|
|
time.sleep(1.5)
|
|
print(f"[MetLife Claim step9] Claim page: {self.driver.current_url}")
|
|
|
|
claim_id = self._extract_claim_id()
|
|
print(f"[MetLife Claim step9] Claim ID: {claim_id}")
|
|
|
|
try:
|
|
pdf_path = self._save_page_pdf(claim_id)
|
|
except Exception as e:
|
|
# The claim is already submitted — don't fail the job over the PDF
|
|
print(f"[MetLife Claim step9] PDF capture failed (non-fatal): {e}")
|
|
pdf_path = None
|
|
return {"claimNumber": claim_id, "pdf_path": pdf_path}
|
|
except TimeoutException:
|
|
return f"ERROR: 'Back to Claim' not found after submit ({self.driver.current_url})"
|
|
except Exception as e:
|
|
return f"ERROR: Reading submitted claim failed: {e}"
|