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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[],
|
||||
|
||||
11
backend/src/modules/cases/lab-case-task.util.ts
Normal file
11
backend/src/modules/cases/lab-case-task.util.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user