import type { CircuitGroupSnapshot, } from "../../domain/models/circuit-group-structure-project-command.model"; import { circuitGroupCategoryNumbers, circuitGroupCategoryLabels, type CircuitGroupCategory, } from "../../shared/constants/circuit-group"; import type { CircuitTreeSectionDto } from "../types"; import type { CircuitGroupReorderAssignment, } from "../../domain/models/circuit-group-structure-project-command.model"; import type { CircuitGroupRenumberPlan, } from "../../domain/services/circuit-group-renumbering"; import type { CircuitGroupMovePlan, } from "../../domain/services/circuit-group-move-planning"; import type { CircuitGroupSubtreeSnapshot, } from "../../domain/models/circuit-group-subtree-snapshot.model"; import { toDistributionBoardComponentSnapshot, } from "./distribution-board-component-editing"; export function toCircuitGroupSnapshot( section: CircuitTreeSectionDto, circuitListId: string ): CircuitGroupSnapshot { if (!section.category || !section.groupNumber) { throw new Error("Der Bereich ist keine verwaltbare Stromkreisgruppe."); } return { id: section.id, circuitListId, key: section.key, displayName: section.displayName, prefix: section.prefix, sortOrder: section.sortOrder, category: section.category, groupNumber: section.groupNumber, }; } export function buildNewCircuitGroupSnapshot(input: { id: string; circuitListId: string; category: CircuitGroupCategory; displayName?: string; sections: readonly CircuitTreeSectionDto[]; }): CircuitGroupSnapshot { const groupedSections = input.sections.filter( (section): section is CircuitTreeSectionDto & { category: CircuitGroupCategory; groupNumber: number; } => Boolean(section.category && section.groupNumber) ); const groupNumber = Math.max( 0, ...groupedSections .filter((section) => section.category === input.category) .map((section) => section.groupNumber) ) + 1; return { id: input.id, circuitListId: input.circuitListId, key: `${input.category}_${groupNumber}`, displayName: input.displayName?.trim() || `${circuitGroupCategoryLabels[input.category]} ${groupNumber}`, prefix: `-${circuitGroupCategoryNumbers[input.category]}F${groupNumber}.`, sortOrder: getNewGroupSortOrder( input.category, groupedSections ), category: input.category, groupNumber, }; } export function renameCircuitGroupSnapshot( expected: CircuitGroupSnapshot, displayName: string ): CircuitGroupSnapshot { return { ...expected, displayName: displayName.trim() }; } export function canDeleteCircuitGroup( section: CircuitTreeSectionDto ): boolean { return section.circuits.length === 0 && section.components.length === 0; } export function buildCircuitGroupSubtreeSnapshot( section: CircuitTreeSectionDto, circuitListId: string ): CircuitGroupSubtreeSnapshot { const components = [...section.components] .sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ) .map(toDistributionBoardComponentSnapshot); const circuits = [...section.circuits] .sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ) .map((circuit) => ({ circuit: { id: circuit.id, circuitListId: circuit.circuitListId, sectionId: circuit.sectionId, equipmentIdentifier: circuit.equipmentIdentifier, displayName: circuit.displayName ?? null, sortOrder: circuit.sortOrder, protectionType: circuit.protectionType ?? null, protectionRatedCurrent: circuit.protectionRatedCurrent ?? null, protectionCharacteristic: circuit.protectionCharacteristic ?? null, cableType: circuit.cableType ?? null, cableCrossSection: circuit.cableCrossSection ?? null, cableLength: circuit.cableLength ?? null, rcdAssignment: circuit.rcdAssignment ?? null, terminalDesignation: circuit.terminalDesignation ?? null, voltage: circuit.voltage ?? null, controlRequirement: circuit.controlRequirement ?? null, status: circuit.status ?? null, isReserve: circuit.isReserve, remark: circuit.remark ?? null, deviceRows: [...circuit.deviceRows] .sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ) .map((row) => ({ id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, legacyConsumerId: row.legacyConsumerId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null, })), }, protectionDevice: circuit.protectionDevice ? { circuitId: circuit.id, type: circuit.protectionDevice.type, ratedCurrentA: circuit.protectionDevice.ratedCurrentA, fuseUtilizationCategory: circuit.protectionDevice.fuseUtilizationCategory ?? null, tripCharacteristic: circuit.protectionDevice.tripCharacteristic ?? null, rcdType: circuit.protectionDevice.rcdType ?? null, ratedResidualCurrentMa: circuit.protectionDevice.ratedResidualCurrentMa ?? null, } : null, })); return { group: toCircuitGroupSnapshot(section, circuitListId), components, circuits, }; } export function summarizeCircuitGroupSubtree( snapshot: CircuitGroupSubtreeSnapshot ) { return { circuitCount: snapshot.circuits.length, deviceRowCount: snapshot.circuits.reduce( (count, entry) => count + entry.circuit.deviceRows.length, 0 ), protectionComponentCount: snapshot.components.length, }; } export function buildCircuitGroupReorderAssignments( sections: readonly CircuitTreeSectionDto[], groupId: string, direction: -1 | 1 ): CircuitGroupReorderAssignment[] | null { const current = sections.find((section) => section.id === groupId); if (!current?.category) { return null; } const categoryGroups = sections .filter((section) => section.category === current.category) .sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ); const currentIndex = categoryGroups.findIndex( (section) => section.id === groupId ); const target = categoryGroups[currentIndex + direction]; if (!target) { return null; } return sections.map((section) => ({ groupId: section.id, expectedSortOrder: section.sortOrder, targetSortOrder: section.id === current.id ? target.sortOrder : section.id === target.id ? current.sortOrder : section.sortOrder, })); } export function buildCircuitGroupRenumberPlan( sections: readonly CircuitTreeSectionDto[], category: CircuitGroupCategory ): CircuitGroupRenumberPlan | null { const groups = getOrderedCategoryGroups(sections, category); if (!canRenumberCircuitGroups(sections, category)) { return null; } return { groups: groups.map((group, index) => { const targetGroupNumber = index + 1; const expectedPrefix = formatGroupPrefix( category, group.groupNumber ); if (group.prefix !== expectedPrefix) { throw new Error( `Das Präfix der Gruppe „${group.displayName}“ passt nicht zu ihrer Gruppennummer.` ); } return { groupId: group.id, category, expectedGroupNumber: group.groupNumber, targetGroupNumber, expectedPrefix, targetPrefix: formatGroupPrefix(category, targetGroupNumber), circuits: group.circuits.map((circuit) => { const circuitNumber = parseCircuitNumber( circuit.equipmentIdentifier, category, group.groupNumber ); return { circuitId: circuit.id, expectedEquipmentIdentifier: circuit.equipmentIdentifier, targetEquipmentIdentifier: formatCircuitIdentifier( category, targetGroupNumber, circuitNumber ), }; }), components: group.components.map((component) => ({ componentId: component.id, expectedEquipmentIdentifier: component.equipmentIdentifier, targetEquipmentIdentifier: formatGroupComponentIdentifier( category, targetGroupNumber, component.role ), })), }; }), }; } export function canRenumberCircuitGroups( sections: readonly CircuitTreeSectionDto[], category: CircuitGroupCategory ) { const groups = getOrderedCategoryGroups(sections, category); return ( groups.length > 0 && groups.some((group, index) => group.groupNumber !== index + 1) ); } export function canMoveCircuitToGroup( sections: readonly CircuitTreeSectionDto[], circuitId: string, targetSectionId: string ) { const source = sections.find((section) => section.circuits.some((circuit) => circuit.id === circuitId) ); const target = sections.find( (section) => section.id === targetSectionId ); return Boolean( source?.category && target?.category && source.id !== target.id && source.category === target.category ); } export function buildCircuitGroupMovePlan(input: { sections: readonly CircuitTreeSectionDto[]; circuitId: string; targetSectionId: string; placement: | { kind: "end" } | { kind: "before" | "after"; targetCircuitId: string; }; }): CircuitGroupMovePlan { const sourceGroup = input.sections.find((section) => section.circuits.some( (circuit) => circuit.id === input.circuitId ) ); const targetGroup = input.sections.find( (section) => section.id === input.targetSectionId ); const circuit = sourceGroup?.circuits.find( (entry) => entry.id === input.circuitId ); if ( !sourceGroup?.category || !sourceGroup.groupNumber || !targetGroup?.category || !targetGroup.groupNumber || !circuit || sourceGroup.id === targetGroup.id || sourceGroup.category !== targetGroup.category ) { throw new Error( "Stromkreise können nur zwischen verschiedenen Gruppen derselben Kategorie verschoben werden." ); } parseCircuitNumber( circuit.equipmentIdentifier, sourceGroup.category, sourceGroup.groupNumber ); const targetCircuitNumbers = targetGroup.circuits.map((entry) => parseCircuitNumber( entry.equipmentIdentifier, targetGroup.category!, targetGroup.groupNumber! ) ); return { circuitId: circuit.id, circuitListId: circuit.circuitListId, expectedSectionId: sourceGroup.id, targetSectionId: targetGroup.id, expectedEquipmentIdentifier: circuit.equipmentIdentifier, targetEquipmentIdentifier: formatCircuitIdentifier( targetGroup.category, targetGroup.groupNumber, Math.max(0, ...targetCircuitNumbers) + 1 ), expectedSortOrder: circuit.sortOrder, targetSortOrder: getCircuitMoveSortOrder( targetGroup.circuits, input.placement ), }; } function getNewGroupSortOrder( category: CircuitGroupCategory, sections: readonly (CircuitTreeSectionDto & { category: CircuitGroupCategory; groupNumber: number; })[] ): number { const ordered = [...sections].sort( (left, right) => left.sortOrder - right.sortOrder ); const sameCategory = ordered.filter( (section) => section.category === category ); const previous = sameCategory[sameCategory.length - 1]; if (!previous) { return ordered.length === 0 ? 10 : Math.max(...ordered.map((section) => section.sortOrder)) + 10; } const next = ordered.find( (section) => section.sortOrder > previous.sortOrder ); return next ? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2 : previous.sortOrder + 10; } function formatGroupPrefix( category: CircuitGroupCategory, groupNumber: number ) { return `-${circuitGroupCategoryNumbers[category]}F${groupNumber}.`; } function getOrderedCategoryGroups( sections: readonly CircuitTreeSectionDto[], category: CircuitGroupCategory ) { return sections .filter( (section): section is CircuitTreeSectionDto & { category: CircuitGroupCategory; groupNumber: number; } => section.category === category && Number.isInteger(section.groupNumber) && (section.groupNumber ?? 0) > 0 ) .sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ); } function formatCircuitIdentifier( category: CircuitGroupCategory, groupNumber: number, circuitNumber: number ) { return `${formatGroupPrefix(category, groupNumber)}${circuitNumber}`; } function parseCircuitNumber( equipmentIdentifier: string, category: CircuitGroupCategory, groupNumber: number ) { const prefix = formatGroupPrefix(category, groupNumber); const suffix = equipmentIdentifier.startsWith(prefix) ? equipmentIdentifier.slice(prefix.length) : ""; const circuitNumber = Number(suffix); if ( !/^[1-9]\d*$/.test(suffix) || !Number.isSafeInteger(circuitNumber) ) { throw new Error( `Das Stromkreis-BMK „${equipmentIdentifier}“ passt nicht zu seiner Gruppe.` ); } return circuitNumber; } function formatGroupComponentIdentifier( category: CircuitGroupCategory, groupNumber: number, role: CircuitTreeSectionDto["components"][number]["role"] ) { const categoryNumber = circuitGroupCategoryNumbers[category]; if (role === "group_upstream_protection") { return `-${categoryNumber}F${groupNumber}.0`; } if (role === "group_residual_current_protection") { return `-${categoryNumber}Q${groupNumber}.0`; } throw new Error( "Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente." ); } function getCircuitMoveSortOrder( circuits: CircuitTreeSectionDto["circuits"], placement: | { kind: "end" } | { kind: "before" | "after"; targetCircuitId: string; } ) { const ordered = [...circuits].sort( (left, right) => left.sortOrder - right.sortOrder || left.id.localeCompare(right.id) ); if (placement.kind === "end") { return ordered.length === 0 ? 10 : ordered[ordered.length - 1].sortOrder + 10; } const index = ordered.findIndex( (circuit) => circuit.id === placement.targetCircuitId ); if (index < 0) { throw new Error("Der Zielstromkreis wurde nicht gefunden."); } const current = ordered[index].sortOrder; if (placement.kind === "before") { const previous = ordered[index - 1]?.sortOrder; return previous === undefined ? current - 10 : previous + (current - previous) / 2; } const next = ordered[index + 1]?.sortOrder; return next === undefined ? current + 10 : current + (next - current) / 2; }