Files
dyolink/backend/src/modules/treatments/treatment.utils.ts
Amin Mousavi dfd376d97a feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).

toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).

Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:05:54 +03:30

75 lines
1.9 KiB
TypeScript

import { FDI_TOOTH_IDS } from '../../common/fdi';
export function normalizeTeeth(teeth: unknown): string[] {
if (!Array.isArray(teeth)) {
return [];
}
const unique = new Set<string>();
for (const tooth of teeth) {
if (typeof tooth !== 'string') continue;
const trimmed = tooth.trim();
if (FDI_TOOTH_IDS.has(trimmed)) {
unique.add(trimmed);
}
}
return [...unique].sort();
}
export type ToothSelectionGroupNormalized = {
groupId: string;
kind: 'connected' | 'single';
teeth: string[];
};
export function normalizeToothSelectionGroups(
value: unknown,
fallbackTeeth: string[] = [],
): ToothSelectionGroupNormalized[] {
if (Array.isArray(value) && value.length > 0) {
const out: ToothSelectionGroupNormalized[] = [];
for (const row of value) {
if (!row || typeof row !== 'object') continue;
const rec = row as Record<string, unknown>;
const groupId =
typeof rec.groupId === 'string' && rec.groupId.trim()
? rec.groupId.trim()
: '';
if (!groupId) continue;
const kind = rec.kind === 'connected' ? 'connected' : 'single';
const teeth = normalizeTeeth(rec.teeth);
if (teeth.length === 0) continue;
out.push({
groupId,
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
teeth,
});
}
if (out.length > 0) return out;
}
return fallbackTeeth.map((tooth, index) => ({
groupId: `legacy-${tooth}-${index}`,
kind: 'single' as const,
teeth: [tooth],
}));
}
export function generateTreatmentTitle(
cases: { treatmentType: string; teeth: string[] }[],
): string {
if (cases.length === 0) {
return 'Treatment';
}
const parts = cases.map((c) => {
const label =
c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
if (c.teeth.length > 0) {
return `${label} ${c.teeth.join(', ')}`;
}
return label;
});
return parts.join(' · ');
}