improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.
This commit is contained in:
@@ -172,7 +172,7 @@ describe('generateLabCaseTasks', () => {
|
||||
expect(stepCodes).toContain('printer_resin');
|
||||
});
|
||||
|
||||
it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => {
|
||||
it('merges teeth with the same prosthesis type across selection groups', async () => {
|
||||
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||
const { tx, created } = buildMockTx({
|
||||
toothProsthesisRows: [
|
||||
@@ -198,21 +198,13 @@ describe('generateLabCaseTasks', () => {
|
||||
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
||||
});
|
||||
|
||||
const count = await generateLabCaseTasks(tx as never, 'lab-case-split', 'en');
|
||||
expect(count).toBe(pfmSteps.length * 2);
|
||||
const rows = created as Array<{
|
||||
teeth: string[];
|
||||
selectionGroupId: string;
|
||||
prosthesisTypeCode: string;
|
||||
}>;
|
||||
const connected = rows.filter((r) => r.selectionGroupId === 'connected-1');
|
||||
const single = rows.filter((r) => r.selectionGroupId === 'single-21');
|
||||
expect(connected).toHaveLength(pfmSteps.length);
|
||||
expect(single).toHaveLength(pfmSteps.length);
|
||||
expect(connected.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(
|
||||
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
|
||||
expect(count).toBe(pfmSteps.length);
|
||||
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,
|
||||
);
|
||||
expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips generation when tasks already exist', async () => {
|
||||
|
||||
@@ -58,8 +58,9 @@ export async function generateLabCaseTasks(
|
||||
|
||||
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||
|
||||
// Group by selection group + prosthesis type so connected spans stay one task set,
|
||||
// and separate singles stay separate even with the same prosthesis type.
|
||||
// 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,
|
||||
{
|
||||
@@ -72,10 +73,8 @@ export async function generateLabCaseTasks(
|
||||
>();
|
||||
|
||||
for (const row of toothProsthesisRows) {
|
||||
const selectionGroupId =
|
||||
row.selectionGroupId?.trim() ||
|
||||
fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode);
|
||||
const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`;
|
||||
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
|
||||
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
||||
const group = groups.get(key) ?? {
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
treatmentType: row.detail.treatmentType,
|
||||
@@ -83,6 +82,10 @@ export async function generateLabCaseTasks(
|
||||
selectionGroupId,
|
||||
teeth: [],
|
||||
};
|
||||
// Keep first non-empty selectionGroupId for persistence; grouping ignores it.
|
||||
if (!group.selectionGroupId && selectionGroupId) {
|
||||
group.selectionGroupId = selectionGroupId;
|
||||
}
|
||||
group.teeth.push(row.tooth);
|
||||
groups.set(key, group);
|
||||
}
|
||||
@@ -121,24 +124,6 @@ export async function generateLabCaseTasks(
|
||||
return taskRows.length;
|
||||
}
|
||||
|
||||
function fallbackGroupIdForTooth(
|
||||
toothSelectionGroups: unknown,
|
||||
tooth: string,
|
||||
prosthesisTypeCode: string,
|
||||
): string {
|
||||
if (Array.isArray(toothSelectionGroups)) {
|
||||
for (const row of toothSelectionGroups) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const rec = row as { groupId?: unknown; teeth?: unknown };
|
||||
if (typeof rec.groupId !== 'string') continue;
|
||||
if (Array.isArray(rec.teeth) && rec.teeth.includes(tooth)) {
|
||||
return rec.groupId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return `legacy-${prosthesisTypeCode}`;
|
||||
}
|
||||
|
||||
function sortTeeth(teeth: string[]): string[] {
|
||||
return [...new Set(teeth)].sort((a, b) => {
|
||||
const na = Number(a);
|
||||
|
||||
@@ -33,6 +33,9 @@ const taskListInclude = {
|
||||
},
|
||||
} satisfies Prisma.LabCaseTaskInclude;
|
||||
|
||||
/** Completing this step completes every matching task in the same case (catalog: first step of all prosthesis types). */
|
||||
const CASE_SCOPED_SCAN_STEP_CODE = 'intraoral_scan';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(
|
||||
@@ -238,12 +241,13 @@ export class TasksService {
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const result = await tx.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: dto.status,
|
||||
lastStatusChangedByUserId: actorUserId,
|
||||
lastStatusChangedAt: new Date(),
|
||||
lastStatusChangedAt: now,
|
||||
},
|
||||
include: taskListInclude,
|
||||
});
|
||||
@@ -259,13 +263,58 @@ export class TasksService {
|
||||
});
|
||||
}
|
||||
|
||||
const cascadedScanTaskIds: string[] = [];
|
||||
if (
|
||||
dto.status === LabTaskStatus.COMPLETED &&
|
||||
task.status !== LabTaskStatus.COMPLETED &&
|
||||
task.workflowStepCode === CASE_SCOPED_SCAN_STEP_CODE
|
||||
) {
|
||||
const siblingScans = await tx.labCaseTask.findMany({
|
||||
where: {
|
||||
labCaseId: task.labCaseId,
|
||||
workflowStepCode: CASE_SCOPED_SCAN_STEP_CODE,
|
||||
status: { not: LabTaskStatus.COMPLETED },
|
||||
id: { not: taskId },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
for (const sibling of siblingScans) {
|
||||
await tx.labCaseTask.update({
|
||||
where: { id: sibling.id },
|
||||
data: {
|
||||
status: LabTaskStatus.COMPLETED,
|
||||
lastStatusChangedByUserId: actorUserId,
|
||||
lastStatusChangedAt: now,
|
||||
},
|
||||
});
|
||||
await tx.labCaseTaskStatusEvent.create({
|
||||
data: {
|
||||
taskId: sibling.id,
|
||||
fromStatus: sibling.status,
|
||||
toStatus: LabTaskStatus.COMPLETED,
|
||||
changedByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
cascadedScanTaskIds.push(sibling.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||
await this.labCaseActivity.record(
|
||||
{
|
||||
labCaseId: task.labCaseId,
|
||||
type: LabCaseActivityType.TASK_COMPLETED,
|
||||
actorUserId,
|
||||
payload: { taskId },
|
||||
payload: {
|
||||
taskId,
|
||||
...(cascadedScanTaskIds.length > 0
|
||||
? {
|
||||
cascadedTaskIds: cascadedScanTaskIds,
|
||||
caseScopedStep: CASE_SCOPED_SCAN_STEP_CODE,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user