2026-07-06 20:40:19 +03:30
|
|
|
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
|
|
|
|
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
2026-09-01 21:03:04 +03:30
|
|
|
import {
|
|
|
|
|
prosthesisGroupKey,
|
|
|
|
|
unionWorkflowStepCodes,
|
|
|
|
|
} from '../../common/prosthesis-group';
|
2026-09-02 17:24:01 +03:30
|
|
|
import { sortProsthesisTeeth } from './lab-case-task.util';
|
|
|
|
|
|
|
|
|
|
const PARTIAL_DENTURE_CODE = 'partial_denture';
|
2026-06-28 16:46:42 +03:30
|
|
|
|
|
|
|
|
type TransactionClient = Prisma.TransactionClient;
|
|
|
|
|
|
|
|
|
|
export async function generateLabCaseTasks(
|
|
|
|
|
tx: TransactionClient,
|
|
|
|
|
labCaseId: string,
|
2026-07-06 20:40:19 +03:30
|
|
|
localeInput?: string | null,
|
2026-06-28 16:46:42 +03:30
|
|
|
): Promise<number> {
|
|
|
|
|
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
|
|
|
|
|
if (existingCount > 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const locale = normalizeCatalogLocale(localeInput);
|
|
|
|
|
|
|
|
|
|
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
|
|
|
|
|
where: { labCaseId },
|
2026-06-28 16:46:42 +03:30
|
|
|
include: {
|
2026-07-17 00:39:32 +03:30
|
|
|
detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
|
2026-08-19 15:05:58 +03:30
|
|
|
line: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
|
2026-07-06 20:40:19 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (toothProsthesisRows.length === 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
|
|
|
|
|
|
|
|
|
|
const prosthesisTypes = await tx.prosthesisType.findMany({
|
2026-09-01 21:03:04 +03:30
|
|
|
where: { code: { in: prosthesisCodes } },
|
2026-07-06 20:40:19 +03:30
|
|
|
include: {
|
|
|
|
|
steps: {
|
|
|
|
|
orderBy: { stepOrder: 'asc' },
|
|
|
|
|
include: { labWorkflowStep: { select: { code: true } } },
|
2026-06-28 16:46:42 +03:30
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const stepsByProsthesisCode = new Map(
|
|
|
|
|
prosthesisTypes.map((type) => [
|
|
|
|
|
type.code,
|
|
|
|
|
type.steps.map((s) => ({
|
|
|
|
|
stepOrder: s.stepOrder,
|
|
|
|
|
workflowStepCode: s.labWorkflowStep.code,
|
|
|
|
|
})),
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-06-28 16:46:42 +03:30
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const allStepCodes = [
|
|
|
|
|
...new Set(
|
|
|
|
|
prosthesisTypes.flatMap((type) =>
|
|
|
|
|
type.steps.map((s) => s.labWorkflowStep.code),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
];
|
2026-06-28 16:46:42 +03:30
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
2026-06-28 16:46:42 +03:30
|
|
|
|
2026-09-01 21:03:04 +03:30
|
|
|
// Per tooth: stacked types become one group key; steps are the union.
|
2026-09-02 17:24:01 +03:30
|
|
|
// Unconnected teeth stay separate even when they share a type.
|
|
|
|
|
// Connected (bridge) teeth that share a type merge into one pipeline; an extra
|
|
|
|
|
// type on one unit of that bridge is its own pipeline. partial_denture is one
|
|
|
|
|
// lab item per sourceKey.
|
2026-09-01 21:03:04 +03:30
|
|
|
type ToothBucket = {
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
treatmentDetailId: string | null;
|
|
|
|
|
lineId: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
selectionGroupId: string;
|
|
|
|
|
codes: Set<string>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const byTooth = new Map<string, ToothBucket>();
|
2026-06-28 16:46:42 +03:30
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
for (const row of toothProsthesisRows) {
|
2026-08-19 15:05:58 +03:30
|
|
|
const sourceKey =
|
|
|
|
|
row.sourceKey ||
|
|
|
|
|
row.treatmentDetailId ||
|
|
|
|
|
row.lineId ||
|
|
|
|
|
'';
|
|
|
|
|
if (!sourceKey) continue;
|
2026-09-01 21:03:04 +03:30
|
|
|
const toothKey = `${sourceKey}::${row.tooth}`;
|
2026-07-18 00:02:40 +03:30
|
|
|
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
2026-09-01 21:03:04 +03:30
|
|
|
const bucket = byTooth.get(toothKey) ?? {
|
2026-08-19 15:05:58 +03:30
|
|
|
sourceKey,
|
2026-07-07 15:31:09 +03:30
|
|
|
treatmentDetailId: row.treatmentDetailId,
|
2026-08-19 15:05:58 +03:30
|
|
|
lineId: row.lineId,
|
|
|
|
|
treatmentType:
|
|
|
|
|
row.detail?.treatmentType ??
|
|
|
|
|
row.line?.treatmentType ??
|
|
|
|
|
row.treatmentType ??
|
|
|
|
|
'prosthesis',
|
2026-07-17 00:39:32 +03:30
|
|
|
selectionGroupId,
|
2026-09-01 21:03:04 +03:30
|
|
|
codes: new Set<string>(),
|
2026-07-07 15:31:09 +03:30
|
|
|
};
|
2026-09-01 21:03:04 +03:30
|
|
|
if (!bucket.selectionGroupId && selectionGroupId) {
|
|
|
|
|
bucket.selectionGroupId = selectionGroupId;
|
2026-07-18 00:02:40 +03:30
|
|
|
}
|
2026-09-01 21:03:04 +03:30
|
|
|
bucket.codes.add(row.prosthesisTypeCode);
|
|
|
|
|
byTooth.set(toothKey, bucket);
|
2026-07-07 15:31:09 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-02 17:24:01 +03:30
|
|
|
const teethByBridge = new Map<string, Set<string>>();
|
|
|
|
|
for (const [toothKey, bucket] of byTooth) {
|
|
|
|
|
if (!bucket.selectionGroupId) continue;
|
|
|
|
|
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
|
|
|
|
|
const bridgeKey = `${bucket.sourceKey}::${bucket.selectionGroupId}`;
|
|
|
|
|
const set = teethByBridge.get(bridgeKey) ?? new Set<string>();
|
|
|
|
|
set.add(tooth);
|
|
|
|
|
teethByBridge.set(bridgeKey, set);
|
|
|
|
|
}
|
|
|
|
|
const bridgeKeys = new Set(
|
|
|
|
|
[...teethByBridge.entries()]
|
|
|
|
|
.filter(([, teeth]) => teeth.size > 1)
|
|
|
|
|
.map(([key]) => key),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
type Pipeline = {
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
treatmentDetailId: string | null;
|
|
|
|
|
lineId: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
selectionGroupId: string;
|
|
|
|
|
tooth: string;
|
|
|
|
|
teeth: string[];
|
|
|
|
|
codes: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PartialMerge = {
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
treatmentDetailId: string | null;
|
|
|
|
|
lineId: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
teeth: string[];
|
|
|
|
|
groupIds: Set<string>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ConnectedTypeMerge = {
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
treatmentDetailId: string | null;
|
|
|
|
|
lineId: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
selectionGroupId: string;
|
|
|
|
|
code: string;
|
|
|
|
|
teeth: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pipelines: Pipeline[] = [];
|
|
|
|
|
const partialMerges = new Map<string, PartialMerge>();
|
|
|
|
|
const connectedTypeMerges = new Map<string, ConnectedTypeMerge>();
|
2026-07-07 15:31:09 +03:30
|
|
|
|
2026-09-01 21:03:04 +03:30
|
|
|
for (const [toothKey, bucket] of byTooth) {
|
|
|
|
|
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
|
2026-09-02 17:24:01 +03:30
|
|
|
const codes = new Set(bucket.codes);
|
|
|
|
|
if (codes.has(PARTIAL_DENTURE_CODE)) {
|
|
|
|
|
codes.delete(PARTIAL_DENTURE_CODE);
|
|
|
|
|
const merge = partialMerges.get(bucket.sourceKey) ?? {
|
|
|
|
|
sourceKey: bucket.sourceKey,
|
|
|
|
|
treatmentDetailId: bucket.treatmentDetailId,
|
|
|
|
|
lineId: bucket.lineId,
|
|
|
|
|
treatmentType: bucket.treatmentType,
|
|
|
|
|
teeth: [],
|
|
|
|
|
groupIds: new Set<string>(),
|
|
|
|
|
};
|
|
|
|
|
merge.teeth.push(tooth);
|
|
|
|
|
if (bucket.selectionGroupId) merge.groupIds.add(bucket.selectionGroupId);
|
|
|
|
|
partialMerges.set(bucket.sourceKey, merge);
|
|
|
|
|
}
|
|
|
|
|
if (codes.size === 0) continue;
|
|
|
|
|
|
|
|
|
|
const bridgeKey = bucket.selectionGroupId
|
|
|
|
|
? `${bucket.sourceKey}::${bucket.selectionGroupId}`
|
|
|
|
|
: '';
|
|
|
|
|
if (bridgeKey && bridgeKeys.has(bridgeKey)) {
|
|
|
|
|
for (const code of codes) {
|
|
|
|
|
const typeKey = `${bridgeKey}::${code}`;
|
|
|
|
|
const merge = connectedTypeMerges.get(typeKey) ?? {
|
|
|
|
|
sourceKey: bucket.sourceKey,
|
|
|
|
|
treatmentDetailId: bucket.treatmentDetailId,
|
|
|
|
|
lineId: bucket.lineId,
|
|
|
|
|
treatmentType: bucket.treatmentType,
|
|
|
|
|
selectionGroupId: bucket.selectionGroupId,
|
|
|
|
|
code,
|
|
|
|
|
teeth: [],
|
|
|
|
|
};
|
|
|
|
|
if (!merge.teeth.includes(tooth)) merge.teeth.push(tooth);
|
|
|
|
|
connectedTypeMerges.set(typeKey, merge);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pipelines.push({
|
|
|
|
|
sourceKey: bucket.sourceKey,
|
|
|
|
|
treatmentDetailId: bucket.treatmentDetailId,
|
|
|
|
|
lineId: bucket.lineId,
|
|
|
|
|
treatmentType: bucket.treatmentType,
|
|
|
|
|
selectionGroupId: bucket.selectionGroupId,
|
|
|
|
|
tooth,
|
|
|
|
|
teeth: [tooth],
|
|
|
|
|
codes: [...codes],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const merge of connectedTypeMerges.values()) {
|
|
|
|
|
const teeth = sortProsthesisTeeth(merge.teeth);
|
|
|
|
|
pipelines.push({
|
|
|
|
|
sourceKey: merge.sourceKey,
|
|
|
|
|
treatmentDetailId: merge.treatmentDetailId,
|
|
|
|
|
lineId: merge.lineId,
|
|
|
|
|
treatmentType: merge.treatmentType,
|
|
|
|
|
selectionGroupId: merge.selectionGroupId,
|
|
|
|
|
tooth: teeth[0] ?? '',
|
|
|
|
|
teeth,
|
|
|
|
|
codes: [merge.code],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const merge of partialMerges.values()) {
|
|
|
|
|
const teeth = sortProsthesisTeeth(merge.teeth);
|
|
|
|
|
const sharedGroup = merge.groupIds.size === 1 ? [...merge.groupIds][0] : '';
|
|
|
|
|
pipelines.push({
|
|
|
|
|
sourceKey: merge.sourceKey,
|
|
|
|
|
treatmentDetailId: merge.treatmentDetailId,
|
|
|
|
|
lineId: merge.lineId,
|
|
|
|
|
treatmentType: merge.treatmentType,
|
|
|
|
|
selectionGroupId: sharedGroup,
|
|
|
|
|
tooth: teeth[0] ?? '',
|
|
|
|
|
teeth,
|
|
|
|
|
codes: [PARTIAL_DENTURE_CODE],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
|
|
|
|
|
|
|
|
|
for (const pipeline of pipelines) {
|
|
|
|
|
const lists = pipeline.codes.map((code) =>
|
2026-09-01 21:03:04 +03:30
|
|
|
(stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode),
|
|
|
|
|
);
|
|
|
|
|
const unioned = unionWorkflowStepCodes(lists);
|
|
|
|
|
if (unioned.length === 0) {
|
2026-06-28 16:46:42 +03:30
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 17:24:01 +03:30
|
|
|
const groupCode = prosthesisGroupKey(pipeline.codes);
|
2026-07-07 15:31:09 +03:30
|
|
|
|
2026-09-01 21:03:04 +03:30
|
|
|
unioned.forEach((workflowStepCode, index) => {
|
2026-07-06 20:40:19 +03:30
|
|
|
taskRows.push({
|
|
|
|
|
labCaseId,
|
2026-09-02 17:24:01 +03:30
|
|
|
treatmentDetailId: pipeline.treatmentDetailId,
|
|
|
|
|
lineId: pipeline.lineId,
|
|
|
|
|
sourceKey: pipeline.sourceKey,
|
|
|
|
|
tooth: pipeline.tooth,
|
|
|
|
|
teeth: pipeline.teeth,
|
|
|
|
|
treatmentType: pipeline.treatmentType,
|
2026-09-01 21:03:04 +03:30
|
|
|
prosthesisTypeCode: groupCode,
|
2026-09-02 17:24:01 +03:30
|
|
|
selectionGroupId: pipeline.selectionGroupId,
|
2026-09-01 21:03:04 +03:30
|
|
|
workflowStepCode,
|
|
|
|
|
stepOrder: index + 1,
|
|
|
|
|
stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode,
|
2026-07-07 15:31:09 +03:30
|
|
|
status: LabTaskStatus.IN_PROGRESS,
|
2026-07-06 20:40:19 +03:30
|
|
|
});
|
2026-09-01 21:03:04 +03:30
|
|
|
});
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskRows.length === 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await tx.labCaseTask.createMany({ data: taskRows });
|
|
|
|
|
return taskRows.length;
|
|
|
|
|
}
|
2026-07-06 20:40:19 +03:30
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|