leistungsbilanz-ts/src/server/controllers/external-objects.controller.ts
Julian Appel 01312b89fe Keep single-device circuit names equal to their device row
A circuit with exactly one device row and no fed distribution board is
shown as that row, so its own name was hidden and could drift (e.g.
"Neuer Stromkreis" in the print view). Commands that create or leave
such a circuit, rename its row or unlink its downstream board now set
the circuit name to the row name and store the change as optional
circuitDisplayNames, so Undo restores an overwritten free name exactly.
Migration 0016 aligns existing data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:56:02 +01:00

314 lines
22 KiB
TypeScript

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<string, number>();
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<typeof externalModelStateRepository.getByProject>["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<ReturnType<typeof buildProjectCircuitTrees>>, 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<ReturnType<typeof findRow>>["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<string, unknown> { 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; }