improvement: new prosthesis type data structure implemented and finally working!
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (pre–selection-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[],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user