feat: United Ucard name+DOB eligibility search via chat, duplicate-record handling, Product ID as member ID

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 22:30:01 -04:00
co-authored by Claude Sonnet 5
parent ce25cdd2a2
commit 7ec82902d9
7 changed files with 366 additions and 64 deletions
@@ -5,6 +5,7 @@ import { getLlm, type AiProvider } from "./llm-factory";
export type InternalChatIntent =
| "check_eligibility" // by patient name → look up in DB
| "eligibility_by_id" // by explicit memberId + dob (no name)
| "eligibility_by_name_dob" // by explicit first name + last name + dob (no memberId) — United Ucard name search
| "batch_eligibility" // multiple patients by memberId + dob
| "batch_eligibility_by_name" // multiple patients by name (no memberId)
| "batch_claim" // claim same procedures for multiple patients by name
@@ -26,6 +27,8 @@ export interface ChatClassification {
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
memberId?: string; // for eligibility_by_id / check_and_claim
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
firstName?: string; // for eligibility_by_name_dob
lastName?: string; // for eligibility_by_name_dob
// --- batch eligibility (multiple patients) ---
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
// --- batch claim (same procedures for multiple patients by name) ---
@@ -58,6 +61,8 @@ Respond ONLY with valid JSON (no markdown fences):
"intent": "<intent>",
"patientName": "<full name if mentioned by name>",
"memberId": "<member/insurance ID if given explicitly or found in history>",
"firstName": "<first name, only for eligibility_by_name_dob>",
"lastName": "<last name, only for eligibility_by_name_dob>",
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
"patientNames": ["<name1>", "<name2>", ...],
@@ -86,6 +91,10 @@ Intents:
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
- eligibility_by_name_dob : user provides a first name, last name AND date of birth but NO member ID
e.g. "check ucard Kai Gao 08/30/1997", "Kai Gao, 8/30/1997"
e.g. "canin gomez, 08/30/1997, ucard" → firstName "canin", lastName "gomez" (ALWAYS: first word = first name, all remaining words = last name, e.g. "min ping wang" → firstName "min", lastName "ping wang")
Set firstName, lastName and dob (MM/DD/YYYY). Set insuranceHint if stated (e.g. "ucard").
- batch_eligibility : user provides MULTIPLE member IDs with dates of birth in one message
e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000"
@@ -236,6 +245,19 @@ function parseBareIdDob(message: string): { memberId: string; dob: string } | nu
return null;
}
// Deterministic fast-path for a bare "First Last DOB" (or "DOB First Last") message,
// e.g. "Kai Gao 08/30/1997" or "Kai Gao, 8/30/1997". Two or more alphabetic words: first word = first name, the rest = last name.
function parseBareNameDob(message: string): { firstName: string; lastName: string; dob: string } | null {
const text = message.trim();
const dob = "(\\d{1,2}[\\/\\-]\\d{1,2}[\\/\\-]\\d{4})";
const name = "([A-Za-z][A-Za-z'\\-]*)\\s+([A-Za-z][A-Za-z'\\-]*(?:\\s+[A-Za-z][A-Za-z'\\-]*)*)";
const nameThenDob = text.match(new RegExp(`^${name}[\\s,]+${dob}$`));
if (nameThenDob) return { firstName: nameThenDob[1]!, lastName: nameThenDob[2]!, dob: nameThenDob[3]!.replace(/-/g, "/") };
const dobThenName = text.match(new RegExp(`^${dob}[\\s,]+${name}$`));
if (dobThenName) return { firstName: dobThenName[2]!, lastName: dobThenName[3]!, dob: dobThenName[1]!.replace(/-/g, "/") };
return null;
}
export async function classifyInternalChat(
message: string,
apiKey: string,
@@ -261,6 +283,17 @@ export async function classifyInternalChat(
};
}
const bareName = parseBareNameDob(message);
if (bareName) {
return {
intent: "eligibility_by_name_dob",
firstName: bareName.firstName,
lastName: bareName.lastName,
dob: bareName.dob,
fallbackReply: `Checking eligibility for ${bareName.firstName} ${bareName.lastName}...`,
};
}
if (!apiKey) return fallback;
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
@@ -324,6 +324,12 @@ export async function runInternalChatWorkflow(
return await handleEligibilityById(classification, storage);
}
// ── Eligibility by first name + last name + DOB (United Ucard name search) ─
if (intent === "eligibility_by_name_dob") {
return handleEligibilityByNameDob(classification);
}
// ── Batch eligibility (multiple patients) ─────────────────────────────────
if (intent === "batch_eligibility") {
@@ -458,6 +464,44 @@ async function handleEligibilityById(
};
}
// ─── eligibility_by_name_dob ──────────────────────────────────────────────────
// Only United Ucard supports searching by name + DOB (no member ID).
function handleEligibilityByNameDob(c: ChatClassification): ChatResponse {
// Rule: first word = first name, everything after = last name ("min ping wang" → "min" / "ping wang").
// Re-split regardless of how the classifier divided it.
const fullName = (c.patientName?.trim() || `${c.firstName ?? ""} ${c.lastName ?? ""}`.trim());
const parts = fullName.split(/\s+/).filter(Boolean);
const firstName = parts[0];
const lastName = parts.length >= 2 ? parts.slice(1).join(" ") : undefined;
const dob = c.dob?.trim();
if (!firstName || !lastName || !dob) {
return { reply: "Please provide the first name, last name and Date of Birth (MM/DD/YYYY)." };
}
const hint = (c.insuranceHint ?? "").toLowerCase();
if (hint && !hint.includes("ucard") && !hint.includes("united")) {
return {
reply: "Searching by name + DOB is only supported for United Ucard. Please provide a Member ID for other plans.",
};
}
return {
reply: `Ready to check United Ucard eligibility for ${firstName} ${lastName} by name.`,
action: "eligibility_id_ready",
actionData: {
patient: null,
memberId: "",
firstName,
lastName,
dob,
siteKey: "UNITED_UCARD",
autoCheck: siteKeyToAutoCheck("UNITED_UCARD"),
appointmentDate: c.appointmentDate ?? null,
},
};
}
// ─── batch_eligibility ───────────────────────────────────────────────────────
async function handleBatchEligibility(
@@ -66,12 +66,16 @@ export interface UnitedUcardEligibilityProcessorResult {
async function processUnitedUcardResult(
userId: number,
insuranceId: string,
searchedInsuranceId: string,
formFirstName: string | undefined,
formLastName: string | undefined,
formDob: string | undefined,
seleniumResult: any
): Promise<UnitedUcardEligibilityProcessorResult> {
// Name-search jobs have no member ID up front; the worker returns the page's Product ID instead.
const scrapedId =
typeof seleniumResult?.memberId === "string" ? seleniumResult.memberId.replace(/\s+/g, "") : "";
const insuranceId = searchedInsuranceId || scrapedId;
const output: UnitedUcardEligibilityProcessorResult = {};
let createdPdfFileId: number | string | null = null;
@@ -82,21 +86,50 @@ async function processUnitedUcardResult(
? seleniumResult.patientName.trim()
: null;
const { firstName, lastName } = rawName
// Name-search jobs (no member ID) always use the names the user typed.
const { firstName, lastName } = rawName && searchedInsuranceId
? splitName(rawName)
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
// 2) Create / update patient
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: formDob,
userId,
});
// 3) Fetch patient
const patient = await storage.getPatientByInsuranceId(insuranceId);
// 2-3) Resolve the patient record.
// Name + DOB searches have no member ID (the portal masks it), so we can only
// update a patient that is already on file — match on first/last name + DOB.
let patient: any = null;
if (insuranceId) {
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName,
lastName,
dob: formDob,
userId,
});
patient = await storage.getPatientByInsuranceId(insuranceId);
} else {
const dobDate = formDob ? new Date(formDob) : null;
const matches = await storage.searchPatients({
filters: {
firstName: { equals: firstName, mode: "insensitive" },
lastName: { equals: lastName, mode: "insensitive" },
...(dobDate && !isNaN(dobDate.getTime()) ? { dateOfBirth: dobDate } : {}),
},
limit: 1,
offset: 0,
});
patient = matches?.[0] ?? null;
if (!patient) {
// The portal masks the subscriber ID, so create the patient from the typed name + DOB
// (no insuranceId); the Member ID can be added later.
patient = await storage.createPatient({
firstName,
lastName,
dateOfBirth: dobDate && !isNaN(dobDate.getTime()) ? dobDate : null,
gender: "",
phone: "",
userId,
} as any);
log("uniteducard-processor", "created patient from name search", { patientId: patient?.id });
}
}
if (!patient?.id) {
output.patientUpdateStatus = "Patient not found; no update performed";
return output;
@@ -115,7 +148,7 @@ async function processUnitedUcardResult(
typeof seleniumResult?.memberId === "string"
? seleniumResult.memberId.replace(/\s+/g, "")
: null;
if (scrapedMemberId && scrapedMemberId !== insuranceId) {
if (insuranceId && scrapedMemberId && scrapedMemberId !== insuranceId) {
statusUpdates.insuranceId = scrapedMemberId;
log("uniteducard-processor", "member ID changed, overwriting on patient record", {
patientId: patient.id,
@@ -113,10 +113,10 @@ export function UnitedUcardEligibilityButton({
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
if (!dateOfBirth || (!memberId && !(firstName && lastName))) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
description: "Date of Birth plus either Member ID or First + Last Name are required.",
variant: "destructive",
});
return;
@@ -231,7 +231,7 @@ export function ChatbotButton() {
// Snapshot of insuranceId/dateOfBirth as originally fetched, so we can tell whether the
// user hand-edited them in the patient-found card and only PUT to the DB when they did.
const originalPatientResultRef = useRef<{ insuranceId: string | null; dateOfBirth: string | null } | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; firstName?: string; lastName?: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
const [batchEligibilityData, setBatchEligibilityData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[] | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
@@ -562,8 +562,8 @@ export function ChatbotButton() {
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
};
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string, names?: { firstName?: string; lastName?: string }) => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck, firstName: names?.firstName, lastName: names?.lastName }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
markJobStarted();
if (pendingFolderRenameRef.current) {
@@ -598,8 +598,13 @@ export function ChatbotButton() {
if (!eligibilityIdData) return;
addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page...");
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
if (eligibilityIdData.memberId) {
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
}
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck, {
firstName: eligibilityIdData.firstName,
lastName: eligibilityIdData.lastName,
});
};
const handleBatchEligibilityRun = () => {
@@ -759,6 +764,8 @@ export function ChatbotButton() {
if (data.action === "eligibility_id_ready" && data.actionData) {
setEligibilityIdData({
memberId: data.actionData.memberId,
firstName: data.actionData.firstName,
lastName: data.actionData.lastName,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
@@ -1134,6 +1141,30 @@ export function ChatbotButton() {
className="h-7 text-xs bg-white"
/>
</div>
{eligibilityIdData.firstName !== undefined && (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">First Name</Label>
<Input
value={eligibilityIdData.firstName ?? ""}
onChange={(e) =>
setEligibilityIdData((prev) => (prev ? { ...prev, firstName: e.target.value } : prev))
}
className="h-7 text-xs bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Last Name</Label>
<Input
value={eligibilityIdData.lastName ?? ""}
onChange={(e) =>
setEligibilityIdData((prev) => (prev ? { ...prev, lastName: e.target.value } : prev))
}
className="h-7 text-xs bg-white"
/>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
<Input
@@ -1316,6 +1347,8 @@ export function ChatbotButton() {
} else if (data.action === "eligibility_id_ready" && data.actionData) {
setEligibilityIdData({
memberId: data.actionData.memberId,
firstName: data.actionData.firstName,
lastName: data.actionData.lastName,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
@@ -115,6 +115,8 @@ export default function InsuranceStatusPage() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const isFormIncomplete = !memberId || !dateOfBirth;
// United Ucard can also search by first name + last name + DOB when there's no member ID.
const isUcardFormIncomplete = !dateOfBirth || (!memberId && !(firstName && lastName));
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
useState(false);
const [isCheckingEligibilityAppointment, setIsCheckingEligibilityAppointment] =
@@ -186,8 +188,10 @@ export default function InsuranceStatusPage() {
const raw = sessionStorage.getItem("chatbot_eligibility");
if (!raw) return;
try {
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
const { memberId: id, dob, autoCheck: ac, firstName: fn, lastName: ln } = JSON.parse(raw);
if (id) setMemberId(id);
if (fn) setFirstName(fn);
if (ln) setLastName(ln);
if (dob) {
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
// A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
@@ -749,7 +753,8 @@ export default function InsuranceStatusPage() {
// Auto-trigger after prefill (from chatbot or schedule page "Check Eligibility")
useEffect(() => {
if (!pendingAutoCheck.current || !memberId || !dateOfBirth) return;
if (!pendingAutoCheck.current || !dateOfBirth) return;
if (!memberId && !(pendingAutoCheck.current === "united-ucard" && firstName && lastName)) return;
const check = pendingAutoCheck.current;
pendingAutoCheck.current = null;
if (check === "mh" || check === "mh-history") {
@@ -1209,7 +1214,7 @@ export default function InsuranceStatusPage() {
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
isFormIncomplete={isUcardFormIncomplete}
autoTrigger={triggerTarget === "united-ucard"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={async (pdfId, fallbackFilename) => {
@@ -394,48 +394,183 @@ class AutomationUnitedUcardEligibilityCheck:
except Exception as e:
return f"ERROR: Could not enter Service Date: {e}"
# Member Date of Birth
try:
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
_fill_field("eligMemDob", dob_digits, "Member DOB")
except Exception as e:
return f"ERROR: Could not enter Member Date of Birth: {e}"
# Attempt 1: Subscriber ID + DOB (default "Search By" radio).
# Skipped if we have no member ID.
if self.memberId:
err = self._search_by_subscriber_id(_fill_field)
if err:
return err
if self._search_succeeded():
print("[UnitedUcard step1] Eligibility search submitted (Subscriber ID)")
return "Success"
print("[UnitedUcard step1] Subscriber ID + DOB search did not return a result")
else:
print("[UnitedUcard step1] No memberId provided; going straight to name search")
# Subscriber ID
try:
_fill_field("eligSubsId", self.memberId, "Subscriber ID")
except Exception as e:
return f"ERROR: Could not enter Subscriber ID: {e}"
# Attempt 2: fall back to First Name + Last Name + DOB.
if not (self.firstName and self.lastName and self.dateOfBirth):
return "ERROR: Subscriber ID + DOB search failed and first name / last name / DOB are not all available for a name search"
time.sleep(0.5)
# Click SEARCH button
try:
search_btn = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='button' and @value='search']"))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", search_btn)
time.sleep(0.3)
try:
search_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", search_btn)
print("[UnitedUcard step1] Clicked SEARCH button")
except Exception as e:
return f"ERROR: Could not click SEARCH button: {e}"
time.sleep(3)
# TODO: confirm what the results look like (same page update? new
# tab? modal?) once we see it, then extend step2() accordingly.
print("[UnitedUcard step1] Eligibility search submitted")
return "Success"
print(f"[UnitedUcard step1] Falling back to name search: {self.firstName} {self.lastName}, DOB={self.dateOfBirth}")
err = self._search_by_name(_fill_field)
if err:
return err
if self._search_succeeded():
print("[UnitedUcard step1] Eligibility search submitted (Name)")
return "Success"
return "ERROR: Eligibility search returned no result by Subscriber ID + DOB or by Name + DOB"
except Exception as e:
print(f"[UnitedUcard step1] Exception: {e}")
return f"ERROR:STEP1 - {e}"
def _click_search(self):
search_btn = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='button' and @value='search']"))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", search_btn)
time.sleep(0.3)
try:
search_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", search_btn)
print("[UnitedUcard step1] Clicked SEARCH button")
time.sleep(3)
def _select_search_by(self, value):
"""Click the 'Search By' radio (value 'searchbyname' or the subscriber ID one)."""
radio = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, f"input[type='radio'][name='searchby'][value='{value}']"))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", radio)
try:
radio.click()
except Exception:
self.driver.execute_script("arguments[0].click();", radio)
time.sleep(1)
print(f"[UnitedUcard step1] Selected Search By radio: {value}")
def _search_by_subscriber_id(self, _fill_field):
"""Returns an error string, or None once SEARCH has been clicked."""
try:
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
_fill_field("eligMemDob", dob_digits, "Member DOB")
except Exception as e:
return f"ERROR: Could not enter Member Date of Birth: {e}"
try:
_fill_field("eligSubsId", self.memberId, "Subscriber ID")
except Exception as e:
return f"ERROR: Could not enter Subscriber ID: {e}"
time.sleep(0.5)
try:
self._click_search()
except Exception as e:
return f"ERROR: Could not click SEARCH button: {e}"
return None
def _search_by_name(self, _fill_field):
"""Switch to the 'Name' radio and search by first/last name + DOB.
Returns an error string, or None once SEARCH has been clicked."""
try:
self._select_search_by("searchbyname")
except Exception as e:
return f"ERROR: Could not select the Name search radio: {e}"
# Switching the radio re-renders the form, so re-enter Service Date too.
try:
from datetime import date
_fill_field("serviceDate", date.today().strftime("%m%d%Y"), "Service Date")
except Exception as e:
return f"ERROR: Could not enter Service Date: {e}"
for field_id, value, label in (("eligFname", self.firstName, "First Name"),
("eligLname", self.lastName, "Last Name")):
try:
_fill_field(field_id, value, label)
except Exception as e:
return f"ERROR: Could not enter {label}: {e}"
try:
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
_fill_field("eligMemDob", dob_digits, "Member DOB")
except Exception as e:
return f"ERROR: Could not enter Member Date of Birth: {e}"
time.sleep(0.5)
try:
self._click_search()
except Exception as e:
return f"ERROR: Could not click SEARCH button: {e}"
return None
def _handle_duplicate_records(self):
"""If the portal lists duplicate member records ("Duplicate records have been
found."), select the Active one (falling back to the last row) and resubmit.
Returns True if a record was selected."""
radios = self.driver.find_elements(By.CSS_SELECTOR, "input[type='radio'][name='refineMemDupliGroup']")
radios = [r for r in radios if r.is_displayed()]
if not radios:
return False
chosen = None
for r in radios:
try:
row_text = r.find_element(By.XPATH, "./ancestor::tr[1]").text.lower()
except Exception:
row_text = ""
if "active" in row_text and "termed" not in row_text and "inactive" not in row_text:
chosen = r
break
if chosen is None:
chosen = radios[-1]
print(f"[UnitedUcard step1] {len(radios)} duplicate records; selecting value={chosen.get_attribute('value')}")
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", chosen)
try:
chosen.click()
except Exception:
self.driver.execute_script("arguments[0].click();", chosen)
time.sleep(1.5)
# Click the "select" button (ng-click="refineSearch()") shown below the duplicate list.
try:
select_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//input[@type='button' and @value='select' and contains(@ng-click,'refineSearch')]"))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", select_btn)
try:
select_btn.click()
except Exception:
self.driver.execute_script("arguments[0].click();", select_btn)
print("[UnitedUcard step1] Clicked select button for duplicate record")
except Exception as e:
print(f"[UnitedUcard step1] Could not click select button: {e}")
return True
def _search_succeeded(self):
"""True once the browser lands on the Eligibility Summary page; False as
soon as the form shows a "Member not found" error (or after 30s).
Duplicate-record lists are resolved along the way."""
deadline = time.time() + 30
duplicates_handled = False
while time.time() < deadline:
try:
if "eligibility-summary" in self.driver.current_url.lower():
return True
body = self.driver.find_element(By.TAG_NAME, "body").text.lower()
if "member not found" in body:
print("[UnitedUcard step1] Portal reported: Member not found")
return False
if not duplicates_handled and self._handle_duplicate_records():
duplicates_handled = True
continue
except Exception as e:
print(f"[UnitedUcard step1] Result check error: {e}")
time.sleep(0.5)
return False
def step2(self):
"""
Extract patient/eligibility info from the "Eligibility Summary" page
@@ -462,6 +597,13 @@ class AutomationUnitedUcardEligibilityCheck:
)
except Exception:
pass
# Wait for the summary content (not just the page shell) before reading/printing it.
try:
WebDriverWait(self.driver, 20).until(
lambda d: "insurance information" in d.find_element(By.TAG_NAME, "body").text.lower()
)
except Exception:
print("[UnitedUcard step2] WARNING: 'Insurance Information' not visible yet; capturing anyway")
time.sleep(2)
current_url = self.driver.current_url
@@ -522,8 +664,21 @@ class AutomationUnitedUcardEligibilityCheck:
if not name_extracted:
print("[UnitedUcard step2] WARNING: Could not extract patient name from page; using form values")
# ── Member ID ────────────────────────────────────────────────
# The Subscriber ID badge is masked, but "Product ID" is the full ID
# (the value the Subscriber ID search field accepts, e.g. "D0033034").
# This page looks the same however the search was done (ID or name),
# so always prefer it; fall back to the ID we searched with.
memberId = self.memberId
pid_match = re.search(r'Product ID:\s*([A-Za-z0-9]+)', page_text)
if pid_match:
memberId = pid_match.group(1)
print(f"[UnitedUcard step2] Member ID from Product ID: {memberId}")
else:
print(f"[UnitedUcard step2] WARNING: Product ID not found on page; using searched ID '{memberId}'")
# ── Capture PDF ──────────────────────────────────────────────
pdf_path = self._capture_pdf(self.memberId)
pdf_path = self._capture_pdf(memberId)
if not pdf_path:
return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
@@ -539,10 +694,9 @@ class AutomationUnitedUcardEligibilityCheck:
"ss_path": pdf_path,
"pdf_path": pdf_path,
"patientName": patientName,
# NOTE: Subscriber ID shown on this page is masked (e.g.
# "******444"), so we return the ID we searched with rather
# than a scraped-and-truncated value.
"memberId": self.memberId,
# NOTE: the Subscriber ID badge is masked (e.g. "******444"), so
# we return the page's Product ID (falling back to the searched ID).
"memberId": memberId,
}
except Exception as e: