improvement: new prosthesis type data structure implemented and finally working!

This commit is contained in:
2026-09-01 21:03:04 +03:30
parent dc71c73ced
commit a3c14a18c1
51 changed files with 3306 additions and 1117 deletions

View File

@@ -20,12 +20,21 @@ import { generateLabCaseTasks } from './lab-case-task.generator';
import {
isLabCaseOverdue,
} from '../../common/lab-case-due-date';
import { normalizeTaskTeeth } from './lab-case-task.util';
import {
aggregateProsthesisGroupsFromTasks,
connectedSelectionGroupIds,
normalizeTaskTeeth,
} from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
import { LabCaseAccessService } from './lab-case-access.service';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
prosthesisTypeCaseWhere,
} from '../../common/prosthesis-group';
const labCaseListInclude = {
treatment: {
@@ -215,7 +224,7 @@ export class CasesService {
}
}
const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
const typeCodes = new Set(atomicProsthesisCodes(taskRows.map((row) => row.prosthesisTypeCode)));
const catalog = await this.prosthesisCatalog.list();
const prosthesisTypes = catalog
.filter((entry) => typeCodes.has(entry.code))
@@ -984,13 +993,7 @@ export class CasesService {
AND: [
{ OR: [received, ownedInternal] },
...(query.prosthesisTypeCode
? [
{
tasks: {
some: { prosthesisTypeCode: query.prosthesisTypeCode },
},
} satisfies Prisma.LabCaseWhereInput,
]
? [prosthesisTypeCaseWhere(query.prosthesisTypeCode)]
: []),
...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []),
],
@@ -1111,7 +1114,7 @@ export class CasesService {
lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null;
const link = lc.details[0];
const { clinic, patient } = this.resolveClinicAndPatient(lc);
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(lc.tasks.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -1208,35 +1211,7 @@ export class CasesService {
connected?: boolean;
selectionGroupId?: string;
}> {
const prosthesisByGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const entry = prosthesisByGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
prosthesisByGroup.set(key, entry);
}
return [...prosthesisByGroup.values()]
.map((group) => ({
prosthesisTypeCode: group.prosthesisTypeCode,
teeth: [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
),
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
return aggregateProsthesisGroupsFromTasks(tasks);
}
private groupTasks(
@@ -1256,16 +1231,18 @@ export class CasesService {
}
>();
const connectedIds = connectedSelectionGroupIds(tasks);
for (const task of tasks) {
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${normalizeTaskTeeth(task.teeth).join(',')}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
selectionGroupId,
tasks: [],
};
@@ -1280,7 +1257,7 @@ export class CasesService {
prosthesisTypeCode: group.prosthesisTypeCode,
prosthesisTypeLabel: group.prosthesisTypeLabel,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId),
tasks: group.tasks,
}));
}
@@ -1296,7 +1273,7 @@ export class CasesService {
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,

View File

@@ -15,8 +15,12 @@ import {
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { aggregateProsthesisGroupsFromTasks } from './lab-case-task.util';
import { isLabCaseOverdue } from '../../common/lab-case-due-date';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
} from '../../common/prosthesis-group';
import { TasksService } from '../tasks/tasks.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
@@ -95,18 +99,14 @@ export class LabCaseAccessService {
}
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [
...new Set(
(
await this.prisma.labCaseTask.findMany({
where: { labCaseId: labCase.id },
select: { prosthesisTypeCode: true },
})
)
.map((t) => t.prosthesisTypeCode)
.filter(Boolean),
),
];
const prosthesisCodes = atomicProsthesisCodes(
(
await this.prisma.labCaseTask.findMany({
where: { labCaseId: labCase.id },
select: { prosthesisTypeCode: true },
})
).map((t) => t.prosthesisTypeCode),
);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -303,37 +303,11 @@ export class LabCaseAccessService {
orderBy: [{ prosthesisTypeCode: 'asc' }],
});
const byGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const entry = byGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
byGroup.set(key, entry);
}
return [...byGroup.values()].map((group) => {
const teeth = [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
return {
prosthesisTypeCode: group.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode,
teeth,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && teeth.length > 1,
};
});
return aggregateProsthesisGroupsFromTasks(tasks).map((group) => ({
...group,
prosthesisTypeLabel:
prosthesisGroupLabel(group.prosthesisTypeCode, prosthesisLabels),
}));
}
private async getMembership(userId: string, organizationId: string) {

View File

@@ -133,7 +133,7 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet');
});
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
it('keeps one task set per tooth even when they share a prosthesis', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
@@ -150,16 +150,24 @@ describe('generateLabCaseTasks', () => {
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
expect(count).toBe(pfmSteps.length * 2 + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
const pfm14 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '14',
);
const pfm15 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '15',
);
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfmRows).toHaveLength(pfmSteps.length);
expect(pfm14).toHaveLength(pfmSteps.length);
expect(pfm15).toHaveLength(pfmSteps.length);
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
// Teeth sharing the prosthesis in the same detail are merged and sorted.
expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
const uniqueKeys = new Set(
rows.map((r) => `${r.teeth.join()}:${r.prosthesisTypeCode}`),
);
expect(uniqueKeys.size).toBe(3);
});
it('omits packing and shipping for smile_design', async () => {
@@ -181,12 +189,13 @@ describe('generateLabCaseTasks', () => {
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(['intraoral_scan', 'design']);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
expect(stepCodes).not.toContain('printer_resin');
});
it('merges teeth with the same prosthesis type across selection groups', async () => {
it('keeps connected and single teeth as separate per-tooth task sets', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
@@ -213,12 +222,10 @@ describe('generateLabCaseTasks', () => {
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
expect(count).toBe(pfmSteps.length);
expect(count).toBe(pfmSteps.length * 3);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
expect(rows).toHaveLength(pfmSteps.length);
expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe(
true,
);
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['14', '15', '21']));
});
it('skips generation when tasks already exist', async () => {
@@ -255,12 +262,56 @@ describe('generateLabCaseTasks', () => {
});
const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(count).toBe(pfmSteps.length * 2);
const rows = created as Array<{ teeth: string[]; lineId: string }>;
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['11', '21']));
expect(created[0]).toMatchObject({
lineId: 'line-1',
sourceKey: 'line-1',
treatmentDetailId: null,
teeth: ['11', '21'],
});
});
it('unions stacked types on the same tooth and de-dupes scan/pack/ship', async () => {
const tiSteps = stepsFromSeed('ti_base_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'ti_base_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'ti_base_abutment', steps: tiSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-stack', 'en');
const rows = created as Array<{
workflowStepCode: string;
prosthesisTypeCode: string;
teeth: string[];
}>;
expect(count).toBe(rows.length);
expect(rows.every((r) => r.teeth.join() === '46')).toBe(true);
expect(rows[0].prosthesisTypeCode).toBe('monolithic_zirconia+ti_base_abutment');
const stepCodes = rows.map((r) => r.workflowStepCode);
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'packing')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'shipping')).toHaveLength(1);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes[stepCodes.length - 1]).toBe('shipping');
expect(stepCodes).toContain('milling_dry');
expect(stepCodes).toContain('sinter');
expect(stepCodes).toContain('polish_prep');
});
});

