improvement: Some improvements done. some bugs fixed.

This commit is contained in:
2026-09-02 17:24:01 +03:30
parent a3c14a18c1
commit 7f92e735fb
32 changed files with 1092 additions and 315 deletions

View File

@@ -1,33 +1,57 @@
import type { Prisma } from '@prisma/client';
/** Union several workflows: scan/design first, packing/shipping last, other steps first-seen. */
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
const middle: string[] = [];
const seenMiddle = new Set<string>();
let hasScan = false;
let hasDesign = false;
let hasPacking = false;
let hasShipping = false;
const UNION_PINNED_PREFIX = [
'intraoral_scan',
'choosing_abutment',
'design',
'model_create',
] as const;
const UNION_SUFFIX = ['packing', 'shipping'] as const;
const UNION_PINNED = new Set<string>(UNION_PINNED_PREFIX);
const UNION_TAIL = new Set<string>(UNION_SUFFIX);
/** Remaining manufacturing steps — matches catalog LAB_WORKFLOW_STEPS order. */
const UNION_MIDDLE_ORDER: Record<string, number> = {
milling_dry: 1,
milling_wet: 2,
printer_resin: 3,
printer_metal: 4,
pressing: 5,
sinter: 6,
build_up: 7,
stain: 8,
glaze: 9,
polish_prep: 10,
};
/** Union stacked workflows: pin scan → abutment → design → model, then catalog order, pack/ship last. */
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
const present = new Set<string>();
for (const list of lists) {
for (const code of list) {
if (code === 'intraoral_scan') hasScan = true;
else if (code === 'design') hasDesign = true;
else if (code === 'packing') hasPacking = true;
else if (code === 'shipping') hasShipping = true;
else if (!seenMiddle.has(code)) {
seenMiddle.add(code);
middle.push(code);
}
if (code) present.add(code);
}
}
const out: string[] = [];
if (hasScan) out.push('intraoral_scan');
if (hasDesign) out.push('design');
for (const code of UNION_PINNED_PREFIX) {
if (present.has(code)) out.push(code);
}
const middle = [...present].filter((code) => !UNION_PINNED.has(code) && !UNION_TAIL.has(code));
middle.sort((a, b) => {
const da = UNION_MIDDLE_ORDER[a] ?? 100;
const db = UNION_MIDDLE_ORDER[b] ?? 100;
if (da !== db) return da - db;
return a.localeCompare(b);
});
out.push(...middle);
if (hasPacking) out.push('packing');
if (hasShipping) out.push('shipping');
for (const code of UNION_SUFFIX) {
if (present.has(code)) out.push(code);
}
return out;
}

View File

