diff --git a/AGENTS.md b/AGENTS.md index e8d4bff..d438bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,6 +333,17 @@ R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and server-side sample detection, and removes the old project-page CSV modal. See `docs/spec/revit-excel-integration-requirements.md` and `docs/spec/revit-excel-phase-14-plan.md`. +R5 adds stateless XLSX preview/planning and the six-step initial-import wizard. +`external-model.apply-initial-import` stores the configuration and complete +external model state atomically; its inverse powers persistent Undo/Redo +without parsing workbook bytes. Apply retransmits and reparses the XLSX, +verifies its preview SHA-256 and creates no circuit, equipment identifier or +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` invalidates stale plans, and each import batch stores a configuration copy. @@ -586,7 +597,7 @@ Critical target rules: selected combined/separate mode block export. - Missing GZF defaults visibly to 1 without separate confirmation. - The corrected R2 return workbook was successfully imported into Linear on - 2026-09-30; R3 and R4 are complete and R5 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`, complete inverse data and the shared project-command transaction boundary. - Do not add automatic circuit planning, Revit circuit adoption, multi-source diff --git a/README.md b/README.md index b70a145..d4aeae4 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind vorhanden. Die Backend- und Persistenzgrundlage der bisherigen Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit -Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen; -die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport, -Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10. +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen; +die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten +Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen. +Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10. ## Unterstützter Arbeitsablauf diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 846debb..4dfa96c 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -643,7 +643,7 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. -## Revit-Anbindung: Linear-XLSX-Grundlage nach R3 +## Revit-Anbindung: Linear-XLSX-Erstimport nach R5 R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014` entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte @@ -663,9 +663,20 @@ CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung abgelehnt. Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser, -seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte -Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch -die Route `/projects/:projectId/revit` ersetzt. +seine Endpunkte und Laufzeitverträge sind entfernt. Die Route +`/projects/:projectId/revit` enthält Einstellungen, Importstatus und den +sechsstufigen Erstimportwizard. + +`POST /api/projects/:projectId/external-model/initial-import/plan` liest eine +lokale XLSX zustandslos, gruppiert Quellräume und exakte Familie-und-Typ-Werte +und liefert die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge. +Unbekannte Familien bleiben blockierend; fehlender GZF wird sichtbar mit 1 +vorbelegt. `apply` erhält die Datei erneut, parst sie erneut und vergleicht den +Vorschau-SHA-256. Der Command `external-model.apply-initial-import` speichert +die dabei verwendete Konfiguration und den vollständigen externen Zustand in +derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder +BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne +Workbookbytes erneut einzulesen oder zu parsen. ### Historische Ausgangslage vor R3 diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 0b1b71a..9b43c4f 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -13,8 +13,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. | | 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. | -| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | -| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | +| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | +| 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. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | @@ -121,6 +121,17 @@ vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests. - ein atomarer Importcommand, keine Row-/Circuit-Erzeugung; - Undo/Redo aus Commandzuständen ohne Workbook-Parsing. +Implementierungsstand vom 30.09.2026: Vorschau und Apply verwenden die +projektweite XLSX-Konfiguration; Apply überträgt und parst die Datei erneut und +vergleicht den SHA-256 der Vorschau. Der Wizard führt über Vorschau, Räume, +Standardverteiler einschließlich Objekten ohne Raum, exakte Familienregeln, +optionale ProjectDevices und Bestätigung. Unbekannte Familien können als +Geräteart mit stabilen Client-UUIDs und sichtbarem GZF-Startwert 1 klassifiziert +werden. `external-model.apply-initial-import` schreibt Konfiguration, Quelle, +vollständigen letzten Batch, Raumzuordnungen und Objekte in einer Revision; +alle `circuitDeviceRowId` bleiben `null`. Seine Inverse speichert beide +Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen. + ## R6 – Objekttabelle und Sammelaktionen - kompakte Objektliste einmal laden; @@ -131,6 +142,14 @@ vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests. - Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte 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 - gruppierter rechter Revit-Bereich mit Anzahl-Badge; diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index 96af41c..fc8b85e 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -1,34 +1,51 @@ "use client"; -import { ArrowLeft, FileSpreadsheet, Settings } from "lucide-react"; +import { ArrowLeft, FileSpreadsheet, Settings, Upload } from "lucide-react"; 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, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; +import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings"; -import type { ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types"; +import type { ExternalInitialImportStatusDto, ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types"; export default function RevitPage() { const { projectId } = useParams<{ projectId: string }>(); const [project, setProject] = useState(null); const [snapshot, setSnapshot] = useState(null); - const [open, setOpen] = useState(false); + const [importStatus, setImportStatus] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - useEffect(() => { Promise.all([getProject(projectId), getExternalModelConfiguration(projectId)]).then(([loadedProject, result]) => { setProject(loadedProject); setSnapshot(result.configuration); }).catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); }, [projectId]); + + useEffect(() => { + Promise.all([getProject(projectId), getExternalModelConfiguration(projectId), getExternalInitialImportStatus(projectId)]) + .then(([loadedProject, configuration, status]) => { setProject(loadedProject); setSnapshot(configuration.configuration); setImportStatus(status); }) + .catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); + }, [projectId]); + async function save(configuration: ExternalModelConfiguration) { if (!project) return; setSaving(true); setError(null); - try { const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); setSnapshot(result.configuration); setProject({ ...project, currentRevision: result.history.currentRevision }); setOpen(false); } - catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); } + try { + const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); + setSnapshot(result.configuration); + setProject({ ...project, currentRevision: result.history.currentRevision }); + setSettingsOpen(false); + } catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); } finally { setSaving(false); } } + return
-
Projekt · Revit

{project?.name ?? "Revit-Anbindung"}

Linear-XLSX austauschen und Revit-Objekte verwalten

+
Projekt · Revit

{project?.name ?? "Revit-Anbindung"}

Linear-XLSX austauschen und Revit-Objekte verwalten

{error ?
{error}
: null} -

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

Noch kein Linear-XLSX-Import vorhanden. Der Erstimport folgt in R5.

