feat: MetLife Dental claim submission via ClaimConnect; MetLife fee schedule
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>
This commit is contained in:
@@ -33,6 +33,7 @@ import helpers_tuftssco_preauth as htuftssco_preauth
|
||||
import helpers_uniteducard_claim as huniteducard_claim
|
||||
import helpers_bcbs_ma_eligibility as hbcbs_ma
|
||||
import helpers_metlife_eligibility as hmetlife
|
||||
import helpers_metlife_claim as hmetlife_claim
|
||||
|
||||
# Import startup session-clear functions
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
@@ -503,6 +504,47 @@ async def metlife_eligibility(request: Request):
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _metlife_claim_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for MetLife claim submission."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hmetlife_claim.start_metlife_claim_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/metlife-claim")
|
||||
async def metlife_claim(request: Request):
|
||||
"""
|
||||
Starts a MetLife claim submission session in the background.
|
||||
Reuses the MetLife eligibility persistent browser/login.
|
||||
Body: { "claim": { "metlifeUsername": "...", "metlifePassword": "...", ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
|
||||
sid = hmetlife_claim.make_session_entry()
|
||||
hmetlife_claim.sessions[sid]["type"] = "metlife_claim"
|
||||
hmetlife_claim.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_metlife_claim_worker_wrapper(
|
||||
sid, body,
|
||||
url="https://dentalprovider.metlife.com/home"
|
||||
))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _deltaindiv_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for Delta Indiv eligibility — acquires semaphore, updates counters."""
|
||||
global active_jobs, waiting_jobs
|
||||
@@ -1208,6 +1250,8 @@ async def submit_otp(request: Request):
|
||||
res = hbcbs_ma.submit_otp(sid, otp)
|
||||
elif sid in hmetlife.sessions:
|
||||
res = hmetlife.submit_otp(sid, otp)
|
||||
elif sid in hmetlife_claim.sessions:
|
||||
res = hmetlife_claim.submit_otp(sid, otp)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
|
||||
@@ -1255,6 +1299,8 @@ async def session_status(sid: str):
|
||||
s = hbcbs_ma.get_session_status(sid)
|
||||
elif sid in hmetlife.sessions:
|
||||
s = hmetlife.get_session_status(sid)
|
||||
elif sid in hmetlife_claim.sessions:
|
||||
s = hmetlife_claim.get_session_status(sid)
|
||||
else:
|
||||
s = {"status": "not_found"}
|
||||
if s.get("status") == "not_found":
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
|
||||
from selenium_MetLife_claimSubmitWorker import AutomationMetLifeClaimSubmit
|
||||
from selenium_MetLife_eligibilityCheckWorker import METLIFE_HOME_URL
|
||||
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120"))
|
||||
|
||||
|
||||
def make_session_entry() -> str:
|
||||
import uuid
|
||||
sid = str(uuid.uuid4())
|
||||
sessions[sid] = {
|
||||
"status": "created",
|
||||
"created_at": time.time(),
|
||||
"last_activity": time.time(),
|
||||
"bot": None,
|
||||
"driver": None,
|
||||
"otp_event": asyncio.Event(),
|
||||
"otp_value": None,
|
||||
"result": None,
|
||||
"message": None,
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
async def cleanup_session(sid: str, message: str | None = None):
|
||||
"""
|
||||
Set final state and remove the session entry.
|
||||
Does NOT quit the browser — the persistent MetLife browser stays alive.
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return
|
||||
try:
|
||||
if s.get("status") not in ("completed", "error", "not_found"):
|
||||
s["status"] = "error"
|
||||
if message:
|
||||
s["message"] = message
|
||||
ev = s.get("otp_event")
|
||||
if ev and not ev.is_set():
|
||||
ev.set()
|
||||
finally:
|
||||
sessions.pop(sid, None)
|
||||
print(f"[helpers_metlife_claim] cleaned session {sid}")
|
||||
|
||||
|
||||
async def _remove_session_later(sid: str, delay: int = 20):
|
||||
await asyncio.sleep(delay)
|
||||
await cleanup_session(sid)
|
||||
|
||||
|
||||
async def start_metlife_claim_run(sid: str, data: dict, url: str):
|
||||
"""
|
||||
Run the MetLife claim workflow — login/OTP mirrors helpers_metlife_eligibility,
|
||||
then continues into claim-specific steps.
|
||||
|
||||
OTP handling uses two complementary strategies:
|
||||
1. Accept OTP submitted from the app (via /submit-otp → otp_value)
|
||||
2. Poll the browser to detect when the user completes MFA themselves
|
||||
"""
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
|
||||
s["status"] = "running"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
bot = AutomationMetLifeClaimSubmit(data)
|
||||
bot.config_driver()
|
||||
|
||||
s["bot"] = bot
|
||||
s["driver"] = bot.driver
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
bot.driver.maximize_window()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Login ---
|
||||
try:
|
||||
login_result = bot.login(url)
|
||||
except WebDriverException as wde:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Selenium driver error during login: {wde}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
except Exception as e:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"Unexpected error during login: {e}"
|
||||
await cleanup_session(sid, s["message"])
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
# ── Already logged in (persistent session) ────────────────────────────
|
||||
if login_result == "ALREADY_LOGGED_IN":
|
||||
print("[MetLife Claim] Session persisted — skipping OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Session persisted"
|
||||
|
||||
# ── OTP required ──────────────────────────────────────────────────────
|
||||
elif login_result == "OTP_REQUIRED":
|
||||
s["status"] = "waiting_for_otp"
|
||||
s["message"] = "OTP required for login — please enter OTP"
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
driver = s["driver"]
|
||||
login_success = False
|
||||
|
||||
print(f"[MetLife Claim OTP] Polling for OTP completion (up to {SESSION_OTP_TIMEOUT}s)...")
|
||||
|
||||
for poll in range(SESSION_OTP_TIMEOUT):
|
||||
await asyncio.sleep(1)
|
||||
s["last_activity"] = time.time()
|
||||
|
||||
try:
|
||||
# a) App submitted OTP via /submit-otp endpoint
|
||||
otp_value = s.get("otp_value")
|
||||
if otp_value:
|
||||
print(f"[MetLife Claim OTP poll {poll+1}] OTP received from app, typing it in...")
|
||||
s["otp_value"] = None
|
||||
if bot.submit_otp_code(otp_value):
|
||||
print("[MetLife Claim OTP] OTP typed and submitted via app")
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
print("[MetLife Claim OTP] OTP input not found — enter it in the browser window")
|
||||
|
||||
# b) Back on the portal with the search form → login completed
|
||||
if bot.is_logged_in(timeout=2):
|
||||
print("[MetLife Claim OTP] Search form found — login successful!")
|
||||
login_success = True
|
||||
break
|
||||
|
||||
print(f"[MetLife Claim OTP poll {poll+1}/{SESSION_OTP_TIMEOUT}] URL: {driver.current_url[:70]}...")
|
||||
except Exception as poll_err:
|
||||
print(f"[MetLife Claim OTP poll {poll+1}] Error: {poll_err}")
|
||||
|
||||
if not login_success:
|
||||
# Final check — navigate directly to the portal home page
|
||||
try:
|
||||
print("[MetLife Claim OTP] Final attempt — navigating to home page...")
|
||||
driver.get(METLIFE_HOME_URL)
|
||||
await asyncio.sleep(3)
|
||||
login_success = bot.is_logged_in(timeout=10)
|
||||
except Exception as final_err:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"OTP verification failed: {final_err}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": s["message"]}
|
||||
|
||||
if not login_success:
|
||||
s["status"] = "error"
|
||||
s["message"] = "OTP timeout — login not completed"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": "OTP not completed in time"}
|
||||
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login successful after OTP"
|
||||
print("[MetLife Claim OTP] Proceeding to claim steps...")
|
||||
|
||||
# ── Login succeeded without OTP ───────────────────────────────────────
|
||||
elif login_result == "SUCCESS":
|
||||
print("[MetLife Claim] Login succeeded without OTP")
|
||||
s["status"] = "running"
|
||||
s["message"] = "Login succeeded"
|
||||
|
||||
# ── Login error ───────────────────────────────────────────────────────
|
||||
else:
|
||||
msg = login_result if isinstance(login_result, str) else "ERROR: unknown login result"
|
||||
s["status"] = "error"
|
||||
s["message"] = msg
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": msg}
|
||||
|
||||
# --- Step 1: Open Submit Claims ---
|
||||
step1_result = bot.step1_open_submit_claims()
|
||||
print(f"[MetLife Claim] step1 result: {step1_result}")
|
||||
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step1_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step1_result}
|
||||
|
||||
# --- Step 2: Open ClaimConnect ---
|
||||
step2_result = bot.step2_open_claimconnect()
|
||||
print(f"[MetLife Claim] step2 result: {step2_result}")
|
||||
if isinstance(step2_result, str) and step2_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step2_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step2_result}
|
||||
|
||||
# --- Step 3: Open Single Claim on DentalXChange ---
|
||||
step3_result = bot.step3_open_single_claim()
|
||||
print(f"[MetLife Claim] step3 result: {step3_result}")
|
||||
if isinstance(step3_result, str) and step3_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step3_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step3_result}
|
||||
|
||||
# --- Step 4: Start New Claim (provider, patient, payer → Next) ---
|
||||
step4_result = bot.step4_start_new_claim()
|
||||
print(f"[MetLife Claim] step4 result: {step4_result}")
|
||||
if isinstance(step4_result, str) and step4_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step4_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step4_result}
|
||||
|
||||
# --- Step 5: Select Patient → Create Claim ---
|
||||
step5_result = bot.step5_select_patient()
|
||||
print(f"[MetLife Claim] step5 result: {step5_result}")
|
||||
if isinstance(step5_result, str) and step5_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step5_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step5_result}
|
||||
|
||||
# --- Step 6: Claim Detail — Signature on file → Next ---
|
||||
step6_result = bot.step6_claim_detail()
|
||||
print(f"[MetLife Claim] step6 result: {step6_result}")
|
||||
if isinstance(step6_result, str) and step6_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step6_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step6_result}
|
||||
|
||||
# --- Step 7: Claim Item Detail — procedure lines → Finish ---
|
||||
step7_result = bot.step7_fill_claim_items()
|
||||
print(f"[MetLife Claim] step7 result: {step7_result}")
|
||||
if isinstance(step7_result, str) and step7_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step7_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step7_result}
|
||||
|
||||
# --- Step 8: Review page → Submit Claim ---
|
||||
step8_result = bot.step8_submit_claim()
|
||||
print(f"[MetLife Claim] step8 result: {step8_result}")
|
||||
if isinstance(step8_result, str) and step8_result.startswith("ERROR"):
|
||||
s["status"] = "error"
|
||||
s["message"] = step8_result
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": step8_result}
|
||||
|
||||
# --- Step 9: Back to Claim → Claim ID + PDF ---
|
||||
# The claim is already submitted, so a failure here is non-fatal.
|
||||
step9_result = bot.step9_save_claim()
|
||||
print(f"[MetLife Claim] step9 result: {step9_result}")
|
||||
if not isinstance(step9_result, dict):
|
||||
step9_result = {}
|
||||
|
||||
claim_number = step9_result.get("claimNumber")
|
||||
pdf_path = step9_result.get("pdf_path")
|
||||
pdf_url = None
|
||||
if pdf_path:
|
||||
port = os.getenv("PORT", "5002")
|
||||
url_host = os.getenv("HOST", "localhost")
|
||||
pdf_url = f"http://{url_host}:{port}/downloads/{os.path.basename(pdf_path)}"
|
||||
print(f"[MetLife Claim] pdf_url: {pdf_url}")
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"message": "MetLife claim submitted via DentalXChange",
|
||||
"claimNumber": claim_number,
|
||||
"pdf_url": pdf_url,
|
||||
}
|
||||
s["status"] = "completed"
|
||||
s["result"] = result
|
||||
s["message"] = "completed"
|
||||
|
||||
asyncio.create_task(_remove_session_later(sid, 60))
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if s:
|
||||
s["status"] = "error"
|
||||
s["message"] = f"worker exception: {e}"
|
||||
await cleanup_session(sid)
|
||||
return {"status": "error", "message": f"worker exception: {e}"}
|
||||
|
||||
|
||||
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "error", "message": "session not found"}
|
||||
if s.get("status") != "waiting_for_otp":
|
||||
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||
s["otp_value"] = otp
|
||||
s["last_activity"] = time.time()
|
||||
try:
|
||||
s["otp_event"].set()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "message": "otp accepted"}
|
||||
|
||||
|
||||
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||
s = sessions.get(sid)
|
||||
if not s:
|
||||
return {"status": "not_found"}
|
||||
return {
|
||||
"session_id": sid,
|
||||
"status": s.get("status"),
|
||||
"message": s.get("message"),
|
||||
"created_at": s.get("created_at"),
|
||||
"last_activity": s.get("last_activity"),
|
||||
"result": s.get("result") if s.get("status") in ("completed", "error") else None,
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user