Complete Revit object editor assignments

This commit is contained in:
Julian Appel 2026-09-30 14:35:14 +02:00
parent 4489cd76f0
commit 77d775fde1
25 changed files with 853 additions and 77 deletions

View file

@ -14,6 +14,7 @@ import {
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
@ -88,6 +89,13 @@ export async function buildProjectCircuitTrees(
circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
]);
const externalState = externalModelStateRepository.getByProject(projectId).state;
const externalCountByRowId = new Map<string, number>();
for (const object of externalState.objects) {
if (object.circuitDeviceRowId) {
externalCountByRowId.set(object.circuitDeviceRowId, (externalCountByRowId.get(object.circuitDeviceRowId) ?? 0) + 1);
}
}
const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds),
@ -237,6 +245,7 @@ export async function buildProjectCircuitTrees(
row.powerPerUnit,
row.simultaneityFactor
),
externalObjectCount: externalCountByRowId.get(row.id) ?? 0,
}));
const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing.");

View file

@ -1,9 +1,17 @@
import type { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-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 ?? "");
@ -49,6 +57,221 @@ export function updateExternalObjects(req: Request, res: Response) {
} 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),
displayName: null,
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); }

View file

@ -63,7 +63,7 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
export const projectRouter = Router();
@ -93,6 +93,10 @@ projectRouter.post("/:projectId/external-model/initial-import/plan", planExterna
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post(