Add virtualized Revit object planning

This commit is contained in:
Julian Appel 2026-09-30 10:03:36 +02:00
parent bfdd048269
commit 4ac32e103d
17 changed files with 277 additions and 9 deletions

View file

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
@ -43,6 +44,7 @@ export default function RevitPage() {
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
</main>;

View file

@ -0,0 +1,29 @@
import { and, eq } from "drizzle-orm";
import { assertExternalObjectPlanningUpdateProjectCommand, invertExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
import type { ExternalObjectPlanningProjectCommandStore } from "../../domain/ports/external-object-planning-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
export class ExternalObjectPlanningProjectCommandRepository implements ExternalObjectPlanningProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectPlanningProjectCommandStore["execute"]>[0]) {
assertExternalObjectPlanningUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const boardIds = new Set(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all().map((row) => row.id));
const deviceIds = new Set(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all().map((row) => row.id));
for (const { expected, target } of input.command.payload.objects) {
const current = tx.select().from(externalModelObjects).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).get();
if (!current || !snapshotsEqual(toExternalObjectSnapshot(current), expected)) throw new Error("Ein Revit-Objekt wurde zwischenzeitlich geändert.");
if (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) throw new Error("Der Verteiler gehört zu einem anderen Projekt.");
if (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)) throw new Error("Das Projektgerät gehört zu einem anderen Projekt.");
const updated = tx.update(externalModelObjects).set({ planningValues: target.planningValues, overriddenFields: target.overriddenFields, distributionBoardId: target.distributionBoardId, linkedProjectDeviceId: target.linkedProjectDeviceId }).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).run();
if (updated.changes !== 1) throw new Error("Revit-Objekt konnte nicht geändert werden.");
}
return invertExternalObjectPlanningUpdateProjectCommand(input.command);
});
}
}

View file

