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(); } + }); +});