diff --git a/AGENTS.md b/AGENTS.md index e8d4bff..d771e59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,6 +333,12 @@ 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. The target configuration has no independent version counter; `expectedRevision` invalidates stale plans, and each import batch stores a configuration copy. @@ -586,7 +592,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 R5 are complete and R6 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..b965df8 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 R5 sind abgeschlossen; +die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten +Erstimport. Objekttabelle, Editorzuordnung, Rückgabe und Folgeimport folgen in +R6 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..f4d1c7c 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -13,7 +13,7 @@ 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. | +| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | | R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | | R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | @@ -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; diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index 96af41c..bf9b39f 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -1,34 +1,49 @@ "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 { 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.

}
+ {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/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/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/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 603a4be..8e4bf36 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -177,6 +177,11 @@ 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 { assertExternalObjectRowAssignmentProjectCommand, externalObjectRowAssignmentCommandType, @@ -242,7 +247,8 @@ 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 ) {} executeUser( @@ -631,6 +637,16 @@ 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 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/types.ts b/src/frontend/types.ts index 401b618..9a18a94 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; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 3694c2e..692640d 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -39,6 +39,10 @@ import type { CircuitTreeResponseDto, ProjectCircuitTreesResponseDto, ExternalModelConfigurationSnapshotDto, + ExternalInitialImportPlanDto, + ExternalInitialImportResultDto, + ExternalInitialImportStatusDto, + ApplyExternalInitialImportInput, CreateEstimateBalanceInput, EstimateBalanceCommandResultDto, EstimateBalanceDto, @@ -198,6 +202,40 @@ 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 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..4d6003a 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -64,6 +64,7 @@ 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.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..4017144 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -23,6 +23,7 @@ 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 { 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 +73,8 @@ export const projectStateRestoreCommandStore = export const projectHistoryStore = new ProjectHistoryRepository(db); export const externalModelConfigurationProjectCommandStore = new ExternalModelConfigurationProjectCommandRepository(db); +export const externalInitialImportProjectCommandStore = + new ExternalInitialImportProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = @@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService( externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, externalObjectNewCircuitProjectCommandStore, - estimateBalanceProjectCommandStore + estimateBalanceProjectCommandStore, + externalInitialImportProjectCommandStore ); 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/routes/project.routes.ts b/src/server/routes/project.routes.ts index d0ca12d..84f2ea3 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -58,6 +58,11 @@ import { detectExternalModelWorkbook, updateExternalModelConfiguration, } from "../controllers/external-model-configuration.controller.js"; +import { + applyExternalInitialImport, + getExternalInitialImportStatus, + planExternalInitialImport, +} from "../controllers/external-initial-import.controller.js"; export const projectRouter = Router(); @@ -82,6 +87,9 @@ 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/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..33400d6 --- /dev/null +++ b/tests/external-xlsx-initial-import.test.ts @@ -0,0 +1,68 @@ +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 { 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 { 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 }); + assert.deepEqual(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort()); + } finally { context.close(); } + }); +}); diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index 090e6b3..22d633c 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, planExternalInitialImport, updateExternalModelConfiguration } from "../src/frontend/utils/api.js"; describe("Revit XLSX settings", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -25,14 +25,41 @@ 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"); 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/); }); });