improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.

This commit is contained in:
2026-07-07 15:31:09 +03:30
parent ed7e7b1d8f
commit cb63ced4e3
35 changed files with 1819 additions and 454 deletions

View File

@@ -35,13 +35,6 @@ export class CasesController {
return this.casesService.listFilterOptions(organizationId, req.user.id);
}
@Get('assignable-members')
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
listAssignableMembers(@Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.listAssignableMembers(organizationId, req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) {
@@ -50,7 +43,7 @@ export class CasesController {
}
@Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Update task assignee or priority' })
@ApiOperation({ summary: 'Toggle task important flag' })
updateTask(
@Param('id') id: string,
@Param('taskId') taskId: string,

View File

@@ -14,6 +14,7 @@ import {
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = {
treatment: {
@@ -41,16 +42,29 @@ const labCaseListInclude = {
},
tasks: {
orderBy: [
{ tooth: 'asc' as const },
{ treatmentType: 'asc' as const },
{ treatmentDetailId: 'asc' as const },
{ prosthesisTypeCode: 'asc' as const },
{ stepOrder: 'asc' as const },
],
include: {
assignee: { select: { id: true, name: true, email: true } },
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' as const },
include: { changedBy: { select: { id: true, name: true } } },
},
},
},
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
include: {
lastStatusChangedBy: { select: { id: true; name: true } };
statusEvents: {
include: { changedBy: { select: { id: true; name: true } } };
};
};
}>;
@Injectable()
export class CasesService {
constructor(
@@ -294,23 +308,15 @@ export class CasesService {
throw new NotFoundException('Task not found');
}
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId);
}
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: {
...(dto.assigneeUserId !== undefined
? {
assigneeUserId: dto.assigneeUserId,
assignedAt: dto.assigneeUserId === null ? null : new Date(),
}
: {}),
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
},
data: { isImportant: dto.isImportant },
include: {
assignee: { select: { id: true, name: true, email: true } },
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' },
include: { changedBy: { select: { id: true, name: true } } },
},
},
});
@@ -324,35 +330,6 @@ export class CasesService {
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const memberships = await this.prisma.membership.findMany({
where: { organizationId: labOrganizationId, isActive: true },
include: {
user: { select: { id: true, name: true, email: true } },
permissions: { include: { permission: true } },
},
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
});
return {
success: true,
data: memberships
.filter((m) => {
if (m.isOwner) return true;
const names = m.permissions.map((p) => p.permission.name);
return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
})
.map((m) => ({
userId: m.user.id,
name: m.user.name,
email: m.user.email,
isOwner: m.isOwner,
})),
};
}
private buildListWhere(
labOrganizationId: string,
query: ListLabCasesDto,
@@ -465,7 +442,7 @@ export class CasesService {
prosthesisCodes,
locale,
);
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
return {
id: lc.id,
@@ -495,27 +472,15 @@ export class CasesService {
};
}
private groupTasksByTooth(
tasks: Array<{
id: string;
tooth: string;
treatmentType: string;
prosthesisTypeCode: 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 groupTasks(
tasks: LabCaseTaskWithRelations[],
prosthesisLabels: Map<string, string>,
) {
const groups = new Map<
string,
{
tooth: string;
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
@@ -524,9 +489,10 @@ export class CasesService {
>();
for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
tooth: task.tooth,
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -541,26 +507,13 @@ export class CasesService {
}
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;
},
task: LabCaseTaskWithRelations,
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
tooth: task.tooth,
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
@@ -569,32 +522,24 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
priority: task.priority,
assignedAt: task.assignedAt?.toISOString() ?? null,
isImportant: task.isImportant,
createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
timeline: task.statusEvents.map((event) => ({
id: event.id,
fromStatus: event.fromStatus,
toStatus: event.toStatus,
changedAt: event.changedAt.toISOString(),
changedBy: event.changedBy
? { id: event.changedBy.id, name: event.changedBy.name }
: null,
})),
};
}
private async ensureAssignableMember(userId: string, labOrganizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId: labOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
if (!membership) {
throw new BadRequestException('Assignee must be an active member of this lab');
}
if (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
return;
}
throw new BadRequestException('Assignee must have access to the Tasks tab');
}
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {

View File

@@ -1,18 +1,9 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class UpdateLabCaseTaskDto {
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsUUID()
assigneeUserId?: string | null;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(5)
priority?: number;
@IsBoolean()
isImportant: boolean;
}
export class ListLabCasesDto {

View File

@@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => {
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
tooth: '14',
teeth: ['14'],
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
status: 'IN_PROGRESS',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
@@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet');
});
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' },
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'monolithic_zirconia', steps: zirconiaSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfmRows).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);
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({

View File

@@ -58,25 +58,46 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
// Group teeth that share the same (treatment detail + prosthesis type): one task set
// per group, with each step covering every tooth in that group.
const groups = new Map<
string,
{ treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
>();
for (const row of toothProsthesisRows) {
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
const group = groups.get(key) ?? {
treatmentDetailId: row.treatmentDetailId,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
teeth: [],
};
group.teeth.push(row.tooth);
groups.set(key, group);
}
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const group of groups.values()) {
const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
continue;
}
const teeth = sortTeeth(group.teeth);
for (const step of typeSteps) {
taskRows.push({
labCaseId,
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
treatmentDetailId: group.treatmentDetailId,
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
status: LabTaskStatus.PENDING,
status: LabTaskStatus.IN_PROGRESS,
});
}
}
@@ -89,6 +110,15 @@ 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

@@ -0,0 +1,11 @@
import { Prisma } from '@prisma/client';
/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */
export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v));
}