fix: baby tooth (A-T) composites and extractions get correct CDT codes
Composite parsing accepts primary tooth letters: A, B, I, J, K, L, S, T are back teeth (D2391-D2394), C-H and M-R front (D2330-D2335). Simple extraction on a primary tooth maps to D7111 unless D7140 is typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -281,15 +281,23 @@ function extractToothSurface(input: string): { toothNumber?: string; toothSurfac
|
||||
};
|
||||
}
|
||||
}
|
||||
// Primary/baby tooth letter A-T (e.g. "# H", "#H")
|
||||
const pm = input.match(/#\s*([A-Ta-t])\b/);
|
||||
if (pm) return { toothNumber: pm[1]!.toUpperCase() };
|
||||
// Primary/baby tooth letter A-T (e.g. "# H", "#H", "#K O")
|
||||
const pm = input.match(/#\s*([A-Ta-t])\b(?:\s+([A-Za-z]{1,5})\b)?/);
|
||||
if (pm) {
|
||||
const surfaces = (pm[2] ?? "").toUpperCase();
|
||||
return {
|
||||
toothNumber: pm[1]!.toUpperCase(),
|
||||
...(surfaces && /^[OMDBLFIV]+$/.test(surfaces) ? { toothSurface: surfaces } : {}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Composite filling tooth classification
|
||||
const FRONT_TEETH = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]);
|
||||
const BACK_TEETH = new Set([1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 28, 29, 30, 31, 32]);
|
||||
// Primary (baby) teeth A-T: molars are back teeth, canines/incisors (C-H, M-R) are front
|
||||
const PRIMARY_BACK_TEETH = new Set(["A", "B", "I", "J", "K", "L", "S", "T"]);
|
||||
|
||||
// RCT tooth classification by tooth type
|
||||
const RCT_ANTERIOR = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); // D3310
|
||||
@@ -297,26 +305,36 @@ const RCT_PREMOLAR = new Set([4, 5, 12, 13, 20, 21, 28, 29]);
|
||||
const RCT_MOLAR = new Set([1, 2, 3, 14, 15, 16, 17, 18, 19, 30, 31, 32]); // D3330
|
||||
|
||||
/**
|
||||
* Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD".
|
||||
* Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD", "#K O".
|
||||
* Returns the correct D233x/D239x code based on tooth location and surface count.
|
||||
*/
|
||||
function parseCompositeCode(input: string): CdtMatch | null {
|
||||
const s = input.trim();
|
||||
// Match #NN SURFACES or tooth NN SURFACES (surfaces = 1-5 letters, valid dental surface chars)
|
||||
const m = s.match(/#\s*(\d{1,2})\s+([A-Za-z]{1,5})\b/) ?? s.match(/\btooth\s+(\d{1,2})\s+([A-Za-z]{1,5})\b/i);
|
||||
// Match #NN SURFACES, tooth NN SURFACES, or primary #X SURFACES (surfaces = 1-5 letters, valid dental surface chars)
|
||||
const m =
|
||||
s.match(/#\s*(\d{1,2}|[A-Ta-t])\s+([A-Za-z]{1,5})\b/) ??
|
||||
s.match(/\btooth\s+(\d{1,2}|[A-Ta-t])\s+([A-Za-z]{1,5})\b/i);
|
||||
if (!m) return null;
|
||||
|
||||
const toothNum = parseInt(m[1]!, 10);
|
||||
const tooth = m[1]!.toUpperCase();
|
||||
const surfaces = m[2]!;
|
||||
|
||||
if (toothNum < 1 || toothNum > 32) return null;
|
||||
// Surfaces must only be dental surface letters (O M D B L F I V)
|
||||
if (!/^[OMDBLFIV]+$/i.test(surfaces)) return null;
|
||||
|
||||
const surfaceCount = surfaces.length;
|
||||
const isFront = FRONT_TEETH.has(toothNum);
|
||||
|
||||
if (!isFront && !BACK_TEETH.has(toothNum)) return null;
|
||||
let isFront: boolean;
|
||||
let toothNumber: string;
|
||||
if (/^[A-T]$/.test(tooth)) {
|
||||
isFront = !PRIMARY_BACK_TEETH.has(tooth);
|
||||
toothNumber = tooth;
|
||||
} else {
|
||||
const toothNum = parseInt(tooth, 10);
|
||||
if (toothNum < 1 || toothNum > 32) return null;
|
||||
isFront = FRONT_TEETH.has(toothNum);
|
||||
if (!isFront && !BACK_TEETH.has(toothNum)) return null;
|
||||
toothNumber = String(toothNum);
|
||||
}
|
||||
|
||||
let code: string;
|
||||
if (isFront) {
|
||||
@@ -326,7 +344,7 @@ function parseCompositeCode(input: string): CdtMatch | null {
|
||||
}
|
||||
|
||||
const row = ALL_CODES.find((r) => r["Procedure Code"] === code);
|
||||
return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum), toothSurface: surfaces.toUpperCase() };
|
||||
return { code, description: row?.Description ?? code, input, toothNumber, toothSurface: surfaces.toUpperCase() };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,8 +626,9 @@ function matchOne(input: string): CdtMatch | null {
|
||||
return { code: bestEntry.code, description: bestEntry.description, input };
|
||||
}
|
||||
|
||||
const TOOTH_SURFACE_RE = /#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})\b/gi;
|
||||
const BARE_TOOTH_SURFACE_RE = /^#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})$/i;
|
||||
// Primary letters (A-T) need the "#" so ordinary words aren't read as teeth
|
||||
const TOOTH_SURFACE_RE = /(?:#\s*([A-T])|#?\s*(\d{1,2}))\s+([OMDBLFIV]{1,5})\b/gi;
|
||||
const BARE_TOOTH_SURFACE_RE = /^#?\s*(\d{1,2}|[A-T])\s+([OMDBLFIV]{1,5})$/i;
|
||||
|
||||
/**
|
||||
* Composite lists often give the "#" only on the first tooth:
|
||||
@@ -624,13 +643,13 @@ function expandCompositeLists(procedureNames: string[]): string[] {
|
||||
const s = name.trim();
|
||||
if (prevComposite && BARE_TOOTH_SURFACE_RE.test(s)) {
|
||||
const m = s.match(BARE_TOOTH_SURFACE_RE)!;
|
||||
out.push(`comp #${m[1]} ${m[2]}`);
|
||||
out.push(`comp #${m[1]!.toUpperCase()} ${m[2]}`);
|
||||
continue;
|
||||
}
|
||||
const isComposite = /^(comp|composite|resin|filling)\b/i.test(s) || /^#\s*\d{1,2}\s+[OMDBLFIV]{1,5}\b/i.test(s);
|
||||
const isComposite = /^(comp|composite|resin|filling)\b/i.test(s) || /^#\s*(\d{1,2}|[A-T])\s+[OMDBLFIV]{1,5}\b/i.test(s);
|
||||
const teeth = isComposite ? [...s.matchAll(TOOTH_SURFACE_RE)] : [];
|
||||
if (teeth.length > 1) {
|
||||
for (const m of teeth) out.push(`comp #${m[1]} ${m[2]}`);
|
||||
for (const m of teeth) out.push(`comp #${(m[1] ?? m[2])!.toUpperCase()} ${m[3]}`);
|
||||
} else {
|
||||
out.push(name);
|
||||
}
|
||||
@@ -791,5 +810,14 @@ export function lookupCdtCodes(
|
||||
}
|
||||
}
|
||||
|
||||
// Simple extraction on a primary (baby) tooth A-T is D7111, unless D7140 was typed explicitly
|
||||
const primaryExtRow = ALL_CODES.find((r) => r["Procedure Code"] === "D7111");
|
||||
for (const r of results) {
|
||||
if (r.code === "D7140" && /^[A-T]$/.test(r.toothNumber ?? "") && !/\bD?7140\b/i.test(r.input)) {
|
||||
r.code = "D7111";
|
||||
r.description = primaryExtRow?.Description ?? "D7111";
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ Rules:
|
||||
e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry
|
||||
A tooth list after one composite keeps the composite for every tooth: "comp on #18 O, 20 O, 21 O" →
|
||||
["comp #18 O", "comp #20 O", "comp #21 O"]
|
||||
Baby (primary) teeth use letters A-T instead of numbers; keep them as-is: "comp # 19 OB, # K O, # T O, #30 OB" →
|
||||
["comp #19 OB", "comp #K O", "comp #T O", "comp #30 OB"]
|
||||
Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is
|
||||
IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling:
|
||||
M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal
|
||||
|
||||
Reference in New Issue
Block a user