fix: auto-price CDT codes and fix crown/recement code mismatches

- Auto-fill service line price when a recognized CDT code is typed
  (e.g. D2740), instead of requiring a separate "Map Price" click
- Fix chatbot preauth prefill using an empty DOB for price lookup,
  causing age-tiered codes (D2740) to price as $0
- Re-apply fee-schedule pricing with the loaded patient DOB before
  MassHealth preauth auto-submit
- Add a "Recement Crown" (D2920) combo button distinct from "Crown"
  (D2740) to avoid picking the wrong code/price
- Fix AI note parsing to prioritize "recement" over "crown" regardless
  of word order or tooth-number format (e.g. "#7 crown recement")

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 23:29:43 -04:00
co-authored by Claude Sonnet 5
parent 00a81b3fda
commit e8889c0ff8
5 changed files with 77 additions and 18 deletions
+35 -6
View File
@@ -354,6 +354,30 @@ function parseSrpCode(input: string): CdtMatch | null {
return { code: codeStr, description: row.Description, input, quad: quadrant };
}
/**
* Parse re-cement wording regardless of word order or tooth-number format:
* "recement crown #7", "#7 crown recement", "recement crown 7". "recement"
* outranks "crown" whenever both appear — this is a re-cement (D2920), not
* a new crown (D2740).
*/
function parseRecementCode(input: string): CdtMatch | null {
const lower = input.toLowerCase();
if (!/\bre[\s-]?cement(?:ed|ing)?\b/.test(lower)) return null;
let toothNumber: string | undefined;
const hashMatch = input.match(/#\s*(\d{1,2})\b/);
const toothWordMatch = input.match(/\btooth\s+(\d{1,2})\b/i);
const bareNumMatch = input.match(/\b(\d{1,2})\b/);
const m = hashMatch ?? toothWordMatch ?? bareNumMatch;
if (m) {
const n = parseInt(m[1]!, 10);
if (n >= 1 && n <= 32) toothNumber = String(n);
}
const row = ALL_CODES.find((r) => r["Procedure Code"] === "D2920");
return { code: "D2920", description: row?.Description ?? "D2920", input, ...(toothNumber ? { toothNumber } : {}) };
}
/**
* Parse RCT/root-canal notation like "rct #29", "#14 rct", "root canal #6".
* Returns D3310 (anterior), D3320 (premolar), or D3330 (molar) based on tooth number.
@@ -545,23 +569,28 @@ export function lookupCdtCodes(
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
}
// 2. Multi-PA shorthand: "4PA", "4PA #3 #14 #19 #30", etc.
// 2. Re-cement wording — outranks "crown"/etc. elsewhere in the same entry,
// regardless of word order or tooth-number format (e.g. "#7 crown recement").
const recementMatch = parseRecementCode(name);
if (recementMatch) return recementMatch;
// 3. Multi-PA shorthand: "4PA", "4PA #3 #14 #19 #30", etc.
const multiPaMatches = parseMultiPaCode(name);
if (multiPaMatches) return multiPaMatches;
// 3. Composite filling by tooth# and surfaces — also determines CDT code from tooth position
// 4. Composite filling by tooth# and surfaces — also determines CDT code from tooth position
const compositeMatch = parseCompositeCode(name);
if (compositeMatch) return compositeMatch; // already carries toothNumber/toothSurface
// 4. RCT by tooth# — D3310/D3320/D3330 based on tooth position
// 5. RCT by tooth# — D3310/D3320/D3330 based on tooth position
const rctMatch = parseRctCode(name);
if (rctMatch) return rctMatch;
// 5. SRP + quadrant — D4341/D4342 with UL/UR/LL/LR
// 6. SRP + quadrant — D4341/D4342 with UL/UR/LL/LR
const srpMatch = parseSrpCode(name);
if (srpMatch) return srpMatch;
// 6. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
// 7. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
const directKey = DIRECT_CODE_MAP[cleaned] ? cleaned : DIRECT_CODE_MAP[strippedCleaned] ? strippedCleaned : null;
if (directKey) {
const code = DIRECT_CODE_MAP[directKey]!;
@@ -569,7 +598,7 @@ export function lookupCdtCodes(
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
}
// 7. Hardcoded alias + keyword search (try stripped name so tooth# doesn't break matching)
// 8. Hardcoded alias + keyword search (try stripped name so tooth# doesn't break matching)
const match = matchOne(strippedCleaned !== cleaned ? strippedCleaned : name);
if (match) return { ...match, input: name, ...toothInfo };
@@ -51,6 +51,9 @@ import {
applyComboToForm,
getDescriptionForCode,
findPriceMismatches,
getCodeMap,
getPriceForCodeWithAgeFromMap,
ageOnDate,
type PriceMismatch,
} from "@/utils/procedureCombosMapping";
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
@@ -624,7 +627,11 @@ export function ClaimForm({
}
});
const filled = { ...prev, insuranceSiteKey, serviceLines: updatedLines };
return mapPricesForForm({ form: filled, patientDOB: "", insuranceSiteKey });
return mapPricesForForm({
form: filled,
patientDOB: patient?.dateOfBirth ?? "",
insuranceSiteKey,
});
});
setPrefillDone(true);
} catch {}
@@ -845,6 +852,22 @@ export function ClaimForm({
} else {
updatedLines[index][field] = value;
}
// Auto-fill the price as soon as a recognized, fully-typed CDT code is entered,
// so users don't have to remember to click "Map Price" for a single line.
if (field === "procedureCode") {
const code = String(value || "").trim().toUpperCase();
if (/^D\d{4}$/.test(code)) {
const line = updatedLines[index];
const map = getCodeMap(form.insuranceSiteKey);
const age = ageOnDate(
patient?.dateOfBirth ?? "",
line.procedureDate || form.serviceDate,
);
const price = getPriceForCodeWithAgeFromMap(map, code, age);
updatedLines[index] = { ...line, totalBilled: price };
}
}
}
setForm({ ...form, serviceLines: updatedLines });
@@ -1873,7 +1896,17 @@ export function ClaimForm({
} else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") {
handleUnitedDHPreAuth();
} else {
handleMHPreAuth();
// Re-apply fee-schedule prices with the now-loaded patient DOB before submitting —
// the earlier chatbot-prefill pass may have run before `patient` finished loading,
// leaving age-tiered codes (e.g. D2740) unpriced.
const siteKeyForPricing =
form.insuranceSiteKey || deriveInsuranceSiteKey(form.insuranceProvider || "");
const pricedForm = mapPricesForForm({
form: { ...form, insuranceSiteKey: siteKeyForPricing },
patientDOB: patient?.dateOfBirth ?? "",
insuranceSiteKey: siteKeyForPricing,
});
handleMHPreAuth(pricedForm);
}
}, [autoSubmitPreauth, prefillDone, isFormReady]);
+6 -1
View File
@@ -303,6 +303,11 @@ export const PROCEDURE_COMBOS: Record<
label: "Crown",
codes: ["D2740"],
},
recementCrown: {
id: "recementCrown",
label: "Recement Crown",
codes: ["D2920"],
},
deepCleaning: {
id: "deepCleaning",
label: "Deep Cleaning",
@@ -396,7 +401,7 @@ export const COMBO_CATEGORIES: Record<
],
Implants: ["implantFull", "implantFixture", "implantAbutment", "implantCrown"],
Endodontics: ["rctAnterior", "rctAnteriorPostCrown", "rctPremolar", "rctPremolarPostCrown", "rctMolar", "rctMolarPostCrown", "postCore", "coreBU"],
Prosthodontics: ["crown"],
Prosthodontics: ["crown", "recementCrown"],
Periodontics: ["deepCleaning"],
Extractions: [
"simpleExtraction",
@@ -150,7 +150,7 @@ const parseDate = (d: DateInput): Date => {
return new Date(s);
};
const ageOnDate = (dob: DateInput, on: DateInput): number => {
export const ageOnDate = (dob: DateInput, on: DateInput): number => {
const birth = parseDate(dob);
const ref = parseDate(on);
let age = ref.getFullYear() - birth.getFullYear();
@@ -637,14 +637,6 @@ class AutomationDeltaDentalMAEligibilityCheck:
print(f"[DDMA step2] PDF saved at: {pdf_path}")
# Close the browser window after PDF (session preserved in profile)
try:
from ddma_browser_manager import get_browser_manager
get_browser_manager().quit_driver()
print("[step2] Browser closed - session preserved in profile")
except Exception as e:
print(f"[step2] Error closing browser: {e}")
print(f"[DDMA step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
return {