improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.

This commit is contained in:
2026-07-18 00:02:40 +03:30
parent 4b2349228a
commit 0740493384
23 changed files with 384 additions and 161 deletions

View File

@@ -0,0 +1,15 @@
-- The tooth_selection_groups migration tried to drop
-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key",
-- but PostgreSQL truncated/renamed that index to
-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key"
-- (see 20260710150528). The old unique index (without selectionGroupId)
-- therefore remained and blocks multiple selection groups that share
-- the same prosthesis type + stepOrder.
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key";
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key";
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_s";
-- Ensure the correct unique index exists (idempotent for DBs that already have it).
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder");

View File

@@ -0,0 +1,34 @@
-- Defensive cleanup for stage/prod (and any DB where the stale unique index
-- survived under a truncated/renamed Postgres identifier).
--
-- History:
-- 20260707130000 created unique (labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder)
-- → PG truncates the index name to 63 chars (...prosthesisTypeCode_s)
-- 20260710150528 renamed that truncated name to ...prosthesisTypeCo_key
-- 20260716120000 dropped the *untruncated* name (no-op) and added the correct
-- unique that includes selectionGroupId — leaving the stale unique in place
-- 20260717123000 drops known stale names; this migration also drops by definition
-- so any leftover old unique is removed regardless of identifier length/rename.
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'lab_case_tasks'
AND indexdef ILIKE '%UNIQUE%'
AND indexdef ILIKE '%prosthesisTypeCode%'
AND indexdef ILIKE '%stepOrder%'
AND indexdef ILIKE '%treatmentDetailId%'
AND indexdef NOT ILIKE '%selectionGroupId%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname);
END LOOP;
END $$;
-- Correct unique (includes selectionGroupId). Idempotent if already present.
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder");

View File

@@ -0,0 +1,40 @@
-- Restore merge-by-prosthesis uniqueness for lab case tasks.
-- Product rule: all teeth on a detail that share the same prosthesis type become
-- one task set (Scan/Design/…). selectionGroupId remains on the table for UI
-- but is not part of the unique key.
--
-- Drop the selectionGroupId-inclusive unique (by known names + by definition).
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'lab_case_tasks'
AND indexdef ILIKE '%UNIQUE%'
AND indexdef ILIKE '%selectionGroupId%'
AND indexdef ILIKE '%prosthesisTypeCode%'
AND indexdef ILIKE '%stepOrder%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname);
END LOOP;
END $$;
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key";
-- Collapse rows that were split by selectionGroupId but share the merge key
-- (status events cascade via FK). Kept row may need regen for full teeth[]; new sends are correct.
DELETE FROM "lab_case_tasks" AS a
USING "lab_case_tasks" AS b
WHERE a."id" > b."id"
AND a."labCaseId" = b."labCaseId"
AND a."treatmentDetailId" = b."treatmentDetailId"
AND a."prosthesisTypeCode" = b."prosthesisTypeCode"
AND a."stepOrder" = b."stepOrder";
-- Short name stays under Postgres 63-char identifier limit.
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder");

View File

@@ -379,7 +379,7 @@ model LabCaseTask {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([labCaseId, treatmentDetailId, selectionGroupId, prosthesisTypeCode, stepOrder])
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key")
@@index([labCaseId, status])
@@index([assigneeUserId])
@@map("lab_case_tasks")

View File

@@ -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 () => {

View File

@@ -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 (preselection-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);

View File

@@ -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,
);