forked from jappel/leistungsbilanz-ts
Harden Linear XLSX end to end
This commit is contained in:
parent
2581beee3c
commit
4ff01b2ccd
26 changed files with 522 additions and 246 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
docs/spec/revit-excel-r10-validation.md
Normal file
60
docs/spec/revit-excel-r10-validation.md
Normal 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.
|
||||
15
src/db/repositories/batched-insert.persistence.ts
Normal file
15
src/db/repositories/batched-insert.persistence.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
25
src/frontend/components/revit-object-table-model.ts
Normal file
25
src/frontend/components/revit-object-table-model.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
20
tests/batched-insert.persistence.test.ts
Normal file
20
tests/batched-insert.persistence.test.ts
Normal 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(); }
|
||||
});
|
||||
});
|
||||
26
tests/fixtures/revit-xlsx-fixtures.ts
vendored
26
tests/fixtures/revit-xlsx-fixtures.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
143
tests/revit-xlsx-end-to-end.test.ts
Normal file
143
tests/revit-xlsx-end-to-end.test.ts
Normal 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")}`; }
|
||||
112
tests/revit-xlsx-scale.test.ts
Normal file
112
tests/revit-xlsx-scale.test.ts
Normal 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(); }
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue