Harden Linear XLSX end to end

This commit is contained in:
Julian Appel 2026-09-30 21:02:20 +02:00
parent 2581beee3c
commit 4ff01b2ccd
26 changed files with 522 additions and 246 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,15 @@
import type { AppDatabase } from "../database-context.js";
const defaultInsertBatchSize = 200;
export function insertManyInBatches<TTable extends Parameters<AppDatabase["insert"]>[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();
}
}

View file

@ -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<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }

View file

@ -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; }

View file

@ -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<TTable extends Parameters<AppDatabase["insert"]>[0]>(
table: TTable,
values: unknown[]
) {
if (values.length === 0) {
return;
}
database
.insert(table)
.values(values as never)
.run();
insertManyInBatches(database, table, values);
}

View file

@ -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<TTable extends Parameters<AppDatabase["insert"]>[0]>(
table: TTable,
values: unknown[]
) {
if (values.length > 0) {
database.insert(table).values(values as never).run();
}
insertManyInBatches(database, table, values);
}

View file

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

View file

@ -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<RevitObjectSort>("family");
const [selected, setSelected] = useState<Set<string>>(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;

View file

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

View file

@ -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,

View file

@ -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<typeof createDatabaseContext>["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<typeof createDatabaseContext>["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")}`; }

View file

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