fix: MH eligibility selenium integration and patient data extraction
This commit is contained in:
0
.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-13
Normal file
0
.turbo/daemon/53b0054db79f7114-turbo.log.2026-04-13
Normal file
0
.turbo/daemon/f6a8bbc9aba2c062-turbo.log.2026-04-14
Normal file
0
.turbo/daemon/f6a8bbc9aba2c062-turbo.log.2026-04-14
Normal file
2327
apps/Backend/backups/dental_backup_1776124800085.sql
Normal file
2327
apps/Backend/backups/dental_backup_1776124800085.sql
Normal file
File diff suppressed because one or more lines are too long
4152
apps/Backend/backups/dental_backup_1776211200056.sql
Normal file
4152
apps/Backend/backups/dental_backup_1776211200056.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -18,50 +18,30 @@ const SELENIUM_BASE_URL =
|
||||
// Python service helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Start an async job on the Python service and return the session ID. */
|
||||
export async function startPythonJob(
|
||||
/**
|
||||
* Call a synchronous Python service endpoint and return its result.
|
||||
* The Python service runs the selenium job inline and returns the result directly.
|
||||
* Timeout is long (8 min) to accommodate slow selenium flows.
|
||||
*/
|
||||
export async function callPythonSync(
|
||||
endpoint: string,
|
||||
payload: any
|
||||
): Promise<string> {
|
||||
payload: any,
|
||||
timeoutMs = 8 * 60 * 1000
|
||||
): Promise<any> {
|
||||
const resp = await axios.post(
|
||||
`${SELENIUM_BASE_URL}${endpoint}`,
|
||||
payload,
|
||||
{ timeout: 10_000 }
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
const sid: string = resp.data?.session_id;
|
||||
if (!sid) throw new Error(`Python service did not return a session_id from ${endpoint}`);
|
||||
return sid;
|
||||
}
|
||||
|
||||
/** Poll /job/<sid>/status until completed/failed or timeout. */
|
||||
export async function pollPythonJob(
|
||||
sid: string,
|
||||
timeoutMs = 5 * 60 * 1000,
|
||||
intervalMs = 2_000
|
||||
): Promise<any> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const resp = await axios.get(
|
||||
`${SELENIUM_BASE_URL}/job/${sid}/status`,
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
const s = resp.data;
|
||||
if (s.status === "completed") {
|
||||
if (s.result?.status === "error") {
|
||||
const data = resp.data;
|
||||
if (data?.status === "error") {
|
||||
const msg =
|
||||
typeof s.result.message === "string"
|
||||
? s.result.message
|
||||
: s.result.message?.msg ?? "Selenium returned error status";
|
||||
typeof data.message === "string"
|
||||
? data.message
|
||||
: data.message?.msg ?? "Selenium returned error status";
|
||||
throw new Error(msg);
|
||||
}
|
||||
return s.result;
|
||||
}
|
||||
if (s.status === "failed") {
|
||||
throw new Error(s.error || "Python job failed");
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw new Error("Selenium job timed out after polling");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,8 +8,7 @@ import path from "path";
|
||||
import { storage } from "../../storage";
|
||||
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
|
||||
import {
|
||||
startPythonJob,
|
||||
pollPythonJob,
|
||||
callPythonSync,
|
||||
imageToPdfBuffer,
|
||||
} from "./_shared";
|
||||
|
||||
@@ -28,14 +27,11 @@ export async function runClaimStatusProcessor(
|
||||
): Promise<ClaimStatusProcessorResult> {
|
||||
const { enrichedPayload, insuranceId } = input;
|
||||
|
||||
// 1) Start async Python job
|
||||
const sid = await startPythonJob("/claim-status-check/async", {
|
||||
// 1) Call the Python service synchronously (BullMQ worker handles async)
|
||||
const result = await callPythonSync("/claim-status-check", {
|
||||
data: enrichedPayload,
|
||||
});
|
||||
|
||||
// 2) Poll for completion
|
||||
const result = await pollPythonJob(sid);
|
||||
|
||||
const outputResult: ClaimStatusProcessorResult = {};
|
||||
|
||||
// 3) Look up patient
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Mirrors routes/claims.ts /selenium-claim and /selenium-claim-pre-auth
|
||||
*/
|
||||
import { storage } from "../../storage";
|
||||
import { startPythonJob, pollPythonJob } from "./_shared";
|
||||
import { callPythonSync } from "./_shared";
|
||||
|
||||
export interface ClaimSubmitProcessorInput {
|
||||
enrichedPayload: any;
|
||||
@@ -37,13 +37,10 @@ export async function runClaimSubmitProcessor(
|
||||
const payload = { claim: enrichedPayload, pdfs, images };
|
||||
|
||||
const endpoint =
|
||||
input.variant === "claim-pre-auth" ? "/claim-pre-auth/async" : "/claimsubmit/async";
|
||||
input.variant === "claim-pre-auth" ? "/claim-pre-auth" : "/claimsubmit";
|
||||
|
||||
// 1) Start async Python job
|
||||
const sid = await startPythonJob(endpoint, payload);
|
||||
|
||||
// 2) Poll for result
|
||||
const result = await pollPythonJob(sid, 10 * 60 * 1000); // claim submit can take up to 10 min
|
||||
// 1) Call the Python service synchronously (BullMQ worker handles async)
|
||||
const result = await callPythonSync(endpoint, payload, 10 * 60 * 1000);
|
||||
|
||||
// 3) Persist claimNumber if returned
|
||||
if (result?.claimNumber && claimId) {
|
||||
|
||||
@@ -11,8 +11,7 @@ import { storage } from "../../storage";
|
||||
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
|
||||
import forwardToPatientDataExtractorService from "../../services/patientDataExtractorService";
|
||||
import {
|
||||
startPythonJob,
|
||||
pollPythonJob,
|
||||
callPythonSync,
|
||||
splitName,
|
||||
createOrUpdatePatientByInsuranceId,
|
||||
} from "./_shared";
|
||||
@@ -45,26 +44,29 @@ export async function runEligibilityProcessor(
|
||||
formDob,
|
||||
} = input;
|
||||
|
||||
// 1) Fire the async Python job
|
||||
const sid = await startPythonJob("/eligibility-check/async", {
|
||||
// 1) Call the Python service synchronously (BullMQ worker handles async)
|
||||
const seleniumResult = await callPythonSync("/eligibility-check", {
|
||||
data: enrichedPayload,
|
||||
});
|
||||
|
||||
// 2) Wait for completion
|
||||
const seleniumResult = await pollPythonJob(sid);
|
||||
|
||||
const outputResult: EligibilityProcessorResult = {};
|
||||
|
||||
// 3) Extract name: prefer selenium extraction → PDF extractor → form input
|
||||
// Normalize: treat empty strings from Python the same as null
|
||||
const seleniumFirst = seleniumResult.firstName?.trim() || null;
|
||||
const seleniumLast = seleniumResult.lastName?.trim() || null;
|
||||
const seleniumName = seleniumResult.name?.trim() || null;
|
||||
const seleniumInsurance = seleniumResult.insurance?.trim() || null;
|
||||
|
||||
const extracted: { firstName?: string | null; lastName?: string | null } = {};
|
||||
|
||||
if (seleniumResult.firstName || seleniumResult.lastName) {
|
||||
extracted.firstName = seleniumResult.firstName ?? null;
|
||||
extracted.lastName = seleniumResult.lastName ?? null;
|
||||
} else if (seleniumResult.name) {
|
||||
const parts = splitName(seleniumResult.name);
|
||||
extracted.firstName = parts.firstName;
|
||||
extracted.lastName = parts.lastName;
|
||||
if (seleniumFirst || seleniumLast) {
|
||||
extracted.firstName = seleniumFirst;
|
||||
extracted.lastName = seleniumLast;
|
||||
} else if (seleniumName) {
|
||||
const parts = splitName(seleniumName);
|
||||
extracted.firstName = parts.firstName || null;
|
||||
extracted.lastName = parts.lastName || null;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -81,14 +83,15 @@ export async function runEligibilityProcessor(
|
||||
} as any);
|
||||
if (extraction.name) {
|
||||
const parts = splitName(extraction.name);
|
||||
extracted.firstName = parts.firstName;
|
||||
extracted.lastName = parts.lastName;
|
||||
extracted.firstName = parts.firstName || null;
|
||||
extracted.lastName = parts.lastName || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[eligibilityProcessor] PDF name extraction failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: use form data sent by the frontend
|
||||
const preferFirst = extracted.firstName || formFirstName || null;
|
||||
const preferLast = extracted.lastName || formLastName || null;
|
||||
|
||||
@@ -113,7 +116,7 @@ export async function runEligibilityProcessor(
|
||||
else if (seleniumResult.eligibility === "N") newStatus = "INACTIVE";
|
||||
|
||||
const updates: any = { status: newStatus };
|
||||
if (seleniumResult.insurance) updates.insuranceProvider = seleniumResult.insurance;
|
||||
if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance;
|
||||
|
||||
await storage.updatePatient(patient.id, updates);
|
||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/redux/slices/seleniumTaskSlice";
|
||||
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
|
||||
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
|
||||
import { socket } from "@/lib/socket";
|
||||
import { InsertPatient, Patient } from "@repo/db/types";
|
||||
import { DateInput } from "@/components/ui/dateInput";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
@@ -117,6 +118,8 @@ export default function InsuranceStatusPage() {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
insuranceSiteKey: "MH",
|
||||
firstName: firstName || undefined,
|
||||
lastName: lastName || undefined,
|
||||
};
|
||||
try {
|
||||
dispatch(
|
||||
@@ -129,11 +132,44 @@ export default function InsuranceStatusPage() {
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/eligibility-check",
|
||||
{ data: data },
|
||||
{ data: data, socketId: socket.id },
|
||||
);
|
||||
// { data: JSON.stringify(data) },
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
const enqueueResult = await response.json();
|
||||
if (enqueueResult.error) throw new Error(enqueueResult.error);
|
||||
|
||||
const jobId = enqueueResult.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Selenium browser starting...",
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the BullMQ worker to emit job:update on this socket
|
||||
const jobResult = await new Promise<any>((resolve, reject) => {
|
||||
const handler = (payload: any) => {
|
||||
if (String(payload.jobId) !== String(jobId)) return;
|
||||
if (payload.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: payload.message ?? "Selenium running...",
|
||||
}),
|
||||
);
|
||||
} else if (payload.status === "completed") {
|
||||
socket.off("job:update", handler);
|
||||
resolve(payload.result ?? {});
|
||||
} else if (payload.status === "failed") {
|
||||
socket.off("job:update", handler);
|
||||
reject(new Error(payload.error ?? "Selenium job failed"));
|
||||
}
|
||||
};
|
||||
socket.on("job:update", handler);
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
@@ -153,12 +189,11 @@ export default function InsuranceStatusPage() {
|
||||
|
||||
setSelectedPatient(null);
|
||||
|
||||
// If server returned pdfFileId: open preview modal
|
||||
if (result.pdfFileId) {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
// If worker returned pdfFileId: open preview modal
|
||||
if (jobResult.pdfFileId) {
|
||||
setPreviewPdfId(Number(jobResult.pdfFileId));
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
@@ -198,10 +233,44 @@ export default function InsuranceStatusPage() {
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status/claim-status-check",
|
||||
{ data: JSON.stringify(data) },
|
||||
{ data: JSON.stringify(data), socketId: socket.id },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
const enqueueResult = await response.json();
|
||||
if (enqueueResult.error) throw new Error(enqueueResult.error);
|
||||
|
||||
const jobId = enqueueResult.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Selenium browser starting...",
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the BullMQ worker to emit job:update on this socket
|
||||
const jobResult = await new Promise<any>((resolve, reject) => {
|
||||
const handler = (payload: any) => {
|
||||
if (String(payload.jobId) !== String(jobId)) return;
|
||||
if (payload.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: payload.message ?? "Selenium running...",
|
||||
}),
|
||||
);
|
||||
} else if (payload.status === "completed") {
|
||||
socket.off("job:update", handler);
|
||||
resolve(payload.result ?? {});
|
||||
} else if (payload.status === "failed") {
|
||||
socket.off("job:update", handler);
|
||||
reject(new Error(payload.error ?? "Selenium job failed"));
|
||||
}
|
||||
};
|
||||
socket.on("job:update", handler);
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
@@ -221,12 +290,11 @@ export default function InsuranceStatusPage() {
|
||||
|
||||
setSelectedPatient(null);
|
||||
|
||||
// If server returned pdfFileId: open preview modal
|
||||
if (result.pdfFileId) {
|
||||
setPreviewPdfId(Number(result.pdfFileId));
|
||||
// optional fallback name while header is parsed
|
||||
// If worker returned pdfFileId: open preview modal
|
||||
if (jobResult.pdfFileId) {
|
||||
setPreviewPdfId(Number(jobResult.pdfFileId));
|
||||
setPreviewFallbackFilename(
|
||||
result.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
jobResult.pdfFilename ?? `eligibility_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,23 @@ export const PROCEDURE_COMBOS: Record<
|
||||
codes: ["D2394"],
|
||||
},
|
||||
|
||||
// Pedo
|
||||
pedoSealants: {
|
||||
id: "pedoSealants",
|
||||
label: "Sealants",
|
||||
codes: ["D1351"],
|
||||
},
|
||||
pedoPulpotomy: {
|
||||
id: "pedoPulpotomy",
|
||||
label: "Pulpotomy",
|
||||
codes: ["D3220"],
|
||||
},
|
||||
pedoSSCrown: {
|
||||
id: "pedoSSCrown",
|
||||
label: "Stainless Steel Crown",
|
||||
codes: ["D2930"],
|
||||
},
|
||||
|
||||
// Dentures / Partials
|
||||
fu: {
|
||||
id: "fu",
|
||||
@@ -303,6 +320,7 @@ export const COMBO_CATEGORIES: Record<
|
||||
"threeSurfCompBack",
|
||||
"fourSurfCompBack",
|
||||
],
|
||||
Pedo: ["pedoSealants", "pedoPulpotomy", "pedoSSCrown"],
|
||||
"Dentures / Partials (>21 price)": [
|
||||
"fu",
|
||||
"fl",
|
||||
|
||||
Binary file not shown.
@@ -162,6 +162,20 @@ class AutomationMassHealthEligibilityCheck:
|
||||
print(f"Error while step1: {e}")
|
||||
return "ERROR:STEP1"
|
||||
|
||||
def _cell_text(self, cell):
|
||||
"""Get text from a cell, falling back to JS innerText if .text is empty."""
|
||||
text = cell.text.strip()
|
||||
if not text:
|
||||
try:
|
||||
text = (self.driver.execute_script("return arguments[0].innerText;", cell) or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return text
|
||||
|
||||
def _normalize_id(self, s):
|
||||
"""Strip all non-alphanumeric characters and lowercase for robust ID matching."""
|
||||
return ''.join(c for c in str(s) if c.isalnum()).lower()
|
||||
|
||||
def _extract_data_from_page(self):
|
||||
wait = WebDriverWait(self.driver, 5)
|
||||
extracted = {}
|
||||
@@ -183,19 +197,32 @@ class AutomationMassHealthEligibilityCheck:
|
||||
for row in eligible_rows:
|
||||
cells = row.find_elements(By.TAG_NAME, "td")
|
||||
|
||||
if len(cells) < 6:
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
|
||||
member_number = cells[2].text.strip()
|
||||
member_number = self._cell_text(cells[2])
|
||||
norm_cell = self._normalize_id(member_number)
|
||||
norm_self = self._normalize_id(self.memberId)
|
||||
print(f"[eligible] cells count={len(cells)}, memberId check: '{norm_self}' vs '{norm_cell}' (raw: '{member_number}')")
|
||||
if len(cells) >= 5:
|
||||
print(f" cells[3]='{self._cell_text(cells[3])}' cells[4]='{self._cell_text(cells[4])}'", end="")
|
||||
if len(cells) > 5:
|
||||
print(f" cells[5]='{self._cell_text(cells[5])}'", end="")
|
||||
if len(cells) > 6:
|
||||
print(f" cells[6]='{self._cell_text(cells[6])}'", end="")
|
||||
print()
|
||||
|
||||
if str(self.memberId) in member_number:
|
||||
full_name = cells[4].text.strip()
|
||||
plan_name = cells[6].text.strip() if len(cells) > 6 else cells[-1].text.strip()
|
||||
if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
|
||||
# name is in cells[4], insurance in cells[6] (fallback to last cell)
|
||||
full_name = self._cell_text(cells[4]) if len(cells) > 4 else ""
|
||||
plan_name = self._cell_text(cells[6]) if len(cells) > 6 else (self._cell_text(cells[-1]) if len(cells) > 4 else "")
|
||||
|
||||
name_parts = full_name.split()
|
||||
first_name = name_parts[0] if name_parts else ""
|
||||
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
|
||||
|
||||
print(f"[eligible] MATCHED → name='{full_name}' plan='{plan_name}'")
|
||||
|
||||
extracted = {
|
||||
"eligibility": "Y",
|
||||
"firstName": first_name,
|
||||
@@ -214,19 +241,29 @@ class AutomationMassHealthEligibilityCheck:
|
||||
for row in ineligible_rows:
|
||||
cells = row.find_elements(By.TAG_NAME, "td")
|
||||
|
||||
if len(cells) < 5:
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
|
||||
member_number = cells[2].text.strip()
|
||||
member_number = self._cell_text(cells[2])
|
||||
norm_cell = self._normalize_id(member_number)
|
||||
norm_self = self._normalize_id(self.memberId)
|
||||
print(f"[ineligible] cells count={len(cells)}, memberId check: '{norm_self}' vs '{norm_cell}' (raw: '{member_number}')")
|
||||
if len(cells) >= 5:
|
||||
print(f" cells[3]='{self._cell_text(cells[3])}' cells[4]='{self._cell_text(cells[4])}'", end="")
|
||||
if len(cells) > 5:
|
||||
print(f" cells[5]='{self._cell_text(cells[5])}'", end="")
|
||||
print()
|
||||
|
||||
if str(self.memberId) in member_number:
|
||||
full_name = cells[4].text.strip()
|
||||
plan_name = cells[5].text.strip()
|
||||
if norm_self and norm_cell and (norm_self in norm_cell or norm_cell in norm_self):
|
||||
full_name = self._cell_text(cells[4]) if len(cells) > 4 else ""
|
||||
plan_name = self._cell_text(cells[5]) if len(cells) > 5 else (self._cell_text(cells[-1]) if len(cells) > 4 else "")
|
||||
|
||||
name_parts = full_name.split()
|
||||
first_name = name_parts[0] if name_parts else ""
|
||||
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
|
||||
|
||||
print(f"[ineligible] MATCHED → name='{full_name}' plan='{plan_name}'")
|
||||
|
||||
extracted = {
|
||||
"eligibility": "N",
|
||||
"firstName": first_name,
|
||||
@@ -236,6 +273,7 @@ class AutomationMassHealthEligibilityCheck:
|
||||
|
||||
return extracted
|
||||
|
||||
print(f"[extraction] No matching row found for memberId='{self.memberId}'")
|
||||
return {"eligibility": None}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user