diff --git a/AGENTS.md b/AGENTS.md index d438bae..e8d4bff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,17 +333,6 @@ 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. @@ -597,7 +586,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 through R6 are complete and R7 is the next package. + 2026-09-30; R3 and R4 are complete and R5 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 d4aeae4..b70a145 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ 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 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. +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. ## Unterstützter Arbeitsablauf diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 4dfa96c..846debb 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-Erstimport nach R5 +## Revit-Anbindung: Linear-XLSX-Grundlage nach R3 R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014` entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte @@ -663,20 +663,9 @@ 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 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. +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. ### 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 9b43c4f..0b1b71a 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 | 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. | +| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | +| 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. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | @@ -121,17 +121,6 @@ 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; @@ -142,14 +131,6 @@ Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen. - 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 fc8b85e..96af41c 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -1,51 +1,34 @@ "use client"; -import { ArrowLeft, FileSpreadsheet, Settings, Upload } from "lucide-react"; +import { ArrowLeft, FileSpreadsheet, Settings } 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, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; +import { detectExternalModelWorkbook, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings"; -import type { ExternalInitialImportStatusDto, ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types"; +import type { 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 [importStatus, setImportStatus] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); - const [importOpen, setImportOpen] = useState(false); + const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - - 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]); - + 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]); 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 }); - setSettingsOpen(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 }); setOpen(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

{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} +

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}
; } diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts deleted file mode 100644 index 70a249b..0000000 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index e6b3b1f..0000000 --- a/src/db/repositories/external-object-planning-project-command.repository.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index c63ae43..0000000 --- a/src/domain/models/external-initial-import-project-command.model.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index f31d126..0000000 --- a/src/domain/models/external-object-planning-project-command.model.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 411d3c3..0000000 --- a/src/domain/ports/external-initial-import-project-command.store.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 19c610b..0000000 --- a/src/domain/ports/external-object-planning-project-command.store.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 ca99c5f..603a4be 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -177,13 +177,6 @@ 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, @@ -249,9 +242,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, - private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, - private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, - private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore + private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore ) {} executeUser( @@ -640,21 +631,6 @@ 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 deleted file mode 100644 index 8a7fd27..0000000 --- a/src/external-model/application/external-xlsx-initial-import-target.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index f89403c..0000000 --- a/src/external-model/application/external-xlsx-initial-import.ts +++ /dev/null @@ -1,246 +0,0 @@ -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 deleted file mode 100644 index 8a4fc4d..0000000 --- a/src/frontend/components/revit-initial-import-wizard.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"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 deleted file mode 100644 index c82d9c7..0000000 --- a/src/frontend/components/revit-object-table.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"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 49466f5..401b618 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -10,6 +10,10 @@ 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 { @@ -18,7 +22,44 @@ 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; @@ -27,7 +68,6 @@ export interface ExternalInitialImportPlanDto { passthroughCount: number; suspectObjectCount: number; suspectRowNumbers: number[]; - warnings: string[]; sourceRooms: Array<{ sourceRoomKey: string; roomNumber: string; @@ -41,9 +81,9 @@ export interface ExternalInitialImportPlanDto { familyAndType: string; objectCount: number; classified: boolean; - deviceTypeId: string | null; - deviceTypeName: string | null; - quantityRule: "one" | "socketCount" | null; + category: string | null; + connectionKind: string | null; + internalDeviceType: string | null; }>; issueCounts: { unknownFamilyAndType: number; @@ -51,7 +91,24 @@ export interface ExternalInitialImportPlanDto { invalidQuantity: number; missingRoom: number; }; - objects: Array<{ worksharingId: string; ifcGuid: string | null; sourceRoomKey: string | null; sourceValues: { familyAndType: string }; suggestedPlanningValues: { simultaneityFactor: number; effectiveQuantity: number }; issues: string[] }>; + 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[]; + }>; existing: { floors: Array<{ id: string; name: string; sortOrder: number }>; rooms: Array<{ @@ -78,12 +135,11 @@ 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; @@ -95,14 +151,6 @@ 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; @@ -111,21 +159,6 @@ 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 c46f80b..3694c2e 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -39,11 +39,6 @@ import type { CircuitTreeResponseDto, ProjectCircuitTreesResponseDto, ExternalModelConfigurationSnapshotDto, - ExternalInitialImportPlanDto, - ExternalInitialImportResultDto, - ExternalInitialImportStatusDto, - ApplyExternalInitialImportInput, - ExternalProjectObjectsDto, CreateEstimateBalanceInput, EstimateBalanceCommandResultDto, EstimateBalanceDto, @@ -203,48 +198,6 @@ 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 2f1f35b..6a191cb 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -64,8 +64,6 @@ 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 669e25d..00f2d5f 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -23,8 +23,6 @@ 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"; @@ -74,10 +72,6 @@ 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 = @@ -113,7 +107,5 @@ export const projectCommandService = new ProjectCommandService( externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, externalObjectNewCircuitProjectCommandStore, - estimateBalanceProjectCommandStore, - externalInitialImportProjectCommandStore, - externalObjectPlanningProjectCommandStore + estimateBalanceProjectCommandStore ); diff --git a/src/server/controllers/external-initial-import.controller.ts b/src/server/controllers/external-initial-import.controller.ts deleted file mode 100644 index 53227f4..0000000 --- a/src/server/controllers/external-initial-import.controller.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index f56f7be..0000000 --- a/src/server/controllers/external-objects.controller.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 53c2225..d0ca12d 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -58,12 +58,6 @@ 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(); @@ -88,11 +82,6 @@ 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 deleted file mode 100644 index 75af423..0000000 --- a/tests/external-xlsx-initial-import.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 ed91b57..090e6b3 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 { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; +import { detectExternalModelWorkbook, getExternalModelConfiguration, updateExternalModelConfiguration } from "../src/frontend/utils/api.js"; describe("Revit XLSX settings", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -25,53 +25,14 @@ 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 } } }]); }); });