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

@@ -0,0 +1,78 @@
import type { Prisma } from '@prisma/client';
/** Union several workflows: scan/design first, packing/shipping last, other steps first-seen. */
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
const middle: string[] = [];
const seenMiddle = new Set<string>();
let hasScan = false;
let hasDesign = false;
let hasPacking = false;
let hasShipping = false;
for (const list of lists) {
for (const code of list) {
if (code === 'intraoral_scan') hasScan = true;
else if (code === 'design') hasDesign = true;
else if (code === 'packing') hasPacking = true;
else if (code === 'shipping') hasShipping = true;
else if (!seenMiddle.has(code)) {
seenMiddle.add(code);
middle.push(code);
}
}
}
const out: string[] = [];
if (hasScan) out.push('intraoral_scan');
if (hasDesign) out.push('design');
out.push(...middle);
if (hasPacking) out.push('packing');
if (hasShipping) out.push('shipping');
return out;
}
export function prosthesisGroupKey(codes: readonly string[]): string {
return [...new Set(codes.filter(Boolean))].sort().join('+');
}
export function splitProsthesisGroupKey(key: string): string[] {
return key.split('+').map((part) => part.trim()).filter(Boolean);
}
export function prosthesisTypeTaskWhere(code: string): Prisma.LabCaseTaskWhereInput {
return {
OR: [
{ prosthesisTypeCode: code },
{ prosthesisTypeCode: { startsWith: `${code}+` } },
{ prosthesisTypeCode: { endsWith: `+${code}` } },
{ prosthesisTypeCode: { contains: `+${code}+` } },
],
};
}
export function prosthesisTypeCaseWhere(code: string): Prisma.LabCaseWhereInput {
return {
OR: [
{ toothProsthesis: { some: { prosthesisTypeCode: code } } },
{ tasks: { some: prosthesisTypeTaskWhere(code) } },
],
};
}
export function atomicProsthesisCodes(codes: Iterable<string>): string[] {
const out = new Set<string>();
for (const code of codes) {
if (!code) continue;
for (const part of splitProsthesisGroupKey(code)) out.add(part);
}
return [...out];
}
export function prosthesisGroupLabel(
groupKey: string,
labels: ReadonlyMap<string, string>,
): string {
const parts = splitProsthesisGroupKey(groupKey);
if (parts.length === 0) return groupKey;
return parts.map((code) => labels.get(code) ?? code).join(' + ');
}

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));
}

View File

@@ -12,12 +12,26 @@ export type ProsthesisTypeCatalogEntry = {
code: string;
sortOrder: number;
label: string;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
addonKind: string;
};
type CatalogRow = {
sortOrder: number;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
addonKind: string;
};
@Injectable()
export class ProsthesisCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, { sortOrder: number }>();
private byCode = new Map<string, CatalogRow>();
constructor(
private readonly prisma: PrismaService,
@@ -32,10 +46,30 @@ export class ProsthesisCatalogService implements OnModuleInit {
const rows = await this.prisma.prosthesisType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { code: true, sortOrder: true },
select: {
code: true,
sortOrder: true,
category: true,
subcategory: true,
chartRegion: true,
stackGroup: true,
addonKind: true,
},
});
this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
this.byCode = new Map(
rows.map((row) => [
row.code,
{
sortOrder: row.sortOrder,
category: row.category,
subcategory: row.subcategory,
chartRegion: row.chartRegion,
stackGroup: row.stackGroup,
addonKind: row.addonKind,
},
]),
);
this.loaded = true;
}
@@ -50,11 +84,19 @@ export class ProsthesisCatalogService implements OnModuleInit {
);
return codes
.map((code) => ({
code,
sortOrder: this.byCode.get(code)!.sortOrder,
label: labels.get(code) ?? code,
}))
.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,
addonKind: row.addonKind,
};
})
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}

View File

@@ -13,6 +13,11 @@ import {
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
prosthesisTypeTaskWhere,
} from '../../common/prosthesis-group';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
@@ -79,7 +84,7 @@ export class TasksService {
]);
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -137,7 +142,7 @@ export class TasksService {
});
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -196,6 +201,7 @@ export class TasksService {
sentAt: effectiveAt,
labCaseId: target.labCaseId,
sourceKey: target.sourceKey,
tooth: target.tooth,
prosthesisTypeCode: target.prosthesisTypeCode,
stepOrder: target.stepOrder,
id: target.id,
@@ -347,7 +353,7 @@ export class TasksService {
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
atomicProsthesisCodes([updated.prosthesisTypeCode]),
locale,
);
@@ -486,7 +492,7 @@ export class TasksService {
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
...(query.unassignedOnly ? { assigneeUserId: null } : {}),
...(query.prosthesisTypeCode?.trim()
? { prosthesisTypeCode: query.prosthesisTypeCode.trim() }
? prosthesisTypeTaskWhere(query.prosthesisTypeCode.trim())
: {}),
};
@@ -496,7 +502,7 @@ export class TasksService {
}
const completedGroups = await this.prisma.labCaseTask.groupBy({
by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'],
by: ['labCaseId', 'sourceKey', 'tooth', 'prosthesisTypeCode'],
where: {
workflowStepCode: stepCompleted,
status: LabTaskStatus.COMPLETED,
@@ -515,6 +521,7 @@ export class TasksService {
OR: completedGroups.map((group) => ({
labCaseId: group.labCaseId,
sourceKey: group.sourceKey,
tooth: group.tooth,
prosthesisTypeCode: group.prosthesisTypeCode,
})),
},
@@ -552,6 +559,7 @@ export class TasksService {
sentAt: Date;
labCaseId: string;
sourceKey: string;
tooth: string;
prosthesisTypeCode: string;
stepOrder: number;
id: string;
@@ -586,6 +594,15 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: { lt: target.tooth } },
],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: { lt: target.prosthesisTypeCode } },
],
},
@@ -594,6 +611,7 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: { lt: target.stepOrder } },
],
@@ -603,6 +621,7 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: target.stepOrder },
{ id: { lt: target.id } },
@@ -729,6 +748,7 @@ export class TasksService {
{ labCase: { sentAt: 'desc' } },
{ labCaseId: 'asc' },
{ sourceKey: 'asc' },
{ tooth: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
@@ -741,6 +761,7 @@ export class TasksService {
{ labCase: { sentAt: dir } },
{ labCaseId: 'asc' },
{ sourceKey: 'asc' },
{ tooth: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
@@ -781,7 +802,7 @@ export class TasksService {
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

@@ -35,6 +35,36 @@ describe('assertCompleteToothProsthesisMap', () => {
).not.toThrow();
});
it('passes when there are no FDI teeth but an arch sentinel is mapped', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: 'UA', prosthesisTypeCode: 'full_denture' },
],
}),
).not.toThrow();
});
it('throws when a prosthesis detail has neither teeth nor an arch job', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] },
},
],
toothProsthesis: [],
}),
).toThrow(AppException);
});
it('throws when a prosthesis tooth is missing from the map', () => {
expect(() =>
assertCompleteToothProsthesisMap({

View File

@@ -32,5 +32,16 @@ export function assertCompleteToothProsthesisMap(labCase: {
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
}
}
if (teeth.length === 0) {
const hasArchJob = labCase.toothProsthesis.some(
(tp) =>
tp.treatmentDetailId === link.treatmentDetailId &&
(tp.tooth === 'UA' || tp.tooth === 'LA') &&
Boolean(tp.prosthesisTypeCode),
);
if (!hasArchJob) {
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
}
}
}
}