View File

@@ -1,5 +1,9 @@
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
import {
prosthesisGroupKey,
unionWorkflowStepCodes,
} from '../../common/prosthesis-group';
type TransactionClient = Prisma.TransactionClient;
@@ -30,7 +34,7 @@ export async function generateLabCaseTasks(
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
const prosthesisTypes = await tx.prosthesisType.findMany({
where: { code: { in: prosthesisCodes }, isActive: true },
where: { code: { in: prosthesisCodes } },
include: {
steps: {
orderBy: { stepOrder: 'asc' },
@@ -59,21 +63,18 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
// Merge all teeth that share the same prosthesis type on a detail into one
// task set (preselection-group behavior). selectionGroupId is stored for
// UI/history only — not part of the grouping key.
const groups = new Map<
string,
{
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
prosthesisTypeCode: string;
selectionGroupId: string;
teeth: string[];
}
>();
// Per tooth: stacked types become one group key; steps are the union.
// Teeth are not merged even when they share the same type-set.
type ToothBucket = {
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
selectionGroupId: string;
codes: Set<string>;
};
const byTooth = new Map<string, ToothBucket>();
for (const row of toothProsthesisRows) {
const sourceKey =
@@ -82,9 +83,9 @@ export async function generateLabCaseTasks(
row.lineId ||
'';
if (!sourceKey) continue;
const key = `${sourceKey}::${row.prosthesisTypeCode}`;
const toothKey = `${sourceKey}::${row.tooth}`;
const selectionGroupId = row.selectionGroupId?.trim() || '';
const group = groups.get(key) ?? {
const bucket = byTooth.get(toothKey) ?? {
sourceKey,
treatmentDetailId: row.treatmentDetailId,
lineId: row.lineId,
@@ -93,44 +94,50 @@ export async function generateLabCaseTasks(
row.line?.treatmentType ??
row.treatmentType ??
'prosthesis',
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId,
teeth: [],
codes: new Set<string>(),
};
// Keep first non-empty selectionGroupId for persistence; grouping ignores it.
if (!group.selectionGroupId && selectionGroupId) {
group.selectionGroupId = selectionGroupId;
if (!bucket.selectionGroupId && selectionGroupId) {
bucket.selectionGroupId = selectionGroupId;
}
group.teeth.push(row.tooth);
groups.set(key, group);
bucket.codes.add(row.prosthesisTypeCode);
byTooth.set(toothKey, bucket);
}
// One lab item per tooth (or arch sentinel): stacked types on that
// tooth become one prosthesisTypeCode group key; steps are the union.
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const group of groups.values()) {
const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
for (const [toothKey, bucket] of byTooth) {
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
const codes = [...bucket.codes];
const lists = codes.map((code) =>
(stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode),
);
const unioned = unionWorkflowStepCodes(lists);
if (unioned.length === 0) {
continue;
}
const teeth = sortTeeth(group.teeth);
const groupCode = prosthesisGroupKey(codes);
for (const step of typeSteps) {
unioned.forEach((workflowStepCode, index) => {
taskRows.push({
labCaseId,
treatmentDetailId: group.treatmentDetailId,
lineId: group.lineId,
sourceKey: group.sourceKey,
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,
selectionGroupId: group.selectionGroupId,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
treatmentDetailId: bucket.treatmentDetailId,
lineId: bucket.lineId,
sourceKey: bucket.sourceKey,
tooth,
teeth: [tooth],
treatmentType: bucket.treatmentType,
prosthesisTypeCode: groupCode,
selectionGroupId: bucket.selectionGroupId,
workflowStepCode,
stepOrder: index + 1,
stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode,
status: LabTaskStatus.IN_PROGRESS,
});
}
});
}
if (taskRows.length === 0) {
@@ -141,15 +148,6 @@ export async function generateLabCaseTasks(
return taskRows.length;
}
function sortTeeth(teeth: string[]): string[] {
return [...new Set(teeth)].sort((a, b) => {
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
return a.localeCompare(b);
});
}
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],

View File

@@ -9,3 +9,87 @@ export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined):
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v));
}
export function sortProsthesisTeeth(teeth: readonly string[]): string[] {
return [...teeth].sort((a, b) => {
const rank = (tooth: string) => (tooth === 'UA' ? 0 : tooth === 'LA' ? 1 : 2);
const diff = rank(a) - rank(b);
if (diff !== 0) return diff;
return a.localeCompare(b, undefined, { numeric: true });
});
}
/** Group ids that span more than one tooth (bridges). */
export function connectedSelectionGroupIds(
rows: Array<{
selectionGroupId?: string | null;
tooth?: string;
teeth?: Prisma.JsonValue;
}>,
): Set<string> {
const byGroup = new Map<string, Set<string>>();
for (const row of rows) {
const id = row.selectionGroupId?.trim();
if (!id) continue;
const teeth =
row.tooth != null && row.tooth !== ''
? [row.tooth]
: normalizeTaskTeeth(row.teeth);
const set = byGroup.get(id) ?? new Set<string>();
for (const tooth of teeth) set.add(tooth);
byGroup.set(id, set);
}
const connected = new Set<string>();
for (const [id, teeth] of byGroup) {
if (teeth.size > 1) connected.add(id);
}
return connected;
}
export function aggregateProsthesisGroupsFromTasks(
tasks: Array<{
prosthesisTypeCode: string;
teeth: Prisma.JsonValue;
selectionGroupId?: string | null;
}>,
): Array<{
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}> {
const connectedIds = connectedSelectionGroupIds(tasks);
const byKey = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId?.trim() ?? '';
const teeth = normalizeTaskTeeth(task.teeth);
const key = `${teeth.join(',')}::${task.prosthesisTypeCode}`;
const entry = byKey.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
if (!entry.selectionGroupId && selectionGroupId) {
entry.selectionGroupId = selectionGroupId;
}
byKey.set(key, entry);
}
return [...byKey.values()]
.map((group) => {
const teeth = sortProsthesisTeeth([...new Set(group.teeth)]);
return {
prosthesisTypeCode: group.prosthesisTypeCode,
teeth,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId),
};
})
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
}