- {open ? setOpen(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} +

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

{importStatus?.imported ? <>

{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}

Quelle: {importStatus.sourceName}

:

Noch kein Linear-XLSX-Import vorhanden.

}
+ {importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} + {settingsOpen ? 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 ? 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}
; } diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts new file mode 100644 index 0000000..70a249b --- /dev/null +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { + assertExternalInitialImportProjectCommand, + createExternalInitialImportProjectCommand, + isEmptyExternalModelState, +} from "../../domain/models/external-initial-import-project-command.model.js"; +import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js"; +import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { AppDatabase } from "../database-context.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { projects } from "../schema/projects.js"; +import { rooms } from "../schema/rooms.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { ExternalModelStateRepository } from "./external-model-state.repository.js"; + +export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore { + constructor(private readonly database: AppDatabase) {} + + execute(input: Parameters[0]) { + assertExternalInitialImportProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId); + if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden."); + const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; + const payload = input.command.payload; + if (!same(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) { + throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); + } + if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState); + replaceConfiguration(tx, input.projectId, payload.targetConfiguration); + replaceState(tx, input.projectId, payload.targetState); + const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state; + const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; + if (!same(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab."); + return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state); + }); + } +} + +function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) { + if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt."); + const batch = state.importBatches[0]; + if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig."); + if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig."); + const bytes = decodeCanonicalBase64(batch.originalContentBase64!); + if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig."); + const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all()); + const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all()); + const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all()); + const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id)); + for (const mapping of state.roomMappings) { + if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); + if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); + } + for (const object of state.objects) { + if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); + if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); + if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); + } +} + +function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) { + database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); + if (target) database.insert(externalModelConfigurations).values(target).run(); +} + +function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { + database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); + if (!target.source) return; + database.insert(externalModelSources).values(target.source).run(); + database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run(); + if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run(); + if (target.objects.length) database.insert(externalModelObjects).values(target.objects).run(); +} + +function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; } +function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); } +function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); } +function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); } diff --git a/src/db/repositories/external-object-planning-project-command.repository.ts b/src/db/repositories/external-object-planning-project-command.repository.ts new file mode 100644 index 0000000..e6b3b1f --- /dev/null +++ b/src/db/repositories/external-object-planning-project-command.repository.ts @@ -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[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); + }); + } +} diff --git a/src/domain/models/external-initial-import-project-command.model.ts b/src/domain/models/external-initial-import-project-command.model.ts new file mode 100644 index 0000000..c63ae43 --- /dev/null +++ b/src/domain/models/external-initial-import-project-command.model.ts @@ -0,0 +1,105 @@ +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; +import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const externalInitialImportCommandType = "external-model.apply-initial-import" as const; +export const externalInitialImportCommandSchemaVersion = 1 as const; + +export interface ExternalInitialImportPayload { + expectedConfiguration: ExternalModelConfigurationSnapshot | null; + targetConfiguration: ExternalModelConfigurationSnapshot | null; + expectedState: ExternalModelStateSnapshot; + targetState: ExternalModelStateSnapshot; +} + +export interface ExternalInitialImportProjectCommand extends SerializedProjectCommand { + schemaVersion: typeof externalInitialImportCommandSchemaVersion; + type: typeof externalInitialImportCommandType; +} + +export function createExternalInitialImportProjectCommand( + expectedConfiguration: ExternalModelConfigurationSnapshot | null, + targetConfiguration: ExternalModelConfigurationSnapshot | null, + expectedState: ExternalModelStateSnapshot, + targetState: ExternalModelStateSnapshot +): ExternalInitialImportProjectCommand { + const command: ExternalInitialImportProjectCommand = { + schemaVersion: externalInitialImportCommandSchemaVersion, + type: externalInitialImportCommandType, + payload: { expectedConfiguration, targetConfiguration, expectedState, targetState }, + }; + assertExternalInitialImportProjectCommand(command); + return command; +} + +export function assertExternalInitialImportProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalInitialImportProjectCommand { + if (command.schemaVersion !== 1 || command.type !== externalInitialImportCommandType || !isRecord(command.payload)) { + throw new Error("Nicht unterstützter Revit-Erstimport-Command."); + } + const keys = Object.keys(command.payload).sort().join(","); + if (keys !== "expectedConfiguration,expectedState,targetConfiguration,targetState") { + throw new Error("Der Revit-Erstimport-Command ist unvollständig."); + } + assertConfigurationSnapshot(command.payload.expectedConfiguration); + assertConfigurationSnapshot(command.payload.targetConfiguration); + const expected = parseExternalModelStateSnapshot(command.payload.expectedState); + const target = parseExternalModelStateSnapshot(command.payload.targetState); + if (isEmptyExternalModelState(expected) === isEmptyExternalModelState(target)) { + throw new Error("Der Erstimport muss zwischen leerem und gefülltem Modellzustand wechseln."); + } + assertPopulatedInitialState(isEmptyExternalModelState(expected) ? target : expected); +} + +export function createEmptyExternalModelState(): ExternalModelStateSnapshot { + return { source: null, importBatches: [], roomMappings: [], objects: [] }; +} + +export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) { + return state.source === null && state.importBatches.length === 0 && state.roomMappings.length === 0 && state.objects.length === 0; +} + +function assertConfigurationSnapshot(value: unknown): asserts value is ExternalModelConfigurationSnapshot | null { + if (value === null) return; + if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") { + throw new Error("Ungültiger Konfigurationszustand im Revit-Erstimport."); + } + assertExternalModelConfiguration(value.configuration); +} + +function assertPopulatedInitialState(state: ExternalModelStateSnapshot) { + if (!state.source || state.source.sourceType !== "revit_xlsx" || state.importBatches.length !== 1 || state.objects.length === 0) { + throw new Error("Der Erstimport benötigt eine Quelle, einen Batch und mindestens ein Objekt."); + } + const source = state.source; + const batch = state.importBatches[0]; + if (batch.importKind !== "initial" || batch.projectId !== source.projectId || batch.sourceId !== source.id || batch.configurationSnapshot === null || batch.originalContentBase64 === null || batch.document === null || batch.objectCount !== state.objects.length) { + throw new Error("Der Erstimport-Batch ist unvollständig."); + } + const mappingIds = uniqueIds(state.roomMappings); + const roomKeys = new Set(); + for (const mapping of state.roomMappings) { + if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id || roomKeys.has(mapping.normalizedSourceRoomKey)) throw new Error("Ungültige oder doppelte Quellraumzuordnung."); + roomKeys.add(mapping.normalizedSourceRoomKey); + } + uniqueIds(state.objects); + const worksharingIds = new Set(); + for (const object of state.objects) { + if (object.projectId !== source.projectId || object.sourceId !== source.id || object.lastSeenImportBatchId !== batch.id || object.lastAcceptedImportBatchId !== batch.id) throw new Error("Ungültige Objektidentität im Erstimport."); + if (worksharingIds.has(object.worksharingId)) throw new Error("Doppelte Worksharing-ID im Erstimport."); + worksharingIds.add(object.worksharingId); + if (object.externalRoomMappingId !== null && !mappingIds.has(object.externalRoomMappingId)) throw new Error("Ungültige Quellraumzuordnung am Revit-Objekt."); + if (object.circuitDeviceRowId !== null) throw new Error("Der Erstimport darf keine Gerätezeile zuordnen."); + if (object.presenceStatus !== "present") throw new Error("Erstimportobjekte müssen vorhanden sein."); + } +} + +function uniqueIds(entries: ReadonlyArray<{ id: string }>) { + const ids = new Set(); + for (const entry of entries) { if (ids.has(entry.id)) throw new Error("Doppelte ID im Revit-Erstimport."); ids.add(entry.id); } + return ids; +} +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/domain/models/external-object-planning-project-command.model.ts b/src/domain/models/external-object-planning-project-command.model.ts new file mode 100644 index 0000000..f31d126 --- /dev/null +++ b/src/domain/models/external-object-planning-project-command.model.ts @@ -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): 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(); + 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 { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/domain/ports/external-initial-import-project-command.store.ts b/src/domain/ports/external-initial-import-project-command.store.ts new file mode 100644 index 0000000..411d3c3 --- /dev/null +++ b/src/domain/ports/external-initial-import-project-command.store.ts @@ -0,0 +1,15 @@ +import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalInitialImportCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalInitialImportProjectCommand; +} +export interface ExternalInitialImportProjectCommandStore { + execute(input: ExecuteExternalInitialImportCommandInput): { revision: AppendedProjectRevision; inverse: ExternalInitialImportProjectCommand }; +} diff --git a/src/domain/ports/external-object-planning-project-command.store.ts b/src/domain/ports/external-object-planning-project-command.store.ts new file mode 100644 index 0000000..19c610b --- /dev/null +++ b/src/domain/ports/external-object-planning-project-command.store.ts @@ -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 }; } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 603a4be..ca99c5f 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -177,6 +177,13 @@ import { externalModelConfigurationUpdateCommandType, } from "../models/external-model-configuration-project-command.model.js"; import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js"; +import { + assertExternalInitialImportProjectCommand, + 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, @@ -242,7 +249,9 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, - private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore + private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, + private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, + private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore ) {} executeUser( @@ -631,6 +640,21 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalInitialImportCommandType: { + assertExternalInitialImportProjectCommand(input.command); + if (!this.externalInitialImportStore) { + throw new Error("External initial import store is not available."); + } + return this.externalInitialImportStore.execute({ + ...input, + 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) { diff --git a/src/external-model/application/external-xlsx-initial-import-target.ts b/src/external-model/application/external-xlsx-initial-import-target.ts new file mode 100644 index 0000000..8a7fd27 --- /dev/null +++ b/src/external-model/application/external-xlsx-initial-import-target.ts @@ -0,0 +1,62 @@ +import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js"; +import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js"; +import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js"; +import type { ExternalInitialImportPlan } from "./external-xlsx-initial-import.js"; + +export interface ExternalRoomDecision { sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null; } +export interface ExternalFamilyProjectDeviceDecision { familyAndType: string; projectDeviceId: string | null; } + +export function buildExternalXlsxInitialImportTarget(input: { + projectId: string; + expectedRevision: number; + sourceName: string; + importedAtIso: string; + configuration: ExternalModelConfiguration; + originalContentBase64: string; + document: ExternalXlsxDocument; + plan: ExternalInitialImportPlan; + roomDecisions: ExternalRoomDecision[]; + familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[]; + objectsWithoutRoomDefaultDistributionBoardId: string | null; + createId: () => string; +}): ExternalModelStateSnapshot { + if (input.plan.familyGroups.some((group) => !group.classified) || input.plan.issueCounts.invalidQuantity > 0) { + throw new Error("Alle Familien müssen klassifiziert sein und gültige Mengen besitzen."); + } + const roomDecisions = exactDecisionMap(input.plan.sourceRooms.map((room) => room.sourceRoomKey), input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum"); + const familyDecisions = exactDecisionMap(input.plan.familyGroups.map((group) => group.familyAndType), input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ"); + const sourceId = input.createId(); + const batchId = input.createId(); + const mappingIds = new Map(input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()])); + const mappings = input.plan.sourceRooms.map((room) => { + const decision = roomDecisions.get(room.sourceRoomKey)!; + return { id: mappingIds.get(room.sourceRoomKey)!, projectId: input.projectId, sourceId, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId }; + }); + const mappingByKey = new Map(mappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping])); + const objects = input.plan.objects.map((object) => { + const roomMapping = object.sourceRoomKey === null ? null : mappingByKey.get(object.sourceRoomKey)!; + const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === object.sourceValues.familyAndType)!; + return { + id: input.createId(), projectId: input.projectId, sourceId, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid, + deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId, + acceptedSourceValues: object.sourceValues, planningValues: object.suggestedPlanningValues, overriddenFields: [], + externalRoomMappingId: roomMapping?.id ?? null, + distributionBoardId: roomMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId, + linkedProjectDeviceId: familyDecisions.get(object.sourceValues.familyAndType)!.projectDeviceId, + circuitDeviceRowId: null, presenceStatus: "present" as const, + }; + }); + return { + source: { id: sourceId, projectId: input.projectId, name: input.sourceName.trim(), sourceType: "revit_xlsx" }, + importBatches: [{ id: batchId, projectId: input.projectId, sourceId, importKind: "initial", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: objects.length, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }], + roomMappings: mappings, + objects, + }; +} + +function exactDecisionMap(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { + const expected = new Set(expectedKeys); const result = new Map(); + for (const decision of decisions) { const key = keyOf(decision); if (!expected.has(key) || result.has(key)) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); result.set(key, decision); } + if (result.size !== expected.size) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); + return result; +} diff --git a/src/external-model/application/external-xlsx-initial-import.ts b/src/external-model/application/external-xlsx-initial-import.ts new file mode 100644 index 0000000..f89403c --- /dev/null +++ b/src/external-model/application/external-xlsx-initial-import.ts @@ -0,0 +1,246 @@ +import { createHash } from "node:crypto"; +import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js"; +import type { + ExternalModelObjectPlanningValues, + ExternalModelObjectSourceValues, +} from "../domain/external-model-contracts.js"; +import type { + ExternalXlsxDocument, + ExternalXlsxRow, + ExternalXlsxScalar, +} from "../xlsx/external-xlsx-contracts.js"; +import { parseExternalXlsx } from "../xlsx/external-xlsx-transport.js"; + +export interface ExternalInitialImportCatalogs { + floors: Array<{ id: string; name: string; sortOrder: number }>; + rooms: Array<{ id: string; floorId: string | null; roomNumber: string; roomName: string }>; + distributionBoards: Array<{ id: string; name: string; floorId: string | null }>; + projectDevices: Array<{ + id: string; + name: string; + displayName: string; + category: string | null; + connectionKind: string | null; + powerPerUnit: number; + }>; +} + +export interface ExternalInitialImportProjectedObject { + worksharingId: string; + ifcGuid: string | null; + sourceRoomKey: string | null; + sourceValues: ExternalModelObjectSourceValues; + suggestedPlanningValues: ExternalModelObjectPlanningValues; + issues: string[]; +} + +export interface ExternalInitialImportPlan { + fileName: string; + sha256: string; + byteLength: number; + rowCount: number; + objectCount: number; + passthroughCount: number; + suspectObjectCount: number; + suspectRowNumbers: number[]; + warnings: string[]; + sourceRooms: Array<{ + sourceRoomKey: string; + roomNumber: string; + roomName: string; + objectCount: number; + suggestedRoomId: string | null; + matchStatus: "exact-number" | "ambiguous-number" | "new"; + candidateRoomIds: string[]; + }>; + familyGroups: Array<{ + familyAndType: string; + objectCount: number; + classified: boolean; + deviceTypeId: string | null; + deviceTypeName: string | null; + quantityRule: "one" | "socketCount" | null; + }>; + issueCounts: { + unknownFamilyAndType: number; + invalidPower: number; + invalidQuantity: number; + missingRoom: number; + }; + objects: ExternalInitialImportProjectedObject[]; + existing: ExternalInitialImportCatalogs; +} + +export async function createExternalXlsxInitialImportPlan(input: { + fileName: string; + bytes: Uint8Array; + configuration: ExternalModelConfiguration; + catalogs: ExternalInitialImportCatalogs; +}): Promise<{ plan: ExternalInitialImportPlan; document: ExternalXlsxDocument }> { + const document = await parseExternalXlsx(input); + const columns = resolveColumns(document, input.configuration); + const rules = new Map(input.configuration.familyTypeRules.map((rule) => [rule.exactFamilyAndType, rule])); + const deviceTypes = new Map(input.configuration.deviceTypes.map((type) => [type.id, type])); + const objects = document.rows + .filter((row) => row.classification === "object") + .map((row) => projectObject(row, columns, rules, deviceTypes)); + + const roomCandidates = new Map(); + for (const room of input.catalogs.rooms) { + const key = normalizeRoomPart(room.roomNumber); + if (!key) continue; + const entries = roomCandidates.get(key) ?? []; + entries.push(room); + roomCandidates.set(key, entries); + } + const sourceRoomMap = new Map(); + for (const object of objects) { + if (object.sourceRoomKey === null) continue; + const existing = sourceRoomMap.get(object.sourceRoomKey); + if (existing) existing.objectCount += 1; + else sourceRoomMap.set(object.sourceRoomKey, { + sourceRoomKey: object.sourceRoomKey, + roomNumber: scalarText(object.sourceValues.roomNumber), + roomName: scalarText(object.sourceValues.roomName), + objectCount: 1, + }); + } + const sourceRooms = [...sourceRoomMap.values()].map((room) => { + const candidates = roomCandidates.get(normalizeRoomPart(room.roomNumber)) ?? []; + return { + ...room, + suggestedRoomId: candidates.length === 1 ? candidates[0].id : null, + matchStatus: candidates.length === 1 ? "exact-number" as const : candidates.length > 1 ? "ambiguous-number" as const : "new" as const, + candidateRoomIds: candidates.map((candidate) => candidate.id), + }; + }).sort((a, b) => a.roomNumber.localeCompare(b.roomNumber, "de", { numeric: true }) || a.roomName.localeCompare(b.roomName, "de")); + + const familyMap = new Map(); + for (const object of objects) { + const name = object.sourceValues.familyAndType; + const existing = familyMap.get(name); + if (existing) { existing.objectCount += 1; continue; } + const rule = rules.get(name); + const type = rule ? deviceTypes.get(rule.deviceTypeId) : undefined; + familyMap.set(name, { + familyAndType: name, + objectCount: 1, + classified: Boolean(rule && type), + deviceTypeId: type?.id ?? null, + deviceTypeName: type?.name ?? null, + quantityRule: rule?.quantityRule ?? null, + }); + } + const countIssue = (issue: string) => objects.filter((object) => object.issues.includes(issue)).length; + return { + document, + plan: { + fileName: input.fileName, + sha256: createHash("sha256").update(input.bytes).digest("hex"), + byteLength: input.bytes.byteLength, + rowCount: document.rows.length, + objectCount: objects.length, + passthroughCount: document.rows.filter((row) => row.classification === "passthrough").length, + suspectObjectCount: document.rows.filter((row) => row.classification === "suspect-object").length, + suspectRowNumbers: document.rows.filter((row) => row.classification === "suspect-object").map((row) => row.rowNumber), + warnings: document.issues.map((issue) => issue.message), + sourceRooms, + familyGroups: [...familyMap.values()].sort((a, b) => a.familyAndType.localeCompare(b.familyAndType, "de")), + issueCounts: { + unknownFamilyAndType: countIssue("unknown-family-and-type"), + invalidPower: countIssue("invalid-power"), + invalidQuantity: countIssue("invalid-quantity"), + missingRoom: countIssue("missing-room"), + }, + objects, + existing: input.catalogs, + }, + }; +} + +function resolveColumns(document: ExternalXlsxDocument, configuration: ExternalModelConfiguration) { + const byHeader = new Map(document.columns.map((column) => [column.header, column.columnNumber])); + return Object.fromEntries(Object.entries(configuration.columns).map(([key, header]) => [key, header === null ? null : byHeader.get(header) ?? null])) as Record; +} + +function projectObject( + row: ExternalXlsxRow, + columns: Record, + rules: Map, + deviceTypes: Map +): ExternalInitialImportProjectedObject { + const value = (key: keyof typeof columns): ExternalXlsxScalar => { + const column = columns[key]; + return column === null ? null : row.cells[column - 1]?.value ?? null; + }; + const worksharingId = scalarText(value("worksharingId")); + const familyAndType = scalarText(value("familyAndType")); + const roomNumber = value("roomNumber"); + const roomName = value("roomName"); + const rule = rules.get(familyAndType); + const deviceType = rule ? deviceTypes.get(rule.deviceTypeId) : undefined; + const issues: string[] = []; + if (!rule || !deviceType) issues.push("unknown-family-and-type"); + const sourcePower = nullableNumber(value("electricalPowerW")); + if (value("electricalPowerW") !== null && sourcePower === null) issues.push("invalid-power"); + let quantity = 1; + if (rule?.quantityRule === "socketCount") { + const parsed = nullableNumber(value("socketCount")); + if (parsed === null || !Number.isSafeInteger(parsed) || parsed < 1) issues.push("invalid-quantity"); + else quantity = parsed; + } + const sourceRoomKey = createExternalRoomKey(roomNumber, roomName); + if (sourceRoomKey === null) issues.push("missing-room"); + const selectionMarker = value("selectionMarker"); + const designation = value("designation"); + const displayName = scalarText(designation) || scalarText(selectionMarker) || familyAndType || null; + return { + worksharingId, + ifcGuid: scalarText(value("ifcGuid")) || null, + sourceRoomKey, + sourceValues: { + rowNumber: row.rowNumber, + roomNumber, + roomName, + familyAndType, + selectionMarker, + socketCount: value("socketCount"), + sourceRemark: value("sourceRemark"), + circuitCombined: value("circuitCombined"), + boardName: value("boardName"), + circuitIdentifier: value("circuitIdentifier"), + designation, + electricalPowerW: value("electricalPowerW"), + voltageV: value("voltageV"), + currentA: value("currentA"), + }, + suggestedPlanningValues: { + displayName, + category: deviceType?.category ?? null, + connectionKind: null, + effectiveQuantity: quantity, + powerPerUnitW: sourcePower ?? deviceType?.defaults.powerPerUnitW ?? null, + simultaneityFactor: deviceType?.defaults.simultaneityFactor ?? 1, + cosPhi: deviceType?.defaults.cosPhi ?? null, + costGroup: deviceType?.defaults.costGroup ?? null, + remark: scalarText(value("sourceRemark")) || null, + }, + issues, + }; +} + +export function createExternalRoomKey(roomNumber: ExternalXlsxScalar, roomName: ExternalXlsxScalar) { + const number = normalizeRoomPart(scalarText(roomNumber)); + const name = normalizeRoomPart(scalarText(roomName)); + return number || name ? `${number}\u0000${name}` : null; +} + +function normalizeRoomPart(value: string) { return value.trim().toLocaleLowerCase("de-DE"); } +function scalarText(value: ExternalXlsxScalar) { return value === null ? "" : String(value).trim(); } +function nullableNumber(value: ExternalXlsxScalar) { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value !== "string" || !value.trim()) return null; + const normalized = value.trim().replace(/\s/g, "").replace(/,(?=\d+$)/, "."); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/src/frontend/components/revit-initial-import-wizard.tsx b/src/frontend/components/revit-initial-import-wizard.tsx new file mode 100644 index 0000000..8a4fc4d --- /dev/null +++ b/src/frontend/components/revit-initial-import-wizard.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration"; +import type { ExternalInitialImportPlanDto, ProjectHistoryStateDto } from "../types"; +import { applyExternalInitialImport, planExternalInitialImport } from "../utils/api"; +import { createClientId } from "../utils/client-id"; +import { FormModal } from "./form-modal"; + +const steps = ["Vorschau", "Räume", "Verteiler", "Familienregeln", "Projektgeräte", "Bestätigung"]; + +export function RevitInitialImportWizard({ projectId, expectedRevision, initialConfiguration, onClose, onImported }: { + projectId: string; + expectedRevision: number; + initialConfiguration: ExternalModelConfiguration; + onClose: () => void; + onImported: (result: { history: ProjectHistoryStateDto; objectCount: number }) => void; +}) { + const [step, setStep] = useState(0); + const [file, setFile] = useState(null); + const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration)); + const [plan, setPlan] = useState(null); + const [roomIds, setRoomIds] = useState>( {} ); + const [boardIds, setBoardIds] = useState>( {} ); + const [deviceIds, setDeviceIds] = useState>( {} ); + const [sourceName, setSourceName] = useState("Revit-Gesamtmodell"); + const [noRoomBoardId, setNoRoomBoardId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function loadPlan(nextFile: File, nextConfiguration = configuration) { + setBusy(true); setError(null); + try { + const loaded = await planExternalInitialImport(projectId, nextFile, nextConfiguration, expectedRevision); + setPlan(loaded); + setRoomIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? room.suggestedRoomId ?? ""]))); + setBoardIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? ""]))); + setDeviceIds((current) => Object.fromEntries(loaded.familyGroups.map((family) => [family.familyAndType, current[family.familyAndType] ?? ""]))); + } catch (value) { setError(value instanceof Error ? value.message : "XLSX-Vorschau fehlgeschlagen."); setPlan(null); } + finally { setBusy(false); } + } + + async function classifyFamily(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount") { + if (!file) return; + const deviceTypeId = createClientId(); + const next = structuredClone(configuration); + next.deviceTypes.push({ id: deviceTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } }); + next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule }); + setConfiguration(next); + await loadPlan(file, next); + } + + async function assignExistingDeviceType(familyAndType: string, encoded: string) { + if (!file || !encoded) return; + const separator = encoded.lastIndexOf("|"); + const deviceTypeId = encoded.slice(0, separator); + const quantityRule = encoded.slice(separator + 1) as "one" | "socketCount"; + const next = structuredClone(configuration); + next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule }); + setConfiguration(next); + await loadPlan(file, next); + } + + async function toggleQuantityRule(familyAndType: string) { + if (!file) return; + const next = structuredClone(configuration); + const rule = next.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === familyAndType); + if (!rule) return; + rule.quantityRule = rule.quantityRule === "one" ? "socketCount" : "one"; + setConfiguration(next); + await loadPlan(file, next); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (step < steps.length - 1) { setStep(step + 1); return; } + if (!file || !plan) return; + setBusy(true); setError(null); + try { + const result = await applyExternalInitialImport(projectId, file, { + expectedRevision, + expectedSha256: plan.sha256, + sourceName, + configuration, + objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null, + roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: roomIds[room.sourceRoomKey] || null, defaultDistributionBoardId: boardIds[room.sourceRoomKey] || null })), + familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: deviceIds[family.familyAndType] || null })), + }); + onImported({ history: result.history, objectCount: result.import.objectCount }); + } catch (value) { setError(value instanceof Error ? value.message : "Erstimport fehlgeschlagen."); } + finally { setBusy(false); } + } + + const blocked = !plan || (step >= 3 && (plan.issueCounts.unknownFamilyAndType > 0 || plan.issueCounts.invalidQuantity > 0)); + return void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Erstimport übernehmen" : "Weiter"}> +
{steps.map((label, index) => {index + 1}. {label})}
+ {error ?
{error}
: null} + {step === 0 ?
{ const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} />
{plan ? <>
{plan.objectCount}
Objekte
{plan.sourceRooms.length}
Quellräume
{plan.familyGroups.length}
Familien
{plan.suspectObjectCount}
übersprungene verdächtige Zeilen
Unbekannte Familien: {plan.issueCounts.unknownFamilyAndType} · Ungültige Leistungen: {plan.issueCounts.invalidPower} · Ungültige Mengen: {plan.issueCounts.invalidQuantity} · Objekte ohne Raum: {plan.issueCounts.missingRoom}
{plan.warnings.length ?
{plan.warnings.map((warning) =>
{warning}
)}
: null} : null}
: null} + {step === 1 && plan ?

Quellräume vorhandenen Projekträumen zuordnen. Neue Räume können vorher in der Gebäudestruktur angelegt werden.

{plan.sourceRooms.map((room) =>
{room.roomNumber || "–"} {room.roomName} {room.objectCount}
)}
: null} + {step === 2 && plan ?

Der Standardverteiler gilt beim Import nur für unzugeordnete Objekte.

{plan.sourceRooms.map((room) =>
{room.roomNumber || "–"} · {room.roomName}
)}{plan.issueCounts.missingRoom > 0 ?
Objekte ohne Quellraum {plan.issueCounts.missingRoom}
: null}
: null} + {step === 3 && plan ?

Exakte Familie-und-Typ-Werte werden einer Geräteart und Mengenregel zugeordnet. Ein fehlender GZF startet sichtbar mit 1.

{plan.familyGroups.map((family) =>
{family.familyAndType} {family.objectCount}
{family.classified ? `${family.deviceTypeName} · ${family.quantityRule === "socketCount" ? "Anzahl aus XLSX" : "Menge 1"} · GZF 1, falls nicht gepflegt` : "Noch nicht klassifiziert"}
{family.classified ? :
{configuration.deviceTypes.length ? : null}
}
)}
: null} + {step === 4 && plan ?

Optional kann jede Familie mit einem vorhandenen Projektgerät verknüpft werden.

{plan.familyGroups.map((family) =>
{family.familyAndType}
)}
: null} + {step === 5 && plan ?
setSourceName(event.target.value)} />
{plan.objectCount} Revit-Objekte werden importiert. Es werden keine Gerätezeilen, Stromkreise oder BMKs erzeugt. Die Datei wird beim Übernehmen erneut übertragen und geprüft.
: null} + {step > 0 ? : null} +
; +} diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx new file mode 100644 index 0000000..c82d9c7 --- /dev/null +++ b/src/frontend/components/revit-object-table.tsx @@ -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(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>(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(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 = {}; + 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
Revit-Objekte werden geladen …
; + return

Revit-Objekte

{rows.length} von {data.objects.length} · {selected.size} ausgewählt
setSearch(event.target.value)} />
+ {error ?
{error}
: null} +
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType}
{object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{linked ? "verknüpft" : "frei"}
; })}
+
{planningField ? : null}
+
; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 401b618..49466f5 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -10,10 +10,6 @@ import type { ProtectionDeviceType, RcdType, } from "../shared/constants/protection-device"; -import type { - ExternalCsvConfiguration, - ExternalCsvDialect, -} from "../external-model/csv/external-csv-contracts"; import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration"; export interface ExternalModelConfigurationSnapshotDto { @@ -22,44 +18,7 @@ export interface ExternalModelConfigurationSnapshotDto { configuration: ExternalModelConfiguration; } -export interface ExternalCsvConfigurationSnapshotDto { - id: string; - projectId: string; - configurationVersion: number; - configuration: ExternalCsvConfiguration; -} - -export interface ExternalCsvPreviewObjectDto { - rowNumber: number; - ifcGuid: string; - roomNumber: string; - roomName: string; - familyAndType: string; - selectionMarker: string; - circuitIdentifier: string; - power: string; - quantity: string | null; - additionalSourceValues: Record; -} - -export interface ExternalCsvPreviewDto { - fileName: string; - byteLength: number; - sha256: string; - dialect: ExternalCsvDialect; - headerRowNumber: number; - headerColumns: string[]; - rowCount: number; - objectCount: number; - passthroughCount: number; - nonEmptyPassthroughCount: number; - suspectObjectCount: number; - objects: ExternalCsvPreviewObjectDto[]; - suspectRowNumbers: number[]; -} - export interface ExternalInitialImportPlanDto { - configurationVersion: number; fileName: string; sha256: string; byteLength: number; @@ -68,6 +27,7 @@ export interface ExternalInitialImportPlanDto { passthroughCount: number; suspectObjectCount: number; suspectRowNumbers: number[]; + warnings: string[]; sourceRooms: Array<{ sourceRoomKey: string; roomNumber: string; @@ -81,9 +41,9 @@ export interface ExternalInitialImportPlanDto { familyAndType: string; objectCount: number; classified: boolean; - category: string | null; - connectionKind: string | null; - internalDeviceType: string | null; + deviceTypeId: string | null; + deviceTypeName: string | null; + quantityRule: "one" | "socketCount" | null; }>; issueCounts: { unknownFamilyAndType: number; @@ -91,24 +51,7 @@ export interface ExternalInitialImportPlanDto { invalidQuantity: number; missingRoom: number; }; - objects: Array<{ - ifcGuid: string; - sourceRoomKey: string | null; - sourceValues: ExternalCsvPreviewObjectDto; - suggestedPlanningValues: { - displayName: string | null; - internalDeviceType: string | null; - category: CircuitGroupCategory | null; - connectionKind: string | null; - effectiveQuantity: number; - powerPerUnitW: number | null; - simultaneityFactor: number; - cosPhi: number | null; - costGroup: string | null; - remark: string | null; - }; - issues: string[]; - }>; + objects: Array<{ worksharingId: string; ifcGuid: string | null; sourceRoomKey: string | null; sourceValues: { familyAndType: string }; suggestedPlanningValues: { simultaneityFactor: number; effectiveQuantity: number }; issues: string[] }>; existing: { floors: Array<{ id: string; name: string; sortOrder: number }>; rooms: Array<{ @@ -135,11 +78,12 @@ export interface ExternalInitialImportPlanDto { export interface ApplyExternalInitialImportInput { expectedRevision: number; - expectedConfigurationVersion: number; expectedSha256: string; fileName: string; contentBase64: string; sourceName: string; + configuration: ExternalModelConfiguration; + objectsWithoutRoomDefaultDistributionBoardId: string | null; roomDecisions: Array<{ sourceRoomKey: string; roomId: string | null; @@ -151,6 +95,14 @@ export interface ApplyExternalInitialImportInput { }>; } +export interface ExternalInitialImportStatusDto { + imported: boolean; + sourceName: string | null; + objectCount: number; + importedAtIso: string | null; + fileName: string | null; +} + export interface ExternalInitialImportResultDto extends ProjectCommandResultDto { import: { sourceId: string; @@ -159,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; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 3694c2e..c46f80b 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -39,6 +39,11 @@ import type { CircuitTreeResponseDto, ProjectCircuitTreesResponseDto, ExternalModelConfigurationSnapshotDto, + ExternalInitialImportPlanDto, + ExternalInitialImportResultDto, + ExternalInitialImportStatusDto, + ApplyExternalInitialImportInput, + ExternalProjectObjectsDto, CreateEstimateBalanceInput, EstimateBalanceCommandResultDto, EstimateBalanceDto, @@ -198,6 +203,48 @@ export async function detectExternalModelWorkbook( ); } +export function getExternalInitialImportStatus(projectId: string) { + return request(`/api/projects/${projectId}/external-model/import-status`); +} + +export async function planExternalInitialImport( + projectId: string, + file: File, + configuration: ExternalModelConfiguration, + expectedRevision: number +) { + return request(`/api/projects/${projectId}/external-model/initial-import/plan`, { + method: "POST", + body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }), + }); +} + +export async function applyExternalInitialImport( + projectId: string, + file: File, + input: Omit +) { + return request(`/api/projects/${projectId}/external-model/initial-import/apply`, { + method: "POST", + body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }), + }); +} + +async function fileToBase64(file: File) { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +export function getExternalProjectObjects(projectId: string) { + return request(`/api/projects/${projectId}/external-model/objects`); +} + +export function updateExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[], patch: Record) { + return request(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) }); +} + export function getProjectHistory(projectId: string) { return request( `/api/projects/${projectId}/history` diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 6a191cb..2f1f35b 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -64,6 +64,8 @@ const commandTypeLabels: Record = { "external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet", "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": diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 00f2d5f..669e25d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -23,6 +23,8 @@ import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/p import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js"; 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"; @@ -72,6 +74,10 @@ export const projectStateRestoreCommandStore = export const projectHistoryStore = new ProjectHistoryRepository(db); 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 = @@ -107,5 +113,7 @@ export const projectCommandService = new ProjectCommandService( externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, externalObjectNewCircuitProjectCommandStore, - estimateBalanceProjectCommandStore + estimateBalanceProjectCommandStore, + externalInitialImportProjectCommandStore, + externalObjectPlanningProjectCommandStore ); diff --git a/src/server/controllers/external-initial-import.controller.ts b/src/server/controllers/external-initial-import.controller.ts new file mode 100644 index 0000000..53227f4 --- /dev/null +++ b/src/server/controllers/external-initial-import.controller.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import type { Request, Response } from "express"; +import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../../domain/models/external-initial-import-project-command.model.js"; +import { createExternalXlsxInitialImportPlan } from "../../external-model/application/external-xlsx-initial-import.js"; +import { buildExternalXlsxInitialImportTarget } from "../../external-model/application/external-xlsx-initial-import-target.js"; +import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; +import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +export function getExternalInitialImportStatus(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + const result = externalModelStateRepository.getByProject(projectId); + if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); + const batch = result.state.importBatches.at(-1) ?? null; + return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null }); +} + +export async function planExternalInitialImport(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 stored = externalModelConfigurationRepository.getByProject(projectId); + if (!stored.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); + if (!stored.configuration) return res.status(409).json({ error: "Für das Projekt ist noch keine Revit-XLSX-Konfiguration gespeichert.", code: "EXTERNAL_XLSX_CONFIGURATION_REQUIRED" }); + const project = await projectRepository.findById(projectId); + if (!project || project.currentRevision !== expectedRevision) return res.status(409).json({ error: "Der Erstimportplan ist wegen einer neueren Projektrevision ungültig.", code: "PROJECT_REVISION_CONFLICT", expectedRevision, currentRevision: project?.currentRevision ?? null }); + if (externalModelStateRepository.getByProject(projectId).state.source) return res.status(409).json({ error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED" }); + const configuration = req.body?.configuration ?? stored.configuration.configuration; + assertExternalModelConfiguration(configuration); + const { plan } = await createExternalXlsxInitialImportPlan({ fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration, catalogs: await loadCatalogs(projectId) }); + return res.json(plan); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Erstimport-Vorschau fehlgeschlagen." }); + } +} + +export async function applyExternalInitialImport(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 stored = externalModelConfigurationRepository.getByProject(projectId); + if (!stored.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); + if (!stored.configuration) return res.status(409).json({ error: "Für das Projekt ist noch keine Revit-XLSX-Konfiguration gespeichert.", code: "EXTERNAL_XLSX_CONFIGURATION_REQUIRED" }); + if (externalModelStateRepository.getByProject(projectId).state.source) return res.status(409).json({ error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED" }); + assertExternalModelConfiguration(req.body?.configuration); + const bytes = decodeBase64(req.body?.contentBase64); + const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: req.body.configuration, catalogs: await loadCatalogs(projectId) }); + if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" }); + const target = buildExternalXlsxInitialImportTarget({ projectId, expectedRevision, sourceName: requiredString(req.body?.sourceName, "Quellenname"), importedAtIso: new Date().toISOString(), configuration: req.body.configuration, originalContentBase64: req.body.contentBase64, document, plan, roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"), familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"), objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"), createId: randomUUID }); + const targetConfiguration = { id: stored.configuration.id, projectId, configuration: req.body.configuration }; + const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `Revit-Erstimport ${plan.fileName}`, command: createExternalInitialImportProjectCommand(stored.configuration, targetConfiguration, createEmptyExternalModelState(), target) }); + return res.status(201).json({ ...result, import: { sourceId: target.source!.id, importBatchId: target.importBatches[0].id, objectCount: target.objects.length } }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +async function loadCatalogs(projectId: string) { + const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); + return { floors, rooms, distributionBoards, projectDevices }; +} +function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; } +function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); } +function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; } +function requireArray(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; } diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts new file mode 100644 index 0000000..f56f7be --- /dev/null +++ b/src/server/controllers/external-objects.controller.ts @@ -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 { 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; } diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index d0ca12d..53c2225 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -58,6 +58,12 @@ import { detectExternalModelWorkbook, updateExternalModelConfiguration, } from "../controllers/external-model-configuration.controller.js"; +import { + applyExternalInitialImport, + getExternalInitialImportStatus, + planExternalInitialImport, +} from "../controllers/external-initial-import.controller.js"; +import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; export const projectRouter = Router(); @@ -82,6 +88,11 @@ projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration); projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration); projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook); +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( diff --git a/tests/external-xlsx-initial-import.test.ts b/tests/external-xlsx-initial-import.test.ts new file mode 100644 index 0000000..75af423 --- /dev/null +++ b/tests/external-xlsx-initial-import.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.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 { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js"; +import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; +import { projects } from "../src/db/schema/projects.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 { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; +import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; +import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; + +const deviceTypeA = "10000000-0000-4000-8000-000000000001"; +const deviceTypeB = "10000000-0000-4000-8000-000000000002"; + +function configured() { + return { + ...createDefaultExternalModelConfiguration(), + ...externalXlsxTestConfiguration, + deviceTypes: [ + { id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: null, cosPhi: 0.9, costGroup: "440" } }, + { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } }, + ], + familyTypeRules: [ + { id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, + { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }, + ], + }; +} + +describe("Linear XLSX initial import", () => { + it("plans exact families, skips suspicious rows and defaults missing GZF to one", async () => { + const { plan } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes: await createRevitXlsxFixture(), configuration: configured(), catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } }); + assert.equal(plan.objectCount, 2); + assert.equal(plan.suspectObjectCount, 1); + assert.equal(plan.issueCounts.unknownFamilyAndType, 0); + assert.equal(plan.objects[0].worksharingId, firstWorksharingId); + assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2); + assert.equal(plan.objects[0].suggestedPlanningValues.simultaneityFactor, 1); + assert.equal(plan.objects[1].suggestedPlanningValues.simultaneityFactor, 0.8); + }); + + it("commits atomically and undo/redo uses stored states", async () => { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + const configuration = configured(); + const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration }; + context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); + try { + const bytes = await createRevitXlsxFixture(); + const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } }); + let sequence = 0; + const target = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T10:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + const repository = new ExternalInitialImportProjectCommandRepository(context.db); + const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), target) }); + assert.equal(inserted.revision.revisionNumber, 1); + assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.length, 2); + 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); + repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse }); + 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(); } + }); +}); diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index 090e6b3..ed91b57 100644 --- a/tests/revit-xlsx-settings-api.test.ts +++ b/tests/revit-xlsx-settings-api.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import { detectExternalModelWorkbook, getExternalModelConfiguration, 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", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -25,14 +25,53 @@ describe("Revit XLSX settings", () => { } finally { globalThis.fetch = originalFetch; } }); + it("uses stateless preview and retransmits the workbook for apply", async () => { + const requests: Array<{ url: string; method: string; body: Record | undefined }> = []; + 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({ imported: false, familyGroups: [], sourceRooms: [], objects: [], warnings: [], issueCounts: {}, existing: {}, history: { currentRevision: 9 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } }); + }; + try { + const configuration = createDefaultExternalModelConfiguration(); + const file = new File([new Uint8Array([1, 2])], "linear.xlsx"); + await getExternalInitialImportStatus("project-1"); + await planExternalInitialImport("project-1", file, configuration, 8); + await applyExternalInitialImport("project-1", file, { expectedRevision: 8, expectedSha256: "a".repeat(64), sourceName: "Revit", configuration, objectsWithoutRoomDefaultDistributionBoardId: null, roomDecisions: [], familyProjectDeviceDecisions: [] }); + assert.equal(requests[0].url, "/api/projects/project-1/external-model/import-status"); + assert.equal(requests[1].url, "/api/projects/project-1/external-model/initial-import/plan"); + assert.equal(requests[2].url, "/api/projects/project-1/external-model/initial-import/apply"); + assert.equal(requests[1].body?.contentBase64, "AQI="); + assert.equal(requests[1].body?.expectedRevision, 8); + assert.equal(requests[2].body?.contentBase64, "AQI="); + assert.equal(requests[2].body?.expectedRevision, 8); + } finally { globalThis.fetch = originalFetch; } + }); + it("exposes the Revit route and removes the old project modal", () => { const projectPage = readFileSync("src/app/projects/[projectId]/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 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.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/); assert.match(revitPage, /Importstatus/); assert.match(settings, /Musterdatei prüfen/); assert.match(settings, /Kombinierte Stromkreisspalte/); + assert.match(wizard, /Schritt.*von/); + assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); + 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 } } }]); }); });