fix: Tufts SCO location select, simple ext → D7140, sanitize custom CDT aliases
- Tufts SCO claim/preauth: robust location dropdown selection (multiple
option selectors, retries, keyboard fallback, HTML dump on failure)
- CDT lookup: map ext/extraction/simple ext to D7140; ignore dangling
"on/of/for/at" left after stripping tooth number
- Custom CDT aliases: keep only the Dxxxx code ("D7140 #25" → "D7140") and
drop tooth numbers from phrases, on save and at lookup — fixes $0 fee
from code "D7140#25"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,6 +214,14 @@ const DIRECT_CODE_MAP: Record<string, string> = {
|
|||||||
"rct primary molar": "D3240",
|
"rct primary molar": "D3240",
|
||||||
"baby tooth rct": "D3230",
|
"baby tooth rct": "D3230",
|
||||||
// Extractions
|
// Extractions
|
||||||
|
"ext": "D7140",
|
||||||
|
"exts": "D7140",
|
||||||
|
"extraction": "D7140",
|
||||||
|
"extractions": "D7140",
|
||||||
|
"simple ext": "D7140",
|
||||||
|
"simple exts": "D7140",
|
||||||
|
"simple extraction": "D7140",
|
||||||
|
"simple extractions": "D7140",
|
||||||
"baby tooth ext": "D7111",
|
"baby tooth ext": "D7111",
|
||||||
"baby tooth extraction": "D7111",
|
"baby tooth extraction": "D7111",
|
||||||
"primary tooth ext": "D7111",
|
"primary tooth ext": "D7111",
|
||||||
@@ -631,6 +639,24 @@ function expandCompositeLists(procedureNames: string[]): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a user-entered alias code down to the bare CDT code:
|
||||||
|
* "D7140 #25" → "D7140", "7140" → "D7140". Returns null if there is no code.
|
||||||
|
*/
|
||||||
|
export function normalizeAliasCode(code: string): string | null {
|
||||||
|
const m = code.trim().match(/^d?\s*(\d{4})\b/i);
|
||||||
|
return m ? `D${m[1]}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop tooth numbers from an alias phrase: "simple ext #25" → "simple ext". */
|
||||||
|
export function normalizeAliasPhrase(phrase: string): string {
|
||||||
|
return phrase
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/,?\s*#\s*(\d{1,2}|[a-t])\b/gi, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit code + tooth list with no surfaces, e.g. "D6010 #5, 9 , 11, 12" or "D6057 #5 #9 and 11"
|
// Explicit code + tooth list with no surfaces, e.g. "D6010 #5, 9 , 11, 12" or "D6057 #5 #9 and 11"
|
||||||
const CODE_TOOTH_LIST_RE =
|
const CODE_TOOTH_LIST_RE =
|
||||||
/\b(D\d{4})\s*#\s*(\d{1,2}(?:\s*(?:,|&|\band\b)?\s*#?\s*\d{1,2})*)\b(?!\s*[OMDBLFIV]{1,5}\b)/gi;
|
/\b(D\d{4})\s*#\s*(\d{1,2}(?:\s*(?:,|&|\band\b)?\s*#?\s*\d{1,2})*)\b(?!\s*[OMDBLFIV]{1,5}\b)/gi;
|
||||||
@@ -664,7 +690,8 @@ export function lookupCdtCodes(
|
|||||||
// Build a lookup map from custom aliases (phrase → cdtCode), lowercased
|
// Build a lookup map from custom aliases (phrase → cdtCode), lowercased
|
||||||
const customMap: Record<string, string> = {};
|
const customMap: Record<string, string> = {};
|
||||||
for (const { phrase, cdtCode } of customAliases) {
|
for (const { phrase, cdtCode } of customAliases) {
|
||||||
if (phrase && cdtCode) customMap[phrase.toLowerCase().trim()] = cdtCode.toUpperCase().trim();
|
const code = cdtCode ? normalizeAliasCode(cdtCode) : null;
|
||||||
|
if (phrase && code) customMap[phrase.toLowerCase().trim()] = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pontics with no stated material — re-coded below to match the crowns
|
// Pontics with no stated material — re-coded below to match the crowns
|
||||||
@@ -693,7 +720,9 @@ export function lookupCdtCodes(
|
|||||||
.replace(/,?\s*#\s*[a-t]\b/gi, "")
|
.replace(/,?\s*#\s*[a-t]\b/gi, "")
|
||||||
.replace(/\(\s*\)/g, "")
|
.replace(/\(\s*\)/g, "")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim()
|
||||||
|
// dangling connector left after removing the tooth, e.g. "simple ext on #25"
|
||||||
|
.replace(/\s+(on|of|for|at)$/, "");
|
||||||
|
|
||||||
// 1. Custom alias exact match (highest priority)
|
// 1. Custom alias exact match (highest priority)
|
||||||
const customKey = customMap[cleaned] ? cleaned : customMap[strippedCleaned] ? strippedCleaned : null;
|
const customKey = customMap[cleaned] ? cleaned : customMap[strippedCleaned] ? strippedCleaned : null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { storage } from "../storage";
|
|||||||
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
||||||
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
|
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
|
||||||
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
|
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
|
||||||
|
import { normalizeAliasCode, normalizeAliasPhrase } from "../ai/cdt-lookup";
|
||||||
import { backupScreenshots } from "../utils/screenshotBackup";
|
import { backupScreenshots } from "../utils/screenshotBackup";
|
||||||
|
|
||||||
const eligibilityImageUpload = multer({ storage: multer.memoryStorage() });
|
const eligibilityImageUpload = multer({ storage: multer.memoryStorage() });
|
||||||
@@ -299,9 +300,10 @@ router.put("/cdt-aliases", async (req: Request, res: Response): Promise<any> =>
|
|||||||
const cleaned = aliases
|
const cleaned = aliases
|
||||||
.filter((a: any) => typeof a?.phrase === "string" && typeof a?.cdtCode === "string")
|
.filter((a: any) => typeof a?.phrase === "string" && typeof a?.cdtCode === "string")
|
||||||
.map((a: any) => ({
|
.map((a: any) => ({
|
||||||
phrase: a.phrase.trim().toLowerCase(),
|
phrase: normalizeAliasPhrase(a.phrase),
|
||||||
cdtCode: a.cdtCode.trim().toUpperCase(),
|
cdtCode: normalizeAliasCode(a.cdtCode),
|
||||||
}));
|
}))
|
||||||
|
.filter((a): a is { phrase: string; cdtCode: string } => !!a.phrase && !!a.cdtCode);
|
||||||
await storage.saveCdtAliases(userId, cleaned);
|
await storage.saveCdtAliases(userId, cleaned);
|
||||||
return res.status(200).json(cleaned);
|
return res.status(200).json(cleaned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -319,7 +321,11 @@ router.post("/cdt-aliases/add", async (req: Request, res: Response): Promise<any
|
|||||||
return res.status(400).json({ message: "Body must be { phrase, cdtCode }" });
|
return res.status(400).json({ message: "Body must be { phrase, cdtCode }" });
|
||||||
}
|
}
|
||||||
const existing = await storage.getCdtAliases(userId);
|
const existing = await storage.getCdtAliases(userId);
|
||||||
const newEntry = { phrase: phrase.trim().toLowerCase(), cdtCode: cdtCode.trim().toUpperCase() };
|
const code = normalizeAliasCode(cdtCode);
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ message: `"${cdtCode}" is not a CDT code (expected e.g. D7140)` });
|
||||||
|
}
|
||||||
|
const newEntry = { phrase: normalizeAliasPhrase(phrase), cdtCode: code };
|
||||||
const updated = [...existing.filter((a) => a.phrase !== newEntry.phrase), newEntry];
|
const updated = [...existing.filter((a) => a.phrase !== newEntry.phrase), newEntry];
|
||||||
await storage.saveCdtAliases(userId, updated);
|
await storage.saveCdtAliases(userId, updated);
|
||||||
return res.status(200).json(newEntry);
|
return res.status(200).json(newEntry);
|
||||||
|
|||||||
@@ -245,6 +245,93 @@ class AutomationTuftsSCOClaimSubmit:
|
|||||||
|
|
||||||
# ── Step 1: Search patient (mirrors DentaQuest eligibility step1) ──────────
|
# ── Step 1: Search patient (mirrors DentaQuest eligibility step1) ──────────
|
||||||
|
|
||||||
|
def _select_location(self, tag):
|
||||||
|
"""Open the Location dropdown on member search and pick the first option."""
|
||||||
|
btn_xpath = '//button[@data-testid="member-search_location_select-btn"]'
|
||||||
|
option_xpaths = [
|
||||||
|
"//*[@role='listbox']//*[@role='option']",
|
||||||
|
"//li[@role='option']",
|
||||||
|
"//*[@role='option']",
|
||||||
|
"//*[@role='listbox']//li",
|
||||||
|
"//*[@role='menu']//*[@role='menuitem' or @role='menuitemradio']",
|
||||||
|
]
|
||||||
|
|
||||||
|
def js_click(el):
|
||||||
|
self.driver.execute_script("arguments[0].click();", el)
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
trigger = WebDriverWait(self.driver, 15).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, btn_xpath))
|
||||||
|
)
|
||||||
|
except TimeoutException:
|
||||||
|
print(f"[{tag}] Warning: Location dropdown button not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
before = (trigger.text or "").strip()
|
||||||
|
try:
|
||||||
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", trigger)
|
||||||
|
if attempt == 0:
|
||||||
|
trigger.click()
|
||||||
|
else:
|
||||||
|
js_click(trigger)
|
||||||
|
except Exception:
|
||||||
|
js_click(trigger)
|
||||||
|
print(f"[{tag}] Clicked location dropdown (attempt {attempt + 1})")
|
||||||
|
|
||||||
|
# Wait for any visible option
|
||||||
|
option = None
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while option is None and time.time() < deadline:
|
||||||
|
for xp in option_xpaths:
|
||||||
|
visible = [e for e in self.driver.find_elements(By.XPATH, xp) if e.is_displayed()]
|
||||||
|
if visible:
|
||||||
|
option = visible[0]
|
||||||
|
break
|
||||||
|
if option is None:
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
if option is not None:
|
||||||
|
opt_text = option.get_attribute("aria-label") or option.text.strip()
|
||||||
|
try:
|
||||||
|
option.click()
|
||||||
|
except Exception:
|
||||||
|
js_click(option)
|
||||||
|
time.sleep(0.5)
|
||||||
|
print(f"[{tag}] Selected location: {opt_text[:60]}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Keyboard fallback: open/highlight the first option and confirm
|
||||||
|
try:
|
||||||
|
trigger.send_keys(Keys.ARROW_DOWN)
|
||||||
|
time.sleep(0.3)
|
||||||
|
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
|
||||||
|
time.sleep(0.5)
|
||||||
|
after = (self.driver.find_element(By.XPATH, btn_xpath).text or "").strip()
|
||||||
|
if after and after != before:
|
||||||
|
print(f"[{tag}] Selected location via keyboard: {after[:60]}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Dump dropdown markup so selector changes on the portal can be diagnosed
|
||||||
|
try:
|
||||||
|
html = self.driver.execute_script(
|
||||||
|
"var lb=document.querySelector('[role=listbox]');"
|
||||||
|
"var b=document.querySelector('[data-testid=\"member-search_location_select-btn\"]');"
|
||||||
|
"return (lb||(b&&b.parentElement.parentElement)||{}).outerHTML||'';"
|
||||||
|
)
|
||||||
|
print(f"[{tag}] Location dropdown HTML: {html[:2000]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def step1_search_patient(self):
|
def step1_search_patient(self):
|
||||||
"""Navigate to member search and find the patient by Member ID + DOB."""
|
"""Navigate to member search and find the patient by Member ID + DOB."""
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
@@ -276,25 +363,8 @@ class AutomationTuftsSCOClaimSubmit:
|
|||||||
return f"ERROR: step1 DOB parse failed: {e}"
|
return f"ERROR: step1 DOB parse failed: {e}"
|
||||||
|
|
||||||
# 1. Select Location from dropdown (required field on DentaQuest portal)
|
# 1. Select Location from dropdown (required field on DentaQuest portal)
|
||||||
try:
|
if not self._select_location("TuftsSCO Claim step1"):
|
||||||
trigger = WebDriverWait(self.driver, 5).until(
|
print("[TuftsSCO Claim step1] Warning: Location not selected (continuing)")
|
||||||
EC.element_to_be_clickable((By.XPATH,
|
|
||||||
'//button[@data-testid="member-search_location_select-btn"]'))
|
|
||||||
)
|
|
||||||
trigger.click()
|
|
||||||
print("[TuftsSCO Claim step1] Clicked location dropdown")
|
|
||||||
time.sleep(0.5)
|
|
||||||
first_option = WebDriverWait(self.driver, 5).until(
|
|
||||||
EC.element_to_be_clickable((By.XPATH, "(//li[@role='option'])[1]"))
|
|
||||||
)
|
|
||||||
opt_text = first_option.get_attribute("aria-label") or first_option.text.strip()
|
|
||||||
first_option.click()
|
|
||||||
print(f"[TuftsSCO Claim step1] Selected location: {opt_text[:60]}")
|
|
||||||
time.sleep(0.3)
|
|
||||||
except TimeoutException:
|
|
||||||
print("[TuftsSCO Claim step1] Warning: Location dropdown not found (continuing)")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[TuftsSCO Claim step1] Warning: Location select failed: {e}")
|
|
||||||
|
|
||||||
# 2. Fill DOB
|
# 2. Fill DOB
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -250,6 +250,93 @@ class AutomationTuftsSCOPreAuth:
|
|||||||
|
|
||||||
# ── Step 1: Search patient ─────────────────────────────────────────────────
|
# ── Step 1: Search patient ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _select_location(self, tag):
|
||||||
|
"""Open the Location dropdown on member search and pick the first option."""
|
||||||
|
btn_xpath = '//button[@data-testid="member-search_location_select-btn"]'
|
||||||
|
option_xpaths = [
|
||||||
|
"//*[@role='listbox']//*[@role='option']",
|
||||||
|
"//li[@role='option']",
|
||||||
|
"//*[@role='option']",
|
||||||
|
"//*[@role='listbox']//li",
|
||||||
|
"//*[@role='menu']//*[@role='menuitem' or @role='menuitemradio']",
|
||||||
|
]
|
||||||
|
|
||||||
|
def js_click(el):
|
||||||
|
self.driver.execute_script("arguments[0].click();", el)
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
trigger = WebDriverWait(self.driver, 15).until(
|
||||||
|
EC.element_to_be_clickable((By.XPATH, btn_xpath))
|
||||||
|
)
|
||||||
|
except TimeoutException:
|
||||||
|
print(f"[{tag}] Warning: Location dropdown button not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
before = (trigger.text or "").strip()
|
||||||
|
try:
|
||||||
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", trigger)
|
||||||
|
if attempt == 0:
|
||||||
|
trigger.click()
|
||||||
|
else:
|
||||||
|
js_click(trigger)
|
||||||
|
except Exception:
|
||||||
|
js_click(trigger)
|
||||||
|
print(f"[{tag}] Clicked location dropdown (attempt {attempt + 1})")
|
||||||
|
|
||||||
|
# Wait for any visible option
|
||||||
|
option = None
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while option is None and time.time() < deadline:
|
||||||
|
for xp in option_xpaths:
|
||||||
|
visible = [e for e in self.driver.find_elements(By.XPATH, xp) if e.is_displayed()]
|
||||||
|
if visible:
|
||||||
|
option = visible[0]
|
||||||
|
break
|
||||||
|
if option is None:
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
if option is not None:
|
||||||
|
opt_text = option.get_attribute("aria-label") or option.text.strip()
|
||||||
|
try:
|
||||||
|
option.click()
|
||||||
|
except Exception:
|
||||||
|
js_click(option)
|
||||||
|
time.sleep(0.5)
|
||||||
|
print(f"[{tag}] Selected location: {opt_text[:60]}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Keyboard fallback: open/highlight the first option and confirm
|
||||||
|
try:
|
||||||
|
trigger.send_keys(Keys.ARROW_DOWN)
|
||||||
|
time.sleep(0.3)
|
||||||
|
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
|
||||||
|
time.sleep(0.5)
|
||||||
|
after = (self.driver.find_element(By.XPATH, btn_xpath).text or "").strip()
|
||||||
|
if after and after != before:
|
||||||
|
print(f"[{tag}] Selected location via keyboard: {after[:60]}")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Dump dropdown markup so selector changes on the portal can be diagnosed
|
||||||
|
try:
|
||||||
|
html = self.driver.execute_script(
|
||||||
|
"var lb=document.querySelector('[role=listbox]');"
|
||||||
|
"var b=document.querySelector('[data-testid=\"member-search_location_select-btn\"]');"
|
||||||
|
"return (lb||(b&&b.parentElement.parentElement)||{}).outerHTML||'';"
|
||||||
|
)
|
||||||
|
print(f"[{tag}] Location dropdown HTML: {html[:2000]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def step1_search_patient(self):
|
def step1_search_patient(self):
|
||||||
"""Navigate to member search and find the patient by Member ID + DOB."""
|
"""Navigate to member search and find the patient by Member ID + DOB."""
|
||||||
wait = WebDriverWait(self.driver, 30)
|
wait = WebDriverWait(self.driver, 30)
|
||||||
@@ -280,25 +367,8 @@ class AutomationTuftsSCOPreAuth:
|
|||||||
return f"ERROR: step1 DOB parse failed: {e}"
|
return f"ERROR: step1 DOB parse failed: {e}"
|
||||||
|
|
||||||
# Select Location from dropdown
|
# Select Location from dropdown
|
||||||
try:
|
if not self._select_location("TuftsSCO PreAuth step1"):
|
||||||
trigger = WebDriverWait(self.driver, 5).until(
|
print("[TuftsSCO PreAuth step1] Warning: Location not selected (continuing)")
|
||||||
EC.element_to_be_clickable((By.XPATH,
|
|
||||||
'//button[@data-testid="member-search_location_select-btn"]'))
|
|
||||||
)
|
|
||||||
trigger.click()
|
|
||||||
print("[TuftsSCO PreAuth step1] Clicked location dropdown")
|
|
||||||
time.sleep(0.5)
|
|
||||||
first_option = WebDriverWait(self.driver, 5).until(
|
|
||||||
EC.element_to_be_clickable((By.XPATH, "(//li[@role='option'])[1]"))
|
|
||||||
)
|
|
||||||
opt_text = first_option.get_attribute("aria-label") or first_option.text.strip()
|
|
||||||
first_option.click()
|
|
||||||
print(f"[TuftsSCO PreAuth step1] Selected location: {opt_text[:60]}")
|
|
||||||
time.sleep(0.3)
|
|
||||||
except TimeoutException:
|
|
||||||
print("[TuftsSCO PreAuth step1] Warning: Location dropdown not found (continuing)")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[TuftsSCO PreAuth step1] Warning: Location select failed: {e}")
|
|
||||||
|
|
||||||
# Fill DOB
|
# Fill DOB
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user