@ -0,0 +1,30 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalObjectPlanningUpdateCommandType = "external-object.update-planning" as const;
export interface ExternalObjectPlanningUpdateProjectCommand extends SerializedProjectCommand<{ objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }> }> {
schemaVersion: 1;
type: typeof externalObjectPlanningUpdateCommandType;
}
export function createExternalObjectPlanningUpdateProjectCommand(objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>): ExternalObjectPlanningUpdateProjectCommand {
const command: ExternalObjectPlanningUpdateProjectCommand = { schemaVersion: 1, type: externalObjectPlanningUpdateCommandType, payload: { objects } };
assertExternalObjectPlanningUpdateProjectCommand(command); return command;
}
export function invertExternalObjectPlanningUpdateProjectCommand(command: ExternalObjectPlanningUpdateProjectCommand) { return createExternalObjectPlanningUpdateProjectCommand(command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected }))); }
export function assertExternalObjectPlanningUpdateProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectPlanningUpdateProjectCommand {
if (command.schemaVersion !== 1 || command.type !== externalObjectPlanningUpdateCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Revit-Objektplanungs-Command.");
const ids = new Set<string>();
for (const transition of command.payload.objects) {
if (!isRecord(transition)) throw new Error("Ungültiger Revit-Objektübergang.");
const expected = parseObject(transition.expected); const target = parseObject(transition.target);
if (expected.id !== target.id || ids.has(expected.id)) throw new Error("Doppelte oder abweichende Revit-Objekt-ID.");
ids.add(expected.id);
if (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte werden über ihre Gerätezeile geplant.");
const left = { ...expected, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
const right = { ...target, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
if (JSON.stringify(left) !== JSON.stringify(right) || JSON.stringify(expected) === JSON.stringify(target)) throw new Error("Der Objektplanungs-Command ändert unzulässige oder keine Felder.");
}
}
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }

View file

@ -0,0 +1,3 @@
import type { ExternalObjectPlanningUpdateProjectCommand } from "../models/external-object-planning-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExternalObjectPlanningProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectPlanningUpdateProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectPlanningUpdateProjectCommand }; }

View file

@ -182,6 +182,8 @@ import {
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
@ -248,7 +250,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
) {}
executeUser(
@ -647,6 +650,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalObjectPlanningUpdateCommandType: {
assertExternalObjectPlanningUpdateProjectCommand(input.command);
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) {

View file

@ -0,0 +1,73 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto } from "../types";
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44;
const viewportHeight = 520;
export function RevitObjectTable({ projectId, onRevision }: { projectId: string; onRevision: (revision: number) => void }) {
const [data, setData] = useState<ExternalProjectObjectsDto | null>(null);
const [search, setSearch] = useState("");
const [boardFilter, setBoardFilter] = useState("all");
const [assignmentFilter, setAssignmentFilter] = useState("all");
const [sort, setSort] = useState<"family" | "room" | "worksharing">("family");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [scrollTop, setScrollTop] = useState(0);
const [bulkBoard, setBulkBoard] = useState("");
const [bulkDevice, setBulkDevice] = useState("");
const [power, setPower] = useState("");
const [gzf, setGzf] = useState("");
const [planningField, setPlanningField] = useState("");
const [planningValue, setPlanningValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
const rows = useMemo(() => {
if (!data) return [];
const needle = search.trim().toLocaleLowerCase("de-DE");
return data.objects.filter((object) => {
if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false;
if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false;
if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false;
return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle));
}).sort((a, b) => {
const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId;
const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId;
return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId);
});
}, [data, search, boardFilter, assignmentFilter, sort]);
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
async function applyBulk() {
if (!data || selected.size === 0) return;
const patch: Record<string, unknown> = {};
if (bulkBoard !== "") patch.distributionBoardId = bulkBoard === "none" ? null : bulkBoard;
if (bulkDevice !== "") patch.linkedProjectDeviceId = bulkDevice === "none" ? null : bulkDevice;
if (power !== "") patch.powerPerUnitW = Number(power.replace(",", "."));
if (gzf !== "") patch.simultaneityFactor = Number(gzf.replace(",", "."));
if (planningField) {
patch[planningField] = ["effectiveQuantity", "cosPhi"].includes(planningField)
? Number(planningValue.replace(",", "."))
: planningValue === "" ? null : planningValue;
}
if (Object.keys(patch).length === 0) { setError("Mindestens eine Sammeländerung auswählen."); return; }
setSaving(true); setError(null);
try { const result = await updateExternalProjectObjects(projectId, data.currentRevision, [...selected], patch); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
catch (value) { setError(value instanceof Error ? value.message : "Sammeländerung fehlgeschlagen."); }
finally { setSaving(false); }
}
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} disabled={linked} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
</div></section>;
}

View file

@ -111,6 +111,21 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto
};
}
export interface ExternalProjectObjectDto {
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
sourceRoomNumber: string; sourceRoomName: string; roomId: string | null;
distributionBoardId: string | null; linkedProjectDeviceId: string | null;
circuitDeviceRowId: string | null; presenceStatus: "present" | "missing";
planningValues: { displayName: string | null; category: CircuitGroupCategory | null; connectionKind: string | null; effectiveQuantity: number; powerPerUnitW: number | null; simultaneityFactor: number | null; cosPhi: number | null; costGroup: string | null; remark: string | null };
}
export interface ExternalProjectObjectsDto {
currentRevision: number; sourceName: string | null;
distributionBoards: Array<{ id: string; name: string }>;
projectDevices: Array<{ id: string; name: string; displayName: string }>;
objects: ExternalProjectObjectDto[];
}
export interface ProjectDto {
id: string;
name: string;

View file

@ -43,6 +43,7 @@ import type {
ExternalInitialImportResultDto,
ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput,
ExternalProjectObjectsDto,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
@ -236,6 +237,14 @@ async function fileToBase64(file: File) {
return btoa(binary);
}
export function getExternalProjectObjects(projectId: string) {
return request<ExternalProjectObjectsDto>(`/api/projects/${projectId}/external-model/objects`);
}
export function updateExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[], patch: Record<string, unknown>) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
}
export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history`

View file

@ -65,6 +65,7 @@ const commandTypeLabels: Record<string, string> = {
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen",
"external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen",
"external-object.update-planning": "Revit-Objekte bearbeitet",
"external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit":

View file

@ -24,6 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -75,6 +76,8 @@ export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectPlanningProjectCommandStore =
new ExternalObjectPlanningProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
@ -111,5 +114,6 @@ export const projectCommandService = new ProjectCommandService(
externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore,
externalInitialImportProjectCommandStore
externalInitialImportProjectCommandStore,
externalObjectPlanningProjectCommandStore
);

View file

@ -0,0 +1,56 @@
import type { Request, Response } from "express";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-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";
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); }
}
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; }

View file

@ -63,6 +63,7 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
export const projectRouter = Router();
@ -90,6 +91,8 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post(