From 2581beee3c16d548f09f059f421113e326828a96 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 20:40:40 +0200 Subject: [PATCH 1/2] Add Linear XLSX follow-up import --- AGENTS.md | 9 +- README.md | 14 +- docs/circuit-list-editor-known-limitations.md | 13 +- docs/current-architecture.md | 5 +- .../revit-excel-integration-requirements.md | 9 ++ docs/spec/revit-excel-phase-14-plan.md | 13 +- src/app/projects/[projectId]/revit/page.tsx | 9 +- ...ow-up-import-project-command.repository.ts | 86 +++++++++++ ...lete-missing-project-command.repository.ts | 59 ++++++++ ...-follow-up-import-project-command.model.ts | 84 +++++++++++ ...ct-delete-missing-project-command.model.ts | 54 +++++++ ...-follow-up-import-project-command.store.ts | 6 + ...ct-delete-missing-project-command.store.ts | 3 + .../services/project-command.service.ts | 18 ++- .../external-xlsx-follow-up-import.ts | 120 +++++++++++++++ .../external-xlsx-follow-up-target.ts | 138 ++++++++++++++++++ .../revit-follow-up-import-wizard.tsx | 91 ++++++++++++ .../components/revit-object-table.tsx | 18 ++- src/frontend/types.ts | 29 ++++ src/frontend/utils/api.ts | 19 +++ .../composition/project-command-stores.ts | 10 +- .../external-follow-up-import.controller.ts | 83 +++++++++++ .../external-initial-import.controller.ts | 2 +- .../external-objects.controller.ts | 34 +++++ src/server/routes/project.routes.ts | 6 +- ...-import-project-command.repository.test.ts | 98 +++++++++++++ tests/external-xlsx-follow-up-import.test.ts | 82 +++++++++++ tests/revit-xlsx-settings-api.test.ts | 33 ++++- 28 files changed, 1114 insertions(+), 31 deletions(-) create mode 100644 src/db/repositories/external-follow-up-import-project-command.repository.ts create mode 100644 src/db/repositories/external-object-delete-missing-project-command.repository.ts create mode 100644 src/domain/models/external-follow-up-import-project-command.model.ts create mode 100644 src/domain/models/external-object-delete-missing-project-command.model.ts create mode 100644 src/domain/ports/external-follow-up-import-project-command.store.ts create mode 100644 src/domain/ports/external-object-delete-missing-project-command.store.ts create mode 100644 src/external-model/application/external-xlsx-follow-up-import.ts create mode 100644 src/external-model/application/external-xlsx-follow-up-target.ts create mode 100644 src/frontend/components/revit-follow-up-import-wizard.tsx create mode 100644 src/server/controllers/external-follow-up-import.controller.ts create mode 100644 tests/external-follow-up-import-project-command.repository.test.ts create mode 100644 tests/external-xlsx-follow-up-import.test.ts diff --git a/AGENTS.md b/AGENTS.md index d438bae..e4b9b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -344,6 +344,13 @@ 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. +R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs +exactly, separates source decisions from return deviations, updates accepted +linked quantities atomically, retains missing objects and compacts every older +batch to metadata. `external-import.apply-follow-up` stores complete inverse +states, so Undo/Redo never parses Excel. A separately confirmed +`external-model-object.delete-missing` command removes missing objects and +adjusts linked row quantities; Undo restores objects, links and quantities. The target configuration has no independent version counter; `expectedRevision` invalidates stale plans, and each import batch stores a configuration copy. @@ -597,7 +604,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 through R9 are complete and R10 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..d1e9523 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind 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 R9 sind abgeschlossen; +die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport, +virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe. +R10 härtet den Ablauf Ende zu Ende und für große Datenmengen. ## Unterstützter Arbeitsablauf @@ -41,9 +41,9 @@ Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10. - grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät übernehmen -- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite, - Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß - Phase-14-Plan in den nächsten Paketen angebunden +- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte + prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an + Linear zurückgeben ## Technik diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index ee2d470..63bc7b3 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,14 +9,11 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. -- The current Revit/CSV foundation and confirmed initial import are a legacy - starting point. The active Phase-14 plan replaces them with the Linear XLSX - exchange and exact Worksharing-ID identity. - R2 must pass a real Linear return-import check before database cutover in R3. - Existing-row assignment, move and detach plus creation of an external-only - row in an existing or newly created circuit have atomic persistent commands. - Their API planning and editor UI, follow-up import, conflict review and return - export are pending. +- Die Linear-XLSX-Anbindung bis R9 ist umgesetzt: Erst- und Folgeimport, + Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes + Löschen fehlender Objekte verwenden persistente Commands. Offen ist die + R10-Härtung des gesamten Ablaufs mit mehreren Tausend synthetischen Objekten, + Messungen sowie Snapshot-/Transfer- und Neustarttests. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 4dfa96c..5e746fe 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -843,9 +843,8 @@ Referenzen gesperrt. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die - Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung - folgt dem R0–R10-Plan +- R10-Härtung des Linear-XLSX-Ablaufs für große Datenmengen sowie + Ende-zu-Ende-Nachweise über Snapshot, Transfer und Neustart - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md index ce80c46..bc86326 100644 --- a/docs/spec/revit-excel-integration-requirements.md +++ b/docs/spec/revit-excel-integration-requirements.md @@ -430,6 +430,15 @@ Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen. Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird auf Metadaten reduziert. +Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute +Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare +`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte +Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command. +Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen +in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte +Löschung verwendet `external-model-object.delete-missing`; Undo stellt +Objekte, Links und Row-Mengen exakt wieder her. + ## 15. Commands, Persistenz und Sicherheit Projektbezogene Writes verwenden typisierte Commands mit vollständiger diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 61d8f2d..d7e6cbe 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -17,7 +17,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. | | R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. | | R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. | -| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | +| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. | | R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | ## R0 – Audit @@ -190,6 +190,17 @@ Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert. - neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren; - ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo. +Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige +XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend +und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert +Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst +die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe +atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre +Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine +gesondert bestätigte Aktion kann sie anschließend mit passender +Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen; +Undo/Redo liest keine XLSX-Datei. + ## R10 – Härtung Ende-zu-Ende-Test: diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index 46c3d5d..8c2735c 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { IconLink } from "../../../../frontend/components/icon-link"; import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; +import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-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"; @@ -19,6 +20,7 @@ export default function RevitPage() { const [importStatus, setImportStatus] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [followUpOpen, setFollowUpOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -41,11 +43,12 @@ export default function RevitPage() { } 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} +

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} · Fehlend: {importStatus.missingObjectCount}

