import type { Request, Response } from "express"; import { randomUUID } from "node:crypto"; import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js"; import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; import { buildProjectCircuitTrees } from "./circuit-tree.controller.js"; import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType } from "../../domain/models/external-object-new-row-project-command.model.js"; import { createExternalObjectNewCircuitProjectCommand, externalObjectAssignToNewCircuitCommandType } from "../../domain/models/external-object-new-circuit-project-command.model.js"; import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults.js"; import { circuitNumberingService } from "../composition/circuit-numbering-service.js"; export async function listExternalObjects(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); const [project, boards, devices] = await Promise.all([projectRepository.findById(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." }); const state = externalModelStateRepository.getByProject(projectId).state; const mappings = new Map(state.roomMappings.map((mapping) => [mapping.id, mapping])); return res.json({ currentRevision: project.currentRevision, sourceName: state.source?.name ?? null, distributionBoards: boards.map(({ id, name }) => ({ id, name })), projectDevices: devices.map(({ id, name, displayName }) => ({ id, name, displayName })), objects: state.objects.map((object) => { const mapping = object.externalRoomMappingId ? mappings.get(object.externalRoomMappingId) : null; return { id: object.id, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid, deviceTypeId: object.deviceTypeId, familyAndType: object.acceptedSourceValues.familyAndType, selectionMarker: scalarText(object.acceptedSourceValues.selectionMarker), sourceCircuitIdentifier: scalarText(object.acceptedSourceValues.circuitCombined) || scalarText(object.acceptedSourceValues.circuitIdentifier), sourceRoomNumber: scalarText(object.acceptedSourceValues.roomNumber), sourceRoomName: scalarText(object.acceptedSourceValues.roomName), roomId: mapping?.roomId ?? null, distributionBoardId: object.distributionBoardId, linkedProjectDeviceId: object.linkedProjectDeviceId, circuitDeviceRowId: object.circuitDeviceRowId, presenceStatus: object.presenceStatus, planningValues: object.planningValues }; }), }); } export function updateExternalObjects(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = req.body?.expectedRevision; if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error("Ungültige Projektrevision."); const objectIds = req.body?.objectIds; const patch = req.body?.patch; if (!Array.isArray(objectIds) || objectIds.length === 0 || new Set(objectIds).size !== objectIds.length || !isRecord(patch)) throw new Error("Objektauswahl oder Änderungen sind ungültig."); const state = externalModelStateRepository.getByProject(projectId); if (!state.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); const byId = new Map(state.state.objects.map((object) => [object.id, object])); const transitions = objectIds.map((id: unknown) => { if (typeof id !== "string") throw new Error("Ungültige Revit-Objekt-ID."); const expected = byId.get(id); if (!expected) throw new Error("Ein ausgewähltes Revit-Objekt wurde nicht gefunden."); if (expected.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte folgen ihrer Gerätezeile und können hier nicht geändert werden."); const planningValues = { ...expected.planningValues }; const overriddenFields = new Set(expected.overriddenFields); for (const field of planningFields) if (hasOwn(patch, field)) { planningValues[field] = patch[field] as never; overriddenFields.add(field); } return { expected, target: { ...expected, planningValues, overriddenFields: [...overriddenFields], distributionBoardId: hasOwn(patch, "distributionBoardId") ? nullableId(patch.distributionBoardId) : expected.distributionBoardId, linkedProjectDeviceId: hasOwn(patch, "linkedProjectDeviceId") ? nullableId(patch.linkedProjectDeviceId) : expected.linkedProjectDeviceId } }; }); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte bearbeitet`, command: createExternalObjectPlanningUpdateProjectCommand(transitions) }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } export async function deleteMissingExternalObjects(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); const objectIds = parseObjectIds(req.body?.objectIds); if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden."); const { state, projectExists } = externalModelStateRepository.getByProject(projectId); if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); const byId = new Map(state.objects.map((object) => [object.id, object])); const selected = objectIds.map((id) => { const object = byId.get(id); if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden."); return object; }); const treeResult = await buildProjectCircuitTrees(projectId); const quantitiesByRowId = new Map(); for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity); const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => { const location = findRow(treeResult, rowId); if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden."); const expected = toRowSnapshot(location.row, location.circuitId); return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } }; }); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${selected.length} fehlende Revit-Objekte gelöscht`, command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }), }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } export async function assignExternalObjectsToRow(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); const objectIds = parseObjectIds(req.body?.objectIds); const rowId = requiredId(req.body?.rowId, "Ungültige Gerätezeile."); const confirmedConflictObjectIds = Array.isArray(req.body?.confirmedConflictObjectIds) ? req.body.confirmedConflictObjectIds.map((value: unknown) => requiredId(value, "Ungültige Konfliktbestätigung.")) : []; const { state, projectExists } = externalModelStateRepository.getByProject(projectId); if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); const treeResult = await buildProjectCircuitTrees(projectId); const location = findRow(treeResult, rowId); if (!location) throw new Error("Gerätezeile wurde nicht gefunden."); const byId = new Map(state.objects.map((object) => [object.id, object])); const selected = objectIds.map((id) => { const object = byId.get(id); if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden."); if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits einer Gerätezeile zugeordnet."); return object; }); const expectedRow = toRowSnapshot(location.row, location.circuitId); const addedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0); const targetRow = { ...expectedRow, quantity: expectedRow.quantity + addedQuantity }; const objects = selected.map((expected) => ({ expected, target: { ...expected, distributionBoardId: location.distributionBoardId, circuitDeviceRowId: rowId }, })); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${objects.length} Revit-Objekte zugeordnet`, command: createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: expectedRow, target: targetRow }], objects, confirmedConflictObjectIds, }), }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } export async function detachExternalObjectsFromRow(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); const objectIds = parseObjectIds(req.body?.objectIds); const { state, projectExists } = externalModelStateRepository.getByProject(projectId); if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); const treeResult = await buildProjectCircuitTrees(projectId); const byId = new Map(state.objects.map((object) => [object.id, object])); const selected = objectIds.map((id) => { const object = byId.get(id); if (!object?.circuitDeviceRowId) throw new Error("Ein Revit-Objekt ist keiner Gerätezeile zugeordnet."); return object; }); const rowIds = new Set(selected.map((object) => object.circuitDeviceRowId!)); if (rowIds.size !== 1) throw new Error("Revit-Objekte können nur aus derselben Gerätezeile gemeinsam gelöst werden."); const rowId = [...rowIds][0]!; const location = findRow(treeResult, rowId); if (!location) throw new Error("Gerätezeile wurde nicht gefunden."); const expectedRow = toRowSnapshot(location.row, location.circuitId); const removedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0); const targetRow = { ...expectedRow, quantity: expectedRow.quantity - removedQuantity }; const copiedFields = ["displayName", "category", "connectionKind", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const; const objects = selected.map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null, distributionBoardId: location.distributionBoardId, linkedProjectDeviceId: expectedRow.linkedProjectDeviceId, planningValues: { ...expected.planningValues, displayName: expectedRow.displayName, category: location.category, connectionKind: expectedRow.connectionKind, powerPerUnitW: expectedRow.powerPerUnit * 1000, simultaneityFactor: expectedRow.simultaneityFactor, cosPhi: expectedRow.cosPhi, costGroup: expectedRow.costGroup, remark: expectedRow.remark, }, overriddenFields: [...new Set([...expected.overriddenFields, ...copiedFields])], }, })); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${objects.length} Revit-Objekte gelöst`, command: createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: expectedRow, target: targetRow }], objects, confirmedConflictObjectIds: [], }), }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } export async function assignExternalObjectsToNewRow(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); const objectIds = parseObjectIds(req.body?.objectIds); const circuitId = requiredId(req.body?.circuitId, "Ungültiger Stromkreis."); const context = await loadAssignmentContext(projectId, objectIds); const circuitLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.flatMap((section) => section.circuits.map((circuit) => ({ tree, section, circuit })))).find((entry) => entry.circuit.id === circuitId); if (!circuitLocation || !circuitLocation.section.category) throw new Error("Stromkreis wurde nicht gefunden."); const row = buildExternalRow(context.objects, context.roomId, circuitId, Math.max(0, ...circuitLocation.circuit.deviceRows.map((entry) => entry.sortOrder)) + 10); const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: circuitLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } })); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neue Gerätezeile zugeordnet`, command: createExternalObjectNewRowProjectCommand(externalObjectAssignToNewRowCommandType, { row, objects: transitions, confirmedConflictObjectIds: objectIds }) }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } export async function assignExternalObjectsToNewCircuit(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); const objectIds = parseObjectIds(req.body?.objectIds); const sectionId = requiredId(req.body?.sectionId, "Ungültige Stromkreisgruppe."); const context = await loadAssignmentContext(projectId, objectIds); const sectionLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.map((section) => ({ tree, section }))).find((entry) => entry.section.id === sectionId); if (!sectionLocation?.section.category) throw new Error("Stromkreisgruppe wurde nicht gefunden."); const circuitId = randomUUID(); const row = buildExternalRow(context.objects, context.roomId, circuitId, 10); const protection = createDefaultCircuitProtection(sectionLocation.section.category); const circuit = { id: circuitId, circuitListId: sectionLocation.tree.circuitListId, sectionId, equipmentIdentifier: await circuitNumberingService.getNextIdentifier(sectionId), // The single external row is shown compactly, so the circuit carries its name. displayName: row.displayName, sortOrder: Math.max(0, ...sectionLocation.section.circuits.map((entry) => entry.sortOrder)) + 10, cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null, terminalDesignation: null, voltage: sectionLocation.section.category === "three_phase" ? sectionLocation.tree.threePhaseVoltageV : sectionLocation.tree.singlePhaseVoltageV, controlRequirement: null, status: null, isReserve: false, remark: null, deviceRows: [row], protectionDevice: { circuitId, type: protection.type, ratedCurrentA: protection.ratedCurrentA, fuseUtilizationCategory: "fuseUtilizationCategory" in protection ? protection.fuseUtilizationCategory : null, tripCharacteristic: "tripCharacteristic" in protection ? protection.tripCharacteristic : null, rcdType: "rcdType" in protection ? protection.rcdType : null, ratedResidualCurrentMa: "ratedResidualCurrentMa" in protection ? protection.ratedResidualCurrentMa : null, }, }; const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: sectionLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } })); const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neuen Stromkreis zugeordnet`, command: createExternalObjectNewCircuitProjectCommand(externalObjectAssignToNewCircuitCommandType, { circuit, objects: transitions, confirmedConflictObjectIds: objectIds }) }); return res.json(result); } catch (error) { return respondWithProjectCommandError(error, res); } } async function loadAssignmentContext(projectId: string, objectIds: string[]) { const { state, projectExists } = externalModelStateRepository.getByProject(projectId); if (!projectExists) throw new Error("Projekt nicht gefunden."); const byId = new Map(state.objects.map((object) => [object.id, object])); const objects = objectIds.map((id) => { const object = byId.get(id); if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden."); if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits zugeordnet."); return object; }); const roomIds = new Set(objects.map((object) => object.externalRoomMappingId === null ? null : state.roomMappings.find((mapping) => mapping.id === object.externalRoomMappingId)?.roomId ?? null)); if (roomIds.size !== 1) throw new Error("Revit-Objekte aus verschiedenen Räumen benötigen getrennte Gerätezeilen."); return { objects, roomId: [...roomIds][0]!, treeResult: await buildProjectCircuitTrees(projectId) }; } function buildExternalRow(objects: ReturnType["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot { const first = objects[0]!; const planning = first.planningValues; const quantity = objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0); return { id: randomUUID(), circuitId, linkedProjectDeviceId: first.linkedProjectDeviceId, sortOrder, name: first.acceptedSourceValues.familyAndType, displayName: planning.displayName ?? (scalarText(first.acceptedSourceValues.selectionMarker) || first.acceptedSourceValues.familyAndType), phaseType: planning.category === "three_phase" ? "three_phase" : "single_phase", connectionKind: planning.connectionKind, costGroup: planning.costGroup, category: planning.category, level: null, roomId, roomNumberSnapshot: scalarText(first.acceptedSourceValues.roomNumber) || null, roomNameSnapshot: scalarText(first.acceptedSourceValues.roomName) || null, quantity, manualQuantity: 0, powerPerUnit: (planning.powerPerUnitW ?? 0) / 1000, simultaneityFactor: planning.simultaneityFactor ?? 1, cosPhi: planning.cosPhi, remark: planning.remark, overriddenFields: null, }; } function parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; } function parseObjectIds(value: unknown): string[] { if (!Array.isArray(value) || value.length === 0) throw new Error("Keine Revit-Objekte ausgewählt."); const ids = value.map((entry) => requiredId(entry, "Ungültige Revit-Objekt-ID.")); if (new Set(ids).size !== ids.length) throw new Error("Revit-Objekte sind doppelt ausgewählt."); return ids; } function requiredId(value: unknown, message: string) { if (typeof value !== "string" || !value) throw new Error(message); return value; } function findRow(result: Awaited>, rowId: string) { if (!result) return null; for (const tree of result.trees) for (const section of tree.sections) for (const circuit of section.circuits) { const row = circuit.deviceRows.find((entry) => entry.id === rowId); if (row) return { row, circuitId: circuit.id, distributionBoardId: tree.distributionBoardId, category: section.category ?? null }; } return null; } function toRowSnapshot(row: NonNullable>["row"], circuitId: string): CircuitDeviceRowSnapshot { return { id: row.id, circuitId, linkedProjectDeviceId: row.linkedProjectDeviceId ?? 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, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null }; } const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[]; function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function hasOwn(value: object, key: PropertyKey) { return Object.prototype.hasOwnProperty.call(value, key); } function nullableId(value: unknown) { if (value === null || value === "") return null; if (typeof value !== "string") throw new Error("Ungültige Zuordnungs-ID."); return value; }