fix(voice): repair the two defects the correctness critic found

Both found by the /orchestrate correctness critic against 15ddb9a, and both
verified by hand before fixing.

1. A picked tooth chip was dropped on Apply. pickCandidate ticked the teeth
   row from `available`, which is memoised from `effective` — the value the
   same handler is in the middle of changing. On "ترمیم برای دندون دو" the
   backend returns no teeth, so `available.teeth` was false at pick time and
   `prev.teeth` was false too; `false || false` stuck permanently. A variant
   of the original live-test failure. Each branch now ticks the row it feeds
   and returns, so nothing reads the stale memo.

2. Decision 41's type-row lock was missing. The treatmentType row was a plain
   toggle, and applyVoiceResult derived `labDependent` from
   `result.treatmentType` rather than the `detail.treatmentType` it writes —
   so unticking the type row saved prosthesis lab rows on whatever type the
   appointment purpose had seeded. Row gains a `locked` state, the type row
   locks while the prosthesis row is ticked, Apply sends the forced tick, and
   `labDependent` now comes from the detail being written.

Gates: backend 16 suites / 209 tests, frontend 37 Vitest tests,
tsc --noEmit clean, next build clean, ESLint 0 new warnings
(VoiceReviewSheet.tsx 0 issues; the 10 in TreatmentWorkspace.tsx are
pre-existing and unchanged in count).

Not covered by a test: both fixes live in component state logic, which the
chosen Vitest scope — pure helpers, no React, no DOM — cannot reach. §12's
manual checks cover them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:27:11 +08:00
parent 15ddb9aac2
commit e271858a33
5 changed files with 47 additions and 19 deletions

View File

@@ -930,6 +930,7 @@
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voicePickJaw": "Which jaw?",
"voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.",
"voiceTeethAndProsthesis": "Teeth and prosthesis",
"voiceNoProsthesisHeard": "no prosthesis heard",
"voiceStackRefused": "not added — cannot be combined",

View File

@@ -931,6 +931,7 @@
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voicePickJaw": "کدام فک؟",
"voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین می‌کند — قابل برداشتن نیست.",
"voiceTeethAndProsthesis": "دندان‌ها و پروتز",
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",

View File

@@ -930,6 +930,7 @@
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voicePickJaw": "Welke kaak?",
"voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.",
"voiceTeethAndProsthesis": "Elementen en prothese",
"voiceNoProsthesisHeard": "geen prothese gehoord",
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",

View File

@@ -62,10 +62,7 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import {
isLabDependentResult,
prosthesisTargetLines,
} from '@/components/treatment/voiceReviewRows';
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -2211,7 +2208,10 @@ export function TreatmentWorkspace({
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
const labDependent = isLabDependentResult(result, labDependentCodes);
// Derived from the detail actually being written, never from `result.treatmentType`.
// Reading the result meant that unticking the type row still took the prosthesis branch,
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
const labDependent = labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({

View File

@@ -110,6 +110,12 @@ export function VoiceReviewSheet({
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog],
);
// Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row
// pins the type row. Locking it is what makes "restoration detail carrying lab rows"
// unreachable rather than merely discouraged.
const typeForcedByProsthesis =
available.prosthesis && selection.prosthesis && effective.treatmentType != null;
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(selection, available);
@@ -134,6 +140,12 @@ export function VoiceReviewSheet({
return chosenTeeth.includes(code as FdiToothId);
};
/**
* A picked candidate has no meaning unless its own row is ticked, so each branch ticks the
* row it feeds. Never derive that from `available`: it is memoised from `effective`, which
* this handler is in the middle of changing, so it still reports the row as unavailable and
* the pick would apply nothing.
*/
const pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken' && index != null) {
@@ -145,12 +157,18 @@ export function VoiceReviewSheet({
[index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch],
};
});
} else if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setChosenLeaves((prev) => {
const cur = prev[index] ?? [];
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
});
} else if (index != null) {
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (index != null) {
setChosenAssignmentTeeth((prev) => {
const cur = prev[index] ?? [];
const tooth = code as FdiToothId;
@@ -159,16 +177,12 @@ export function VoiceReviewSheet({
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
};
});
} else {
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
// A picked candidate has no meaning unless its row is ticked.
setSelection((prev) => ({
...prev,
teeth: prev.teeth || available.teeth,
prosthesis: true,
}));
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
setSelection((prev) => ({ ...prev, teeth: true }));
};
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
@@ -209,8 +223,10 @@ export function VoiceReviewSheet({
{available.treatmentType ? (
<Row
label={t('treatmentType')}
checked={selection.treatmentType}
checked={selection.treatmentType || typeForcedByProsthesis}
onChange={toggle('treatmentType')}
locked={typeForcedByProsthesis}
warning={typeForcedByProsthesis ? t('voiceTypeForcedByProsthesis') : undefined}
>
<span className="text-sm text-text-primary">
{labelFor(effective.treatmentType, treatmentCatalog)}
@@ -378,7 +394,13 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(selection, effective)}
onClick={() =>
onApply(
// The locked type row is ticked in the UI, so it must be ticked in what applies.
typeForcedByProsthesis ? { ...selection, treatmentType: true } : selection,
effective,
)
}
fullWidth
className="sm:w-auto"
>
@@ -395,17 +417,20 @@ function Row({
checked,
onChange,
warning,
locked,
children,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
warning?: string;
/** Ticked and not untickable — the value is implied by another row (decision 41). */
locked?: boolean;
children: React.ReactNode;
}) {
return (
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
<Checkbox checked={checked} onChange={onChange} label={label} />
<Checkbox checked={checked} onChange={onChange} label={label} disabled={locked} />
<div className="mt-1 ps-7 min-w-0">{children}</div>
{warning ? (
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">