@@ -192,10 +192,9 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toEqual(['intraoral_scan', 'design']);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).not.toContain('printer_resin');
});
it('keeps connected and single teeth as separate per-tooth task sets', async () => {
it('merges connected teeth that share a prosthesis type into one task set', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
@@ -222,10 +221,59 @@ describe('generateLabCaseTasks', () => {
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
expect(count).toBe(pfmSteps.length * 3);
expect(count).toBe(pfmSteps.length * 2);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['14', '15', '21']));
expect(teethSets).toEqual(new Set(['14,15', '21']));
});
it('keeps an extra type on one connected tooth as its own task set', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const postSteps = stepsFromSeed('fiber_post_core');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'fiber_post_core',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '15',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '16',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'fiber_post_core', steps: postSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-bridge-addon', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const byType = new Map<string, string>();
for (const row of rows) {
byType.set(row.prosthesisTypeCode, row.teeth.join(','));
}
expect(byType.get('pfm_crown')).toBe('14,15,16');
expect(byType.get('fiber_post_core')).toBe('14');
});
it('skips generation when tasks already exist', async () => {
@@ -313,5 +361,121 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_dry');
expect(stepCodes).toContain('sinter');
expect(stepCodes).toContain('polish_prep');
expect(stepCodes).toContain('model_create');
expect(stepCodes).toContain('design');
expect(stepCodes.indexOf('model_create')).toBeGreaterThan(stepCodes.indexOf('design'));
});
it('omits design for prefab abutments and inserts choosing_abutment before model_create', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '36',
prosthesisTypeCode: 'prefabricated_abutment',
},
],
prosthesisTypes: [{ code: 'prefabricated_abutment', steps: prefabSteps }],
});
await generateLabCaseTasks(tx as never, 'lab-case-prefab', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual([
'intraoral_scan',
'choosing_abutment',
'model_create',
'polish_prep',
'packing',
'shipping',
]);
expect(stepCodes).not.toContain('design');
});
it('pins choosing_abutment before design when stacking prefab with a crown', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'prefabricated_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'prefabricated_abutment', steps: prefabSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-union-pin', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes.indexOf('choosing_abutment')).toBeLessThan(stepCodes.indexOf('design'));
expect(stepCodes.indexOf('design')).toBeLessThan(stepCodes.indexOf('model_create'));
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
});
it('merges partial_denture rows on the same sourceKey into one task set', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '17', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '18', prosthesisTypeCode: 'partial_denture' },
],
prosthesisTypes: [{ code: 'partial_denture', steps: partialSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-partial', 'en');
expect(count).toBe(partialSteps.length);
const rows = created as Array<{
teeth: string[];
tooth: string;
prosthesisTypeCode: string;
}>;
expect(rows.every((r) => r.prosthesisTypeCode === 'partial_denture')).toBe(true);
expect(rows.every((r) => r.teeth.join(',') === '14,15,16,17,18')).toBe(true);
expect(new Set(rows.map((r) => r.tooth)).size).toBe(1);
});
it('keeps crowns on abutment teeth as separate per-tooth jobs beside a merged partial', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
prosthesisTypes: [
{ code: 'partial_denture', steps: partialSteps },
{ code: 'pfm_crown', steps: pfmSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-partial-crown', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const partialRows = rows.filter((r) => r.prosthesisTypeCode === 'partial_denture');
const crownRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
expect(partialRows).toHaveLength(partialSteps.length);
expect(partialRows.every((r) => r.teeth.join(',') === '14,15')).toBe(true);
expect(crownRows).toHaveLength(pfmSteps.length);
expect(crownRows.every((r) => r.teeth.join(',') === '14')).toBe(true);
});
});

View File

@@ -4,6 +4,9 @@ import {
prosthesisGroupKey,
unionWorkflowStepCodes,
} from '../../common/prosthesis-group';
import { sortProsthesisTeeth } from './lab-case-task.util';
const PARTIAL_DENTURE_CODE = 'partial_denture';
type TransactionClient = Prisma.TransactionClient;
@@ -64,7 +67,10 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
// Per tooth: stacked types become one group key; steps are the union.
// Teeth are not merged even when they share the same type-set.
// 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.
type ToothBucket = {
sourceKey: string;
treatmentDetailId: string | null;
@@ -104,14 +110,140 @@ export async function generateLabCaseTasks(
byTooth.set(toothKey, bucket);
}
// One lab item per tooth (or arch sentinel): stacked types on that
// tooth become one prosthesisTypeCode group key; steps are the union.
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
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>();
for (const [toothKey, bucket] of byTooth) {
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
const codes = [...bucket.codes];
const lists = codes.map((code) =>
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) =>
(stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode),
);
const unioned = unionWorkflowStepCodes(lists);
@@ -119,19 +251,19 @@ export async function generateLabCaseTasks(
continue;
}
const groupCode = prosthesisGroupKey(codes);
const groupCode = prosthesisGroupKey(pipeline.codes);
unioned.forEach((workflowStepCode, index) => {
taskRows.push({
labCaseId,
treatmentDetailId: bucket.treatmentDetailId,
lineId: bucket.lineId,
sourceKey: bucket.sourceKey,
tooth,
teeth: [tooth],
treatmentType: bucket.treatmentType,
treatmentDetailId: pipeline.treatmentDetailId,
lineId: pipeline.lineId,
sourceKey: pipeline.sourceKey,
tooth: pipeline.tooth,
teeth: pipeline.teeth,
treatmentType: pipeline.treatmentType,
prosthesisTypeCode: groupCode,
selectionGroupId: bucket.selectionGroupId,
selectionGroupId: pipeline.selectionGroupId,
workflowStepCode,
stepOrder: index + 1,
stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode,

View File

@@ -74,7 +74,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
this.ensureLoaded();
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(

View File

@@ -785,7 +785,8 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
const isArchSentinel = row.tooth === 'UA' || row.tooth === 'LA';
if (!isArchSentinel && !teeth.includes(row.tooth)) {
throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);