- AI-column auto-submit and manual "Claim All" dispatchers in claim-form.tsx had no United Ucard branch, so United Ucard claims silently fell through to the MassHealth (MH) submit handler instead of opening the United Ucard site. - Added a United Ucard fee schedule (copied from United SCO as a starting point) and wired it into the frontend price-mapping/mismatch-check code and the backend fee-schedule update route, which previously had no schedule for United Ucard and fell back to MassHealth's prices. - Selenium claim worker: when the member search returns multiple coverage records, it now tries the last row first (existing default) and falls back to the first row only if the "Member Details" link never appears (i.e. the last row was an inactive record), instead of only ever trying one or the other. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
817 lines
38 KiB
Python
817 lines
38 KiB
Python
"""
|
|
United Ucard (UHC Dental / OneHealthcareID) claim submission worker.
|
|
|
|
Scaffolded from selenium_UnitedUcard_eligibilityCheckWorker.py — same shape
|
|
(config_driver / login / claim steps) so it plugs into the same
|
|
helpers_uniteducard_claim.py + agent.py session/OTP pattern, and shares the
|
|
persistent browser (uniteducard_browser_manager.py) with the eligibility
|
|
worker so a device already trusted by an eligibility check skips OTP here too.
|
|
|
|
Login flow is identical to the eligibility worker (confirmed against the
|
|
live site, 2026-09-13) — see selenium_UnitedUcard_eligibilityCheckWorker.py
|
|
for the full annotated step-by-step.
|
|
|
|
The claim-specific steps below (search patient / open claim form / fill
|
|
service lines / attach files / submit / save confirmation PDF) are left as
|
|
TODO stubs — fill these in once we know the exact DOM for the claim
|
|
submission flow on the UHC Dental provider dashboard.
|
|
"""
|
|
from selenium.common.exceptions import WebDriverException, TimeoutException
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
import time
|
|
import os
|
|
|
|
from uniteducard_browser_manager import get_browser_manager
|
|
|
|
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
_BACKEND_CWD = os.path.normpath(os.path.join(_SERVICE_DIR, "..", "Backend"))
|
|
|
|
|
|
class AutomationUnitedUcardClaimSubmit:
|
|
def __init__(self, data):
|
|
self.headless = False
|
|
self.driver = None
|
|
|
|
self.data = data.get("claim", {}) if isinstance(data, dict) else {}
|
|
|
|
# Flatten values for convenience
|
|
self.memberId = self.data.get("memberId", "")
|
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
|
self.serviceDate = self.data.get("serviceDate", "")
|
|
self.firstName = self.data.get("firstName", "")
|
|
self.lastName = self.data.get("lastName", "")
|
|
self.serviceLines = self.data.get("serviceLines", [])
|
|
self.claimFiles = self.data.get("claimFiles", [])
|
|
self.providerName = self.data.get("providerName", "")
|
|
self.uniteducard_username = self.data.get("uniteducardUsername", "")
|
|
self.uniteducard_password = self.data.get("uniteducardPassword", "")
|
|
self.payment_group_id = self.data.get("paymentGroupId", "")
|
|
|
|
# Use browser manager's download dir (shared with eligibility worker)
|
|
self.download_dir = get_browser_manager().download_dir
|
|
os.makedirs(self.download_dir, exist_ok=True)
|
|
|
|
def config_driver(self):
|
|
# Use persistent browser from manager (keeps device trust tokens,
|
|
# shared with the eligibility worker's login session)
|
|
self.driver = get_browser_manager().get_driver(self.headless)
|
|
|
|
def _force_logout(self):
|
|
"""Force logout by clearing cookies for the One Healthcare ID domain."""
|
|
try:
|
|
print("[UnitedUcard Claim login] Forcing logout due to credential change...")
|
|
browser_manager = get_browser_manager()
|
|
try:
|
|
self.driver.delete_all_cookies()
|
|
print("[UnitedUcard Claim login] Cleared all cookies")
|
|
except Exception as e:
|
|
print(f"[UnitedUcard Claim login] Error clearing cookies: {e}")
|
|
browser_manager.clear_credentials_hash()
|
|
print("[UnitedUcard Claim login] Logout complete")
|
|
return True
|
|
except Exception as e:
|
|
print(f"[UnitedUcard Claim login] Error during forced logout: {e}")
|
|
return False
|
|
|
|
def login(self, url):
|
|
"""
|
|
Log in via https://www.uhcdental.com/content/provider/dental.html (Sign In ->
|
|
Provider Login), which OIDC-redirects through identity.onehealthcareid.com.
|
|
Identical flow to AutomationUnitedUcardEligibilityCheck.login().
|
|
|
|
Returns one of:
|
|
"ALREADY_LOGGED_IN" / "SUCCESS" - logged in, caller should proceed to claim steps
|
|
"OTP_REQUIRED" - caller should poll for OTP entry
|
|
"ERROR: <msg>" - login failed
|
|
"""
|
|
wait = WebDriverWait(self.driver, 30)
|
|
browser_manager = get_browser_manager()
|
|
|
|
try:
|
|
if self.uniteducard_username and browser_manager.credentials_changed(self.uniteducard_username):
|
|
self._force_logout()
|
|
self.driver.get(url)
|
|
time.sleep(2)
|
|
|
|
self.driver.get(url)
|
|
time.sleep(3)
|
|
|
|
current_url = self.driver.current_url
|
|
print(f"[UnitedUcard Claim login] Current URL: {current_url}")
|
|
|
|
if "secure.uhcdental.com" in current_url.lower():
|
|
print("[UnitedUcard Claim login] Already logged in - on UHC Dental dashboard")
|
|
return "ALREADY_LOGGED_IN"
|
|
|
|
if "onehealthcareid.com" not in current_url.lower():
|
|
try:
|
|
signin_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH,
|
|
"//span[contains(@class,'header-signin-btn') or contains(text(),'Sign In')]"))
|
|
)
|
|
signin_btn.click()
|
|
print("[UnitedUcard Claim login] Clicked 'Sign In' to open dropdown")
|
|
time.sleep(1)
|
|
except TimeoutException:
|
|
return "ERROR: 'Sign In' button not found on UHC Dental provider site"
|
|
|
|
clicked_provider_login = False
|
|
try:
|
|
provider_login_link = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//a[normalize-space(text())='Provider Login']"))
|
|
)
|
|
self.driver.execute_script(
|
|
"arguments[0].scrollIntoView({block:'center'});", provider_login_link
|
|
)
|
|
time.sleep(0.3)
|
|
try:
|
|
provider_login_link.click()
|
|
print("[UnitedUcard Claim login] Clicked 'Provider Login' (direct)")
|
|
clicked_provider_login = True
|
|
except Exception as direct_click_err:
|
|
print(f"[UnitedUcard Claim login] Direct click failed ({direct_click_err}); trying JS click")
|
|
self.driver.execute_script("arguments[0].click();", provider_login_link)
|
|
print("[UnitedUcard Claim login] Clicked 'Provider Login' (JS)")
|
|
clicked_provider_login = True
|
|
time.sleep(3)
|
|
except TimeoutException:
|
|
pass
|
|
|
|
if not clicked_provider_login:
|
|
try:
|
|
dropdown_link = WebDriverWait(self.driver, 5).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"(//a[contains(@class,'sign-in-button-link')])[1]"))
|
|
)
|
|
self.driver.execute_script("arguments[0].click();", dropdown_link)
|
|
print("[UnitedUcard Claim login] Clicked first dropdown link (Provider Login, by position)")
|
|
clicked_provider_login = True
|
|
time.sleep(3)
|
|
except Exception as e:
|
|
return f"ERROR: 'Provider Login' link not found in Sign In dropdown: {e}"
|
|
|
|
current_url = self.driver.current_url
|
|
print(f"[UnitedUcard Claim login] After Provider Login click URL: {current_url}")
|
|
|
|
if "secure.uhcdental.com" in current_url.lower():
|
|
print("[UnitedUcard Claim login] Already logged in - redirected straight to dashboard")
|
|
return "ALREADY_LOGGED_IN"
|
|
|
|
try:
|
|
WebDriverWait(self.driver, 3).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
|
|
"contains(@aria-label,'Verification') or contains(@id,'otp') or contains(@name,'otp')]"))
|
|
)
|
|
print("[UnitedUcard Claim login] OTP input found on landing")
|
|
return "OTP_REQUIRED"
|
|
except TimeoutException:
|
|
pass
|
|
|
|
try:
|
|
username_field = wait.until(
|
|
EC.element_to_be_clickable((By.ID, "username"))
|
|
)
|
|
username_field.clear()
|
|
username_field.send_keys(self.uniteducard_username)
|
|
print(f"[UnitedUcard Claim login] Entered username: {self.uniteducard_username}")
|
|
except TimeoutException:
|
|
return "ERROR: Username field not found on login page"
|
|
|
|
try:
|
|
continue_btn = wait.until(
|
|
EC.element_to_be_clickable((By.ID, "btnLogin"))
|
|
)
|
|
continue_btn.click()
|
|
print("[UnitedUcard Claim login] Clicked Continue (btnLogin) after username")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
return "ERROR: Continue button (btnLogin) not found after entering username"
|
|
|
|
try:
|
|
password_field = wait.until(
|
|
EC.presence_of_element_located((By.ID, "login-pwd"))
|
|
)
|
|
password_field.clear()
|
|
password_field.send_keys(self.uniteducard_password)
|
|
print("[UnitedUcard Claim login] Entered password")
|
|
except TimeoutException:
|
|
return "ERROR: Password field not found on login page"
|
|
|
|
try:
|
|
signin_button = wait.until(
|
|
EC.element_to_be_clickable((By.ID, "btnLogin"))
|
|
)
|
|
signin_button.click()
|
|
print("[UnitedUcard Claim login] Clicked Continue (btnLogin) on password page")
|
|
except TimeoutException:
|
|
return "ERROR: Continue button (btnLogin) not found on password page"
|
|
|
|
if self.uniteducard_username:
|
|
browser_manager.save_credentials_hash(self.uniteducard_username)
|
|
|
|
time.sleep(5)
|
|
|
|
current_url_after_login = self.driver.current_url.lower()
|
|
print(f"[UnitedUcard Claim login] After login URL: {current_url_after_login}")
|
|
|
|
if "secure.uhcdental.com" in current_url_after_login:
|
|
print("[UnitedUcard Claim login] Login successful - redirected to UHC Dental dashboard")
|
|
return "SUCCESS"
|
|
|
|
if "rba/options" in current_url_after_login:
|
|
try:
|
|
text_msg_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.ID, "textMsg"))
|
|
)
|
|
text_msg_btn.click()
|
|
print("[UnitedUcard Claim login] Clicked 'Via Text Message' on MFA options page")
|
|
time.sleep(3)
|
|
except TimeoutException:
|
|
print("[UnitedUcard Claim login] MFA options page detected but 'Via Text Message' button not found")
|
|
|
|
current_url_after_login = self.driver.current_url.lower()
|
|
print(f"[UnitedUcard Claim login] After MFA method selection URL: {current_url_after_login}")
|
|
|
|
try:
|
|
WebDriverWait(self.driver, 15).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//input[@type='tel' or contains(@placeholder,'code') or contains(@placeholder,'Code') or "
|
|
"contains(@aria-label,'Verification') or contains(@aria-label,'verification') or "
|
|
"contains(@name,'otp') or contains(@name,'code') or contains(@id,'otp') or contains(@id,'code')]"
|
|
))
|
|
)
|
|
print("[UnitedUcard Claim login] OTP input detected -> OTP_REQUIRED")
|
|
return "OTP_REQUIRED"
|
|
except TimeoutException:
|
|
print("[UnitedUcard Claim login] No OTP input detected")
|
|
|
|
current_url_after_login = self.driver.current_url.lower()
|
|
if "secure.uhcdental.com" in current_url_after_login:
|
|
print("[UnitedUcard Claim login] Login successful - redirected to UHC Dental dashboard")
|
|
return "SUCCESS"
|
|
|
|
try:
|
|
error_elem = self.driver.find_element(By.XPATH,
|
|
"//*[contains(@class,'error') or contains(@class,'alert')]")
|
|
error_text = error_elem.text
|
|
if error_text:
|
|
print(f"[UnitedUcard Claim login] Error on page: {error_text}")
|
|
return f"ERROR: {error_text}"
|
|
except Exception:
|
|
pass
|
|
|
|
return "ERROR: Login did not complete - still on identity domain"
|
|
|
|
except Exception as e:
|
|
print(f"[UnitedUcard Claim login] Exception: {e}")
|
|
return f"ERROR:LOGIN FAILED: {e}"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Claim steps — the UHC Dental "Dental Claims" flow is a wizard:
|
|
# Provider Information -> Patient Information -> Claim Information ->
|
|
# Upload Documents -> Review and Sign
|
|
# Each step should return "ERROR: <message>" on failure, or a truthy
|
|
# result on success.
|
|
# ------------------------------------------------------------------
|
|
|
|
def _click_next(self, context: str = ""):
|
|
"""Click the wizard's bottom 'Next' pill button.
|
|
|
|
Confirmed against the live site (2026-09-13): on most wizard steps
|
|
the visible label is a plain <div> ("Next"); on the Claim Information
|
|
step it's instead a chevron_right.jpg image with no text. Either way
|
|
the click target is whichever clickable ancestor wraps it.
|
|
"""
|
|
try:
|
|
next_label = WebDriverWait(self.driver, 15).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//div[normalize-space(text())='Next'] | "
|
|
"//img[contains(@src,'chevron_right')]"))
|
|
)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", next_label)
|
|
time.sleep(0.3)
|
|
# Prefer clicking a clickable ancestor (button/a/[role=button]) so
|
|
# the wizard's own click handler fires; fall back to the div itself.
|
|
target = next_label
|
|
try:
|
|
target = next_label.find_element(By.XPATH,
|
|
"ancestor::*[self::button or self::a or @role='button'][1]")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
target.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", target)
|
|
print(f"[UnitedUcard Claim] Clicked 'Next'{f' ({context})' if context else ''}")
|
|
time.sleep(2)
|
|
return True
|
|
except TimeoutException:
|
|
return f"ERROR: 'Next' button not found{f' on {context}' if context else ''}"
|
|
|
|
def step1_open_claims_page(self):
|
|
"""
|
|
Click 'Claims' in the top nav to open the Dental Claims wizard.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<a id="Claims" onclick="preLoginProviderSearch(event , 'Claims')"
|
|
href="/content/dental-benefits-provider/en/secure/dental-claim-form.html">Claims</a>
|
|
Lands on the "Provider Information" step, pre-filled from the
|
|
logged-in account (phone, email, treating location, NPI, TIN).
|
|
"""
|
|
try:
|
|
claims_link = WebDriverWait(self.driver, 15).until(
|
|
EC.element_to_be_clickable((By.ID, "Claims"))
|
|
)
|
|
claims_link.click()
|
|
print("[UnitedUcard Claim] Clicked 'Claims' nav link")
|
|
except Exception as click_err:
|
|
try:
|
|
self.driver.get(
|
|
"https://www.uhcdental.com/content/dental-benefits-provider/en/secure/dental-claim-form.html"
|
|
)
|
|
print("[UnitedUcard Claim] Navigated directly to dental-claim-form.html (nav click failed)")
|
|
except Exception as nav_err:
|
|
return f"ERROR: Could not open Claims page: {click_err} / {nav_err}"
|
|
|
|
try:
|
|
WebDriverWait(self.driver, 20).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//*[contains(text(),'Provider Information')]"))
|
|
)
|
|
print("[UnitedUcard Claim] Dental Claims wizard loaded - Provider Information step")
|
|
return True
|
|
except TimeoutException:
|
|
return "ERROR: Provider Information step did not load after clicking Claims"
|
|
|
|
def step2_confirm_provider_information(self):
|
|
"""
|
|
Provider Information is pre-filled from the account (Phone, Email,
|
|
Treating Location, NPI, TIN) — nothing to fill, just advance.
|
|
"""
|
|
return self._click_next("Provider Information step")
|
|
|
|
def _format_date_digits(self, date_str):
|
|
"""Convert YYYY-MM-DD to MMDDYYYY (no separators) for typing into a
|
|
native <input type="date"> — Chrome's date widget auto-advances
|
|
between the month/day/year segments as digits are typed."""
|
|
if date_str and "-" in date_str:
|
|
parts = date_str.split("-")
|
|
if len(parts) == 3:
|
|
yyyy, mm, dd = parts
|
|
return f"{mm}{dd}{yyyy}"
|
|
return date_str
|
|
|
|
def step3_fill_patient_information(self):
|
|
"""
|
|
Fill the Patient Information step: Date of Service, Subscriber ID,
|
|
Date of Birth, then click Search to look up the member.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<input type="date" class="date-input date-of-service" placeholder="mm/dd/yyyy">
|
|
<input type="text" class="subscriber-id">
|
|
<input type="date" class="date-input date-of-birth" placeholder="mm/dd/yyyy">
|
|
<button class="search-button member-search">Search</button>
|
|
"""
|
|
try:
|
|
service_date_field = WebDriverWait(self.driver, 20).until(
|
|
EC.element_to_be_clickable((By.CSS_SELECTOR, "input.date-of-service"))
|
|
)
|
|
service_date_field.click()
|
|
service_date_field.send_keys(self._format_date_digits(self.serviceDate))
|
|
print(f"[UnitedUcard Claim] Entered Date of Service: {self.serviceDate}")
|
|
except TimeoutException:
|
|
return "ERROR: Date of Service field not found on Patient Information step"
|
|
|
|
try:
|
|
subscriber_id_field = self.driver.find_element(By.CSS_SELECTOR, "input.subscriber-id")
|
|
subscriber_id_field.clear()
|
|
subscriber_id_field.send_keys(self.memberId)
|
|
print(f"[UnitedUcard Claim] Entered Subscriber ID: {self.memberId}")
|
|
except Exception as e:
|
|
return f"ERROR: Subscriber ID field not found on Patient Information step: {e}"
|
|
|
|
try:
|
|
dob_field = self.driver.find_element(By.CSS_SELECTOR, "input.date-of-birth")
|
|
dob_field.click()
|
|
dob_field.send_keys(self._format_date_digits(self.dateOfBirth))
|
|
print(f"[UnitedUcard Claim] Entered Date of Birth: {self.dateOfBirth}")
|
|
except Exception as e:
|
|
return f"ERROR: Date of Birth field not found on Patient Information step: {e}"
|
|
|
|
try:
|
|
search_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.CSS_SELECTOR, "button.search-button.member-search"))
|
|
)
|
|
search_btn.click()
|
|
print("[UnitedUcard Claim] Clicked Search (member-search)")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
return "ERROR: Search button not found on Patient Information step"
|
|
|
|
return self._select_search_result()
|
|
|
|
def _select_search_result(self):
|
|
"""
|
|
When the Subscriber ID + DOB search matches multiple coverage records
|
|
("Duplicate records have been found"), select the LAST row in the
|
|
results table (the default/most-recent coverage record) and click
|
|
SELECT. If the "Member Details" name link doesn't show up afterward
|
|
(the last row turned out to be an inactive coverage record — seen
|
|
for some providers' patients, e.g. provider "JOHN"), fall back to
|
|
selecting the FIRST row instead and click SELECT again.
|
|
|
|
Confirmed against the live site (2026-09-13): a results table with
|
|
columns Name / Group / Coverage End Date / Address, a radio button
|
|
in the first cell of each row, and CANCEL / SELECT pill buttons
|
|
below the table.
|
|
"""
|
|
try:
|
|
result_rows = WebDriverWait(self.driver, 15).until(
|
|
lambda d: d.find_elements(By.CSS_SELECTOR, "table tbody tr") or None
|
|
)
|
|
except TimeoutException:
|
|
# A single unambiguous match may skip the results table entirely
|
|
# and advance straight to Claim Information.
|
|
try:
|
|
WebDriverWait(self.driver, 5).until(
|
|
EC.presence_of_element_located((By.XPATH, "//*[contains(text(),'Claim Information')]"))
|
|
)
|
|
print("[UnitedUcard Claim] Single match — already advanced to Claim Information")
|
|
return True
|
|
except TimeoutException:
|
|
return "ERROR: No search results table found after Search"
|
|
|
|
if not result_rows:
|
|
return "ERROR: No matching patient records found"
|
|
|
|
total_rows = len(result_rows)
|
|
result = self._select_row_and_wait_for_member_link(-1, "last", total_rows, member_link_timeout=8)
|
|
if result is True:
|
|
return self._click_next("Patient Information step")
|
|
|
|
if result != "NO_MEMBER_LINK":
|
|
return result # propagate a hard ERROR (radio/select button not found)
|
|
|
|
if total_rows < 2:
|
|
return "ERROR: Member Details link not found after selecting last row"
|
|
|
|
print("[UnitedUcard Claim] Last row showed no active member — retrying with first row")
|
|
try:
|
|
WebDriverWait(self.driver, 10).until(
|
|
lambda d: d.find_elements(By.CSS_SELECTOR, "table tbody tr") or None
|
|
)
|
|
except TimeoutException:
|
|
return "ERROR: Results table no longer available to retry with first row"
|
|
|
|
result = self._select_row_and_wait_for_member_link(0, "first", total_rows, member_link_timeout=15)
|
|
if result is True:
|
|
return self._click_next("Patient Information step")
|
|
if result == "NO_MEMBER_LINK":
|
|
return "ERROR: Member Details link not found after selecting either the last or first row"
|
|
return result
|
|
|
|
def _select_row_and_wait_for_member_link(self, row_index, row_label, total_rows, member_link_timeout):
|
|
"""
|
|
Clicks the radio button in the row at `row_index` (re-located fresh,
|
|
since a prior failed SELECT may have altered the DOM), clicks SELECT,
|
|
then waits up to `member_link_timeout` seconds for the "Member
|
|
Details" name link to appear.
|
|
|
|
Returns True (link found & clicked), "NO_MEMBER_LINK" (link never
|
|
appeared — likely an inactive record), or an "ERROR: ..." string for
|
|
a hard failure (radio/select button not found).
|
|
"""
|
|
try:
|
|
row = self.driver.find_elements(By.CSS_SELECTOR, "table tbody tr")[row_index]
|
|
except IndexError:
|
|
return f"ERROR: Could not re-locate {row_label} search result row"
|
|
|
|
try:
|
|
radio = row.find_element(By.CSS_SELECTOR, "input[type='radio']")
|
|
except Exception:
|
|
try:
|
|
radio = row.find_element(By.XPATH, ".//*[@role='radio' or contains(@class,'radio')]")
|
|
except Exception as e:
|
|
return f"ERROR: Could not find radio button in {row_label} search result row: {e}"
|
|
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", radio)
|
|
time.sleep(0.3)
|
|
try:
|
|
radio.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", radio)
|
|
print(f"[UnitedUcard Claim] Selected {row_label} matching patient record ({total_rows} found)")
|
|
|
|
# Confirmed against the live site (2026-09-13):
|
|
# <input type="button" class="btn btn-sm btn-primary pull-right margin-left-10"
|
|
# onclick="refineSearch(this);" value="select">
|
|
try:
|
|
select_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.CSS_SELECTOR, "input[type='button'][value='select']"))
|
|
)
|
|
select_btn.click()
|
|
print("[UnitedUcard Claim] Clicked SELECT (refineSearch)")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
try:
|
|
select_btn = WebDriverWait(self.driver, 5).until(
|
|
EC.element_to_be_clickable((By.XPATH,
|
|
"//input[@onclick='refineSearch(this);'] | "
|
|
"//*[self::button or self::input][translate(@value,'SELECT','select')='select' or "
|
|
"normalize-space(text())='SELECT' or normalize-space(text())='Select']"))
|
|
)
|
|
select_btn.click()
|
|
print("[UnitedUcard Claim] Clicked SELECT (fallback selector)")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
return f"ERROR: SELECT button not found after choosing {row_label} patient record"
|
|
|
|
# After SELECT, a "Member Details" table appears below with the
|
|
# patient's name as a link. Clicking it does NOT navigate away —
|
|
# it replaces the table with a confirmation card (avatar initials +
|
|
# name, Group Number, Subscriber ID, DOB, Address) on the same
|
|
# Patient Information page, and enables the bottom "Next" button.
|
|
# If the selected record is inactive, this link never appears.
|
|
try:
|
|
member_link = WebDriverWait(self.driver, member_link_timeout).until(
|
|
EC.element_to_be_clickable((By.XPATH,
|
|
"//*[contains(text(),'Member Details')]/following::table[1]//a[1]"))
|
|
)
|
|
member_name = member_link.text.strip()
|
|
member_link.click()
|
|
print(f"[UnitedUcard Claim] Clicked member link: {member_name}")
|
|
time.sleep(2)
|
|
return True
|
|
except TimeoutException:
|
|
print(f"[UnitedUcard Claim] Member Details link did not appear after selecting {row_label} row")
|
|
return "NO_MEMBER_LINK"
|
|
|
|
def _click_radio_by_id(self, element_id, label=""):
|
|
try:
|
|
radio = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.ID, element_id))
|
|
)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", radio)
|
|
try:
|
|
radio.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", radio)
|
|
print(f"[UnitedUcard Claim] Selected '{label or element_id}'")
|
|
return True
|
|
except TimeoutException:
|
|
return f"ERROR: Radio button #{element_id} ({label}) not found on Claim Information step"
|
|
|
|
def step4_fill_claim_information(self):
|
|
"""
|
|
Fill the top of the Claim Information step's claim-level questions,
|
|
then (TODO) the Procedure section (Procedure Code / Date of Service /
|
|
Procedure Fee / Diagnosis Code(s)) from self.serviceLines.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
"Does the patient have additional insurance...?" -> No
|
|
<input type="radio" name="PatientAdditionalCoverage" id="PatientAdditionalCoverage1" value="No">
|
|
"Does this claim include EPSDT / Title XIX?" -> No
|
|
<input type="radio" name="ClaimEpsdt" id="epsdtNo" value="No">
|
|
"Who should be reimbursed for this claim?" -> Provider
|
|
<input type="radio" name="PHSubscriberDirectPayment" id="PHSubscriberDirectPayment0" value="Provider">
|
|
"""
|
|
for element_id, label in [
|
|
("PatientAdditionalCoverage1", "Additional Insurance: No"),
|
|
("epsdtNo", "EPSDT / Title XIX: No"),
|
|
("PHSubscriberDirectPayment0", "Reimbursement: Provider"),
|
|
]:
|
|
result = self._click_radio_by_id(element_id, label)
|
|
if isinstance(result, str) and result.startswith("ERROR"):
|
|
return result
|
|
|
|
if not self.serviceLines:
|
|
return "ERROR: No procedure/service lines to submit"
|
|
|
|
for idx, line in enumerate(self.serviceLines):
|
|
if idx > 0:
|
|
add_result = self._click_add_procedure()
|
|
if isinstance(add_result, str) and add_result.startswith("ERROR"):
|
|
return add_result
|
|
result = self._fill_and_save_procedure(line, idx)
|
|
if isinstance(result, str) and result.startswith("ERROR"):
|
|
return result
|
|
|
|
# After all procedures are saved, three more claim-level questions
|
|
# appear. Confirmed against the live site (2026-09-13):
|
|
# "Does this claim include orthodontics?" -> No
|
|
# <input type="radio" name="ClaimOrtho" id="ClaimOrtho1" value="No">
|
|
# "Does this claim include prosthesis (crown, bridge, or denture)?" -> No
|
|
# <input type="radio" name="ClaimProsthesis" id="ClaimProsthesis1" value="No">
|
|
# "Does this claim relate to an accident/injury/illness?" -> No
|
|
# <input type="radio" name="OrthAccidentInjury" id="OrthAccidentInjury1" value="No">
|
|
for element_id, label in [
|
|
("ClaimOrtho1", "Orthodontics: No"),
|
|
("ClaimProsthesis1", "Prosthesis: No"),
|
|
("OrthAccidentInjury1", "Accident/Injury/Illness: No"),
|
|
]:
|
|
result = self._click_radio_by_id(element_id, label)
|
|
if isinstance(result, str) and result.startswith("ERROR"):
|
|
return result
|
|
|
|
return self._click_next("Claim Information step")
|
|
|
|
def _click_add_procedure(self):
|
|
"""
|
|
For a 2nd+ procedure line, click "Add a Procedure" to open a fresh
|
|
Procedure Code / Date of Service / Procedure Fee row before filling it.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<div id="dentalProcedures" onclick="addProcCode()">Add a Procedure</div>
|
|
"""
|
|
try:
|
|
add_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, "//*[@onclick='addProcCode()']"))
|
|
)
|
|
add_btn.click()
|
|
print("[UnitedUcard Claim] Clicked 'Add a Procedure'")
|
|
time.sleep(1)
|
|
return True
|
|
except TimeoutException:
|
|
return "ERROR: 'Add a Procedure' button not found"
|
|
|
|
def _fill_and_save_procedure(self, line: dict, idx: int):
|
|
"""
|
|
Fill one procedure row (Procedure Code, Date of Service, Procedure
|
|
Fee) and click Save Procedure.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<input id="dentalProcCode" type="text" min="5" max="5"> (5-char code, e.g. D0120)
|
|
<input id="serviceDate" type="date">
|
|
<input id="ROSFeeX" type="number" class="currency-input">
|
|
<div onclick="saveProcCode()">Save Procedure</div>
|
|
"""
|
|
procedure_code = str(line.get("procedureCode", "")).strip()
|
|
fee = line.get("fee")
|
|
procedure_date = line.get("procedureDate") or self.serviceDate
|
|
|
|
if not procedure_code:
|
|
return f"ERROR: Procedure line {idx + 1} has no procedure code"
|
|
|
|
try:
|
|
code_field = WebDriverWait(self.driver, 15).until(
|
|
EC.element_to_be_clickable((By.ID, "dentalProcCode"))
|
|
)
|
|
code_field.clear()
|
|
code_field.send_keys(procedure_code)
|
|
print(f"[UnitedUcard Claim] Entered Procedure Code: {procedure_code}")
|
|
except TimeoutException:
|
|
return f"ERROR: Procedure Code field not found for line {idx + 1}"
|
|
|
|
try:
|
|
date_field = self.driver.find_element(By.ID, "serviceDate")
|
|
date_field.click()
|
|
date_field.send_keys(self._format_date_digits(procedure_date))
|
|
print(f"[UnitedUcard Claim] Entered procedure Date of Service: {procedure_date}")
|
|
except Exception as e:
|
|
return f"ERROR: Date of Service field not found for line {idx + 1}: {e}"
|
|
|
|
try:
|
|
fee_field = self.driver.find_element(By.ID, "ROSFeeX")
|
|
fee_field.clear()
|
|
fee_field.send_keys(str(fee) if fee is not None else "")
|
|
print(f"[UnitedUcard Claim] Entered Procedure Fee: {fee}")
|
|
except Exception as e:
|
|
return f"ERROR: Procedure Fee field not found for line {idx + 1}: {e}"
|
|
|
|
try:
|
|
save_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, "//*[@onclick='saveProcCode()']"))
|
|
)
|
|
save_btn.click()
|
|
print(f"[UnitedUcard Claim] Clicked Save Procedure for line {idx + 1}")
|
|
time.sleep(1.5)
|
|
except TimeoutException:
|
|
return f"ERROR: Save Procedure button not found for line {idx + 1}"
|
|
|
|
return True
|
|
|
|
def step5_upload_documents(self):
|
|
"""
|
|
Upload claim attachments via the dropzone's hidden file input, then
|
|
click "Review and Sign" to advance.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<input type="file" id="file-input" class="file-input" multiple
|
|
accept=".pdf,.png,.jpg,.jpeg,.txt,.tif,.tiff,.gif,.bmp">
|
|
<div>...Review and Sign</div> (48px pill button, below Helpful Resources)
|
|
"""
|
|
attached = 0
|
|
for cf in self.claimFiles:
|
|
relative_path = cf.get("filePath") or ""
|
|
if not relative_path:
|
|
print(f"[UnitedUcard Claim step5] Skipping file with no filePath: {cf}")
|
|
continue
|
|
|
|
abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/")))
|
|
if not os.path.isfile(abs_path):
|
|
print(f"[UnitedUcard Claim step5] File not found on disk: {abs_path}")
|
|
continue
|
|
|
|
try:
|
|
# Don't click the visible "Browse" button — it's wired to the
|
|
# hidden #file-input and triggers the OS-native file picker,
|
|
# which WebDriver cannot control. Send the path directly to
|
|
# the input instead (chromedriver allows send_keys on file
|
|
# inputs even while display:none/hidden by the dropzone CSS).
|
|
file_input = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.ID, "file-input"))
|
|
)
|
|
file_input.send_keys(abs_path)
|
|
time.sleep(1.5)
|
|
print(f"[UnitedUcard Claim step5] Attached: {os.path.basename(abs_path)}")
|
|
attached += 1
|
|
except Exception as e:
|
|
print(f"[UnitedUcard Claim step5] Could not attach {abs_path}: {type(e).__name__}: {e}")
|
|
|
|
print(f"[UnitedUcard Claim step5] Attached {attached}/{len(self.claimFiles)} file(s)")
|
|
|
|
try:
|
|
review_label = WebDriverWait(self.driver, 15).until(
|
|
EC.presence_of_element_located((By.XPATH,
|
|
"//div[normalize-space(text())='Review and Sign']"))
|
|
)
|
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", review_label)
|
|
time.sleep(0.3)
|
|
target = review_label
|
|
try:
|
|
target = review_label.find_element(By.XPATH,
|
|
"ancestor::*[self::button or self::a or @role='button' or @data-state][1]")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
target.click()
|
|
except Exception:
|
|
self.driver.execute_script("arguments[0].click();", target)
|
|
print("[UnitedUcard Claim] Clicked 'Review and Sign'")
|
|
time.sleep(2)
|
|
return True
|
|
except TimeoutException:
|
|
return "ERROR: 'Review and Sign' button not found on Upload Documents step"
|
|
|
|
def step6_review_and_sign(self):
|
|
"""
|
|
Check the two "signature on file" attestation checkboxes at the
|
|
bottom of the Review and Sign step.
|
|
|
|
Confirmed against the live site (2026-09-13):
|
|
<input type="checkbox" name="MemberPatientSignatureOnFile" id="MemberPatientSignatureOnFile_0" value="yes">
|
|
<input type="checkbox" name="PHSubscriberSignatureOnFile" id="PHSubscriberSignatureOnFile_0" value="yes">
|
|
"""
|
|
for element_id, label in [
|
|
("MemberPatientSignatureOnFile_0", "Patient signature on file"),
|
|
("PHSubscriberSignatureOnFile_0", "Subscriber signature on file"),
|
|
]:
|
|
result = self._click_radio_by_id(element_id, label)
|
|
if isinstance(result, str) and result.startswith("ERROR"):
|
|
return result
|
|
|
|
# Provider signature — the default provider configured in Settings
|
|
# (sortOrder asc, first entry), passed through as self.providerName.
|
|
# Confirmed against the live site (2026-09-13):
|
|
# <input type="text" id="userSignatureText">
|
|
try:
|
|
signature_field = WebDriverWait(self.driver, 15).until(
|
|
EC.element_to_be_clickable((By.ID, "userSignatureText"))
|
|
)
|
|
signature_field.clear()
|
|
signature_field.send_keys(self.providerName)
|
|
print(f"[UnitedUcard Claim] Entered provider signature: {self.providerName}")
|
|
except TimeoutException:
|
|
return "ERROR: Provider signature field (#userSignatureText) not found on Review and Sign step"
|
|
|
|
# Confirmed against the live site (2026-09-13):
|
|
# <div class="submit-claim" onclick="submitClaimForm();">Submit</div>
|
|
try:
|
|
submit_btn = WebDriverWait(self.driver, 10).until(
|
|
EC.element_to_be_clickable((By.CSS_SELECTOR, ".submit-claim"))
|
|
)
|
|
submit_btn.click()
|
|
print("[UnitedUcard Claim] Clicked Submit (submitClaimForm)")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
try:
|
|
submit_btn = WebDriverWait(self.driver, 5).until(
|
|
EC.element_to_be_clickable((By.XPATH, "//*[@onclick='submitClaimForm();']"))
|
|
)
|
|
submit_btn.click()
|
|
print("[UnitedUcard Claim] Clicked Submit (fallback selector)")
|
|
time.sleep(2)
|
|
except TimeoutException:
|
|
return "ERROR: Submit button (.submit-claim) not found on Review and Sign step"
|
|
|
|
return True
|
|
|
|
def step7_save_confirmation_pdf(self):
|
|
"""TODO: capture the claim confirmation number and save/print the confirmation as PDF
|
|
into self.download_dir. Return {"pdf_path": "...", "claimNumber": "..."}."""
|
|
return {}
|