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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user