2026-08-19 01:43:50 +03:30
|
|
|
import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
|
|
|
|
|
import { AppException, ErrorCode } from '../../common/errors';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { CatalogEntityKind } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
|
|
|
|
import {
|
|
|
|
|
CatalogLabelService,
|
|
|
|
|
CatalogLocale,
|
|
|
|
|
normalizeCatalogLocale,
|
|
|
|
|
} from '../catalog/catalog-label.service';
|
|
|
|
|
|
|
|
|
|
export type ProsthesisTypeCatalogEntry = {
|
|
|
|
|
code: string;
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
label: string;
|
2026-09-01 21:03:04 +03:30
|
|
|
category: string;
|
|
|
|
|
subcategory: string;
|
|
|
|
|
chartRegion: string;
|
|
|
|
|
stackGroup: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type CatalogRow = {
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
category: string;
|
|
|
|
|
subcategory: string;
|
|
|
|
|
chartRegion: string;
|
|
|
|
|
stackGroup: string;
|
2026-07-06 20:40:19 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class ProsthesisCatalogService implements OnModuleInit {
|
|
|
|
|
private loaded = false;
|
2026-09-01 21:03:04 +03:30
|
|
|
private byCode = new Map<string, CatalogRow>();
|
2026-07-06 20:40:19 +03:30
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly catalogLabels: CatalogLabelService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async onModuleInit() {
|
|
|
|
|
await this.refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refresh(): Promise<void> {
|
|
|
|
|
const rows = await this.prisma.prosthesisType.findMany({
|
|
|
|
|
where: { isActive: true },
|
|
|
|
|
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
2026-09-01 21:03:04 +03:30
|
|
|
select: {
|
|
|
|
|
code: true,
|
|
|
|
|
sortOrder: true,
|
|
|
|
|
category: true,
|
|
|
|
|
subcategory: true,
|
|
|
|
|
chartRegion: true,
|
|
|
|
|
stackGroup: true,
|
|
|
|
|
},
|
2026-07-06 20:40:19 +03:30
|
|
|
});
|
|
|
|
|
|
2026-09-01 21:03:04 +03:30
|
|
|
this.byCode = new Map(
|
|
|
|
|
rows.map((row) => [
|
|
|
|
|
row.code,
|
|
|
|
|
{
|
|
|
|
|
sortOrder: row.sortOrder,
|
|
|
|
|
category: row.category,
|
|
|
|
|
subcategory: row.subcategory,
|
|
|
|
|
chartRegion: row.chartRegion,
|
|
|
|
|
stackGroup: row.stackGroup,
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-07-06 20:40:19 +03:30
|
|
|
this.loaded = true;
|
|
|
|
|
}
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
async list(
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
): Promise<ProsthesisTypeCatalogEntry[]> {
|
2026-09-02 17:24:01 +03:30
|
|
|
await this.refresh();
|
2026-07-06 20:40:19 +03:30
|
|
|
const locale = normalizeCatalogLocale(localeInput);
|
|
|
|
|
const codes = [...this.byCode.keys()];
|
|
|
|
|
const labels = await this.catalogLabels.resolveLabels(
|
|
|
|
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
|
|
|
|
codes,
|
|
|
|
|
locale,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return codes
|
2026-09-01 21:03:04 +03:30
|
|
|
.map((code) => {
|
|
|
|
|
const row = this.byCode.get(code)!;
|
|
|
|
|
return {
|
|
|
|
|
code,
|
|
|
|
|
sortOrder: row.sortOrder,
|
|
|
|
|
label: labels.get(code) ?? code,
|
|
|
|
|
category: row.category,
|
|
|
|
|
subcategory: row.subcategory,
|
|
|
|
|
chartRegion: row.chartRegion,
|
|
|
|
|
stackGroup: row.stackGroup,
|
|
|
|
|
};
|
|
|
|
|
})
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
.sort(
|
|
|
|
|
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The 7 distinct category codes present in the active catalog, with labels in `locale`. */
|
|
|
|
|
async listCategories(
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
): Promise<{ code: string; label: string }[]> {
|
|
|
|
|
return this.listDistinct(
|
|
|
|
|
(row) => row.category,
|
|
|
|
|
CatalogEntityKind.PROSTHESIS_CATEGORY,
|
|
|
|
|
localeInput,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */
|
|
|
|
|
async listSubcategories(
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
): Promise<{ code: string; label: string }[]> {
|
|
|
|
|
return this.listDistinct(
|
|
|
|
|
(row) => row.subcategory,
|
|
|
|
|
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
|
|
|
|
|
localeInput,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async listDistinct(
|
|
|
|
|
pick: (row: CatalogRow) => string,
|
|
|
|
|
entityKind: CatalogEntityKind,
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
): Promise<{ code: string; label: string }[]> {
|
|
|
|
|
await this.refresh();
|
|
|
|
|
const locale = normalizeCatalogLocale(localeInput);
|
|
|
|
|
const codes = [
|
|
|
|
|
...new Set([...this.byCode.values()].map(pick).filter(Boolean)),
|
|
|
|
|
].sort();
|
|
|
|
|
const labels = await this.catalogLabels.resolveLabels(
|
|
|
|
|
entityKind,
|
|
|
|
|
codes,
|
|
|
|
|
locale,
|
|
|
|
|
);
|
|
|
|
|
return codes.map((code) => ({ code, label: labels.get(code) ?? code }));
|
2026-07-06 20:40:19 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assertKnownProsthesisType(code: string): void {
|
|
|
|
|
this.ensureLoaded();
|
|
|
|
|
if (!this.byCode.has(code)) {
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
throw new AppException(
|
|
|
|
|
ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE,
|
|
|
|
|
HttpStatus.BAD_REQUEST,
|
|
|
|
|
);
|
2026-07-06 20:40:19 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
async getStepCodesForProsthesisType(
|
|
|
|
|
prosthesisTypeCode: string,
|
|
|
|
|
): Promise<string[]> {
|
2026-07-06 20:40:19 +03:30
|
|
|
const type = await this.prisma.prosthesisType.findUnique({
|
|
|
|
|
where: { code: prosthesisTypeCode },
|
|
|
|
|
select: {
|
|
|
|
|
steps: {
|
|
|
|
|
orderBy: { stepOrder: 'asc' },
|
|
|
|
|
select: { labWorkflowStep: { select: { code: true } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!type) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return type.steps.map((s) => s.labWorkflowStep.code);
|
|
|
|
|
}
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
async resolveStepLabels(
|
|
|
|
|
stepCodes: string[],
|
|
|
|
|
locale: CatalogLocale,
|
|
|
|
|
): Promise<Map<string, string>> {
|
2026-07-06 20:40:19 +03:30
|
|
|
return this.catalogLabels.resolveLabels(
|
|
|
|
|
CatalogEntityKind.LAB_WORKFLOW_STEP,
|
|
|
|
|
stepCodes,
|
|
|
|
|
locale,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ensureLoaded() {
|
|
|
|
|
if (!this.loaded) {
|
|
|
|
|
throw new Error('Prosthesis catalog is not loaded yet');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|