TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.

This commit is contained in:
2026-07-06 20:40:19 +03:30
parent 3e81c110a3
commit 667b08ed0c
48 changed files with 1813 additions and 275 deletions

View File

@@ -46,7 +46,7 @@ export class CasesController {
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.getOne(id, organizationId, req.user.id);
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
}
@Patch(':id/tasks/:taskId')
@@ -58,6 +58,6 @@ export class CasesController {
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
}
}

View File

@@ -4,9 +4,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
@@ -52,6 +56,7 @@ export class CasesService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -142,8 +147,8 @@ export class CasesService {
}
}
const treatmentTypes = this.treatmentCatalog
.list()
const catalog = await this.treatmentCatalog.list();
const treatmentTypes = catalog
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
@@ -218,6 +223,7 @@ export class CasesService {
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
localeInput?: string | null,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
@@ -233,10 +239,18 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
return {
success: true,
data: await this.mapLabCaseDetail(labCase, localeInput),
};
}
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
async getOne(
labCaseId: string,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({
@@ -252,7 +266,7 @@ export class CasesService {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async updateTask(
@@ -261,6 +275,7 @@ export class CasesService {
dto: UpdateLabCaseTaskDto,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
@@ -299,7 +314,14 @@ export class CasesService {
},
});
return { success: true, data: this.mapTask(updated) };
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
@@ -431,9 +453,19 @@ export class CasesService {
};
}
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
private async mapLabCaseDetail(
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
localeInput?: string | null,
) {
const locale = normalizeCatalogLocale(localeInput);
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -454,7 +486,7 @@ export class CasesService {
organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(),
})),
tasks: lc.tasks.map((t) => this.mapTask(t)),
tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
tasksByTooth,
taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
@@ -468,6 +500,7 @@ export class CasesService {
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
@@ -477,47 +510,62 @@ export class CasesService {
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}>,
prosthesisLabels: Map<string, string>,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: ReturnType<CasesService['mapTask']>[];
}
>();
for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}`;
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
tasks: [],
};
entry.tasks.push(this.mapTask(task));
entry.tasks.push(this.mapTask(task, prosthesisLabels));
groups.set(key, entry);
}
return [...groups.values()];
}
private mapTask(task: {
id: string;
tooth: string;
treatmentType: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assigneeUserId: string | null;
assignedAt: Date | null;
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
}) {
private mapTask(
task: {
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: string;
workflowStepCode?: string;
stepOrder: number;
stepLabel: string;
status: LabTaskStatus;
priority: number;
assigneeUserId: string | null;
assignedAt: Date | null;
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
},
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
tooth: task.tooth,
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,

View File

@@ -0,0 +1,154 @@
import { CatalogEntityKind } from '@prisma/client';
import {
PROSTHESIS_TYPES,
buildProsthesisStepCodes,
} from '../../../prisma/catalog-seed-data';
import { generateLabCaseTasks } from './lab-case-task.generator';
function buildMockTx(options: {
existingCount?: number;
toothProsthesisRows: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
treatmentType?: string;
}>;
prosthesisTypes: Array<{
code: string;
steps: Array<{ stepOrder: number; workflowStepCode: string }>;
}>;
stepLabels?: Record<string, string>;
}) {
const created: unknown[] = [];
const tx = {
labCaseTask: {
count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
createMany: jest.fn().mockImplementation(({ data }) => {
created.push(...data);
return { count: data.length };
}),
},
labCaseToothProsthesis: {
findMany: jest.fn().mockResolvedValue(
options.toothProsthesisRows.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
detail: {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
},
})),
),
},
prosthesisType: {
findMany: jest.fn().mockResolvedValue(
options.prosthesisTypes.map((type) => ({
code: type.code,
isActive: true,
steps: type.steps.map((step) => ({
stepOrder: step.stepOrder,
labWorkflowStep: { code: step.workflowStepCode },
})),
})),
),
},
catalogTranslation: {
findMany: jest.fn().mockImplementation(({ where }) => {
const codes = where.entityCode?.in ?? [];
return codes.map((code: string) => ({
entityCode: code,
locale: 'en',
label: options.stepLabels?.[code] ?? code,
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
}));
}),
},
};
return { tx, created };
}
function stepsFromSeed(code: string) {
const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
if (!seed) {
throw new Error(`Unknown prosthesis code: ${code}`);
}
return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
stepOrder: index + 1,
workflowStepCode,
}));
}
describe('generateLabCaseTasks', () => {
it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
expect(stepCodes).toContain('packing');
expect(stepCodes).toContain('shipping');
expect(stepCodes).toContain('milling_wet');
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '11',
prosthesisTypeCode: 'smile_design',
},
],
prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
expect(count).toBe(smileSteps.length);
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
});
it('skips generation when tasks already exist', async () => {
const { tx } = buildMockTx({
existingCount: 3,
toothProsthesisRows: [],
prosthesisTypes: [],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
expect(count).toBe(0);
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
});
});

View File

@@ -1,84 +1,83 @@
import { LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
type TransactionClient = Prisma.TransactionClient;
export async function generateLabCaseTasks(
tx: TransactionClient,
labCaseId: string,
localeInput?: string | null,
): Promise<number> {
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
if (existingCount > 0) {
return 0;
}
const labCase = await tx.labCase.findUnique({
where: { id: labCaseId },
const locale = normalizeCatalogLocale(localeInput);
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
where: { labCaseId },
include: {
details: {
include: {
detail: {
select: { id: true, treatmentType: true, teeth: true },
},
},
detail: { select: { id: true, treatmentType: true } },
},
});
if (toothProsthesisRows.length === 0) {
return 0;
}
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
const prosthesisTypes = await tx.prosthesisType.findMany({
where: { code: { in: prosthesisCodes }, isActive: true },
include: {
steps: {
orderBy: { stepOrder: 'asc' },
include: { labWorkflowStep: { select: { code: true } } },
},
},
});
if (!labCase?.details.length) {
return 0;
}
const stepsByProsthesisCode = new Map(
prosthesisTypes.map((type) => [
type.code,
type.steps.map((s) => ({
stepOrder: s.stepOrder,
workflowStepCode: s.labWorkflowStep.code,
})),
]),
);
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
const allStepCodes = [
...new Set(
prosthesisTypes.flatMap((type) =>
type.steps.map((s) => s.labWorkflowStep.code),
),
),
];
const labDependentTypes = await tx.treatmentType.findMany({
where: { code: { in: treatmentTypeCodes }, labDependent: true },
select: { id: true, code: true },
});
if (labDependentTypes.length === 0) {
return 0;
}
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
include: { treatmentType: { select: { code: true } } },
});
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
for (const step of workflowSteps) {
const code = step.treatmentType.code;
const list = stepsByTypeCode.get(code) ?? [];
list.push({ stepOrder: step.stepOrder, label: step.label });
stepsByTypeCode.set(code, list);
}
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const link of labCase.details) {
const detail = link.detail;
if (!labDependentCodes.has(detail.treatmentType)) {
for (const row of toothProsthesisRows) {
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
continue;
}
const teeth = normalizeTeeth(detail.teeth);
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? [];
for (const tooth of teeth) {
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: detail.id,
tooth,
treatmentType: detail.treatmentType,
stepOrder: step.stepOrder,
stepLabel: step.label,
status: LabTaskStatus.PENDING,
});
}
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
status: LabTaskStatus.PENDING,
});
}
}
@@ -89,3 +88,37 @@ export async function generateLabCaseTasks(
await tx.labCaseTask.createMany({ data: taskRows });
return taskRows.length;
}
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],
locale: string,
): Promise<Map<string, string>> {
if (stepCodes.length === 0) {
return new Map();
}
const rows = await tx.catalogTranslation.findMany({
where: {
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
entityCode: { in: stepCodes },
locale: { in: [locale, 'en'] },
},
select: { entityCode: true, locale: true, label: true },
});
const byCode = new Map<string, { en?: string; locale?: string }>();
for (const row of rows) {
const entry = byCode.get(row.entityCode) ?? {};
if (row.locale === 'en') entry.en = row.label;
if (row.locale === locale) entry.locale = row.label;
byCode.set(row.entityCode, entry);
}
const result = new Map<string, string>();
for (const code of stepCodes) {
const entry = byCode.get(code);
result.set(code, entry?.locale ?? entry?.en ?? code);
}
return result;
}