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 =
|
export type InternalChatIntent =
|
||||||
| "check_eligibility" // by patient name → look up in DB
|
| "check_eligibility" // by patient name → look up in DB
|
||||||
| "eligibility_by_id" // by explicit memberId + dob (no name)
|
| "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" // multiple patients by memberId + dob
|
||||||
| "batch_eligibility_by_name" // multiple patients by name (no memberId)
|
| "batch_eligibility_by_name" // multiple patients by name (no memberId)
|
||||||
| "batch_claim" // claim same procedures for multiple patients by name
|
| "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
|
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
|
||||||
memberId?: string; // for eligibility_by_id / check_and_claim
|
memberId?: string; // for eligibility_by_id / check_and_claim
|
||||||
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
|
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) ---
|
// --- batch eligibility (multiple patients) ---
|
||||||
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
|
patients?: { memberId: string; dob: string }[]; // for batch_eligibility
|
||||||
// --- batch claim (same procedures for multiple patients by name) ---
|
// --- batch claim (same procedures for multiple patients by name) ---
|
||||||
@@ -58,6 +61,8 @@ Respond ONLY with valid JSON (no markdown fences):
|
|||||||
"intent": "<intent>",
|
"intent": "<intent>",
|
||||||
"patientName": "<full name if mentioned by name>",
|
"patientName": "<full name if mentioned by name>",
|
||||||
"memberId": "<member/insurance ID if given explicitly or found in history>",
|
"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>",
|
"dob": "<date of birth in MM/DD/YYYY if given explicitly or found in history>",
|
||||||
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
|
"patients": [{"memberId": "<id>", "dob": "<MM/DD/YYYY>"}, ...],
|
||||||
"patientNames": ["<name1>", "<name2>", ...],
|
"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 schedule on 4/10/2026"
|
||||||
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/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)
|
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
|
- 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 mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000"
|
||||||
e.g. "check 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;
|
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(
|
export async function classifyInternalChat(
|
||||||
message: string,
|
message: string,
|
||||||
apiKey: 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;
|
if (!apiKey) return fallback;
|
||||||
|
|
||||||
// Prefer the client's local date (avoids UTC midnight rollover for US timezones)
|
// 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);
|
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) ─────────────────────────────────
|
// ── Batch eligibility (multiple patients) ─────────────────────────────────
|
||||||
|
|
||||||
if (intent === "batch_eligibility") {
|
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 ───────────────────────────────────────────────────────
|
// ─── batch_eligibility ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleBatchEligibility(
|
async function handleBatchEligibility(
|
||||||
|
|||||||
@@ -66,12 +66,16 @@ export interface UnitedUcardEligibilityProcessorResult {
|
|||||||
|
|
||||||
async function processUnitedUcardResult(
|
async function processUnitedUcardResult(
|
||||||
userId: number,
|
userId: number,
|
||||||
insuranceId: string,
|
searchedInsuranceId: string,
|
||||||
formFirstName: string | undefined,
|
formFirstName: string | undefined,
|
||||||
formLastName: string | undefined,
|
formLastName: string | undefined,
|
||||||
formDob: string | undefined,
|
formDob: string | undefined,
|
||||||
seleniumResult: any
|
seleniumResult: any
|
||||||
): Promise<UnitedUcardEligibilityProcessorResult> {
|
): 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 = {};
|
const output: UnitedUcardEligibilityProcessorResult = {};
|
||||||
let createdPdfFileId: number | string | null = null;
|
let createdPdfFileId: number | string | null = null;
|
||||||
|
|
||||||
@@ -82,21 +86,50 @@ async function processUnitedUcardResult(
|
|||||||
? seleniumResult.patientName.trim()
|
? seleniumResult.patientName.trim()
|
||||||
: null;
|
: 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)
|
? splitName(rawName)
|
||||||
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||||
|
|
||||||
// 2) Create / update patient
|
// 2-3) Resolve the patient record.
|
||||||
await createOrUpdatePatientByInsuranceId({
|
// Name + DOB searches have no member ID (the portal masks it), so we can only
|
||||||
insuranceId,
|
// update a patient that is already on file — match on first/last name + DOB.
|
||||||
firstName,
|
let patient: any = null;
|
||||||
lastName,
|
if (insuranceId) {
|
||||||
dob: formDob,
|
await createOrUpdatePatientByInsuranceId({
|
||||||
userId,
|
insuranceId,
|
||||||
});
|
firstName,
|
||||||
|
lastName,
|
||||||
// 3) Fetch patient
|
dob: formDob,
|
||||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
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) {
|
if (!patient?.id) {
|
||||||
output.patientUpdateStatus = "Patient not found; no update performed";
|
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||||
return output;
|
return output;
|
||||||
@@ -115,7 +148,7 @@ async function processUnitedUcardResult(
|
|||||||
typeof seleniumResult?.memberId === "string"
|
typeof seleniumResult?.memberId === "string"
|
||||||
? seleniumResult.memberId.replace(/\s+/g, "")
|
? seleniumResult.memberId.replace(/\s+/g, "")
|
||||||
: null;
|
: null;
|
||||||
if (scrapedMemberId && scrapedMemberId !== insuranceId) {
|
if (insuranceId && scrapedMemberId && scrapedMemberId !== insuranceId) {
|
||||||
statusUpdates.insuranceId = scrapedMemberId;
|
statusUpdates.insuranceId = scrapedMemberId;
|
||||||
log("uniteducard-processor", "member ID changed, overwriting on patient record", {
|
log("uniteducard-processor", "member ID changed, overwriting on patient record", {
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ export function UnitedUcardEligibilityButton({
|
|||||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
if (!memberId || !dateOfBirth) {
|
if (!dateOfBirth || (!memberId && !(firstName && lastName))) {
|
||||||
toast({
|
toast({
|
||||||
title: "Missing fields",
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export function ChatbotButton() {
|
|||||||
// Snapshot of insuranceId/dateOfBirth as originally fetched, so we can tell whether the
|
// 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.
|
// 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 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 [batchEligibilityData, setBatchEligibilityData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[] | null>(null);
|
||||||
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | 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);
|
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));
|
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
|
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string, names?: { firstName?: string; lastName?: string }) => {
|
||||||
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck, firstName: names?.firstName, lastName: names?.lastName }));
|
||||||
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
||||||
markJobStarted();
|
markJobStarted();
|
||||||
if (pendingFolderRenameRef.current) {
|
if (pendingFolderRenameRef.current) {
|
||||||
@@ -598,8 +598,13 @@ export function ChatbotButton() {
|
|||||||
if (!eligibilityIdData) return;
|
if (!eligibilityIdData) return;
|
||||||
addMsg("user", "Check eligibility now");
|
addMsg("user", "Check eligibility now");
|
||||||
addMsg("bot", "Opening the eligibility check page...");
|
addMsg("bot", "Opening the eligibility check page...");
|
||||||
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
if (eligibilityIdData.memberId) {
|
||||||
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
||||||
|
}
|
||||||
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck, {
|
||||||
|
firstName: eligibilityIdData.firstName,
|
||||||
|
lastName: eligibilityIdData.lastName,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchEligibilityRun = () => {
|
const handleBatchEligibilityRun = () => {
|
||||||
@@ -759,6 +764,8 @@ export function ChatbotButton() {
|
|||||||
if (data.action === "eligibility_id_ready" && data.actionData) {
|
if (data.action === "eligibility_id_ready" && data.actionData) {
|
||||||
setEligibilityIdData({
|
setEligibilityIdData({
|
||||||
memberId: data.actionData.memberId,
|
memberId: data.actionData.memberId,
|
||||||
|
firstName: data.actionData.firstName,
|
||||||
|
lastName: data.actionData.lastName,
|
||||||
dob: data.actionData.dob,
|
dob: data.actionData.dob,
|
||||||
siteKey: data.actionData.siteKey,
|
siteKey: data.actionData.siteKey,
|
||||||
autoCheck: data.actionData.autoCheck,
|
autoCheck: data.actionData.autoCheck,
|
||||||
@@ -1134,6 +1141,30 @@ export function ChatbotButton() {
|
|||||||
className="h-7 text-xs bg-white"
|
className="h-7 text-xs bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
|
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1316,6 +1347,8 @@ export function ChatbotButton() {
|
|||||||
} else if (data.action === "eligibility_id_ready" && data.actionData) {
|
} else if (data.action === "eligibility_id_ready" && data.actionData) {
|
||||||
setEligibilityIdData({
|
setEligibilityIdData({
|
||||||
memberId: data.actionData.memberId,
|
memberId: data.actionData.memberId,
|
||||||
|
firstName: data.actionData.firstName,
|
||||||
|
lastName: data.actionData.lastName,
|
||||||
dob: data.actionData.dob,
|
dob: data.actionData.dob,
|
||||||
siteKey: data.actionData.siteKey,
|
siteKey: data.actionData.siteKey,
|
||||||
autoCheck: data.actionData.autoCheck,
|
autoCheck: data.actionData.autoCheck,
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export default function InsuranceStatusPage() {
|
|||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const isFormIncomplete = !memberId || !dateOfBirth;
|
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] =
|
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isCheckingEligibilityAppointment, setIsCheckingEligibilityAppointment] =
|
const [isCheckingEligibilityAppointment, setIsCheckingEligibilityAppointment] =
|
||||||
@@ -186,8 +188,10 @@ export default function InsuranceStatusPage() {
|
|||||||
const raw = sessionStorage.getItem("chatbot_eligibility");
|
const raw = sessionStorage.getItem("chatbot_eligibility");
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
try {
|
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 (id) setMemberId(id);
|
||||||
|
if (fn) setFirstName(fn);
|
||||||
|
if (ln) setLastName(ln);
|
||||||
if (dob) {
|
if (dob) {
|
||||||
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
|
// 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
|
// 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")
|
// Auto-trigger after prefill (from chatbot or schedule page "Check Eligibility")
|
||||||
useEffect(() => {
|
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;
|
const check = pendingAutoCheck.current;
|
||||||
pendingAutoCheck.current = null;
|
pendingAutoCheck.current = null;
|
||||||
if (check === "mh" || check === "mh-history") {
|
if (check === "mh" || check === "mh-history") {
|
||||||
@@ -1209,7 +1214,7 @@ export default function InsuranceStatusPage() {
|
|||||||
dateOfBirth={dateOfBirth}
|
dateOfBirth={dateOfBirth}
|
||||||
firstName={firstName}
|
firstName={firstName}
|
||||||
lastName={lastName}
|
lastName={lastName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
isFormIncomplete={isUcardFormIncomplete}
|
||||||
autoTrigger={triggerTarget === "united-ucard"}
|
autoTrigger={triggerTarget === "united-ucard"}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
onPdfReady={async (pdfId, fallbackFilename) => {
|
onPdfReady={async (pdfId, fallbackFilename) => {
|
||||||
|
|||||||
@@ -394,48 +394,183 @@ class AutomationUnitedUcardEligibilityCheck:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR: Could not enter Service Date: {e}"
|
return f"ERROR: Could not enter Service Date: {e}"
|
||||||
|
|
||||||
# Member Date of Birth
|
# Attempt 1: Subscriber ID + DOB (default "Search By" radio).
|
||||||
try:
|
# Skipped if we have no member ID.
|
||||||
dob_digits = re.sub(r"\D", "", self._format_dob(self.dateOfBirth))
|
if self.memberId:
|
||||||
_fill_field("eligMemDob", dob_digits, "Member DOB")
|
err = self._search_by_subscriber_id(_fill_field)
|
||||||
except Exception as e:
|
if err:
|
||||||
return f"ERROR: Could not enter Member Date of Birth: {e}"
|
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
|
# Attempt 2: fall back to First Name + Last Name + DOB.
|
||||||
try:
|
if not (self.firstName and self.lastName and self.dateOfBirth):
|
||||||
_fill_field("eligSubsId", self.memberId, "Subscriber ID")
|
return "ERROR: Subscriber ID + DOB search failed and first name / last name / DOB are not all available for a name search"
|
||||||
except Exception as e:
|
|
||||||
return f"ERROR: Could not enter Subscriber ID: {e}"
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
print(f"[UnitedUcard step1] Falling back to name search: {self.firstName} {self.lastName}, DOB={self.dateOfBirth}")
|
||||||
|
err = self._search_by_name(_fill_field)
|
||||||
# Click SEARCH button
|
if err:
|
||||||
try:
|
return err
|
||||||
search_btn = WebDriverWait(self.driver, 10).until(
|
if self._search_succeeded():
|
||||||
EC.presence_of_element_located((By.XPATH,
|
print("[UnitedUcard step1] Eligibility search submitted (Name)")
|
||||||
"//input[@type='button' and @value='search']"))
|
return "Success"
|
||||||
)
|
return "ERROR: Eligibility search returned no result by Subscriber ID + DOB or by Name + DOB"
|
||||||
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"
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[UnitedUcard step1] Exception: {e}")
|
print(f"[UnitedUcard step1] Exception: {e}")
|
||||||
return f"ERROR:STEP1 - {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):
|
def step2(self):
|
||||||
"""
|
"""
|
||||||
Extract patient/eligibility info from the "Eligibility Summary" page
|
Extract patient/eligibility info from the "Eligibility Summary" page
|
||||||
@@ -462,6 +597,13 @@ class AutomationUnitedUcardEligibilityCheck:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
time.sleep(2)
|
||||||
|
|
||||||
current_url = self.driver.current_url
|
current_url = self.driver.current_url
|
||||||
@@ -522,8 +664,21 @@ class AutomationUnitedUcardEligibilityCheck:
|
|||||||
if not name_extracted:
|
if not name_extracted:
|
||||||
print("[UnitedUcard step2] WARNING: Could not extract patient name from page; using form values")
|
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 ──────────────────────────────────────────────
|
# ── Capture PDF ──────────────────────────────────────────────
|
||||||
pdf_path = self._capture_pdf(self.memberId)
|
pdf_path = self._capture_pdf(memberId)
|
||||||
if not pdf_path:
|
if not pdf_path:
|
||||||
return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
|
return {"status": "error", "message": "STEP2 FAILED: Could not generate PDF"}
|
||||||
|
|
||||||
@@ -539,10 +694,9 @@ class AutomationUnitedUcardEligibilityCheck:
|
|||||||
"ss_path": pdf_path,
|
"ss_path": pdf_path,
|
||||||
"pdf_path": pdf_path,
|
"pdf_path": pdf_path,
|
||||||
"patientName": patientName,
|
"patientName": patientName,
|
||||||
# NOTE: Subscriber ID shown on this page is masked (e.g.
|
# NOTE: the Subscriber ID badge is masked (e.g. "******444"), so
|
||||||
# "******444"), so we return the ID we searched with rather
|
# we return the page's Product ID (falling back to the searched ID).
|
||||||
# than a scraped-and-truncated value.
|
"memberId": memberId,
|
||||||
"memberId": self.memberId,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user