fix: United Ucard claim advances past Upload Documents

Fill the required Provider Narrative (claim remarks with shorthand expanded,
or a plain-language summary of the procedures), find the Review and Sign
button by its navy pill instead of the sidebar step, and verify each step
is actually visible since every step lives in the DOM at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 23:38:36 -04:00
co-authored by Claude Opus 5.5
parent 36eba7c9d2
commit d600d94497
@@ -46,6 +46,7 @@ class AutomationUnitedUcardClaimSubmit:
self.lastName = self.data.get("lastName", "")
self.serviceLines = self.data.get("serviceLines", [])
self.claimFiles = self.data.get("claimFiles", [])
self.remarks = self.data.get("remarks", "")
self.providerName = self.data.get("providerName", "")
self.uniteducard_username = self.data.get("uniteducardUsername", "")
self.uniteducard_password = self.data.get("uniteducardPassword", "")
@@ -792,28 +793,212 @@ class AutomationUnitedUcardClaimSubmit:
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)
result = self._fill_provider_narrative()
if isinstance(result, str) and result.startswith("ERROR"):
return result
if self._click_review_and_sign_button():
return True
return "ERROR: Clicked 'Review and Sign' but the Review and Sign step never appeared (stuck on Upload Documents)"
@staticmethod
def _describe_procedure(code):
"""Plain-language name for a CDT code (falls back to the code itself)."""
m = re.match(r"^D(\d{4})", code.upper())
n = int(m.group(1)) if m else -1
if 2140 <= n <= 2161:
return "amalgam filling"
if 2330 <= n <= 2394:
return "composite filling"
if 2510 <= n <= 2664:
return "inlay/onlay"
if 2710 <= n <= 2799:
return "crown"
if 3310 <= n <= 3348:
return "root canal treatment"
if n in (7140, 7210):
return "extraction"
if n == 1110:
return "adult prophylaxis"
if n == 1120:
return "child prophylaxis"
return code
@staticmethod
def _expand_narrative_shorthand(text):
"""Expand office shorthand, e.g. "comp #8 MDB" -> "composite filling on #8 MDB"."""
text = re.sub(r"\bcomp(?:osite)?\b(?:\s+on\b)?", "composite filling on", text, flags=re.I)
text = re.sub(r"\bamal(?:gam)?\b(?:\s+on\b)?", "amalgam filling on", text, flags=re.I)
return text
def _build_provider_narrative(self):
"""
Claim remarks (with shorthand expanded), or a description built from
the service lines, e.g. "Composite filling on #8 MDB."
"""
text = self._expand_narrative_shorthand((self.remarks or "").strip())
if not text:
parts = []
for line in self.serviceLines:
code = str(line.get("procedureCode", "")).strip()
if not code:
continue
tooth = str(line.get("toothNumber") or line.get("tooth") or "").strip()
surface = str(line.get("toothSurface") or line.get("surface") or "").strip().upper()
desc = self._describe_procedure(code)
if tooth:
desc += f" on #{tooth}"
if surface:
desc += f" {surface}"
parts.append(desc)
text = "; ".join(parts)
if text:
text += "."
text = " ".join(text.split())
if text:
text = text[0].upper() + text[1:]
return text[:250]
def _fill_provider_narrative(self):
"""
The site requires a Provider Narrative before "Review and Sign" will
advance (the box turns red otherwise).
Confirmed against the live site (2026-10-22):
<textarea id="narrative" class="narrative-textarea" maxlength="250"
placeholder="Type provider narrative here"></textarea>
"""
narrative = self._build_provider_narrative()
try:
box = WebDriverWait(self.driver, 10).until(
EC.visibility_of_element_located((By.ID, "narrative"))
)
except TimeoutException:
return "ERROR: 'Review and Sign' button not found on Upload Documents step"
return "ERROR: Provider Narrative box (#narrative) not found on Upload Documents step"
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", box)
box.click()
box.clear()
box.send_keys(narrative)
print(f"[UnitedUcard Claim] Entered Provider Narrative: {narrative}")
return True
def _click_review_and_sign_button(self):
"""
Click "Review and Sign" on the Upload Documents step and confirm the
Review and Sign step actually rendered. Every step lives in the DOM
at once (hidden when inactive), so presence checks prove nothing —
wait for a *visible* button, and for #userSignatureText to become
visible afterwards.
Confirmed against the live site (2026-10-22):
<textarea id="narrative" class="narrative-textarea" ...> (Provider Narrative, above the button)
<div data-state="Disabled" ...><div ...>Review and Sign</div></div>
"""
# "Review and Sign" also appears in the left step-nav sidebar (greyed
# out, not clickable) — target the bottom primary pill, falling back
# to the lowest visible match on the page.
primary_xpath = "//*[@data-variant='Primary'][normalize-space(.)='Review and Sign']"
any_xpath = "//*[normalize-space(.)='Review and Sign' and not(*[normalize-space(.)='Review and Sign'])]"
def blue_button(driver):
# The real button is the visible navy (#002677 = rgb(0, 38, 119))
# pill; the sidebar step label has no such background.
return driver.execute_script("""
var hits = Array.from(document.querySelectorAll('div, button, a')).filter(function(el) {
if (el.textContent.trim() !== 'Review and Sign') return false;
var r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
var bg = getComputedStyle(el).backgroundColor.replace(/\\s/g, '');
return bg === 'rgb(0,38,119)' || bg === 'rgba(0,38,119,1)';
});
return hits.length ? hits[hits.length - 1] : null;
""")
def visible_label(driver):
el = blue_button(driver)
if el:
return el
shown = [el for el in driver.find_elements(By.XPATH, primary_xpath) if el.is_displayed()]
if not shown:
shown = [el for el in driver.find_elements(By.XPATH, any_xpath) if el.is_displayed()]
if not shown:
return False
return max(shown, key=lambda el: el.rect.get("y", 0))
def review_step_shown():
try:
WebDriverWait(self.driver, 6).until(
EC.visibility_of_element_located((By.ID, "userSignatureText"))
)
return True
except TimeoutException:
return False
for attempt in range(1, 4):
try:
review_label = WebDriverWait(self.driver, 15).until(visible_label)
except TimeoutException:
print("[UnitedUcard Claim] No visible 'Review and Sign' button found")
return False
# Make sure the Provider Narrative textarea isn't holding focus.
self.driver.execute_script("if (document.activeElement) document.activeElement.blur();")
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", review_label)
time.sleep(0.5)
if attempt == 1:
try:
review_label.click()
how = "click"
except Exception:
self.driver.execute_script("arguments[0].click();", review_label)
how = "JS click"
elif attempt == 2:
self.driver.execute_script("arguments[0].click();", review_label)
how = "JS click"
else:
self.driver.execute_script(
"var el = arguments[0];"
"['pointerdown','mousedown','pointerup','mouseup','click'].forEach(function(t){"
" el.dispatchEvent(new MouseEvent(t, {bubbles: true, cancelable: true, view: window}));"
"});", review_label)
how = "mouse events"
rect = review_label.rect
print(f"[UnitedUcard Claim] Clicked 'Review and Sign' (attempt {attempt}, {how}) "
f"on <{review_label.tag_name} data-variant={review_label.get_attribute('data-variant')}> "
f"at x={rect.get('x')}, y={rect.get('y')}, w={rect.get('width')}")
if review_step_shown():
return True
print("[UnitedUcard Claim] Review and Sign step not visible yet")
return False
def _check_checkbox_by_id(self, element_id, label=""):
try:
box = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, element_id))
)
except TimeoutException:
return f"ERROR: Checkbox #{element_id} ({label}) not found on Review and Sign step"
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", box)
time.sleep(0.2)
if not box.is_selected():
try:
box.click()
except Exception:
# Styled checkbox: the real input is hidden, so click its label,
# falling back to a JS click on the input itself.
try:
self.driver.find_element(By.CSS_SELECTOR, f"label[for='{element_id}']").click()
except Exception:
self.driver.execute_script("arguments[0].click();", box)
if not box.is_selected():
self.driver.execute_script(
"arguments[0].checked = true;"
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));", box)
print(f"[UnitedUcard Claim] Checked '{label or element_id}'")
return True
def step6_review_and_sign(self):
"""
@@ -824,11 +1009,19 @@ class AutomationUnitedUcardClaimSubmit:
<input type="checkbox" name="MemberPatientSignatureOnFile" id="MemberPatientSignatureOnFile_0" value="yes">
<input type="checkbox" name="PHSubscriberSignatureOnFile" id="PHSubscriberSignatureOnFile_0" value="yes">
"""
# Step5 already confirmed the step is visible; this is a safety net.
try:
WebDriverWait(self.driver, 10).until(
EC.visibility_of_element_located((By.ID, "userSignatureText"))
)
except TimeoutException:
return "ERROR: Review and Sign step is not visible (still on Upload Documents?)"
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)
result = self._check_checkbox_by_id(element_id, label)
if isinstance(result, str) and result.startswith("ERROR"):
return result