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

@ -339,6 +339,11 @@ external model state atomically; its inverse powers persistent Undo/Redo
without parsing workbook bytes. Apply retransmits and reparses the XLSX, without parsing workbook bytes. Apply retransmits and reparses the XLSX,
verifies its preview SHA-256 and creates no circuit, equipment identifier or verifies its preview SHA-256 and creates no circuit, equipment identifier or
CircuitDeviceRow. CircuitDeviceRow.
R6 adds one compact project-wide external-object GET without pagination or
batch payloads. The Revit page filters and sorts it in the client and renders a
virtualized window. `external-object.update-planning` applies multi-object
board, ProjectDevice and planning-value changes as one persistent command;
linked objects are excluded because they follow their CircuitDeviceRow board.
The target configuration has no independent version counter; `expectedRevision` The target configuration has no independent version counter; `expectedRevision`
invalidates stale plans, and each import batch stores a configuration copy. invalidates stale plans, and each import batch stores a configuration copy.
@ -592,7 +597,7 @@ Critical target rules:
selected combined/separate mode block export. selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation. - Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on - The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 through R5 are complete and R6 is the next package. 2026-09-30; R3 through R6 are complete and R7 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`, - All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary. complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source - Do not add automatic circuit planning, Revit circuit adoption, multi-source

View file

@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R5 sind abgeschlossen; Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen;
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten
Erstimport. Objekttabelle, Editorzuordnung, Rückgabe und Folgeimport folgen in Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen.
R6 bis R10. Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf

View file

@ -14,7 +14,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. | | R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. | | R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | | R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | | R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | | R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
@ -142,6 +142,14 @@ Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen.
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte - Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
Objekte folgen unverändert ihrer Row. Objekte folgen unverändert ihrer Row.
Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert
eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite
sucht, filtert und sortiert vollständig im Client und rendert nur den
sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler,
ProjectDevice, Leistung und GZF in einem persistenten
`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese
Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
## R7 – Stromkreislisteneditor ## R7 – Stromkreislisteneditor
- gruppierter rechter Revit-Bereich mit Anzahl-Badge; - gruppierter rechter Revit-Bereich mit Anzahl-Badge;

View file

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link"; import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; 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 { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration"; import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; 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> <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} {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> <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} {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} {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>; </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, externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js"; } from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.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 { import {
assertExternalObjectRowAssignmentProjectCommand, assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType, externalObjectRowAssignmentCommandType,
@ -248,7 +250,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
) {} ) {}
executeUser( executeUser(
@ -647,6 +650,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command); assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) { 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 { export interface ProjectDto {
id: string; id: string;
name: string; name: string;

View file

@ -43,6 +43,7 @@ import type {
ExternalInitialImportResultDto, ExternalInitialImportResultDto,
ExternalInitialImportStatusDto, ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput, ApplyExternalInitialImportInput,
ExternalProjectObjectsDto,
CreateEstimateBalanceInput, CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto, EstimateBalanceCommandResultDto,
EstimateBalanceDto, EstimateBalanceDto,
@ -236,6 +237,14 @@ async function fileToBase64(file: File) {
return btoa(binary); 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) { export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>( return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history` `/api/projects/${projectId}/history`

View file

@ -65,6 +65,7 @@ const commandTypeLabels: Record<string, string> = {
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet", "external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen", "external-import.apply-initial": "Revit-Erstimport übernommen",
"external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen", "external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen",
"external-object.update-planning": "Revit-Objekte bearbeitet",
"external-object.assign-to-new-circuit": "external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen", "Externes Objekt in neuen Stromkreis übernommen",
"external-object.unassign-and-delete-created-circuit": "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 { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.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 { 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 { 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 { 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"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -75,6 +76,8 @@ export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db); new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore = export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db); new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectPlanningProjectCommandStore =
new ExternalObjectPlanningProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore = export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db); new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore = export const externalObjectNewRowProjectCommandStore =
@ -111,5 +114,6 @@ export const projectCommandService = new ProjectCommandService(
externalObjectNewRowProjectCommandStore, externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore, externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore, 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, getExternalInitialImportStatus,
planExternalInitialImport, planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js"; } from "../controllers/external-initial-import.controller.js";
import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
export const projectRouter = Router(); 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.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); 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.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( projectRouter.post(

View file

@ -5,9 +5,11 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js"; import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js";
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js";
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
@ -62,7 +64,15 @@ describe("Linear XLSX initial import", () => {
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse }); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse });
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.source, null); assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.source, null);
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse }); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse });
assert.deepEqual(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort()); const redone = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
assert.deepEqual(redone.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort());
const expectedObject = redone.objects[0];
const targetObject = { ...expectedObject, planningValues: { ...expectedObject.planningValues, simultaneityFactor: 0.5 }, overriddenFields: [...expectedObject.overriddenFields, "simultaneityFactor" as const] };
const planningRepository = new ExternalObjectPlanningProjectCommandRepository(context.db);
const planned = planningRepository.execute({ projectId: "project-1", expectedRevision: 3, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: expectedObject, target: targetObject }]) });
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 0.5);
planningRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "undo", historyTargetChangeSetId: planned.revision.changeSetId, command: planned.inverse });
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 1);
} finally { context.close(); } } finally { context.close(); }
}); });
}); });

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, planExternalInitialImport, updateExternalModelConfiguration } from "../src/frontend/utils/api.js"; import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => { describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
@ -53,6 +53,7 @@ describe("Revit XLSX settings", () => {
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8"); const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8"); const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8"); const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8");
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/); assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/); assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
assert.match(revitPage, /Importstatus/); assert.match(revitPage, /Importstatus/);
@ -61,5 +62,16 @@ describe("Revit XLSX settings", () => {
assert.match(wizard, /Schritt.*von/); assert.match(wizard, /Schritt.*von/);
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
assert.match(wizard, /GZF 1/); assert.match(wizard, /GZF 1/);
assert.match(objectTable, /viewportHeight/);
assert.match(objectTable, /Sammeländerung anwenden/);
});
it("loads one project object list and sends revision-safe bulk updates", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ currentRevision: 4, sourceName: null, distributionBoards: [], projectDevices: [], objects: [], history: { currentRevision: 5 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
try { await getExternalProjectObjects("project-1"); await updateExternalProjectObjects("project-1", 4, ["object-1"], { simultaneityFactor: 0.7 }); }
finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]);
}); });
}); });