:

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} + {followUpOpen && project && snapshot ? setFollowUpOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setFollowUpOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
; } diff --git a/src/db/repositories/external-follow-up-import-project-command.repository.ts b/src/db/repositories/external-follow-up-import-project-command.repository.ts new file mode 100644 index 0000000..7303200 --- /dev/null +++ b/src/db/repositories/external-follow-up-import-project-command.repository.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; +import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js"; +import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.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 { rooms } from "../schema/rooms.js"; +import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; +import { ExternalModelStateRepository } from "./external-model-state.repository.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore { + constructor(private readonly database: AppDatabase) {} + execute(input: Parameters[0]) { + assertExternalFollowUpImportProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId); + const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get(); + if (!currentState.projectExists || !currentConfiguration || !same(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert."); + const rowIds = input.command.payload.rows.map(({ expected }) => expected.id); + const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : []; + if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden."); + const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition])); + for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert."); + validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows); + for (const transition of input.command.payload.rows) { + const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run(); + if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert."); + } + replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration); + replaceState(tx, input.projectId, input.command.payload.targetState); + return invertExternalFollowUpImportProjectCommand(input.command); + }); + } +} + +function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) { + if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt."); + const growing = target.importBatches.length > expectedState.importBatches.length; + const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!; + if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig."); + if (growing) { + if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig."); + const bytes = Buffer.from(latest.originalContentBase64!, "base64"); + if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports 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.deviceTypes.map((entry) => entry.id)); + for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); } + for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); } + assertRows(database, projectId, target, rows); +} + +function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) { + const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))]; + const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])]; + if (!rowsToLoad.length) return; + const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all(); + if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt."); + const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition])); + for (const context of contexts) { + const current = toCircuitDeviceRowSnapshot(context.row); + const effective = transitionById.get(current.id)?.target ?? current; + assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) }); + } +} +function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); 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 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-delete-missing-project-command.repository.ts b/src/db/repositories/external-object-delete-missing-project-command.repository.ts new file mode 100644 index 0000000..600c19a --- /dev/null +++ b/src/db/repositories/external-object-delete-missing-project-command.repository.ts @@ -0,0 +1,59 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js"; +import { assertExternalObjectDeleteMissingProjectCommand, invertExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js"; +import type { ExternalObjectDeleteMissingProjectCommandStore } from "../../domain/ports/external-object-delete-missing-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; +import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class ExternalObjectDeleteMissingProjectCommandRepository implements ExternalObjectDeleteMissingProjectCommandStore { + constructor(private readonly database: AppDatabase) {} + execute(input: Parameters[0]) { + assertExternalObjectDeleteMissingProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const rowById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition])); + const rowIds = [...rowById.keys()]; + const rowContexts = rowIds.length ? tx.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowIds)).all() : []; + if (rowContexts.length !== rowIds.length || rowContexts.some((entry) => entry.projectId !== input.projectId || !snapshotsEqual(toCircuitDeviceRowSnapshot(entry.row), rowById.get(entry.row.id)!.expected))) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert."); + const currentObjects = tx.select().from(externalModelObjects).where(eq(externalModelObjects.projectId, input.projectId)).all().map(toExternalObjectSnapshot); + const currentById = new Map(currentObjects.map((object) => [object.id, object])); + for (const transition of input.command.payload.objects) { + const current = currentById.get((transition.expected ?? transition.target)!.id) ?? null; + if (transition.expected === null ? current !== null : current === null || !snapshotsEqual(current, transition.expected)) throw new Error("Ein fehlendes Revit-Objekt wurde zwischenzeitlich geändert."); + } + const sourceIds = ids(tx.select({ id: externalModelSources.id }).from(externalModelSources).where(eq(externalModelSources.projectId, input.projectId)).all()); + const batchIds = ids(tx.select({ id: externalImportBatches.id }).from(externalImportBatches).where(eq(externalImportBatches.projectId, input.projectId)).all()); + const mappingIds = ids(tx.select({ id: externalRoomMappings.id }).from(externalRoomMappings).where(eq(externalRoomMappings.projectId, input.projectId)).all()); + const boardIds = ids(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all()); + const deviceIds = ids(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all()); + for (const { target } of input.command.payload.objects) if (target && (target.projectId !== input.projectId || !sourceIds.has(target.sourceId) || !batchIds.has(target.lastSeenImportBatchId) || !batchIds.has(target.lastAcceptedImportBatchId) || (target.externalRoomMappingId !== null && !mappingIds.has(target.externalRoomMappingId)) || (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) || (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)))) throw new Error("Ein wiederhergestelltes Revit-Objekt verweist auf ein anderes Projekt."); + const targetById = new Map(currentObjects.map((object) => [object.id, object])); + for (const transition of input.command.payload.objects) { const id = (transition.expected ?? transition.target)!.id; if (transition.target) targetById.set(id, transition.target); else targetById.delete(id); } + for (const transition of input.command.payload.rows) assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, externalObjects: [...targetById.values()].filter((object) => object.circuitDeviceRowId === transition.target.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) }); + for (const transition of input.command.payload.rows) { + const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run(); + if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Löschens geändert."); + } + for (const transition of input.command.payload.objects) { + const object = transition.expected ?? transition.target!; + if (transition.target === null) { + const result = tx.delete(externalModelObjects).where(and(eq(externalModelObjects.id, object.id), eq(externalModelObjects.projectId, input.projectId))).run(); + if (result.changes !== 1) throw new Error("Ein fehlendes Revit-Objekt konnte nicht gelöscht werden."); + } else tx.insert(externalModelObjects).values(transition.target).run(); + } + return invertExternalObjectDeleteMissingProjectCommand(input.command); + }); + } +} + +function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); } diff --git a/src/domain/models/external-follow-up-import-project-command.model.ts b/src/domain/models/external-follow-up-import-project-command.model.ts new file mode 100644 index 0000000..f9581a2 --- /dev/null +++ b/src/domain/models/external-follow-up-import-project-command.model.ts @@ -0,0 +1,84 @@ +import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js"; +import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; + +export const externalFollowUpImportCommandType = "external-import.apply-follow-up" as const; +export const externalFollowUpImportCommandSchemaVersion = 1 as const; + +export interface ExternalFollowUpImportPayload { + expectedConfiguration: ExternalModelConfigurationSnapshot; + targetConfiguration: ExternalModelConfigurationSnapshot; + expectedState: ExternalModelStateSnapshot; + targetState: ExternalModelStateSnapshot; + rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>; +} + +export interface ExternalFollowUpImportProjectCommand extends SerializedProjectCommand { + schemaVersion: typeof externalFollowUpImportCommandSchemaVersion; + type: typeof externalFollowUpImportCommandType; +} + +export function createExternalFollowUpImportProjectCommand(payload: ExternalFollowUpImportPayload): ExternalFollowUpImportProjectCommand { + const command: ExternalFollowUpImportProjectCommand = { schemaVersion: 1, type: externalFollowUpImportCommandType, payload: { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target) })) } }; + assertExternalFollowUpImportProjectCommand(command); + return command; +} + +export function invertExternalFollowUpImportProjectCommand(command: ExternalFollowUpImportProjectCommand) { + return createExternalFollowUpImportProjectCommand({ + expectedConfiguration: command.payload.targetConfiguration, + targetConfiguration: command.payload.expectedConfiguration, + expectedState: command.payload.targetState, + targetState: command.payload.expectedState, + rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })), + }); +} + +export function assertExternalFollowUpImportProjectCommand(command: SerializedProjectCommand): asserts command is ExternalFollowUpImportProjectCommand { + if (command.schemaVersion !== 1 || command.type !== externalFollowUpImportCommandType || !isRecord(command.payload)) throw new Error("Nicht unterstützter Revit-Folgeimport-Command."); + if (Object.keys(command.payload).sort().join(",") !== "expectedConfiguration,expectedState,rows,targetConfiguration,targetState" || !Array.isArray(command.payload.rows)) throw new Error("Der Revit-Folgeimport-Command ist unvollständig."); + const expectedConfiguration = parseConfiguration(command.payload.expectedConfiguration); + const targetConfiguration = parseConfiguration(command.payload.targetConfiguration); + if (expectedConfiguration.id !== targetConfiguration.id || expectedConfiguration.projectId !== targetConfiguration.projectId) throw new Error("Der Folgeimport darf die Konfigurationsidentität nicht ändern."); + const expectedState = parseExternalModelStateSnapshot(command.payload.expectedState); + const targetState = parseExternalModelStateSnapshot(command.payload.targetState); + if (!expectedState.source || !targetState.source || expectedState.source.id !== targetState.source.id || expectedState.source.projectId !== targetState.source.projectId) throw new Error("Der Folgeimport benötigt dieselbe Revit-Quelle auf beiden Seiten."); + const batchDelta = targetState.importBatches.length - expectedState.importBatches.length; + if (Math.abs(batchDelta) !== 1) throw new Error("Der Folgeimport muss genau einen Importbatch hinzufügen oder entfernen."); + const larger = batchDelta > 0 ? targetState : expectedState; + const smaller = batchDelta > 0 ? expectedState : targetState; + const largerConfiguration = batchDelta > 0 ? targetConfiguration : expectedConfiguration; + const addedBatch = larger.importBatches.at(-1)!; + if (addedBatch.importKind !== "follow_up" || addedBatch.configurationSnapshot === null || addedBatch.originalContentBase64 === null || addedBatch.document === null) throw new Error("Der Folgeimport-Batch ist unvollständig."); + if (canonical(addedBatch.configurationSnapshot) !== canonical(largerConfiguration.configuration)) throw new Error("Die Konfigurationskopie des Folgeimports stimmt nicht mit dem Ziel überein."); + if (larger.importBatches.length !== smaller.importBatches.length + 1 || smaller.importBatches.some((batch, index) => !sameBatchMetadata(batch, larger.importBatches[index]))) throw new Error("Der Folgeimport darf bestehende Importbatch-Metadaten nicht verändern."); + const largerObjectById = new Map(larger.objects.map((object) => [object.id, object])); + for (const object of smaller.objects) { + const next = largerObjectById.get(object.id); + if (!next || next.worksharingId !== object.worksharingId || next.sourceId !== object.sourceId || next.circuitDeviceRowId !== object.circuitDeviceRowId || next.linkedProjectDeviceId !== object.linkedProjectDeviceId) throw new Error("Der Folgeimport darf bestehende Objektidentitäten oder Links nicht verändern."); + } + const smallerIds = new Set(smaller.objects.map((object) => object.id)); + if (larger.objects.some((object) => !smallerIds.has(object.id) && (object.circuitDeviceRowId !== null || object.presenceStatus !== "present" || object.lastSeenImportBatchId !== addedBatch.id))) throw new Error("Neue Folgeimportobjekte müssen unverknüpft und vorhanden sein."); + if (larger.objects.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === addedBatch.id).length !== addedBatch.objectCount) throw new Error("Die Objektzahl des Folgeimportbatches ist inkonsistent."); + const largerMappings = new Map(larger.roomMappings.map((mapping) => [mapping.id, mapping])); + if (smaller.roomMappings.some((mapping) => canonical(mapping) !== canonical(largerMappings.get(mapping.id)))) throw new Error("Der Folgeimport darf bestehende Raumzuordnungen nicht verändern."); + const rowIds = new Set(); + for (const transition of command.payload.rows) { + if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang im Folgeimport."); + const expected = parseRow(transition.expected); const target = parseRow(transition.target); + if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität im Folgeimport."); + const expectedRest = { ...expected, quantity: 0 }; const targetRest = { ...target, quantity: 0 }; + if (canonical(expectedRest) !== canonical(targetRest) || expected.quantity === target.quantity) throw new Error("Der Folgeimport darf an Gerätezeilen nur die Gesamtmenge ändern."); + rowIds.add(expected.id); + } +} + +function parseConfiguration(value: unknown): ExternalModelConfigurationSnapshot { if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") throw new Error("Ungültige Revit-Konfiguration im Folgeimport."); assertExternalModelConfiguration(value.configuration); return value as unknown as ExternalModelConfigurationSnapshot; } +function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; } +function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); } +function sameBatchMetadata(left: ExternalModelStateSnapshot["importBatches"][number], right: ExternalModelStateSnapshot["importBatches"][number] | undefined) { if (!right) return false; return left.id === right.id && left.projectId === right.projectId && left.sourceId === right.sourceId && left.importKind === right.importKind && left.importedAtIso === right.importedAtIso && left.fileName === right.fileName && left.sha256 === right.sha256 && left.objectCount === right.objectCount; } diff --git a/src/domain/models/external-object-delete-missing-project-command.model.ts b/src/domain/models/external-object-delete-missing-project-command.model.ts new file mode 100644 index 0000000..ddb7d63 --- /dev/null +++ b/src/domain/models/external-object-delete-missing-project-command.model.ts @@ -0,0 +1,54 @@ +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; +import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js"; + +export const externalObjectDeleteMissingCommandType = "external-model-object.delete-missing" as const; +type ObjectTransition = { expected: ExternalModelObjectSnapshot | null; target: ExternalModelObjectSnapshot | null }; +export interface ExternalObjectDeleteMissingProjectCommand extends SerializedProjectCommand<{ objects: ObjectTransition[]; rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> }> { + schemaVersion: 1; + type: typeof externalObjectDeleteMissingCommandType; +} + +export function createExternalObjectDeleteMissingProjectCommand(payload: ExternalObjectDeleteMissingProjectCommand["payload"]): ExternalObjectDeleteMissingProjectCommand { + const command: ExternalObjectDeleteMissingProjectCommand = { schemaVersion: 1, type: externalObjectDeleteMissingCommandType, payload }; + assertExternalObjectDeleteMissingProjectCommand(command); + return command; +} + +export function invertExternalObjectDeleteMissingProjectCommand(command: ExternalObjectDeleteMissingProjectCommand) { + return createExternalObjectDeleteMissingProjectCommand({ + objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })), + rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })), + }); +} + +export function assertExternalObjectDeleteMissingProjectCommand(command: SerializedProjectCommand): asserts command is ExternalObjectDeleteMissingProjectCommand { + if (command.schemaVersion !== 1 || command.type !== externalObjectDeleteMissingCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.rows) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Command zum Löschen fehlender Revit-Objekte."); + const objectIds = new Set(); + let deletion: boolean | null = null; + for (const transition of command.payload.objects) { + if (!isRecord(transition) || (transition.expected === null) === (transition.target === null)) throw new Error("Ungültiger Löschübergang für ein Revit-Objekt."); + const object = parseObject(transition.expected ?? transition.target); + if (object.presenceStatus !== "missing" || objectIds.has(object.id)) throw new Error("Nur eindeutig fehlende Revit-Objekte dürfen gelöscht werden."); + const nextDeletion = transition.target === null; + if (deletion !== null && deletion !== nextDeletion) throw new Error("Löschen und Wiederherstellen dürfen nicht gemischt werden."); + deletion = nextDeletion; + objectIds.add(object.id); + } + const affectedRowIds = new Set(command.payload.objects.flatMap(({ expected, target }) => (expected ?? target)!.circuitDeviceRowId ? [(expected ?? target)!.circuitDeviceRowId!] : [])); + const rowIds = new Set(); + for (const transition of command.payload.rows) { + if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang."); + const expected = parseRow(transition.expected); const target = parseRow(transition.target); + if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität."); + if (canonical({ ...expected, quantity: 0 }) !== canonical({ ...target, quantity: 0 }) || expected.quantity === target.quantity) throw new Error("Beim Löschen fehlender Objekte darf nur die Gesamtmenge geändert werden."); + rowIds.add(expected.id); + } + if (canonical([...affectedRowIds].sort()) !== canonical([...rowIds].sort())) throw new Error("Die Mengenübergänge der betroffenen Gerätezeilen sind unvollständig."); +} + +function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; } +function parseRow(value: unknown) { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); } diff --git a/src/domain/ports/external-follow-up-import-project-command.store.ts b/src/domain/ports/external-follow-up-import-project-command.store.ts new file mode 100644 index 0000000..d39efc9 --- /dev/null +++ b/src/domain/ports/external-follow-up-import-project-command.store.ts @@ -0,0 +1,6 @@ +import type { ExternalFollowUpImportProjectCommand } from "../models/external-follow-up-import-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExternalFollowUpImportProjectCommandStore { + execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalFollowUpImportProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalFollowUpImportProjectCommand }; +} diff --git a/src/domain/ports/external-object-delete-missing-project-command.store.ts b/src/domain/ports/external-object-delete-missing-project-command.store.ts new file mode 100644 index 0000000..d6f2e8f --- /dev/null +++ b/src/domain/ports/external-object-delete-missing-project-command.store.ts @@ -0,0 +1,3 @@ +import type { ExternalObjectDeleteMissingProjectCommand } from "../models/external-object-delete-missing-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; +export interface ExternalObjectDeleteMissingProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectDeleteMissingProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectDeleteMissingProjectCommand }; } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index ca99c5f..960ee64 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -182,8 +182,12 @@ import { externalInitialImportCommandType, } from "../models/external-initial-import-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js"; +import { assertExternalFollowUpImportProjectCommand, externalFollowUpImportCommandType } from "../models/external-follow-up-import-project-command.model.js"; +import type { ExternalFollowUpImportProjectCommandStore } from "../ports/external-follow-up-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 { assertExternalObjectDeleteMissingProjectCommand, externalObjectDeleteMissingCommandType } from "../models/external-object-delete-missing-project-command.model.js"; +import type { ExternalObjectDeleteMissingProjectCommandStore } from "../ports/external-object-delete-missing-project-command.store.js"; import { assertExternalObjectRowAssignmentProjectCommand, externalObjectRowAssignmentCommandType, @@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, - private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore + private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore, + private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore, + private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore ) {} executeUser( @@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalFollowUpImportCommandType: { + assertExternalFollowUpImportProjectCommand(input.command); + if (!this.externalFollowUpImportStore) throw new Error("External follow-up import store is not available."); + return this.externalFollowUpImportStore.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 externalObjectDeleteMissingCommandType: { + assertExternalObjectDeleteMissingProjectCommand(input.command); + if (!this.externalObjectDeleteMissingStore) throw new Error("External missing-object delete store is not available."); + return this.externalObjectDeleteMissingStore.execute({ ...input, command: input.command }).revision; + } case externalObjectRowAssignmentCommandType: { assertExternalObjectRowAssignmentProjectCommand(input.command); if (!this.externalObjectRowAssignmentStore) { diff --git a/src/external-model/application/external-xlsx-follow-up-import.ts b/src/external-model/application/external-xlsx-follow-up-import.ts new file mode 100644 index 0000000..9e0293a --- /dev/null +++ b/src/external-model/application/external-xlsx-follow-up-import.ts @@ -0,0 +1,120 @@ +import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js"; +import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js"; +import type { ExternalXlsxReturnValues, ExternalXlsxScalar } from "../xlsx/external-xlsx-contracts.js"; +import { createExternalXlsxInitialImportPlan, type ExternalInitialImportCatalogs, type ExternalInitialImportPlan, type ExternalInitialImportProjectedObject } from "./external-xlsx-initial-import.js"; + +export const externalSourceDecisionFields = ["ifcGuid", "roomNumber", "roomName", "familyAndType", "selectionMarker", "socketCount", "sourceRemark"] as const; +export type ExternalSourceDecisionField = (typeof externalSourceDecisionFields)[number]; + +export interface ExternalFollowUpChange { + objectId: string; + worksharingId: string; + familyAndType: string; + changedFields: ExternalSourceDecisionField[]; + previousQuantity: number; + suggestedQuantity: number; + circuitDeviceRowId: string | null; +} + +export interface ExternalXlsxFollowUpPlan extends ExternalInitialImportPlan { + newObjectCount: number; + changedObjectCount: number; + unchangedObjectCount: number; + missingObjectCount: number; + returnDeviationCount: number; + newObjectWorksharingIds: string[]; + newSourceRoomKeys: string[]; + newFamilyAndTypes: string[]; + changes: ExternalFollowUpChange[]; + missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>; + returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>; +} + +export async function createExternalXlsxFollowUpImportPlan(input: { + fileName: string; + bytes: Uint8Array; + configuration: ExternalModelConfiguration; + catalogs: ExternalInitialImportCatalogs; + currentState: ExternalModelStateSnapshot; + currentReturnValuesByObjectId: ReadonlyMap; +}): Promise<{ plan: ExternalXlsxFollowUpPlan; document: Awaited>["document"] }> { + if (!input.currentState.source || input.currentState.importBatches.length === 0) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import."); + const parsed = await createExternalXlsxInitialImportPlan(input); + const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object])); + const incomingByWorksharingId = new Map(parsed.plan.objects.map((object) => [object.worksharingId, object])); + const newObjects: ExternalInitialImportProjectedObject[] = []; + const changes: ExternalFollowUpChange[] = []; + let unchangedObjectCount = 0; + const returnDeviations: ExternalXlsxFollowUpPlan["returnDeviations"] = []; + + for (const incoming of parsed.plan.objects) { + const existing = existingByWorksharingId.get(incoming.worksharingId); + if (!existing) { newObjects.push(incoming); continue; } + const changedFields = changedSourceFields(existing, incoming); + if (changedFields.length) { + changes.push({ + objectId: existing.id, + worksharingId: existing.worksharingId, + familyAndType: incoming.sourceValues.familyAndType, + changedFields, + previousQuantity: existing.planningValues.effectiveQuantity, + suggestedQuantity: incoming.suggestedPlanningValues.effectiveQuantity, + circuitDeviceRowId: existing.circuitDeviceRowId, + }); + } else unchangedObjectCount += 1; + const returnFields = changedReturnFields(input.currentReturnValuesByObjectId.get(existing.id), incoming.sourceValues); + if (returnFields.length) returnDeviations.push({ objectId: existing.id, worksharingId: existing.worksharingId, fields: returnFields }); + } + + const missingObjects = input.currentState.objects + .filter((object) => !incomingByWorksharingId.has(object.worksharingId)) + .map((object) => ({ objectId: object.id, worksharingId: object.worksharingId, familyAndType: object.acceptedSourceValues.familyAndType, circuitDeviceRowId: object.circuitDeviceRowId })); + const existingRoomKeys = new Set(input.currentState.roomMappings.map((mapping) => mapping.normalizedSourceRoomKey)); + const newSourceRoomKeys = parsed.plan.sourceRooms.map((room) => room.sourceRoomKey).filter((key) => !existingRoomKeys.has(key)); + const newFamilyAndTypes = [...new Set(newObjects.map((object) => object.sourceValues.familyAndType))].sort((a, b) => a.localeCompare(b, "de")); + return { + document: parsed.document, + plan: { + ...parsed.plan, + newObjectCount: newObjects.length, + changedObjectCount: changes.length, + unchangedObjectCount, + missingObjectCount: missingObjects.length, + returnDeviationCount: returnDeviations.length, + newObjectWorksharingIds: newObjects.map((object) => object.worksharingId), + newSourceRoomKeys, + newFamilyAndTypes, + changes, + missingObjects, + returnDeviations, + }, + }; +} + +function changedSourceFields(existing: ExternalModelObjectSnapshot, incoming: ExternalInitialImportProjectedObject) { + const fields: ExternalSourceDecisionField[] = []; + if (!sameScalar(existing.ifcGuid, incoming.ifcGuid)) fields.push("ifcGuid"); + for (const field of externalSourceDecisionFields.slice(1) as readonly (keyof ExternalModelObjectSourceValues)[]) { + if (!sameScalar(existing.acceptedSourceValues[field], incoming.sourceValues[field])) fields.push(field as ExternalSourceDecisionField); + } + return fields; +} + +function changedReturnFields(expected: ExternalXlsxReturnValues | undefined, incoming: ExternalModelObjectSourceValues) { + if (!expected) return []; + const fields = ["circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"] as const; + return fields.filter((field) => !sameScalar(expected[field], incoming[field])); +} + +function sameScalar(left: ExternalXlsxScalar | undefined, right: ExternalXlsxScalar | undefined) { + return normalize(left) === normalize(right); +} + +function normalize(value: ExternalXlsxScalar | undefined): string | number | boolean { + if (value === null || value === undefined || value === "") return ""; + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return ""; + const numeric = Number(trimmed.replace(",", ".")); + return Number.isFinite(numeric) ? numeric : trimmed; +} diff --git a/src/external-model/application/external-xlsx-follow-up-target.ts b/src/external-model/application/external-xlsx-follow-up-target.ts new file mode 100644 index 0000000..db62b52 --- /dev/null +++ b/src/external-model/application/external-xlsx-follow-up-target.ts @@ -0,0 +1,138 @@ +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js"; +import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js"; +import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js"; +import type { ExternalFamilyProjectDeviceDecision, ExternalRoomDecision } from "./external-xlsx-initial-import-target.js"; +import type { ExternalSourceDecisionField, ExternalXlsxFollowUpPlan } from "./external-xlsx-follow-up-import.js"; + +export interface ExternalSourceChangeDecision { objectId: string; accept: boolean; } +export interface ExternalRowQuantityTransition { expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot; } + +export function buildExternalXlsxFollowUpTarget(input: { + projectId: string; + expectedRevision: number; + importedAtIso: string; + configuration: ExternalModelConfiguration; + originalContentBase64: string; + document: ExternalXlsxDocument; + plan: ExternalXlsxFollowUpPlan; + currentState: ExternalModelStateSnapshot; + sourceChangeDecisions: ExternalSourceChangeDecision[]; + roomDecisions: ExternalRoomDecision[]; + familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[]; + objectsWithoutRoomDefaultDistributionBoardId: string | null; + rowsById: ReadonlyMap; + createId: () => string; +}): { targetState: ExternalModelStateSnapshot; rowTransitions: ExternalRowQuantityTransition[] } { + const source = input.currentState.source; + if (!source || source.projectId !== input.projectId) throw new Error("Für den Folgeimport fehlt die Revit-Quelle."); + const decisions = exactDecisionMap(input.plan.changes.map((change) => change.objectId), input.sourceChangeDecisions, (decision) => decision.objectId, "Quelländerung"); + const newRoomSet = new Set(input.plan.newSourceRoomKeys); + const roomDecisions = exactDecisionMap(input.plan.newSourceRoomKeys, input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum"); + const newFamilySet = new Set(input.plan.newFamilyAndTypes); + const familyDecisions = exactDecisionMap(input.plan.newFamilyAndTypes, input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ"); + const batchId = input.createId(); + const oldMappings = input.currentState.roomMappings.map((mapping) => ({ ...mapping })); + const mappingByKey = new Map(oldMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping])); + const newObjectIds = new Set(input.plan.newObjectWorksharingIds); + const neededRoomKeys = new Set(input.plan.objects.filter((object) => newObjectIds.has(object.worksharingId)).map((object) => object.sourceRoomKey).filter((key): key is string => key !== null)); + for (const change of input.plan.changes) { + if (!decisions.get(change.objectId)!.accept || !change.changedFields.some((field) => field === "roomNumber" || field === "roomName")) continue; + const key = input.plan.objects.find((object) => object.worksharingId === change.worksharingId)?.sourceRoomKey; + if (key) neededRoomKeys.add(key); + } + for (const room of input.plan.sourceRooms.filter((candidate) => newRoomSet.has(candidate.sourceRoomKey) && neededRoomKeys.has(candidate.sourceRoomKey))) { + const decision = roomDecisions.get(room.sourceRoomKey)!; + const mapping = { id: input.createId(), projectId: input.projectId, sourceId: source.id, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId }; + oldMappings.push(mapping); mappingByKey.set(mapping.normalizedSourceRoomKey, mapping); + } + const incomingById = new Map(input.plan.objects.map((object) => [object.worksharingId, object])); + const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object])); + const changedById = new Map(input.plan.changes.map((change) => [change.objectId, change])); + const quantityDeltaByRow = new Map(); + const objects: ExternalModelObjectSnapshot[] = []; + + for (const existing of input.currentState.objects) { + const incoming = incomingById.get(existing.worksharingId); + if (!incoming) { objects.push({ ...existing, presenceStatus: "missing" }); continue; } + const change = changedById.get(existing.id); + const accept = change ? decisions.get(existing.id)!.accept : false; + if (accept && incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Eine akzeptierte Quelländerung besitzt keine gültige Familien- oder Mengenregel."); + const planningValues = { ...existing.planningValues }; + let deviceTypeId = existing.deviceTypeId; + let externalRoomMappingId = existing.externalRoomMappingId; + let distributionBoardId = existing.distributionBoardId; + let ifcGuid = existing.ifcGuid; + if (accept && change) { + const fields = new Set(change.changedFields); + if (fields.has("ifcGuid")) ifcGuid = incoming.ifcGuid; + if (fields.has("familyAndType")) { + const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType); + if (!rule) throw new Error("Eine geänderte Familie ist nicht klassifiziert."); + deviceTypeId = rule.deviceTypeId; + const type = input.configuration.deviceTypes.find((candidate) => candidate.id === rule.deviceTypeId)!; + if (!existing.overriddenFields.includes("category")) planningValues.category = type.category; + } + if (fields.has("selectionMarker") && !existing.overriddenFields.includes("displayName")) planningValues.displayName = scalarText(incoming.sourceValues.selectionMarker) || incoming.sourceValues.familyAndType; + if (fields.has("sourceRemark") && !existing.overriddenFields.includes("remark")) planningValues.remark = scalarText(incoming.sourceValues.sourceRemark) || null; + if (fields.has("socketCount") && !existing.overriddenFields.includes("effectiveQuantity")) planningValues.effectiveQuantity = incoming.suggestedPlanningValues.effectiveQuantity; + if (fields.has("roomNumber") || fields.has("roomName")) { + const oldMapping = existing.externalRoomMappingId ? oldMappings.find((mapping) => mapping.id === existing.externalRoomMappingId) : null; + const nextMapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null; + externalRoomMappingId = nextMapping?.id ?? null; + if (!existing.circuitDeviceRowId && (existing.distributionBoardId === null || existing.distributionBoardId === oldMapping?.defaultDistributionBoardId)) distributionBoardId = nextMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId; + } + } + if (existing.circuitDeviceRowId && planningValues.effectiveQuantity !== existing.planningValues.effectiveQuantity) { + quantityDeltaByRow.set(existing.circuitDeviceRowId, (quantityDeltaByRow.get(existing.circuitDeviceRowId) ?? 0) + planningValues.effectiveQuantity - existing.planningValues.effectiveQuantity); + } + objects.push({ + ...existing, + ifcGuid, deviceTypeId, externalRoomMappingId, distributionBoardId, + lastSeenImportBatchId: batchId, + lastAcceptedImportBatchId: change && !accept ? existing.lastAcceptedImportBatchId : batchId, + acceptedSourceValues: mergeAcceptedSource(existing.acceptedSourceValues, incoming.sourceValues, accept), + planningValues, + presenceStatus: "present", + }); + } + + for (const incoming of input.plan.objects.filter((object) => !existingByWorksharingId.has(object.worksharingId))) { + if (incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Ein neues Revit-Objekt besitzt keine gültige Familien- oder Mengenregel."); + const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType); + if (!rule) throw new Error("Eine neue Familie ist nicht klassifiziert."); + const mapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null; + objects.push({ + id: input.createId(), projectId: input.projectId, sourceId: source.id, worksharingId: incoming.worksharingId, ifcGuid: incoming.ifcGuid, + deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId, + acceptedSourceValues: incoming.sourceValues, planningValues: incoming.suggestedPlanningValues, overriddenFields: [], + externalRoomMappingId: mapping?.id ?? null, + distributionBoardId: mapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId, + linkedProjectDeviceId: newFamilySet.has(incoming.sourceValues.familyAndType) ? familyDecisions.get(incoming.sourceValues.familyAndType)!.projectDeviceId : null, + circuitDeviceRowId: null, presenceStatus: "present", + }); + } + const rowTransitions = [...quantityDeltaByRow].map(([rowId, delta]) => { + const expected = input.rowsById.get(rowId); + if (!expected) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden."); + return { expected, target: { ...expected, quantity: expected.quantity + delta } }; + }); + const previousBatches = input.currentState.importBatches.map((batch) => ({ ...batch, appliedProjectRevision: null, configurationSnapshot: null, originalContentBase64: null, document: null })); + return { + targetState: { + source: { ...source }, + importBatches: [...previousBatches, { id: batchId, projectId: input.projectId, sourceId: source.id, importKind: "follow_up", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: input.plan.objectCount, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }], + roomMappings: oldMappings, + objects, + }, + rowTransitions, + }; +} + +const returnFields = new Set(["rowNumber", "circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"]); +function mergeAcceptedSource(previous: ExternalModelObjectSourceValues, incoming: ExternalModelObjectSourceValues, acceptSourceChanges: boolean) { + if (acceptSourceChanges) return incoming; + return Object.fromEntries(Object.keys(previous).map((key) => [key, returnFields.has(key as keyof ExternalModelObjectSourceValues) ? incoming[key as keyof ExternalModelObjectSourceValues] : previous[key as keyof ExternalModelObjectSourceValues]])) as unknown as ExternalModelObjectSourceValues; +} +function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value).trim(); } +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/frontend/components/revit-follow-up-import-wizard.tsx b/src/frontend/components/revit-follow-up-import-wizard.tsx new file mode 100644 index 0000000..8b561c5 --- /dev/null +++ b/src/frontend/components/revit-follow-up-import-wizard.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration"; +import type { ExternalFollowUpImportPlanDto, ProjectHistoryStateDto } from "../types"; +import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../utils/api"; +import { createClientId } from "../utils/client-id"; +import { FormModal } from "./form-modal"; + +const steps = ["Vorschau", "Quelländerungen", "Neue Räume", "Familienregeln", "Projektgeräte", "Bestätigung"]; +const fieldLabels: Record = { ifcGuid: "IfcGUID", roomNumber: "Raumnummer", roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner", socketCount: "Anzahl", sourceRemark: "Bemerkung" }; + +export function RevitFollowUpImportWizard({ 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 [accepted, setAccepted] = useState>({}); + const [roomIds, setRoomIds] = useState>({}); + const [boardIds, setBoardIds] = useState>({}); + const [deviceIds, setDeviceIds] = useState>({}); + 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 planExternalFollowUpImport(projectId, nextFile, nextConfiguration, expectedRevision); + setPlan(loaded); + setAccepted((current) => Object.fromEntries(loaded.changes.map((change) => [change.objectId, current[change.objectId] ?? true]))); + setRoomIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => { const room = loaded.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return [key, current[key] ?? room.suggestedRoomId ?? ""]; }))); + setBoardIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => [key, current[key] ?? ""]))); + setDeviceIds((current) => Object.fromEntries(loaded.newFamilyAndTypes.map((family) => [family, current[family] ?? ""]))); + } catch (value) { setError(value instanceof Error ? value.message : "Folgeimport-Vorschau fehlgeschlagen."); setPlan(null); } + finally { setBusy(false); } + } + + async function addRule(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount", deviceTypeId?: string) { + if (!file) return; + const next = structuredClone(configuration); + const targetTypeId = deviceTypeId ?? createClientId(); + if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } }); + next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, 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 applyExternalFollowUpImport(projectId, file, { + expectedRevision, expectedSha256: plan.sha256, configuration, objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null, + sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: accepted[change.objectId] ?? false })), + roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: roomIds[sourceRoomKey] || null, defaultDistributionBoardId: boardIds[sourceRoomKey] || null })), + familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: deviceIds[familyAndType] || null })), + }); + onImported({ history: result.history, objectCount: result.import.objectCount }); + } catch (value) { setError(value instanceof Error ? value.message : "Folgeimport fehlgeschlagen."); } + finally { setBusy(false); } + } + + const relevantUnknownFamilies = plan?.familyGroups.filter((family) => !family.classified && (plan.newFamilyAndTypes.includes(family.familyAndType) || plan.changes.some((change) => accepted[change.objectId] && change.familyAndType === family.familyAndType))) ?? []; + const relevantInvalidQuantity = plan?.objects.some((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))) ?? false; + const invalidQuantityFamilies = plan ? [...new Set(plan.objects.filter((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))).map((object) => object.sourceValues.familyAndType))] : []; + const blocked = !plan || relevantUnknownFamilies.length > 0 || relevantInvalidQuantity; + return void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Folgeimport ü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.returnDeviationCount} Objekte enthalten Rückgabefelder, die in Revit noch nicht aktualisiert sind.
{plan.warnings.length ?
{plan.warnings.map((warning) =>
{warning}
)}
: null} : null}
: null} + {step === 1 && plan ?

Nur fachliche Quelländerungen benötigen eine Entscheidung. Abweichende Rückgabefelder werden lediglich als „in Revit noch nicht aktualisiert“ gemeldet.

{plan.changes.length ? plan.changes.map((change) => ) :
Keine fachlichen Quelländerungen.
}{plan.missingObjects.length ?
{plan.missingObjects.length} fehlende Objekte werden markiert. Links und Mengen bleiben bestehen.
: null}
: null} + {step === 2 && plan ?

Nur neue, tatsächlich übernommene Quellräume benötigen eine Zuordnung.

{plan.newSourceRoomKeys.map((key) => { const room = plan.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return
{room.roomNumber || "–"} {room.roomName}
; })}
: null} + {step === 3 && plan ?

Unbekannte Familien müssen nur für neue Objekte oder akzeptierte Familienänderungen klassifiziert werden.

{relevantUnknownFamilies.map((family) =>
{family.familyAndType}
{configuration.deviceTypes.flatMap((type) => [, ])}
)}{!relevantUnknownFamilies.length ?
Alle benötigten Familien sind klassifiziert.
: null}{relevantInvalidQuantity ?
Mindestens eine übernommene Mengenangabe ist ungültig.{invalidQuantityFamilies.map((family) => )}
: null}
: null} + {step === 4 && plan ?

Neue Revit-Objekte können familienweise mit einem vorhandenen Projektgerät verknüpft werden.

{plan.newFamilyAndTypes.map((family) =>
{family}
)}
: null} + {step === 5 && plan ?
{plan.objectCount} Objekte im vollständigen Import.
Neu: {plan.newObjectCount}, fachlich geändert: {plan.changedObjectCount}, fehlend: {plan.missingObjectCount}. Fehlende Objekte werden nicht gelöst oder gelöscht. Apply liest die Datei erneut; Undo und Redo verwenden ausschließlich die gespeicherten Zustände.
: null} + {step > 0 ? : null} +
; +} + +function Count({ label, value }: { label: string; value: number }) { return
{value}
{label}
; } diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx index 2fdee3e..d2a02d0 100644 --- a/src/frontend/components/revit-object-table.tsx +++ b/src/frontend/components/revit-object-table.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types"; -import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; +import { createExternalReturnWorkbook, deleteMissingExternalProjectObjects, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; const rowHeight = 44; const viewportHeight = 520; @@ -48,7 +48,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; 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); + const returnSelectable = rows.filter((object) => object.presenceStatus === "present"); const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false; + const selectedMissing = data?.objects.filter((object) => selected.has(object.id) && object.presenceStatus === "missing") ?? []; + const selectionContainsPresentObject = data?.objects.some((object) => selected.has(object.id) && object.presenceStatus === "present") ?? false; async function applyBulk() { if (!data || selected.size === 0) return; @@ -84,12 +87,21 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; } } + async function deleteMissing() { + if (!data || selectedMissing.length === 0 || selectionContainsPresentObject) return; + if (!window.confirm(`${selectedMissing.length} fehlende Revit-Objekte wirklich löschen? Verknüpfte Mengen werden angepasst. Undo stellt Objekte, Links und Mengen wieder her.`)) return; + setSaving(true); setError(null); + try { const result = await deleteMissingExternalProjectObjects(projectId, data.currentRevision, selectedMissing.map((object) => object.id)); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); } + catch (value) { setError(value instanceof Error ? value.message : "Fehlende Revit-Objekte konnten nicht gelöscht werden."); } + 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}
{exportMessage ?
{exportMessage}
: null}{exportWarnings.length ?
Hinweise zur Rückgabe:
    {exportWarnings.map((warning) =>
  • {warning}
  • )}
: 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}
+
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; const missing = object.presenceStatus === "missing"; 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}
{missing ? "fehlend" : linked ? "verknüpft" : "frei"}
; })}
+
{planningField ? : null}
; } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index a4239b1..78b8227 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -99,6 +99,7 @@ export interface ExternalInitialImportStatusDto { imported: boolean; sourceName: string | null; objectCount: number; + missingObjectCount: number; importedAtIso: string | null; fileName: string | null; } @@ -111,6 +112,34 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto }; } +export interface ExternalFollowUpImportPlanDto extends ExternalInitialImportPlanDto { + newObjectCount: number; + changedObjectCount: number; + unchangedObjectCount: number; + missingObjectCount: number; + returnDeviationCount: number; + newObjectWorksharingIds: string[]; + newSourceRoomKeys: string[]; + newFamilyAndTypes: string[]; + changes: Array<{ objectId: string; worksharingId: string; familyAndType: string; changedFields: string[]; previousQuantity: number; suggestedQuantity: number; circuitDeviceRowId: string | null }>; + missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>; + returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>; +} + +export interface ApplyExternalFollowUpImportInput { + expectedRevision: number; + expectedSha256: string; + configuration: ExternalModelConfiguration; + objectsWithoutRoomDefaultDistributionBoardId: string | null; + sourceChangeDecisions: Array<{ objectId: string; accept: boolean }>; + roomDecisions: Array<{ sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null }>; + familyProjectDeviceDecisions: Array<{ familyAndType: string; projectDeviceId: string | null }>; +} + +export interface ExternalFollowUpImportResultDto extends ProjectCommandResultDto { + import: { importBatchId: string; objectCount: number; newObjectCount: number; changedObjectCount: number; missingObjectCount: number }; +} + export interface ExternalProjectObjectDto { id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string; familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index c873ef3..a793a95 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -41,6 +41,9 @@ import type { ExternalModelConfigurationSnapshotDto, ExternalInitialImportPlanDto, ExternalInitialImportResultDto, + ExternalFollowUpImportPlanDto, + ExternalFollowUpImportResultDto, + ApplyExternalFollowUpImportInput, ExternalInitialImportStatusDto, ApplyExternalInitialImportInput, ExternalProjectObjectsDto, @@ -232,6 +235,18 @@ export async function applyExternalInitialImport( }); } +export async function planExternalFollowUpImport(projectId: string, file: File, configuration: ExternalModelConfiguration, expectedRevision: number) { + return request(`/api/projects/${projectId}/external-model/follow-up-import/plan`, { + method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }), + }); +} + +export async function applyExternalFollowUpImport(projectId: string, file: File, input: ApplyExternalFollowUpImportInput) { + return request(`/api/projects/${projectId}/external-model/follow-up-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 = ""; @@ -247,6 +262,10 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision return request(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) }); } +export function deleteMissingExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[]) { + return request(`/api/projects/${projectId}/external-model/objects/delete-missing`, { method: "POST", body: JSON.stringify({ expectedRevision, objectIds, confirmed: true }) }); +} + export function createExternalReturnWorkbook( projectId: string, expectedRevision: number, diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 669e25d..e2cc30a 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -24,7 +24,9 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalFollowUpImportProjectCommandRepository } from "../../db/repositories/external-follow-up-import-project-command.repository.js"; import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js"; +import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/repositories/external-object-delete-missing-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"; @@ -76,8 +78,12 @@ export const externalModelConfigurationProjectCommandStore = new ExternalModelConfigurationProjectCommandRepository(db); export const externalInitialImportProjectCommandStore = new ExternalInitialImportProjectCommandRepository(db); +export const externalFollowUpImportProjectCommandStore = + new ExternalFollowUpImportProjectCommandRepository(db); export const externalObjectPlanningProjectCommandStore = new ExternalObjectPlanningProjectCommandRepository(db); +export const externalObjectDeleteMissingProjectCommandStore = + new ExternalObjectDeleteMissingProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = @@ -115,5 +121,7 @@ export const projectCommandService = new ProjectCommandService( externalObjectNewCircuitProjectCommandStore, estimateBalanceProjectCommandStore, externalInitialImportProjectCommandStore, - externalObjectPlanningProjectCommandStore + externalObjectPlanningProjectCommandStore, + externalFollowUpImportProjectCommandStore, + externalObjectDeleteMissingProjectCommandStore ); diff --git a/src/server/controllers/external-follow-up-import.controller.ts b/src/server/controllers/external-follow-up-import.controller.ts new file mode 100644 index 0000000..8e70c5b --- /dev/null +++ b/src/server/controllers/external-follow-up-import.controller.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import type { Request, Response } from "express"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import { createExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js"; +import { createExternalXlsxFollowUpImportPlan } from "../../external-model/application/external-xlsx-follow-up-import.js"; +import { buildExternalXlsxFollowUpTarget } from "../../external-model/application/external-xlsx-follow-up-target.js"; +import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.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 { buildProjectCircuitTrees } from "./circuit-tree.controller.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +export async function planExternalFollowUpImport(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + try { + const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); + const context = await loadContext(projectId, expectedRevision, req.body?.configuration); + const { plan } = await createExternalXlsxFollowUpImportPlan({ + fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration: context.configuration, + catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues, + }); + return res.json(plan); + } catch (error) { return res.status(400).json({ error: error instanceof Error ? error.message : "Folgeimport-Vorschau fehlgeschlagen." }); } +} + +export async function applyExternalFollowUpImport(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + try { + const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); + const context = await loadContext(projectId, expectedRevision, req.body?.configuration); + const bytes = decodeBase64(req.body?.contentBase64); + const { plan, document } = await createExternalXlsxFollowUpImportPlan({ + fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: context.configuration, + catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues, + }); + 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 built = buildExternalXlsxFollowUpTarget({ + projectId, expectedRevision, importedAtIso: new Date().toISOString(), configuration: context.configuration, + originalContentBase64: String(req.body.contentBase64), document, plan, currentState: context.state, + sourceChangeDecisions: requireArray(req.body?.sourceChangeDecisions, "Quelländerungsentscheidungen"), + roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"), + familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"), + objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"), + rowsById: context.rowsById, createId: randomUUID, + }); + const targetConfiguration = { ...context.configurationSnapshot, configuration: context.configuration }; + const result = projectCommandService.executeUser({ + projectId, expectedRevision, description: `Revit-Folgeimport ${plan.fileName}`, + command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: context.configurationSnapshot, targetConfiguration, expectedState: context.state, targetState: built.targetState, rows: built.rowTransitions }), + }); + return res.status(201).json({ ...result, import: { importBatchId: built.targetState.importBatches.at(-1)!.id, objectCount: plan.objectCount, newObjectCount: plan.newObjectCount, changedObjectCount: plan.changedObjectCount, missingObjectCount: plan.missingObjectCount } }); + } catch (error) { return respondWithProjectCommandError(error, res); } +} + +async function loadContext(projectId: string, expectedRevision: number, configurationValue: unknown) { + const [project, treeResult] = await Promise.all([projectRepository.findById(projectId), buildProjectCircuitTrees(projectId)]); + if (!project || !treeResult) throw new Error("Projekt nicht gefunden."); + if (project.currentRevision !== expectedRevision || treeResult.currentRevision !== expectedRevision) throw new Error("Der Folgeimportplan ist wegen einer neueren Projektrevision ungültig."); + const stored = externalModelConfigurationRepository.getByProject(projectId); + if (!stored.configuration) throw new Error("Für das Projekt ist keine Revit-XLSX-Konfiguration gespeichert."); + assertExternalModelConfiguration(configurationValue); + const stateResult = externalModelStateRepository.getByProject(projectId); + if (!stateResult.state.source) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import."); + const rowContexts = createRowContexts(treeResult); + const returnValues = new Map(stateResult.state.objects.map((object) => [object.id, projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? rowContexts.get(object.circuitDeviceRowId)?.returnContext ?? null : null, circuitMode: configurationValue.circuitMode, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV }).values])); + return { configuration: configurationValue, configurationSnapshot: stored.configuration, state: stateResult.state, returnValues, rowsById: new Map([...rowContexts].map(([id, value]) => [id, value.snapshot])) }; +} + +function createRowContexts(treeResult: NonNullable>>) { + const result = new Map(); + for (const tree of treeResult.trees) for (const section of tree.sections) for (const circuit of section.circuits) for (const row of circuit.deviceRows) result.set(row.id, { + returnContext: { rowId: row.id, displayName: row.displayName, powerPerUnitKw: row.powerPerUnit, cosPhi: row.cosPhi ?? null, phaseType: row.phaseType ?? null, category: section.category ?? null, circuitIdentifier: circuit.equipmentIdentifier, circuitVoltageV: circuit.voltage ?? null, distributionBoardName: tree.distributionBoardName }, + snapshot: { id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null }, + }); + return result; +} +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 parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; } +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-initial-import.controller.ts b/src/server/controllers/external-initial-import.controller.ts index 53227f4..25eb284 100644 --- a/src/server/controllers/external-initial-import.controller.ts +++ b/src/server/controllers/external-initial-import.controller.ts @@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) { 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 }); + return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: batch?.objectCount ?? result.state.objects.length, missingObjectCount: result.state.objects.filter((object) => object.presenceStatus === "missing").length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null }); } export async function planExternalInitialImport(req: Request, res: Response) { diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts index 8e90673..a381faa 100644 --- a/src/server/controllers/external-objects.controller.ts +++ b/src/server/controllers/external-objects.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from "express"; import { randomUUID } from "node:crypto"; import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; +import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js"; import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; @@ -57,6 +58,39 @@ export function updateExternalObjects(req: Request, res: Response) { } catch (error) { return respondWithProjectCommandError(error, res); } } +export async function deleteMissingExternalObjects(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + try { + const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); + const objectIds = parseObjectIds(req.body?.objectIds); + if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden."); + const { state, projectExists } = externalModelStateRepository.getByProject(projectId); + if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); + const byId = new Map(state.objects.map((object) => [object.id, object])); + const selected = objectIds.map((id) => { + const object = byId.get(id); + if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden."); + return object; + }); + const treeResult = await buildProjectCircuitTrees(projectId); + const quantitiesByRowId = new Map(); + for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity); + const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => { + const location = findRow(treeResult, rowId); + if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden."); + const expected = toRowSnapshot(location.row, location.circuitId); + return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } }; + }); + const result = projectCommandService.executeUser({ + projectId, + expectedRevision, + description: `${selected.length} fehlende Revit-Objekte gelöscht`, + command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }), + }); + return res.json(result); + } catch (error) { return respondWithProjectCommandError(error, res); } +} + export async function assignExternalObjectsToRow(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 436ff52..5d83ee0 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -63,8 +63,9 @@ import { getExternalInitialImportStatus, planExternalInitialImport, } from "../controllers/external-initial-import.controller.js"; -import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; +import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js"; +import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js"; export const projectRouter = Router(); @@ -92,8 +93,11 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus); projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); +projectRouter.post("/:projectId/external-model/follow-up-import/plan", planExternalFollowUpImport); +projectRouter.post("/:projectId/external-model/follow-up-import/apply", applyExternalFollowUpImport); projectRouter.get("/:projectId/external-model/objects", listExternalObjects); projectRouter.put("/:projectId/external-model/objects", updateExternalObjects); +projectRouter.post("/:projectId/external-model/objects/delete-missing", deleteMissingExternalObjects); projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow); projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow); projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit); diff --git a/tests/external-follow-up-import-project-command.repository.test.ts b/tests/external-follow-up-import-project-command.repository.test.ts new file mode 100644 index 0000000..8d4acf4 --- /dev/null +++ b/tests/external-follow-up-import-project-command.repository.test.ts @@ -0,0 +1,98 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import ExcelJS from "exceljs"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js"; +import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js"; +import { createExternalObjectDeleteMissingProjectCommand } from "../src/domain/models/external-object-delete-missing-project-command.model.js"; +import { ExternalObjectDeleteMissingProjectCommandRepository } from "../src/db/repositories/external-object-delete-missing-project-command.repository.js"; +import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; +import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; +import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.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"; +const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, 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 }] }; +const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; + +describe("external follow-up import project command", () => { + it("commits object and row quantities atomically and restores exact batches on undo and redo", async () => { + const context = createDatabaseContext(":memory:"); + try { + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + context.db.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); + context.db.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); + context.db.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); + context.db.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run(); + context.db.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run(); + const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration }; + context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); + const initialBytes = await createRevitXlsxFixture(); + const initialParsed = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs }); + let sequence = 0; + const initialState = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initialParsed.document, plan: initialParsed.plan, roomDecisions: initialParsed.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: initialParsed.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) }); + const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!; + context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run(); + + const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; + const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); + const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state; + const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }])); + const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues }); + const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!); + const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + const repository = new ExternalFollowUpImportProjectCommandRepository(context.db); + const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] }); + assert.throws( + () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: commandWithoutRequiredRowTransition }), + /Materialized quantity 3 does not match manual and external quantity 4/, + ); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3); + const applied = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: built.rowTransitions }) }); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); + let state = new ExternalModelStateRepository(context.db).getByProject("project-1").state; + assert.equal(state.importBatches.length, 2); assert.equal(state.importBatches[0]!.document, null); assert.equal(state.objects.find((object) => object.worksharingId === firstWorksharingId)!.planningValues.effectiveQuantity, 3); + + const undone = repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse }); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3); + state = new ExternalModelStateRepository(context.db).getByProject("project-1").state; + assert.equal(state.importBatches.length, 1); assert.ok(state.importBatches[0]!.document); + repository.execute({ projectId: "project-1", expectedRevision: 3, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse }); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); + + context.db.update(externalModelObjects).set({ presenceStatus: "missing" }).where(eq(externalModelObjects.id, importedFirst.id)).run(); + const missingObject = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.id === importedFirst.id)!; + const rowBeforeDelete = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!); + const deleteRepository = new ExternalObjectDeleteMissingProjectCommandRepository(context.db); + const deleted = deleteRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "user", command: createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: missingObject, target: null }], rows: [{ expected: rowBeforeDelete, target: { ...rowBeforeDelete, quantity: 1 } }] }) }); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 1); + assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get(), undefined); + const foreignRestore = createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: null, target: { ...missingObject, projectId: "project-2" } }], rows: deleted.inverse.payload.rows }); + assert.throws(() => deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "user", command: foreignRestore }), /anderes Projekt/); + deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse }); + assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); + assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get()!.circuitDeviceRowId, "row-1"); + } finally { context.close(); } + }); +}); + diff --git a/tests/external-xlsx-follow-up-import.test.ts b/tests/external-xlsx-follow-up-import.test.ts new file mode 100644 index 0000000..70cb7d9 --- /dev/null +++ b/tests/external-xlsx-follow-up-import.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import ExcelJS from "exceljs"; +import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; +import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.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 type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js"; +import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; + +const deviceTypeA = "10000000-0000-4000-8000-000000000001"; +const deviceTypeB = "10000000-0000-4000-8000-000000000002"; +const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003"; +const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; +const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, 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 }] }; + +async function initialState() { + const bytes = await createRevitXlsxFixture(); + const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs }); + let sequence = 0; + const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08: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 missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099"; + state.objects.push(missing); + return state; +} + +async function changedWorkbook(firstId = firstWorksharingId) { + const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture()); + const sheet = workbook.worksheets[0]!; + sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3; + sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1; + return new Uint8Array(await workbook.xlsx.writeBuffer()); +} + +function currentReturns(state: Awaited>) { + const map = new Map(); + for (const object of state.objects) map.set(object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }); + map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" }); + return map; +} + +describe("Linear XLSX follow-up import", () => { + it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => { + const state = await initialState(); + const bytes = await changedWorkbook(); + const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); + assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 }); + assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]); + assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true); + + const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); + assert.equal(caseChanged.plan.newObjectCount, 2); + assert.equal(caseChanged.plan.missingObjectCount, 2); + }); + + it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => { + const state = await initialState(); + const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!; + first.circuitDeviceRowId = "row-1"; + const bytes = await changedWorkbook(); + const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); + let sequence = 0; + const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null }; + const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + assert.equal(built.rowTransitions[0]!.target.quantity, 4); + assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3); + const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!; + assert.equal(missing.presenceStatus, "missing"); + assert.equal(missing.circuitDeviceRowId, null); + assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null); + assert.ok(built.targetState.importBatches[1]!.originalContentBase64); + + const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!; + assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity); + assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId); + assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId); + assert.equal(rejected.rowTransitions.length, 0); + }); +}); + diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index 90161b3..a7d20da 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, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; +import { applyExternalFollowUpImport, applyExternalInitialImport, createExternalReturnWorkbook, deleteMissingExternalProjectObjects, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalFollowUpImport, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; describe("Revit XLSX settings", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -53,6 +53,7 @@ describe("Revit XLSX settings", () => { const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8"); const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8"); const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8"); + const followUpWizard = readFileSync("src/frontend/components/revit-follow-up-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/); @@ -64,10 +65,23 @@ describe("Revit XLSX settings", () => { assert.match(wizard, /Schritt.*von/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /GZF 1/); + assert.match(revitPage, /Folgeimport/); + assert.match(followUpWizard, /in Revit noch nicht aktualisiert/); + assert.match(followUpWizard, /Fehlende Objekte werden nicht gelöst oder gelöscht/); assert.match(objectTable, /viewportHeight/); assert.match(objectTable, /Sammeländerung anwenden/); assert.match(objectTable, /Rückgabe-Excel herunterladen/); assert.match(objectTable, /Nur geänderte Objekte/); + assert.match(objectTable, /Ausgewählte fehlende löschen/); + }); + + it("deletes missing objects only after explicit confirmation", 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({ history: { currentRevision: 6 } }), { status: 200, headers: { "Content-Type": "application/json" } }); }; + try { await deleteMissingExternalProjectObjects("project-1", 5, ["object-1"]); } + finally { globalThis.fetch = originalFetch; } + assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/delete-missing", method: "POST", body: { expectedRevision: 5, objectIds: ["object-1"], confirmed: true } }]); }); it("loads one project object list and sends revision-safe bulk updates", async () => { @@ -90,4 +104,21 @@ describe("Revit XLSX settings", () => { finally { globalThis.fetch = originalFetch; } assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]); }); + + it("previews and retransmits the complete workbook for follow-up 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({ history: { currentRevision: 10 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } }); }; + try { + const configuration = createDefaultExternalModelConfiguration(); + const file = new File([new Uint8Array([1, 2])], "follow.xlsx"); + await planExternalFollowUpImport("project-1", file, configuration, 9); + await applyExternalFollowUpImport("project-1", file, { expectedRevision: 9, expectedSha256: "a".repeat(64), configuration, objectsWithoutRoomDefaultDistributionBoardId: null, sourceChangeDecisions: [], roomDecisions: [], familyProjectDeviceDecisions: [] }); + } finally { globalThis.fetch = originalFetch; } + assert.equal(requests[0].url, "/api/projects/project-1/external-model/follow-up-import/plan"); + assert.equal(requests[1].url, "/api/projects/project-1/external-model/follow-up-import/apply"); + assert.equal(requests[0].body?.contentBase64, "AQI="); + assert.equal(requests[1].body?.contentBase64, "AQI="); + assert.equal(requests[1].body?.expectedRevision, 9); + }); }); From 4ff01b2ccdbd3fdcfe7e281cbe98ee481298cd08 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 21:02:20 +0200 Subject: [PATCH 2/2] Harden Linear XLSX end to end --- AGENTS.md | 8 +- README.md | 4 +- docs/README.md | 4 +- docs/building-structure.md | 2 +- docs/circuit-list-editor-api.md | 61 +++--- docs/circuit-list-editor-known-limitations.md | 8 +- docs/current-architecture.md | 183 ++---------------- docs/print-view.md | 5 +- ...history-and-external-model-architecture.md | 10 +- docs/revit-requirements-llm-context.md | 12 +- docs/spec/07-implementation-phases-todo.md | 3 + docs/spec/08-current-product-backlog.md | 6 +- .../revit-excel-integration-requirements.md | 8 + docs/spec/revit-excel-phase-14-plan.md | 11 +- docs/spec/revit-excel-r10-validation.md | 60 ++++++ .../batched-insert.persistence.ts | 15 ++ ...ow-up-import-project-command.repository.ts | 3 +- ...itial-import-project-command.repository.ts | 7 +- ...roject-state-restore-command.repository.ts | 9 +- .../project-transfer.repository.ts | 5 +- .../components/revit-object-table-model.ts | 25 +++ .../components/revit-object-table.tsx | 18 +- tests/batched-insert.persistence.test.ts | 20 ++ tests/fixtures/revit-xlsx-fixtures.ts | 26 +++ tests/revit-xlsx-end-to-end.test.ts | 143 ++++++++++++++ tests/revit-xlsx-scale.test.ts | 112 +++++++++++ 26 files changed, 522 insertions(+), 246 deletions(-) create mode 100644 docs/spec/revit-excel-r10-validation.md create mode 100644 src/db/repositories/batched-insert.persistence.ts create mode 100644 src/frontend/components/revit-object-table-model.ts create mode 100644 tests/batched-insert.persistence.test.ts create mode 100644 tests/revit-xlsx-end-to-end.test.ts create mode 100644 tests/revit-xlsx-scale.test.ts diff --git a/AGENTS.md b/AGENTS.md index e4b9b94..75fdcf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,6 +351,12 @@ batch to metadata. `external-import.apply-follow-up` stores complete inverse states, so Undo/Redo never parses Excel. A separately confirmed `external-model-object.delete-missing` command removes missing objects and adjusts linked row quantities; Undo restores objects, links and quantities. +R10 verifies the complete path through return workbook, follow-up import, +snapshot restore, portable duplicate and restart-safe Undo/Redo. A 5,000-object +synthetic workbook covers parser, command/snapshot size, transactions and the +client-side virtual window. Complete external-state inserts use bounded batches +inside their existing transaction for initial/follow-up import, restore and +duplicate. See `docs/spec/revit-excel-r10-validation.md`. The target configuration has no independent version counter; `expectedRevision` invalidates stale plans, and each import batch stores a configuration copy. @@ -604,7 +610,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 R9 are complete and R10 is the next package. + 2026-09-30; R3 through R10 and Phase 14 are complete. - 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 d1e9523..51a3860 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind 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 R9 sind abgeschlossen; +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R10 sind abgeschlossen; die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport, virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe. -R10 härtet den Ablauf Ende zu Ende und für große Datenmengen. +Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüft. ## Unterstützter Arbeitsablauf diff --git a/docs/README.md b/docs/README.md index 90c5fc3..be55461 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Deployment und Betrieb](deployment.md) -## Aktive Revit-Umstellung +## Revit / Linear XLSX - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) – @@ -39,6 +39,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status, Paketgrenzen und Linear-Freigabepunkt nach R2 - [R0-Bestandsaudit](spec/revit-excel-r0-audit.md) +- [R10-Abnahme](spec/revit-excel-r10-validation.md) – Ende-zu-Ende- und + 5.000-Objekte-Nachweis - [LLM-Kontext für Phase 14](revit-requirements-llm-context.md) ## Zukunftsarchitektur diff --git a/docs/building-structure.md b/docs/building-structure.md index 9db2c91..2264b3f 100644 --- a/docs/building-structure.md +++ b/docs/building-structure.md @@ -101,7 +101,7 @@ bleiben. ## Offen - Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die - aktive Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten + abgeschlossene Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten Raumzuordnung im Importwizard nicht im Weg. - Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index de724aa..c88e4aa 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -67,8 +67,9 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo, Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen importierbar. -Das aktuelle Projekt-Snapshot-Schema 9 enthält die logischen Einspeisungsverweise -der Verteilungen; Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8 +Das aktuelle Projekt-Snapshot-Schema 10 enthält Linear-XLSX-Zustand und die in +Schema 9 eingeführten logischen Einspeisungsverweise der Verteilungen; +Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8 enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`. Snapshot-Versionen 1–7 werden dafür mit `areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene @@ -485,40 +486,32 @@ read nor write project data; applying a result uses `circuit.update` through Full request example and calculation rules: `docs/cable-sizing-module.md`. -## Revit CSV Foundation +## Revit / Linear XLSX -- `GET /projects/:projectId/external-csv/configuration` - - returns the complete versioned project configuration or `null` -- `PUT /projects/:projectId/external-csv/configuration` - - request: `expectedRevision` and the complete validated configuration - - executes `external-csv-configuration.update` - - returns configuration, revision and project history state -- `POST /projects/:projectId/external-csv/preview` - - request: file name and Base64-encoded CSV content up to 18 MB - - requires a stored project configuration - - returns transport metadata, SHA-256, row classifications, suspect row - numbers and mapped source values for every recognized IFC object - - does not persist a draft, mutate project state or create a revision -- `POST /projects/:projectId/external-csv/initial-import/plan` - - reparses the file and returns grouped room/family decisions plus current - room, distribution-board and ProjectDevice catalogs - - is stateless and rejects an already imported external source -- `POST /projects/:projectId/external-csv/initial-import/apply` - - requires the expected revision, configuration version, file hash and one - decision for every room and exact family/type group - - reparses the file, blocks unknown family/type values and executes - `external-import.apply-initial` as one undoable project revision - - creates no Circuit or CircuitDeviceRow -- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects` - - resolves the circuit list's distribution board server-side - - returns only external objects assigned to that board, with unassigned - objects first, their source room/marker values and current row-link status - - returns an empty list when the optional Revit import does not exist - - is read-only and exposes no stored command payloads or import bytes +- `GET|PUT /projects/:projectId/external-model/configuration` reads or writes + the complete XLSX configuration. PUT requires `expectedRevision`. +- `POST /projects/:projectId/external-model/configuration/detect` detects the + sheet layout and columns from an uploaded `.xlsx` sample without persisting. +- `GET /projects/:projectId/external-model/import-status` returns the latest + batch metadata, object count and current missing-object count. +- `POST .../initial-import/plan|apply` provides the stateless initial plan and + its confirmed atomic application. Apply retransmits and reparses the XLSX. +- `POST .../follow-up-import/plan|apply` classifies exact Worksharing-ID + additions, source changes, unchanged and missing objects. Apply requires the + preview hash and source decisions and updates linked row quantities atomically. +- `GET|PUT .../external-model/objects` loads one compact project list and + applies revision-safe multi-object planning changes. +- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row` + changes external-object row assignments through persistent commands. +- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only + objects marked missing and adjusts linked row quantities in the same command. +- `POST .../external-model/return-workbook` creates the structure-preserving + return XLSX for all, deviating or selected objects. +- `GET .../external-model/export-batches` returns append-only export metadata. -Parser errors expose stable codes such as `header-not-found`, -`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed -import uploads and parses the file again and verifies its hash. +Uploads accept only `.xlsx` up to 10 MiB. Worksharing-IDs are exact and +case-sensitive; IfcGUID is optional and non-unique. Undo/Redo uses persisted +command states and never reparses workbook bytes. ## Document Branding (print view) diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 63bc7b3..f9fecb5 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,11 +9,11 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. -- Die Linear-XLSX-Anbindung bis R9 ist umgesetzt: Erst- und Folgeimport, +- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport, Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes - Löschen fehlender Objekte verwenden persistente Commands. Offen ist die - R10-Härtung des gesamten Ablaufs mit mehreren Tausend synthetischen Objekten, - Messungen sowie Snapshot-/Transfer- und Neustarttests. + Löschen fehlender Objekte verwenden persistente Commands. Die vollständige + Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und + Neustarttests geprüft. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 5e746fe..d7f28cb 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -626,7 +626,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details: `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und - Transfer-Integration verwenden aktuell Snapshot-Schema 9. Persistente + Transfer-Integration verwenden aktuell Snapshot-Schema 10. Persistente Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur @@ -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 nach R10 R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014` entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte @@ -678,173 +678,32 @@ derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne Workbookbytes erneut einzulesen oder zu parsen. -### Historische Ausgangslage vor R3 +R6 bis R9 ergänzen die einmal geladene, clientseitig gefilterte und +virtualisierte Projektobjektliste, revisionierte Sammelplanung, atomare +Zuordnung im Stromkreislisteneditor, strukturtreue Rückgabe und den +vollständigen Folgeimport. Der Folgeimport trennt fachliche Quelländerungen +von noch nicht nach Revit übernommenen Rückgabewerten, aktualisiert akzeptierte +Mengen samt Row-Summe und markiert fehlende Objekte ohne ihren Link zu lösen. +Eine gesondert bestätigte Aktion löscht fehlende Objekte samt Mengenübergang; +Undo stellt Objekt, Link und Menge wieder her. -Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle -Laufzeitdokumentation mehr. +R10 weist den Ablauf über Rückgabe, Folgeimport, Snapshot-Restore, +Projektduplikat und Undo/Redo nach Datenbankneustart nach. Persistenzpfade für +vollständige externe Zustände schreiben große Mengen in begrenzten +SQLite-Blöcken. Der synthetische Abnahmetest verarbeitet 5.000 Objekte; Details +und Messwerte stehen in +[R10-Abnahme](spec/revit-excel-r10-validation.md). -Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen -Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte -Ausgangslage für den schrittweisen Cutover; er darf nicht erweitert werden. -Das verbindliche Ziel ist der XLSX-Austausch des Linear-Plugins mit exakter, -case-sensitiver Worksharing-ID. Details: -[Excel-Anforderungen](spec/revit-excel-integration-requirements.md) und -[R0–R10-Plan](spec/revit-excel-phase-14-plan.md). +## Historische CSV-Unterlagen -Der reine Adapter unter `src/external-model/csv` validiert die projektweite -Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine -klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt- -und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte -einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl -`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei. - -`external_csv_configurations` enthält höchstens eine versionierte Konfiguration -je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder -Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5–8, -Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren -werden Konfigurations-UUID und Projektlink remapped. - -Migration `0003` ergänzt die relationale Grundlage für genau eine implizite -`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte -Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte. -Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte -Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite -Konfigurationsversion am Batch, getrennt von der Schema-Version des -Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten -der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice -und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den -vollständigen Zustand deterministisch und unterscheidet unbekannte von noch -nicht importierten Projekten. Der letzte bestätigte Batch wird aus der -Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. -Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit -dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes, -Matrix und alle internen Links werden weiterhin vollständig erfasst, -stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim -Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben -unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für -einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen -Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes -erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und -validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt. -Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten -Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine -CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, -Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung -erzeugen weiterhin keine dieser Zeilen. - -Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom -weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit -`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe -Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der -effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide -Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln. -`external-object.update-row-assignment` ist diese atomare Schreibgrenze für -bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt -sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden -die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit, -Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft. -Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung; -unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht -still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem -unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die -Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach -Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen. -`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue -Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row -ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und -Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte -und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind. -Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine -normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel -bestehen. -`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit -vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer -externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks. -Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert -gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein -allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei -unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt -dieselben IDs, dasselbe BMK und denselben Schutzsnapshot. -Die drei Zuordnungsadapter teilen sich -`external-object-assignment.persistence.ts` für Snapshotvergleich, -Kompatibilitätsregeln und link-sichere Updates. -`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` -liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer. -Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur -dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte -Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige -Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen -Revit-Import ist die Projektion leer. -`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder -ändern die Konfiguration; der PUT plant Identität und nächsten -Konfigurationsstand serverseitig und verwendet den typisierten Command. -`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und -Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash, -Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte -Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. -`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet -denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen -noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei. -Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen -Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte, -liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die -vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge. -Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben -sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt. -`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei, -erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen. -Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen -Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional -einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende -Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt -stabile interne UUIDs und übergibt den vollständigen Zustand an -`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte -und alle `circuitDeviceRowId` bleiben `null`. -Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt, -Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. -Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den -zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur -die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der -Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende -Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er -zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume, -Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die -Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog -wird der vollständige Import als eine persistente, rückgängig machbare -Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen. -Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über -die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese -Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt. -Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt -den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert -ihn für die betroffene Gruppe. - -### Freigegebener Zielpfad ab R2 - -R2 ersetzt zunächst nur den Transportadapter und endet mit einem realen -Linear-Rückimporttest. R3 bleibt bis zu dessen Freigabe gesperrt. Scheitert der -ExcelJS-Roundtrip, wird vor R3 ein Adapter vorgeschlagen, der die Original-XLSX -direkt auf OOXML-Ebene ändert. - -Der Zielzustand verwendet Worksharing-ID als exakten, case-sensitiven externen -Schlüssel; IfcGUID bleibt optional. Die Konfiguration hat keinen eigenen -Versionszähler. `expectedRevision` bindet Vorschau und Apply, Importbatches -speichern eine Konfigurationskopie. Nur der letzte Batch hält Workbook und -Matrix vollständig, ältere Batches nur Metadaten. Apply parst die erneut -übertragene Datei, Undo/Redo arbeitet nur auf persistierten Zuständen. - -Die eigene Route `/projects/:projectId/revit` lädt eine kompakte Objektliste -einmal und stellt sie clientseitig gefiltert, sortiert und virtualisiert dar. -Verknüpfte Objekte folgen dem Verteiler ihrer Row. Row-, Circuit- und -Gruppenlöschungen lösen externe Links nach Warnung im selben Command; Undo -stellt sie wieder her. Raum- und Verteilerlöschungen bleiben bei externen -Referenzen gesperrt. +Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige +Ausgangslage bleibt ausschließlich im [R0-Audit](spec/revit-excel-r0-audit.md) +und in den als abgelöst markierten CSV-Spezifikationen nachvollziehbar. Alte +CSV-Snapshots und CSV-History werden weiterhin ausdrücklich abgelehnt. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- R10-Härtung des Linear-XLSX-Ablaufs für große Datenmengen sowie - Ende-zu-Ende-Nachweise über Snapshot, Transfer und Neustart - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/print-view.md b/docs/print-view.md index ecc00e2..04b266d 100644 --- a/docs/print-view.md +++ b/docs/print-view.md @@ -131,8 +131,9 @@ ihren Wert. Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`). -Snapshot-Schema 9 enthält zusätzlich die logischen Einspeisungsverweise; die -unterstützten Versionen 1–8 werden mit leeren Verweisen hochgestuft. Restore und +Snapshot-Schema 10 enthält den aktuellen Projektzustand; die in Schema 9 +eingeführten Einspeisungsverweise werden in den unterstützten Versionen 1–8 +mit leeren Verweisen hochgestuft. Restore und Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht zum logischen Projektsnapshot oder Projekttransfer. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 91b82ca..3629c23 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -116,8 +116,8 @@ Implemented automatic-snapshot policy: Phase 14 is active and follows the [Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and -[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The existing CSV/IFCGUID -runtime is a legacy starting point for the cutover, not the target model. +[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The CSV/IFCGUID runtime has +been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID. External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. @@ -152,6 +152,12 @@ Required behavior: - Missing objects and duplicate/conflicting Worksharing-IDs are reported, never silently reassigned. +R10 verifies the complete implemented path through initial import, planning, +row assignment, return workbook, follow-up import, snapshot restore, portable +duplicate and restart-safe Undo/Redo. Large state replacement uses bounded +SQLite insert batches so imports, restores and duplicates do not exceed the +driver's SQL-variable limit. + The project configuration has no separate revision counter. Stateless plans and apply requests bind to `expectedRevision`; every applied import batch holds its configuration copy. Apply reparses the retransmitted workbook. Undo and diff --git a/docs/revit-requirements-llm-context.md b/docs/revit-requirements-llm-context.md index 3782150..b9f84b2 100644 --- a/docs/revit-requirements-llm-context.md +++ b/docs/revit-requirements-llm-context.md @@ -1,15 +1,17 @@ # LLM-Kontext für die Revit-Integration -Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und -abgelöst. Für Arbeiten an Phase 14 sind vollständig zu lesen: +Die frühere Revit-/CSV-Anforderungsplanung ist abgelöst und Phase 14 ist mit +R10 abgeschlossen. Für Arbeiten an der Linear-XLSX-Anbindung sind vollständig +zu lesen: - [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) - [R0–R10-Plan](spec/revit-excel-phase-14-plan.md) - [R0-Bestandsaudit](spec/revit-excel-r0-audit.md) +- [R10-Abnahme](spec/revit-excel-r10-validation.md) - [Aktuelle Systemarchitektur](current-architecture.md) - [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md) Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel, -Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der derzeit noch im -Code vorhandene CSV-Stand ist ausschließlich die in R1 dokumentierte -Legacy-Ausgangslage für den schrittweisen Cutover. +Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad +ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen +Migrationsmeldung abgelehnt. diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 0043ef2..5968766 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -532,6 +532,9 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip +Status: abgeschlossen am 30.09.2026 mit R10. Siehe +[R10-Abnahme](revit-excel-r10-validation.md). + Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 6d1eae4..dda8fbb 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -162,7 +162,7 @@ ProjectDevices and device rows (#20). 3. [x] Snapshot-to-revision descriptions and history presentation. 4. [x] Distribution-board power summary and project column layout. -The Revit/Linear-XLSX round-trip is now the separately planned active Phase 14. -It uses exact Worksharing-ID identity and must not be inferred from these -unrelated backlog tasks. See +The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses +exact Worksharing-ID identity and must not be inferred from these unrelated +backlog tasks. See [the R0–R10 plan](revit-excel-phase-14-plan.md). diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md index bc86326..effceb6 100644 --- a/docs/spec/revit-excel-integration-requirements.md +++ b/docs/spec/revit-excel-integration-requirements.md @@ -478,6 +478,14 @@ synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal geladenen kompakten Liste arbeiten. +Umsetzungsstand R10: Die vollständige Kette ist mit dateibasierter SQLite- +Persistenz und Neustart getestet. Der synthetische Lasttest verarbeitet 5.000 +Objekte, misst Transport, Planung, Transaktionen, Command-/Snapshotgröße, +Heap und Clientprojektion und prüft Restore sowie Projektduplikat. Große +Zustandsersetzungen verwenden begrenzte Insertblöcke innerhalb derselben +atomaren Transaktion. Die Ergebnisse sind in der +[R10-Abnahme](revit-excel-r10-validation.md) dokumentiert. + Jedes Paket R1–R10 führt fokussierte Tests sowie aus: ```text diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index d7e6cbe..5039852 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -18,7 +18,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. | | R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. | | R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. | -| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | +| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. | ## R0 – Audit @@ -214,6 +214,15 @@ Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI, Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen, alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand. +Umsetzungsstand vom 30.09.2026: Der dateibasierte Integrationstest deckt die +vollständige Kette bis zu Undo/Redo nach einem Datenbankneustart ab. Ein +synthetisches Workbook mit 5.000 Objekten durchläuft Erst- und Folgeimport, +Snapshot-Restore, Projektduplikat sowie die extrahierte Clientfilter- und +Virtualisierungslogik. Die dabei gefundene SQLite-Grenze ungebatchter Inserts +ist für alle vollständigen External-Model-Zustandspfade behoben. Messwerte und +Abnahme stehen in [R10-Abnahme](revit-excel-r10-validation.md). Phase 14 ist +damit abgeschlossen. + ## Pflichtprüfungen je Paket R1–R10 ```text diff --git a/docs/spec/revit-excel-r10-validation.md b/docs/spec/revit-excel-r10-validation.md new file mode 100644 index 0000000..0bdc392 --- /dev/null +++ b/docs/spec/revit-excel-r10-validation.md @@ -0,0 +1,60 @@ +# R10-Abnahme der Linear-XLSX-Anbindung + +Stand: 30.09.2026. R10 schließt Phase 14 mit synthetischen, reproduzierbaren +Integrations- und Lasttests ab. Die reale Musterdatei wird dafür nicht benötigt +und bleibt unversioniert. + +## Ende-zu-Ende-Nachweis + +`tests/revit-xlsx-end-to-end.test.ts` verwendet eine dateibasierte SQLite- +Datenbank und prüft in einer Kette: + +```text +Erstimport → Objektplanung → Row-Zuordnung → Rückgabe-XLSX + → Folgeimport → Snapshot/Restore → Projektduplikat + → Datenbankneustart → Undo → Redo +``` + +Der Test bestätigt insbesondere die materialisierte Row-Menge, die Reduktion +des älteren Importbatches auf Metadaten, das Remapping aller internen UUIDs im +Duplikat und parserfreies Undo/Redo nach dem Neustart. + +## Last- und Größenmessung + +`tests/revit-xlsx-scale.test.ts` erzeugt 5.000 synthetische Objekte in einer +Linear-kompatiblen XLSX. Der Test misst Erst- und Folgeimport, Command- und +Snapshotgröße, Heap-Wachstum sowie clientseitiges Filtern, Sortieren und das +virtuelle Fenster. Er führt außerdem Snapshot-Restore und Projektduplikat mit +dem vollständigen Zustand aus. + +Messung auf der Entwicklungsumgebung am 30.09.2026: + +| Messwert | Ergebnis | +| --- | ---: | +| Objekte | 5.000 | +| XLSX-Datei | 191.019 Bytes | +| Erstimport-Command | 15.598.253 Bytes | +| logischer Projektsnapshot | 15.598.071 Bytes | +| XLSX-Erstplanung | 6.831 ms | +| atomare Erstimporttransaktion | 672 ms | +| XLSX-Folgeplanung | 6.303 ms | +| atomare Folgeimporttransaktion | 887 ms | +| Clientfilter, Sortierung und Fenster | 37 ms | +| Heap-Zuwachs des erfolgreichen Gesamtlaufs | 154.125.568 Bytes | + +Der Heap-Wert ist wegen Garbage Collection und der absichtlich verwendeten +In-Memory-Datenbank nicht deterministisch. Wiederholte Auditläufe lagen unter +der Testobergrenze von 768 MiB. Das virtuelle Fenster enthält bei 520 px Höhe +maximal 22 statt 5.000 Zeilen. + +## Gefundene und behobene Grenze + +Der erste 5.000-Objekte-Lauf scheiterte an einem ungebatchten SQLite- +Mehrfach-Insert. `insertManyInBatches` begrenzt nun die Insertblöcke für +Erstimport, Folgeimport, Snapshot-Restore und Projektduplikat. Alle Blöcke +bleiben Teil der jeweiligen bestehenden Transaktion; ein Fehler rollt weiterhin +die gesamte Operation zurück. + +Die vorhandenen Tests decken außerdem die deutschen Ablehnungen alter +CSV-Snapshots und CSV-History, Löschwarnungen, atomare Link-Inversen und den +Schutz referenzierter Räume und Verteiler ab. diff --git a/src/db/repositories/batched-insert.persistence.ts b/src/db/repositories/batched-insert.persistence.ts new file mode 100644 index 0000000..1dea2e1 --- /dev/null +++ b/src/db/repositories/batched-insert.persistence.ts @@ -0,0 +1,15 @@ +import type { AppDatabase } from "../database-context.js"; + +const defaultInsertBatchSize = 200; + +export function insertManyInBatches[0]>( + database: AppDatabase, + table: TTable, + values: readonly unknown[], + batchSize = defaultInsertBatchSize +) { + if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("Insert batch size must be a positive integer."); + for (let index = 0; index < values.length; index += batchSize) { + database.insert(table).values(values.slice(index, index + batchSize) as never).run(); + } +} diff --git a/src/db/repositories/external-follow-up-import-project-command.repository.ts b/src/db/repositories/external-follow-up-import-project-command.repository.ts index 7303200..f0b3078 100644 --- a/src/db/repositories/external-follow-up-import-project-command.repository.ts +++ b/src/db/repositories/external-follow-up-import-project-command.repository.ts @@ -21,6 +21,7 @@ import { rooms } from "../schema/rooms.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { ExternalModelStateRepository } from "./external-model-state.repository.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { insertManyInBatches } from "./batched-insert.persistence.js"; export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore { constructor(private readonly database: AppDatabase) {} @@ -80,7 +81,7 @@ function assertRows(database: AppDatabase, projectId: string, target: ExternalMo } } function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); 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 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(); insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))); insertManyInBatches(database, externalRoomMappings, target.roomMappings); insertManyInBatches(database, externalModelObjects, target.objects); } 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-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts index 70a249b..ede8875 100644 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -20,6 +20,7 @@ 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"; +import { insertManyInBatches } from "./batched-insert.persistence.js"; export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore { constructor(private readonly database: AppDatabase) {} @@ -76,9 +77,9 @@ function replaceState(database: AppDatabase, projectId: string, target: External 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(); + insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))); + insertManyInBatches(database, externalRoomMappings, target.roomMappings); + insertManyInBatches(database, externalModelObjects, target.objects); } 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; } diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 006b742..2386bf6 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -45,6 +45,7 @@ import { executeProjectCommandTransaction } from "./project-command-transaction. import { readProjectStateSnapshot, } from "./project-state-snapshot.persistence.js"; +import { insertManyInBatches } from "./batched-insert.persistence.js"; export class ProjectStateRestoreCommandRepository implements ProjectStateRestoreCommandStore @@ -428,11 +429,5 @@ function insertMany[0]>( table: TTable, values: unknown[] ) { - if (values.length === 0) { - return; - } - database - .insert(table) - .values(values as never) - .run(); + insertManyInBatches(database, table, values); } diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index fc73db5..c203388 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -37,6 +37,7 @@ import { readProjectStateSnapshot, } from "./project-state-snapshot.persistence.js"; import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js"; +import { insertManyInBatches } from "./batched-insert.persistence.js"; export class ProjectTransferRepository implements ProjectTransferStore { constructor(private readonly database: AppDatabase) {} @@ -256,7 +257,5 @@ function insertMany[0]>( table: TTable, values: unknown[] ) { - if (values.length > 0) { - database.insert(table).values(values as never).run(); - } + insertManyInBatches(database, table, values); } diff --git a/src/frontend/components/revit-object-table-model.ts b/src/frontend/components/revit-object-table-model.ts new file mode 100644 index 0000000..2618ccc --- /dev/null +++ b/src/frontend/components/revit-object-table-model.ts @@ -0,0 +1,25 @@ +import type { ExternalProjectObjectDto } from "../types"; + +export type RevitObjectSort = "family" | "room" | "worksharing"; + +export function filterAndSortRevitObjects( + objects: readonly ExternalProjectObjectDto[], + input: { search: string; boardFilter: string; assignmentFilter: string; sort: RevitObjectSort } +) { + const needle = input.search.trim().toLocaleLowerCase("de-DE"); + return objects.filter((object) => { + if (input.boardFilter !== "all" && (input.boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== input.boardFilter)) return false; + if (input.assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false; + if (input.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((leftObject, rightObject) => { + const left = input.sort === "family" ? leftObject.familyAndType : input.sort === "room" ? `${leftObject.sourceRoomNumber}\u0000${leftObject.sourceRoomName}` : leftObject.worksharingId; + const right = input.sort === "family" ? rightObject.familyAndType : input.sort === "room" ? `${rightObject.sourceRoomNumber}\u0000${rightObject.sourceRoomName}` : rightObject.worksharingId; + return left.localeCompare(right, "de", { numeric: true }) || leftObject.worksharingId.localeCompare(rightObject.worksharingId); + }); +} + +export function createVirtualRevitObjectWindow(rows: readonly T[], scrollTop: number, rowHeight: number, viewportHeight: number, overscan = 5) { + const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + return { first, rows: rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + overscan * 2) }; +} diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx index d2a02d0..2ab56c9 100644 --- a/src/frontend/components/revit-object-table.tsx +++ b/src/frontend/components/revit-object-table.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types"; import { createExternalReturnWorkbook, deleteMissingExternalProjectObjects, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; +import { createVirtualRevitObjectWindow, filterAndSortRevitObjects, type RevitObjectSort } from "./revit-object-table-model"; const rowHeight = 44; const viewportHeight = 520; @@ -12,7 +13,7 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; const [search, setSearch] = useState(""); const [boardFilter, setBoardFilter] = useState("all"); const [assignmentFilter, setAssignmentFilter] = useState("all"); - const [sort, setSort] = useState<"family" | "room" | "worksharing">("family"); + const [sort, setSort] = useState("family"); const [selected, setSelected] = useState>(new Set()); const [scrollTop, setScrollTop] = useState(0); const [bulkBoard, setBulkBoard] = useState(""); @@ -33,20 +34,9 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; 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); - }); + return filterAndSortRevitObjects(data.objects, { search, boardFilter, assignmentFilter, sort }); }, [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 { first, rows: visible } = createVirtualRevitObjectWindow(rows, scrollTop, rowHeight, viewportHeight); const selectable = rows.filter((object) => object.circuitDeviceRowId === null); const returnSelectable = rows.filter((object) => object.presenceStatus === "present"); const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false; diff --git a/tests/batched-insert.persistence.test.ts b/tests/batched-insert.persistence.test.ts new file mode 100644 index 0000000..e7b2127 --- /dev/null +++ b/tests/batched-insert.persistence.test.ts @@ -0,0 +1,20 @@ +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 { insertManyInBatches } from "../src/db/repositories/batched-insert.persistence.js"; +import { projects } from "../src/db/schema/projects.js"; + +describe("batched SQLite inserts", () => { + it("keeps every batch inside the caller transaction", () => { + const context = createDatabaseContext(":memory:"); + try { + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + const values = Array.from({ length: 401 }, (_, index) => ({ id: `project-${index}`, name: `Projekt ${index}` })); + values[400] = { id: "project-0", name: "Duplikat" }; + assert.throws(() => context.db.transaction((tx) => insertManyInBatches(tx, projects, values))); + assert.equal(context.db.select().from(projects).all().length, 0); + } finally { context.close(); } + }); +}); diff --git a/tests/fixtures/revit-xlsx-fixtures.ts b/tests/fixtures/revit-xlsx-fixtures.ts index 48e3cbd..a4afd7f 100644 --- a/tests/fixtures/revit-xlsx-fixtures.ts +++ b/tests/fixtures/revit-xlsx-fixtures.ts @@ -122,6 +122,32 @@ export async function createRevitXlsxFixture(options?: { return new Uint8Array(await workbook.xlsx.writeBuffer()); } +export async function createLargeRevitXlsxFixture(objectCount: number) { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Linear Export"); + worksheet.addRow([ + "Worksharing-ID", "CAx_Auswahlkenner", "MEP-Raum: Nummer", "MEP-Raum: Name", + "Familie und Typ", "CAX_AnzahlObjekte", "CAx_Anmerkung", "kbp_Stromkreisnummer", + "kbp-E-Verteiler", "kbp-E-Stromkreis", "kbp-E-Bezeichnung", + "kbp-E-Elektrische Leistung", "kbp-E-Spannung", "kbp-E-Stromstärke", "IfcGUID", + ]); + worksheet.addRow([]); + for (let index = 0; index < objectCount; index += 1) { + const suffix = String(index + 1).padStart(8, "0"); + worksheet.addRow([ + `AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-${suffix}`, + `Steckdose ${index + 1}`, + String(100 + (index % 250)), + `Raum ${index % 250}`, + "Steckdose: Standard", + (index % 4) + 1, + null, null, null, null, null, null, null, null, + `IFC-${suffix}`, + ]); + } + return new Uint8Array(await workbook.xlsx.writeBuffer()); +} + function setRowValues( worksheet: ExcelJS.Worksheet, rowNumber: number, diff --git a/tests/revit-xlsx-end-to-end.test.ts b/tests/revit-xlsx-end-to-end.test.ts new file mode 100644 index 0000000..45569be --- /dev/null +++ b/tests/revit-xlsx-end-to-end.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { existsSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { describe, it } from "node:test"; +import ExcelJS from "exceljs"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js"; +import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js"; +import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; +import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; +import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js"; +import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; +import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js"; +import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; +import { assertProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; +import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; +import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; +import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; +import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; +import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js"; +import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; +import { createExternalXlsxReturn } from "../src/external-model/xlsx/external-xlsx-transport.js"; +import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; + +const projectId = "project-e2e"; +const boardId = "board-e2e"; +const listId = "list-e2e"; +const sectionId = "section-e2e"; +const circuitId = "circuit-e2e"; +const rowId = "row-e2e"; +const deviceTypeA = "10000000-0000-4000-8000-000000000001"; +const deviceTypeB = "10000000-0000-4000-8000-000000000002"; +const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, 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 end-to-end", () => { + it("survives return, follow-up, snapshot restore, duplicate and restart undo/redo", async () => { + const databasePath = path.join(tmpdir(), `leistungsbilanz-r10-${randomUUID()}.sqlite`); + let context = createDatabaseContext(databasePath); + try { + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + seedProject(context.db); + const configurationSnapshot = { id: "config-e2e", projectId, configuration }; + context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); + + const initialBytes = await createRevitXlsxFixture(); + const initial = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs: emptyCatalogs() }); + let sequence = 0; + const initialState = buildExternalXlsxInitialImportTarget({ projectId, expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initial.document, plan: initial.plan, roomDecisions: initial.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: initial.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, createId: () => nextId("3", ++sequence) }); + new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) }); + + let state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; + const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!; + const plannedFirst = { ...first, planningValues: { ...first.planningValues, remark: "Geprüft" }, overriddenFields: [...first.overriddenFields, "remark" as const] }; + new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 1, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: first, target: plannedFirst }]) }); + + const rowBeforeAssignment = currentRow(context.db); + const assignedFirst = { ...plannedFirst, distributionBoardId: boardId, circuitDeviceRowId: rowId }; + new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 2, source: "user", command: createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: rowBeforeAssignment, target: { ...rowBeforeAssignment, quantity: 3 } }], objects: [{ expected: plannedFirst, target: assignedFirst }], confirmedConflictObjectIds: [first.id] }) }); + + state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; + const projected = state.objects.map((object) => projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? { rowId, displayName: "Steckdose", powerPerUnitKw: 0.125, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } : null, circuitMode: configuration.circuitMode, singlePhaseVoltageV: 230, threePhaseVoltageV: 400 })); + const latestBatch = state.importBatches.at(-1)!; + const returned = await createExternalXlsxReturn({ fileName: latestBatch.fileName, bytes: Buffer.from(latestBatch.originalContentBase64!, "base64"), configuration, selectedWorksharingIds: projected.map((entry) => entry.worksharingId), updates: projected.map((entry) => ({ worksharingId: entry.worksharingId, values: entry.values })) }); + const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(returned.bytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; + const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); + const returnValues = new Map(projected.map((entry) => [entry.objectId, entry.values])); + const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs: emptyCatalogs(), currentState: state, currentReturnValuesByObjectId: returnValues }); + const built = buildExternalXlsxFollowUpTarget({ projectId, expectedRevision: 3, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: follow.document, plan: follow.plan, currentState: state, sourceChangeDecisions: follow.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: follow.plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: follow.plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, rowsById: new Map([[rowId, currentRow(context.db)]]), createId: () => nextId("4", ++sequence) }); + new ExternalFollowUpImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 3, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: state, targetState: built.targetState, rows: built.rowTransitions }) }); + assert.equal(currentRow(context.db).quantity, 4); + state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; + assert.equal(state.importBatches.length, 2); + assert.equal(state.importBatches[0]!.originalContentBase64, null); + + const snapshot = new ProjectSnapshotRepository(context.db).createNamed({ projectId, expectedRevision: 4, name: "Nach Folgeimport" }); + assert.ok(snapshot); + const second = state.objects.find((object) => object.worksharingId === secondWorksharingId)!; + const changedSecond = { ...second, planningValues: { ...second.planningValues, simultaneityFactor: 0.55 }, overriddenFields: [...second.overriddenFields, "simultaneityFactor" as const] }; + new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 4, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: second, target: changedSecond }]) }); + const prepared = new ProjectSnapshotRepository(context.db).prepareRestore(projectId, snapshot.id); + assert.ok(prepared); + new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 5, source: "restore", command: prepared.command }); + + const transferRepository = new ProjectTransferRepository(context.db); + const transfer = transferRepository.exportProject(projectId); + assert.ok(transfer); + const duplicate = transferRepository.importDuplicate(transfer); + const duplicateState = readProjectStateSnapshot(context.db, duplicate.projectId)!.state; + const restoredState = readProjectStateSnapshot(context.db, projectId)!.state; + assert.deepEqual(duplicateState.externalModel.objects.map((object) => object.worksharingId).sort(), restoredState.externalModel.objects.map((object) => object.worksharingId).sort()); + assert.notEqual(duplicateState.externalModel.objects[0]!.id, restoredState.externalModel.objects[0]!.id); + assert.equal(duplicateState.externalModel.importBatches.at(-1)!.configurationSnapshot!.deviceTypes[0]!.id, duplicateState.externalModelConfiguration!.configuration.deviceTypes[0]!.id); + + context.close(); + context = createDatabaseContext(databasePath); + const history = new ProjectHistoryRepository(context.db); + const undo = history.getNextCommand(projectId, "undo"); + assert.ok(undo); assertProjectStateRestoreCommand(undo.command); + new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 6, source: "undo", historyTargetChangeSetId: undo.changeSetId, command: undo.command }); + assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.55); + const redo = history.getNextCommand(projectId, "redo"); + assert.ok(redo); assertProjectStateRestoreCommand(redo.command); + new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 7, source: "redo", historyTargetChangeSetId: redo.changeSetId, command: redo.command }); + assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.8); + assert.equal(currentRow(context.db).quantity, 4); + } finally { + context.close(); + for (const file of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) if (existsSync(file)) unlinkSync(file); + } + }); +}); + +function seedProject(database: ReturnType["db"]) { + database.insert(projects).values({ id: projectId, name: "R10 Projekt" }).run(); + database.insert(distributionBoards).values({ id: boardId, projectId, name: "UV 1" }).run(); + database.insert(circuitLists).values({ id: listId, projectId, distributionBoardId: boardId, name: "Liste" }).run(); + database.insert(circuitSections).values({ id: sectionId, circuitListId: listId, key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); + database.insert(circuits).values({ id: circuitId, circuitListId: listId, sectionId, equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run(); + database.insert(circuitProtectionDevices).values({ circuitId, type: "LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null }).run(); + database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run(); +} +function currentRow(database: ReturnType["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); } +function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1" }], projectDevices: [] }; } +function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; } diff --git a/tests/revit-xlsx-scale.test.ts b/tests/revit-xlsx-scale.test.ts new file mode 100644 index 0000000..386916f --- /dev/null +++ b/tests/revit-xlsx-scale.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +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 { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js"; +import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; +import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.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 { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js"; +import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; +import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; +import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; +import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; +import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; +import { createVirtualRevitObjectWindow, filterAndSortRevitObjects } from "../src/frontend/components/revit-object-table-model.js"; +import type { ExternalProjectObjectDto } from "../src/frontend/types.js"; +import { createLargeRevitXlsxFixture, externalXlsxTestConfiguration } from "./fixtures/revit-xlsx-fixtures.js"; + +const objectCount = 5_000; +const deviceTypeId = "10000000-0000-4000-8000-000000000001"; +const configuration = { + ...createDefaultExternalModelConfiguration(), + ...externalXlsxTestConfiguration, + deviceTypes: [{ id: deviceTypeId, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" } }], + familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId, quantityRule: "socketCount" as const }], +}; + +describe("Linear XLSX scale", () => { + it("imports, persists, filters and virtualizes 5,000 synthetic objects within bounded resources", async () => { + const heapBefore = process.memoryUsage().heapUsed; + const bytes = await createLargeRevitXlsxFixture(objectCount); + const parseStarted = performance.now(); + const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "large.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } }); + const parseMs = performance.now() - parseStarted; + assert.equal(plan.objectCount, objectCount); + assert.ok(bytes.byteLength < 10 * 1024 * 1024); + + let sequence = 0; + const target = buildExternalXlsxInitialImportTarget({ projectId: "project-scale", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T12: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 configurationSnapshot = { id: "config-scale", projectId: "project-scale", configuration }; + const command = createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), target); + const commandBytes = Buffer.byteLength(JSON.stringify(command)); + + const context = createDatabaseContext(":memory:"); + try { + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db.insert(projects).values({ id: "project-scale", name: "Lasttest" }).run(); + context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); + const transactionStarted = performance.now(); + new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-scale", expectedRevision: 0, source: "user", command }); + const transactionMs = performance.now() - transactionStarted; + let snapshot = readProjectStateSnapshot(context.db, "project-scale"); + assert.ok(snapshot); + assert.equal(snapshot.state.externalModel.objects.length, objectCount); + + const currentState = new ExternalModelStateRepository(context.db).getByProject("project-scale").state; + const returnValues = new Map(currentState.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }])); + const followParseStarted = performance.now(); + const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "large-follow.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }, currentState, currentReturnValuesByObjectId: returnValues }); + const followParseMs = performance.now() - followParseStarted; + const followTarget = buildExternalXlsxFollowUpTarget({ projectId: "project-scale", expectedRevision: 1, importedAtIso: "2026-09-30T13:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document: follow.document, plan: follow.plan, currentState, sourceChangeDecisions: [], roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map(), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); + const followCommand = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: currentState, targetState: followTarget.targetState, rows: followTarget.rowTransitions }); + const followTransactionStarted = performance.now(); + new ExternalFollowUpImportProjectCommandRepository(context.db).execute({ projectId: "project-scale", expectedRevision: 1, source: "user", command: followCommand }); + const followTransactionMs = performance.now() - followTransactionStarted; + const followedState = new ExternalModelStateRepository(context.db).getByProject("project-scale").state; + assert.equal(followedState.importBatches.length, 2); + assert.equal(followedState.importBatches[0]!.document, null); + assert.equal(followedState.objects.length, objectCount); + + const namedSnapshot = new ProjectSnapshotRepository(context.db).createNamed({ projectId: "project-scale", expectedRevision: 2, name: "Lasttest" }); + assert.ok(namedSnapshot); + const preparedRestore = new ProjectSnapshotRepository(context.db).prepareRestore("project-scale", namedSnapshot.id); + assert.ok(preparedRestore); + new ProjectStateRestoreCommandRepository(context.db).execute({ projectId: "project-scale", expectedRevision: 2, source: "restore", command: preparedRestore.command }); + const transferRepository = new ProjectTransferRepository(context.db); + const transfer = transferRepository.exportProject("project-scale"); + assert.ok(transfer); + const duplicate = transferRepository.importDuplicate(transfer); + assert.equal(readProjectStateSnapshot(context.db, duplicate.projectId)!.state.externalModel.objects.length, objectCount); + snapshot = readProjectStateSnapshot(context.db, "project-scale"); + assert.ok(snapshot); + + const clientRows: ExternalProjectObjectDto[] = target.objects.map((object) => ({ id: object.id, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid, deviceTypeId: object.deviceTypeId, familyAndType: object.acceptedSourceValues.familyAndType, selectionMarker: String(object.acceptedSourceValues.selectionMarker ?? ""), sourceCircuitIdentifier: "", sourceRoomNumber: String(object.acceptedSourceValues.roomNumber ?? ""), sourceRoomName: String(object.acceptedSourceValues.roomName ?? ""), roomId: null, distributionBoardId: object.distributionBoardId, linkedProjectDeviceId: object.linkedProjectDeviceId, circuitDeviceRowId: object.circuitDeviceRowId, presenceStatus: object.presenceStatus, planningValues: object.planningValues })); + const clientStarted = performance.now(); + const filtered = filterAndSortRevitObjects(clientRows, { search: "Raum 42", boardFilter: "all", assignmentFilter: "all", sort: "worksharing" }); + const window = createVirtualRevitObjectWindow(filtered, 4_400, 44, 520); + const clientMs = performance.now() - clientStarted; + assert.ok(filtered.length > 0); + assert.ok(window.rows.length <= 22); + + const heapDeltaBytes = Math.max(0, process.memoryUsage().heapUsed - heapBefore); + const snapshotBytes = Buffer.byteLength(snapshot.payloadJson); + assert.ok(parseMs < 15_000, `XLSX parse took ${parseMs.toFixed(0)} ms`); + assert.ok(transactionMs < 15_000, `Import transaction took ${transactionMs.toFixed(0)} ms`); + assert.ok(followParseMs < 15_000, `Follow-up parse took ${followParseMs.toFixed(0)} ms`); + assert.ok(followTransactionMs < 15_000, `Follow-up transaction took ${followTransactionMs.toFixed(0)} ms`); + assert.ok(clientMs < 1_000, `Client projection took ${clientMs.toFixed(0)} ms`); + assert.ok(heapDeltaBytes < 768 * 1024 * 1024, `Heap grew by ${heapDeltaBytes} bytes`); + assert.ok(commandBytes < 100 * 1024 * 1024); + console.info("R10 scale metrics", { objectCount, workbookBytes: bytes.byteLength, commandBytes, snapshotBytes, parseMs: Math.round(parseMs), transactionMs: Math.round(transactionMs), followParseMs: Math.round(followParseMs), followTransactionMs: Math.round(followTransactionMs), clientMs: Math.round(clientMs), heapDeltaBytes }); + } finally { context.close(); } + }); +});