diff --git a/AGENTS.md b/AGENTS.md index 75fdcf3..d438bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -344,19 +344,6 @@ batch payloads. The Revit page filters and sorts it in the client and renders a virtualized window. `external-object.update-planning` applies multi-object board, ProjectDevice and planning-value changes as one persistent command; linked objects are excluded because they follow their CircuitDeviceRow board. -R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs -exactly, separates source decisions from return deviations, updates accepted -linked quantities atomically, retains missing objects and compacts every older -batch to metadata. `external-import.apply-follow-up` stores complete inverse -states, so Undo/Redo never parses Excel. A separately confirmed -`external-model-object.delete-missing` command removes missing objects and -adjusts linked row quantities; Undo restores objects, links and quantities. -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. @@ -610,7 +597,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 R10 and Phase 14 are complete. + 2026-09-30; R3 through R6 are complete and R7 is the next package. - All confirmed writes use typed project commands with `expectedRevision`, complete inverse data and the shared project-command transaction boundary. - Do not add automatic circuit planning, Revit circuit adoption, multi-source diff --git a/README.md b/README.md index 51a3860..d4aeae4 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind vorhanden. Die Backend- und Persistenzgrundlage der bisherigen Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit -Worksharing-ID als externem Schlüssel ersetzt. R0 bis R10 sind abgeschlossen; -die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport, -virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe. -Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüft. +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen; +die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten +Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen. +Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10. ## Unterstützter Arbeitsablauf @@ -41,9 +41,9 @@ Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüf - grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät übernehmen -- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte - prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an - Linear zurückgeben +- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite, + Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß + Phase-14-Plan in den nächsten Paketen angebunden ## Technik diff --git a/docs/README.md b/docs/README.md index be55461..90c5fc3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Deployment und Betrieb](deployment.md) -## Revit / Linear XLSX +## Aktive Revit-Umstellung - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) – @@ -39,8 +39,6 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status, Paketgrenzen und Linear-Freigabepunkt nach R2 - [R0-Bestandsaudit](spec/revit-excel-r0-audit.md) -- [R10-Abnahme](spec/revit-excel-r10-validation.md) – Ende-zu-Ende- und - 5.000-Objekte-Nachweis - [LLM-Kontext für Phase 14](revit-requirements-llm-context.md) ## Zukunftsarchitektur diff --git a/docs/building-structure.md b/docs/building-structure.md index 2264b3f..9db2c91 100644 --- a/docs/building-structure.md +++ b/docs/building-structure.md @@ -101,7 +101,7 @@ bleiben. ## Offen - Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die - abgeschlossene Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten + aktive Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten Raumzuordnung im Importwizard nicht im Weg. - Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index c88e4aa..de724aa 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -67,9 +67,8 @@ 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 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 +Das aktuelle Projekt-Snapshot-Schema 9 enthält die 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 @@ -486,32 +485,40 @@ read nor write project data; applying a result uses `circuit.update` through Full request example and calculation rules: `docs/cable-sizing-module.md`. -## Revit / Linear XLSX +## Revit CSV Foundation -- `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. +- `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 -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. +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. ## Document Branding (print view) diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index f9fecb5..ee2d470 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,11 +9,14 @@ - 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 R10 ist umgesetzt: Erst- und Folgeimport, - Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes - Löschen fehlender Objekte verwenden persistente Commands. Die vollständige - Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und - Neustarttests geprüft. +- The current Revit/CSV foundation and confirmed initial import are a legacy + starting point. The active Phase-14 plan replaces them with the Linear XLSX + exchange and exact Worksharing-ID identity. + R2 must pass a real Linear return-import check before database cutover in R3. + Existing-row assignment, move and detach plus creation of an external-only + row in an existing or newly created circuit have atomic persistent commands. + Their API planning and editor UI, follow-up import, conflict review and return + export are pending. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index d7f28cb..4dfa96c 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -626,7 +626,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details: `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und - Transfer-Integration verwenden aktuell Snapshot-Schema 10. Persistente + Transfer-Integration verwenden aktuell Snapshot-Schema 9. 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 nach R10 +## Revit-Anbindung: Linear-XLSX-Erstimport nach R5 R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014` entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte @@ -678,32 +678,174 @@ 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. -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. +### Historische Ausgangslage vor R3 -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 folgende Text beschreibt den Stand nach R1 und ist keine aktuelle +Laufzeitdokumentation mehr. -## Historische CSV-Unterlagen +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). -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. +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. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung +- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die + Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung + folgt dem R0–R10-Plan - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/print-view.md b/docs/print-view.md index 04b266d..ecc00e2 100644 --- a/docs/print-view.md +++ b/docs/print-view.md @@ -131,9 +131,8 @@ 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 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 +Snapshot-Schema 9 enthält zusätzlich die logischen Einspeisungsverweise; die +unterstützten Versionen 1–8 werden mit leeren Verweisen hochgestuft. Restore und Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht zum logischen Projektsnapshot oder Projekttransfer. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 3629c23..91b82ca 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -116,8 +116,8 @@ Implemented automatic-snapshot policy: Phase 14 is active and follows the [Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and -[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The CSV/IFCGUID runtime has -been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID. +[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. External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. @@ -152,12 +152,6 @@ Required behavior: - Missing objects and duplicate/conflicting Worksharing-IDs are reported, never silently reassigned. -R10 verifies the complete implemented path through initial import, planning, -row assignment, return workbook, follow-up import, snapshot restore, portable -duplicate and restart-safe Undo/Redo. Large state replacement uses bounded -SQLite insert batches so imports, restores and duplicates do not exceed the -driver's SQL-variable limit. - The project configuration has no separate revision counter. Stateless plans and apply requests bind to `expectedRevision`; every applied import batch holds its configuration copy. Apply reparses the retransmitted workbook. Undo and diff --git a/docs/revit-requirements-llm-context.md b/docs/revit-requirements-llm-context.md index b9f84b2..3782150 100644 --- a/docs/revit-requirements-llm-context.md +++ b/docs/revit-requirements-llm-context.md @@ -1,17 +1,15 @@ # LLM-Kontext für die Revit-Integration -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: +Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und +abgelöst. Für Arbeiten an Phase 14 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 CSV-Laufzeitpfad -ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen -Migrationsmeldung abgelehnt. +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. diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 5968766..0043ef2 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -532,9 +532,6 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip -Status: abgeschlossen am 30.09.2026 mit R10. Siehe -[R10-Abnahme](revit-excel-r10-validation.md). - Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index dda8fbb..6d1eae4 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -162,7 +162,7 @@ ProjectDevices and device rows (#20). 3. [x] Snapshot-to-revision descriptions and history presentation. 4. [x] Distribution-board power summary and project column layout. -The Revit/Linear-XLSX round-trip was completed separately in 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 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 R0–R10 plan](revit-excel-phase-14-plan.md). diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md index effceb6..ce80c46 100644 --- a/docs/spec/revit-excel-integration-requirements.md +++ b/docs/spec/revit-excel-integration-requirements.md @@ -430,15 +430,6 @@ Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen. Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird auf Metadaten reduziert. -Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute -Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare -`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte -Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command. -Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen -in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte -Löschung verwendet `external-model-object.delete-missing`; Undo stellt -Objekte, Links und Row-Mengen exakt wieder her. - ## 15. Commands, Persistenz und Sicherheit Projektbezogene Writes verwenden typisierte Commands mit vollständiger @@ -478,14 +469,6 @@ synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal geladenen kompakten Liste arbeiten. -Umsetzungsstand R10: Die vollständige Kette ist mit dateibasierter SQLite- -Persistenz und Neustart getestet. Der synthetische Lasttest verarbeitet 5.000 -Objekte, misst Transport, Planung, Transaktionen, Command-/Snapshotgröße, -Heap und Clientprojektion und prüft Restore sowie Projektduplikat. Große -Zustandsersetzungen verwenden begrenzte Insertblöcke innerhalb derselben -atomaren Transaktion. Die Ergebnisse sind in der -[R10-Abnahme](revit-excel-r10-validation.md) dokumentiert. - Jedes Paket R1–R10 führt fokussierte Tests sowie aus: ```text diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 5039852..61d8f2d 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -17,8 +17,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. | | R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. | | R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. | -| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. | -| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. | +| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | +| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | ## R0 – Audit @@ -190,17 +190,6 @@ Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert. - neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren; - ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo. -Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige -XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend -und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert -Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst -die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe -atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre -Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine -gesondert bestätigte Aktion kann sie anschließend mit passender -Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen; -Undo/Redo liest keine XLSX-Datei. - ## R10 – Härtung Ende-zu-Ende-Test: @@ -214,15 +203,6 @@ Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI, Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen, alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand. -Umsetzungsstand vom 30.09.2026: Der dateibasierte Integrationstest deckt die -vollständige Kette bis zu Undo/Redo nach einem Datenbankneustart ab. Ein -synthetisches Workbook mit 5.000 Objekten durchläuft Erst- und Folgeimport, -Snapshot-Restore, Projektduplikat sowie die extrahierte Clientfilter- und -Virtualisierungslogik. Die dabei gefundene SQLite-Grenze ungebatchter Inserts -ist für alle vollständigen External-Model-Zustandspfade behoben. Messwerte und -Abnahme stehen in [R10-Abnahme](revit-excel-r10-validation.md). Phase 14 ist -damit abgeschlossen. - ## Pflichtprüfungen je Paket R1–R10 ```text diff --git a/docs/spec/revit-excel-r10-validation.md b/docs/spec/revit-excel-r10-validation.md deleted file mode 100644 index 0bdc392..0000000 --- a/docs/spec/revit-excel-r10-validation.md +++ /dev/null @@ -1,60 +0,0 @@ -# R10-Abnahme der Linear-XLSX-Anbindung - -Stand: 30.09.2026. R10 schließt Phase 14 mit synthetischen, reproduzierbaren -Integrations- und Lasttests ab. Die reale Musterdatei wird dafür nicht benötigt -und bleibt unversioniert. - -## Ende-zu-Ende-Nachweis - -`tests/revit-xlsx-end-to-end.test.ts` verwendet eine dateibasierte SQLite- -Datenbank und prüft in einer Kette: - -```text -Erstimport → Objektplanung → Row-Zuordnung → Rückgabe-XLSX - → Folgeimport → Snapshot/Restore → Projektduplikat - → Datenbankneustart → Undo → Redo -``` - -Der Test bestätigt insbesondere die materialisierte Row-Menge, die Reduktion -des älteren Importbatches auf Metadaten, das Remapping aller internen UUIDs im -Duplikat und parserfreies Undo/Redo nach dem Neustart. - -## Last- und Größenmessung - -`tests/revit-xlsx-scale.test.ts` erzeugt 5.000 synthetische Objekte in einer -Linear-kompatiblen XLSX. Der Test misst Erst- und Folgeimport, Command- und -Snapshotgröße, Heap-Wachstum sowie clientseitiges Filtern, Sortieren und das -virtuelle Fenster. Er führt außerdem Snapshot-Restore und Projektduplikat mit -dem vollständigen Zustand aus. - -Messung auf der Entwicklungsumgebung am 30.09.2026: - -| Messwert | Ergebnis | -| --- | ---: | -| Objekte | 5.000 | -| XLSX-Datei | 191.019 Bytes | -| Erstimport-Command | 15.598.253 Bytes | -| logischer Projektsnapshot | 15.598.071 Bytes | -| XLSX-Erstplanung | 6.831 ms | -| atomare Erstimporttransaktion | 672 ms | -| XLSX-Folgeplanung | 6.303 ms | -| atomare Folgeimporttransaktion | 887 ms | -| Clientfilter, Sortierung und Fenster | 37 ms | -| Heap-Zuwachs des erfolgreichen Gesamtlaufs | 154.125.568 Bytes | - -Der Heap-Wert ist wegen Garbage Collection und der absichtlich verwendeten -In-Memory-Datenbank nicht deterministisch. Wiederholte Auditläufe lagen unter -der Testobergrenze von 768 MiB. Das virtuelle Fenster enthält bei 520 px Höhe -maximal 22 statt 5.000 Zeilen. - -## Gefundene und behobene Grenze - -Der erste 5.000-Objekte-Lauf scheiterte an einem ungebatchten SQLite- -Mehrfach-Insert. `insertManyInBatches` begrenzt nun die Insertblöcke für -Erstimport, Folgeimport, Snapshot-Restore und Projektduplikat. Alle Blöcke -bleiben Teil der jeweiligen bestehenden Transaktion; ein Fehler rollt weiterhin -die gesamte Operation zurück. - -Die vorhandenen Tests decken außerdem die deutschen Ablehnungen alter -CSV-Snapshots und CSV-History, Löschwarnungen, atomare Link-Inversen und den -Schutz referenzierter Räume und Verteiler ab. diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index 8c2735c..46c3d5d 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -5,7 +5,6 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { IconLink } from "../../../../frontend/components/icon-link"; import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; -import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard"; import { RevitObjectTable } from "../../../../frontend/components/revit-object-table"; import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal"; import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration"; @@ -20,7 +19,6 @@ export default function RevitPage() { const [importStatus, setImportStatus] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); - const [followUpOpen, setFollowUpOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -43,12 +41,11 @@ export default function RevitPage() { } return
-
Projekt · Revit

{project?.name ?? "Revit-Anbindung"}

Linear-XLSX austauschen und Revit-Objekte verwalten

+
Projekt · Revit

{project?.name ?? "Revit-Anbindung"}

Linear-XLSX austauschen und Revit-Objekte verwalten

{error ?
{error}
: null} -

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

{importStatus?.imported ? <>

{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}

Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}

:

Noch kein Linear-XLSX-Import vorhanden.

}
- {importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} +

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

{importStatus?.imported ? <>

{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}

Quelle: {importStatus.sourceName}

:

Noch kein Linear-XLSX-Import vorhanden.

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

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

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

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

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

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

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

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

{plan.newFamilyAndTypes.map((family) =>
{family}
)}
: null} - {step === 5 && plan ?
{plan.objectCount} Objekte im vollständigen Import.
Neu: {plan.newObjectCount}, fachlich geändert: {plan.changedObjectCount}, fehlend: {plan.missingObjectCount}. Fehlende Objekte werden nicht gelöst oder gelöscht. Apply liest die Datei erneut; Undo und Redo verwenden ausschließlich die gespeicherten Zustände.
: null} - {step > 0 ? : null} -
; -} - -function Count({ label, value }: { label: string; value: number }) { return
{value}
{label}
; } diff --git a/src/frontend/components/revit-object-table-model.ts b/src/frontend/components/revit-object-table-model.ts deleted file mode 100644 index 2618ccc..0000000 --- a/src/frontend/components/revit-object-table-model.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ExternalProjectObjectDto } from "../types"; - -export type RevitObjectSort = "family" | "room" | "worksharing"; - -export function filterAndSortRevitObjects( - objects: readonly ExternalProjectObjectDto[], - input: { search: string; boardFilter: string; assignmentFilter: string; sort: RevitObjectSort } -) { - const needle = input.search.trim().toLocaleLowerCase("de-DE"); - return objects.filter((object) => { - if (input.boardFilter !== "all" && (input.boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== input.boardFilter)) return false; - if (input.assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false; - if (input.assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false; - return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle)); - }).sort((leftObject, rightObject) => { - const left = input.sort === "family" ? leftObject.familyAndType : input.sort === "room" ? `${leftObject.sourceRoomNumber}\u0000${leftObject.sourceRoomName}` : leftObject.worksharingId; - const right = input.sort === "family" ? rightObject.familyAndType : input.sort === "room" ? `${rightObject.sourceRoomNumber}\u0000${rightObject.sourceRoomName}` : rightObject.worksharingId; - return left.localeCompare(right, "de", { numeric: true }) || leftObject.worksharingId.localeCompare(rightObject.worksharingId); - }); -} - -export function createVirtualRevitObjectWindow(rows: readonly T[], scrollTop: number, rowHeight: number, viewportHeight: number, overscan = 5) { - const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); - return { first, rows: rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + overscan * 2) }; -} diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx index 2ab56c9..2fdee3e 100644 --- a/src/frontend/components/revit-object-table.tsx +++ b/src/frontend/components/revit-object-table.tsx @@ -2,8 +2,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"; +import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; const rowHeight = 44; const viewportHeight = 520; @@ -13,7 +12,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"); + const [sort, setSort] = useState<"family" | "room" | "worksharing">("family"); const [selected, setSelected] = useState>(new Set()); const [scrollTop, setScrollTop] = useState(0); const [bulkBoard, setBulkBoard] = useState(""); @@ -34,14 +33,22 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; const rows = useMemo(() => { if (!data) return []; - return filterAndSortRevitObjects(data.objects, { search, boardFilter, assignmentFilter, sort }); + const needle = search.trim().toLocaleLowerCase("de-DE"); + return data.objects.filter((object) => { + if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false; + if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false; + if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false; + return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle)); + }).sort((a, b) => { + const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId; + const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId; + return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId); + }); }, [data, search, boardFilter, assignmentFilter, sort]); - const { first, rows: visible } = createVirtualRevitObjectWindow(rows, scrollTop, rowHeight, viewportHeight); + const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5); + const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10); const selectable = rows.filter((object) => object.circuitDeviceRowId === null); - const returnSelectable = rows.filter((object) => object.presenceStatus === "present"); const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false; - const selectedMissing = data?.objects.filter((object) => selected.has(object.id) && object.presenceStatus === "missing") ?? []; - const selectionContainsPresentObject = data?.objects.some((object) => selected.has(object.id) && object.presenceStatus === "present") ?? false; async function applyBulk() { if (!data || selected.size === 0) return; @@ -77,21 +84,12 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string; } } - async function deleteMissing() { - if (!data || selectedMissing.length === 0 || selectionContainsPresentObject) return; - if (!window.confirm(`${selectedMissing.length} fehlende Revit-Objekte wirklich löschen? Verknüpfte Mengen werden angepasst. Undo stellt Objekte, Links und Mengen wieder her.`)) return; - setSaving(true); setError(null); - try { const result = await deleteMissingExternalProjectObjects(projectId, data.currentRevision, selectedMissing.map((object) => object.id)); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); } - catch (value) { setError(value instanceof Error ? value.message : "Fehlende Revit-Objekte konnten nicht gelöscht werden."); } - finally { setSaving(false); } - } - if (!data) return
Revit-Objekte werden geladen …
; return

Revit-Objekte

{rows.length} von {data.objects.length} · {selected.size} ausgewählt
setSearch(event.target.value)} />
{error ?
{error}
: null}
{exportMessage ?
{exportMessage}
: null}{exportWarnings.length ?
Hinweise zur Rückgabe:
    {exportWarnings.map((warning) =>
  • {warning}
  • )}
: null}
-
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; const missing = object.presenceStatus === "missing"; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType}
{object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{missing ? "fehlend" : linked ? "verknüpft" : "frei"}
; })}
-
{planningField ? : null}
+
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType}
{object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{linked ? "verknüpft" : "frei"}
; })}
+
{planningField ? : null}
; } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 78b8227..a4239b1 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -99,7 +99,6 @@ export interface ExternalInitialImportStatusDto { imported: boolean; sourceName: string | null; objectCount: number; - missingObjectCount: number; importedAtIso: string | null; fileName: string | null; } @@ -112,34 +111,6 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto }; } -export interface ExternalFollowUpImportPlanDto extends ExternalInitialImportPlanDto { - newObjectCount: number; - changedObjectCount: number; - unchangedObjectCount: number; - missingObjectCount: number; - returnDeviationCount: number; - newObjectWorksharingIds: string[]; - newSourceRoomKeys: string[]; - newFamilyAndTypes: string[]; - changes: Array<{ objectId: string; worksharingId: string; familyAndType: string; changedFields: string[]; previousQuantity: number; suggestedQuantity: number; circuitDeviceRowId: string | null }>; - missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>; - returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>; -} - -export interface ApplyExternalFollowUpImportInput { - expectedRevision: number; - expectedSha256: string; - configuration: ExternalModelConfiguration; - objectsWithoutRoomDefaultDistributionBoardId: string | null; - sourceChangeDecisions: Array<{ objectId: string; accept: boolean }>; - roomDecisions: Array<{ sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null }>; - familyProjectDeviceDecisions: Array<{ familyAndType: string; projectDeviceId: string | null }>; -} - -export interface ExternalFollowUpImportResultDto extends ProjectCommandResultDto { - import: { importBatchId: string; objectCount: number; newObjectCount: number; changedObjectCount: number; missingObjectCount: number }; -} - export interface ExternalProjectObjectDto { id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string; familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index a793a95..c873ef3 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -41,9 +41,6 @@ import type { ExternalModelConfigurationSnapshotDto, ExternalInitialImportPlanDto, ExternalInitialImportResultDto, - ExternalFollowUpImportPlanDto, - ExternalFollowUpImportResultDto, - ApplyExternalFollowUpImportInput, ExternalInitialImportStatusDto, ApplyExternalInitialImportInput, ExternalProjectObjectsDto, @@ -235,18 +232,6 @@ export async function applyExternalInitialImport( }); } -export async function planExternalFollowUpImport(projectId: string, file: File, configuration: ExternalModelConfiguration, expectedRevision: number) { - return request(`/api/projects/${projectId}/external-model/follow-up-import/plan`, { - method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }), - }); -} - -export async function applyExternalFollowUpImport(projectId: string, file: File, input: ApplyExternalFollowUpImportInput) { - return request(`/api/projects/${projectId}/external-model/follow-up-import/apply`, { - method: "POST", body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }), - }); -} - async function fileToBase64(file: File) { const bytes = new Uint8Array(await file.arrayBuffer()); let binary = ""; @@ -262,10 +247,6 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision return request(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) }); } -export function deleteMissingExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[]) { - return request(`/api/projects/${projectId}/external-model/objects/delete-missing`, { method: "POST", body: JSON.stringify({ expectedRevision, objectIds, confirmed: true }) }); -} - export function createExternalReturnWorkbook( projectId: string, expectedRevision: number, diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index e2cc30a..669e25d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -24,9 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; -import { ExternalFollowUpImportProjectCommandRepository } from "../../db/repositories/external-follow-up-import-project-command.repository.js"; import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js"; -import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/repositories/external-object-delete-missing-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js"; @@ -78,12 +76,8 @@ export const externalModelConfigurationProjectCommandStore = new ExternalModelConfigurationProjectCommandRepository(db); export const externalInitialImportProjectCommandStore = new ExternalInitialImportProjectCommandRepository(db); -export const externalFollowUpImportProjectCommandStore = - new ExternalFollowUpImportProjectCommandRepository(db); export const externalObjectPlanningProjectCommandStore = new ExternalObjectPlanningProjectCommandRepository(db); -export const externalObjectDeleteMissingProjectCommandStore = - new ExternalObjectDeleteMissingProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = @@ -121,7 +115,5 @@ export const projectCommandService = new ProjectCommandService( externalObjectNewCircuitProjectCommandStore, estimateBalanceProjectCommandStore, externalInitialImportProjectCommandStore, - externalObjectPlanningProjectCommandStore, - externalFollowUpImportProjectCommandStore, - externalObjectDeleteMissingProjectCommandStore + externalObjectPlanningProjectCommandStore ); diff --git a/src/server/controllers/external-follow-up-import.controller.ts b/src/server/controllers/external-follow-up-import.controller.ts deleted file mode 100644 index 8e70c5b..0000000 --- a/src/server/controllers/external-follow-up-import.controller.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { Request, Response } from "express"; -import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; -import { createExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js"; -import { createExternalXlsxFollowUpImportPlan } from "../../external-model/application/external-xlsx-follow-up-import.js"; -import { buildExternalXlsxFollowUpTarget } from "../../external-model/application/external-xlsx-follow-up-target.js"; -import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js"; -import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; -import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js"; -import { projectCommandService } from "../composition/project-command-stores.js"; -import { buildProjectCircuitTrees } from "./circuit-tree.controller.js"; -import { respondWithProjectCommandError } from "./project-command.controller.js"; - -export async function planExternalFollowUpImport(req: Request, res: Response) { - const projectId = String(req.params.projectId ?? ""); - try { - const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); - const context = await loadContext(projectId, expectedRevision, req.body?.configuration); - const { plan } = await createExternalXlsxFollowUpImportPlan({ - fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration: context.configuration, - catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues, - }); - return res.json(plan); - } catch (error) { return res.status(400).json({ error: error instanceof Error ? error.message : "Folgeimport-Vorschau fehlgeschlagen." }); } -} - -export async function applyExternalFollowUpImport(req: Request, res: Response) { - const projectId = String(req.params.projectId ?? ""); - try { - const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); - const context = await loadContext(projectId, expectedRevision, req.body?.configuration); - const bytes = decodeBase64(req.body?.contentBase64); - const { plan, document } = await createExternalXlsxFollowUpImportPlan({ - fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: context.configuration, - catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues, - }); - if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" }); - const built = buildExternalXlsxFollowUpTarget({ - projectId, expectedRevision, importedAtIso: new Date().toISOString(), configuration: context.configuration, - originalContentBase64: String(req.body.contentBase64), document, plan, currentState: context.state, - sourceChangeDecisions: requireArray(req.body?.sourceChangeDecisions, "Quelländerungsentscheidungen"), - roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"), - familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"), - objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"), - rowsById: context.rowsById, createId: randomUUID, - }); - const targetConfiguration = { ...context.configurationSnapshot, configuration: context.configuration }; - const result = projectCommandService.executeUser({ - projectId, expectedRevision, description: `Revit-Folgeimport ${plan.fileName}`, - command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: context.configurationSnapshot, targetConfiguration, expectedState: context.state, targetState: built.targetState, rows: built.rowTransitions }), - }); - return res.status(201).json({ ...result, import: { importBatchId: built.targetState.importBatches.at(-1)!.id, objectCount: plan.objectCount, newObjectCount: plan.newObjectCount, changedObjectCount: plan.changedObjectCount, missingObjectCount: plan.missingObjectCount } }); - } catch (error) { return respondWithProjectCommandError(error, res); } -} - -async function loadContext(projectId: string, expectedRevision: number, configurationValue: unknown) { - const [project, treeResult] = await Promise.all([projectRepository.findById(projectId), buildProjectCircuitTrees(projectId)]); - if (!project || !treeResult) throw new Error("Projekt nicht gefunden."); - if (project.currentRevision !== expectedRevision || treeResult.currentRevision !== expectedRevision) throw new Error("Der Folgeimportplan ist wegen einer neueren Projektrevision ungültig."); - const stored = externalModelConfigurationRepository.getByProject(projectId); - if (!stored.configuration) throw new Error("Für das Projekt ist keine Revit-XLSX-Konfiguration gespeichert."); - assertExternalModelConfiguration(configurationValue); - const stateResult = externalModelStateRepository.getByProject(projectId); - if (!stateResult.state.source) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import."); - const rowContexts = createRowContexts(treeResult); - const returnValues = new Map(stateResult.state.objects.map((object) => [object.id, projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? rowContexts.get(object.circuitDeviceRowId)?.returnContext ?? null : null, circuitMode: configurationValue.circuitMode, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV }).values])); - return { configuration: configurationValue, configurationSnapshot: stored.configuration, state: stateResult.state, returnValues, rowsById: new Map([...rowContexts].map(([id, value]) => [id, value.snapshot])) }; -} - -function createRowContexts(treeResult: NonNullable>>) { - const result = new Map(); - for (const tree of treeResult.trees) for (const section of tree.sections) for (const circuit of section.circuits) for (const row of circuit.deviceRows) result.set(row.id, { - returnContext: { rowId: row.id, displayName: row.displayName, powerPerUnitKw: row.powerPerUnit, cosPhi: row.cosPhi ?? null, phaseType: row.phaseType ?? null, category: section.category ?? null, circuitIdentifier: circuit.equipmentIdentifier, circuitVoltageV: circuit.voltage ?? null, distributionBoardName: tree.distributionBoardName }, - snapshot: { id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null }, - }); - return result; -} -async function loadCatalogs(projectId: string) { const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); return { floors, rooms, distributionBoards, projectDevices }; } -function parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; } -function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; } -function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); } -function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; } -function requireArray(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; } diff --git a/src/server/controllers/external-initial-import.controller.ts b/src/server/controllers/external-initial-import.controller.ts index 25eb284..53227f4 100644 --- a/src/server/controllers/external-initial-import.controller.ts +++ b/src/server/controllers/external-initial-import.controller.ts @@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) { const result = externalModelStateRepository.getByProject(projectId); if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); const batch = result.state.importBatches.at(-1) ?? null; - return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: batch?.objectCount ?? result.state.objects.length, missingObjectCount: result.state.objects.filter((object) => object.presenceStatus === "missing").length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null }); + return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null }); } export async function planExternalInitialImport(req: Request, res: Response) { diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts index a381faa..8e90673 100644 --- a/src/server/controllers/external-objects.controller.ts +++ b/src/server/controllers/external-objects.controller.ts @@ -1,7 +1,6 @@ import type { Request, Response } from "express"; import { randomUUID } from "node:crypto"; import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; -import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js"; import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; @@ -58,39 +57,6 @@ export function updateExternalObjects(req: Request, res: Response) { } catch (error) { return respondWithProjectCommandError(error, res); } } -export async function deleteMissingExternalObjects(req: Request, res: Response) { - const projectId = String(req.params.projectId ?? ""); - try { - const expectedRevision = parseExpectedRevision(req.body?.expectedRevision); - const objectIds = parseObjectIds(req.body?.objectIds); - if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden."); - const { state, projectExists } = externalModelStateRepository.getByProject(projectId); - if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); - const byId = new Map(state.objects.map((object) => [object.id, object])); - const selected = objectIds.map((id) => { - const object = byId.get(id); - if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden."); - return object; - }); - const treeResult = await buildProjectCircuitTrees(projectId); - const quantitiesByRowId = new Map(); - for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity); - const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => { - const location = findRow(treeResult, rowId); - if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden."); - const expected = toRowSnapshot(location.row, location.circuitId); - return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } }; - }); - const result = projectCommandService.executeUser({ - projectId, - expectedRevision, - description: `${selected.length} fehlende Revit-Objekte gelöscht`, - command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }), - }); - return res.json(result); - } catch (error) { return respondWithProjectCommandError(error, res); } -} - export async function assignExternalObjectsToRow(req: Request, res: Response) { const projectId = String(req.params.projectId ?? ""); try { diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 5d83ee0..436ff52 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -63,9 +63,8 @@ import { getExternalInitialImportStatus, planExternalInitialImport, } from "../controllers/external-initial-import.controller.js"; -import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; +import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js"; -import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js"; export const projectRouter = Router(); @@ -93,11 +92,8 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus); projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); -projectRouter.post("/:projectId/external-model/follow-up-import/plan", planExternalFollowUpImport); -projectRouter.post("/:projectId/external-model/follow-up-import/apply", applyExternalFollowUpImport); projectRouter.get("/:projectId/external-model/objects", listExternalObjects); projectRouter.put("/:projectId/external-model/objects", updateExternalObjects); -projectRouter.post("/:projectId/external-model/objects/delete-missing", deleteMissingExternalObjects); projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow); projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow); projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit); diff --git a/tests/batched-insert.persistence.test.ts b/tests/batched-insert.persistence.test.ts deleted file mode 100644 index e7b2127..0000000 --- a/tests/batched-insert.persistence.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import assert from "node:assert/strict"; -import path from "node:path"; -import { describe, it } from "node:test"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { createDatabaseContext } from "../src/db/database-context.js"; -import { insertManyInBatches } from "../src/db/repositories/batched-insert.persistence.js"; -import { projects } from "../src/db/schema/projects.js"; - -describe("batched SQLite inserts", () => { - it("keeps every batch inside the caller transaction", () => { - const context = createDatabaseContext(":memory:"); - try { - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - const values = Array.from({ length: 401 }, (_, index) => ({ id: `project-${index}`, name: `Projekt ${index}` })); - values[400] = { id: "project-0", name: "Duplikat" }; - assert.throws(() => context.db.transaction((tx) => insertManyInBatches(tx, projects, values))); - assert.equal(context.db.select().from(projects).all().length, 0); - } finally { context.close(); } - }); -}); diff --git a/tests/external-follow-up-import-project-command.repository.test.ts b/tests/external-follow-up-import-project-command.repository.test.ts deleted file mode 100644 index 8d4acf4..0000000 --- a/tests/external-follow-up-import-project-command.repository.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import ExcelJS from "exceljs"; -import { eq } from "drizzle-orm"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { createDatabaseContext } from "../src/db/database-context.js"; -import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js"; -import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; -import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; -import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; -import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; -import { circuitLists } from "../src/db/schema/circuit-lists.js"; -import { circuitSections } from "../src/db/schema/circuit-sections.js"; -import { circuits } from "../src/db/schema/circuits.js"; -import { distributionBoards } from "../src/db/schema/distribution-boards.js"; -import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; -import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; -import { projects } from "../src/db/schema/projects.js"; -import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js"; -import { createExternalObjectDeleteMissingProjectCommand } from "../src/domain/models/external-object-delete-missing-project-command.model.js"; -import { ExternalObjectDeleteMissingProjectCommandRepository } from "../src/db/repositories/external-object-delete-missing-project-command.repository.js"; -import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; -import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; -import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; -import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; -import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; -import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; - -const deviceTypeA = "10000000-0000-4000-8000-000000000001"; -const deviceTypeB = "10000000-0000-4000-8000-000000000002"; -const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; -const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; - -describe("external follow-up import project command", () => { - it("commits object and row quantities atomically and restores exact batches on undo and redo", async () => { - const context = createDatabaseContext(":memory:"); - try { - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - context.db.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); - context.db.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); - context.db.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); - context.db.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run(); - context.db.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run(); - const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration }; - context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); - const initialBytes = await createRevitXlsxFixture(); - const initialParsed = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs }); - let sequence = 0; - const initialState = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initialParsed.document, plan: initialParsed.plan, roomDecisions: initialParsed.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: initialParsed.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); - new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) }); - const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!; - context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run(); - - const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; - const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); - const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state; - const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }])); - const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues }); - const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!); - const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); - const repository = new ExternalFollowUpImportProjectCommandRepository(context.db); - const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] }); - assert.throws( - () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: commandWithoutRequiredRowTransition }), - /Materialized quantity 3 does not match manual and external quantity 4/, - ); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3); - const applied = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: built.rowTransitions }) }); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); - let state = new ExternalModelStateRepository(context.db).getByProject("project-1").state; - assert.equal(state.importBatches.length, 2); assert.equal(state.importBatches[0]!.document, null); assert.equal(state.objects.find((object) => object.worksharingId === firstWorksharingId)!.planningValues.effectiveQuantity, 3); - - const undone = repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse }); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3); - state = new ExternalModelStateRepository(context.db).getByProject("project-1").state; - assert.equal(state.importBatches.length, 1); assert.ok(state.importBatches[0]!.document); - repository.execute({ projectId: "project-1", expectedRevision: 3, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse }); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); - - context.db.update(externalModelObjects).set({ presenceStatus: "missing" }).where(eq(externalModelObjects.id, importedFirst.id)).run(); - const missingObject = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.id === importedFirst.id)!; - const rowBeforeDelete = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!); - const deleteRepository = new ExternalObjectDeleteMissingProjectCommandRepository(context.db); - const deleted = deleteRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "user", command: createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: missingObject, target: null }], rows: [{ expected: rowBeforeDelete, target: { ...rowBeforeDelete, quantity: 1 } }] }) }); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 1); - assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get(), undefined); - const foreignRestore = createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: null, target: { ...missingObject, projectId: "project-2" } }], rows: deleted.inverse.payload.rows }); - assert.throws(() => deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "user", command: foreignRestore }), /anderes Projekt/); - deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse }); - assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4); - assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get()!.circuitDeviceRowId, "row-1"); - } finally { context.close(); } - }); -}); - diff --git a/tests/external-xlsx-follow-up-import.test.ts b/tests/external-xlsx-follow-up-import.test.ts deleted file mode 100644 index 70cb7d9..0000000 --- a/tests/external-xlsx-follow-up-import.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import ExcelJS from "exceljs"; -import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; -import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; -import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; -import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; -import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js"; -import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; - -const deviceTypeA = "10000000-0000-4000-8000-000000000001"; -const deviceTypeB = "10000000-0000-4000-8000-000000000002"; -const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003"; -const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; -const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; - -async function initialState() { - const bytes = await createRevitXlsxFixture(); - const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs }); - let sequence = 0; - const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); - const missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099"; - state.objects.push(missing); - return state; -} - -async function changedWorkbook(firstId = firstWorksharingId) { - const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture()); - const sheet = workbook.worksheets[0]!; - sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3; - sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1; - return new Uint8Array(await workbook.xlsx.writeBuffer()); -} - -function currentReturns(state: Awaited>) { - const map = new Map(); - for (const object of state.objects) map.set(object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }); - map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" }); - return map; -} - -describe("Linear XLSX follow-up import", () => { - it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => { - const state = await initialState(); - const bytes = await changedWorkbook(); - const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); - assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 }); - assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]); - assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true); - - const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); - assert.equal(caseChanged.plan.newObjectCount, 2); - assert.equal(caseChanged.plan.missingObjectCount, 2); - }); - - it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => { - const state = await initialState(); - const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!; - first.circuitDeviceRowId = "row-1"; - const bytes = await changedWorkbook(); - const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); - let sequence = 0; - const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null }; - const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); - assert.equal(built.rowTransitions[0]!.target.quantity, 4); - assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3); - const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!; - assert.equal(missing.presenceStatus, "missing"); - assert.equal(missing.circuitDeviceRowId, null); - assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null); - assert.ok(built.targetState.importBatches[1]!.originalContentBase64); - - const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); - const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!; - assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity); - assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId); - assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId); - assert.equal(rejected.rowTransitions.length, 0); - }); -}); - diff --git a/tests/fixtures/revit-xlsx-fixtures.ts b/tests/fixtures/revit-xlsx-fixtures.ts index a4afd7f..48e3cbd 100644 --- a/tests/fixtures/revit-xlsx-fixtures.ts +++ b/tests/fixtures/revit-xlsx-fixtures.ts @@ -122,32 +122,6 @@ export async function createRevitXlsxFixture(options?: { return new Uint8Array(await workbook.xlsx.writeBuffer()); } -export async function createLargeRevitXlsxFixture(objectCount: number) { - const workbook = new ExcelJS.Workbook(); - const worksheet = workbook.addWorksheet("Linear Export"); - worksheet.addRow([ - "Worksharing-ID", "CAx_Auswahlkenner", "MEP-Raum: Nummer", "MEP-Raum: Name", - "Familie und Typ", "CAX_AnzahlObjekte", "CAx_Anmerkung", "kbp_Stromkreisnummer", - "kbp-E-Verteiler", "kbp-E-Stromkreis", "kbp-E-Bezeichnung", - "kbp-E-Elektrische Leistung", "kbp-E-Spannung", "kbp-E-Stromstärke", "IfcGUID", - ]); - worksheet.addRow([]); - for (let index = 0; index < objectCount; index += 1) { - const suffix = String(index + 1).padStart(8, "0"); - worksheet.addRow([ - `AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-${suffix}`, - `Steckdose ${index + 1}`, - String(100 + (index % 250)), - `Raum ${index % 250}`, - "Steckdose: Standard", - (index % 4) + 1, - null, null, null, null, null, null, null, null, - `IFC-${suffix}`, - ]); - } - return new Uint8Array(await workbook.xlsx.writeBuffer()); -} - function setRowValues( worksheet: ExcelJS.Worksheet, rowNumber: number, diff --git a/tests/revit-xlsx-end-to-end.test.ts b/tests/revit-xlsx-end-to-end.test.ts deleted file mode 100644 index 45569be..0000000 --- a/tests/revit-xlsx-end-to-end.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync, unlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { describe, it } from "node:test"; -import ExcelJS from "exceljs"; -import { eq } from "drizzle-orm"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { createDatabaseContext } from "../src/db/database-context.js"; -import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js"; -import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; -import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; -import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js"; -import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; -import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; -import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; -import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; -import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; -import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; -import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; -import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; -import { circuitLists } from "../src/db/schema/circuit-lists.js"; -import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; -import { circuitSections } from "../src/db/schema/circuit-sections.js"; -import { circuits } from "../src/db/schema/circuits.js"; -import { distributionBoards } from "../src/db/schema/distribution-boards.js"; -import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; -import { projects } from "../src/db/schema/projects.js"; -import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js"; -import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; -import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js"; -import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; -import { assertProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; -import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js"; -import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js"; -import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; -import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; -import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js"; -import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import { createExternalXlsxReturn } from "../src/external-model/xlsx/external-xlsx-transport.js"; -import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js"; - -const projectId = "project-e2e"; -const boardId = "board-e2e"; -const listId = "list-e2e"; -const sectionId = "section-e2e"; -const circuitId = "circuit-e2e"; -const rowId = "row-e2e"; -const deviceTypeA = "10000000-0000-4000-8000-000000000001"; -const deviceTypeB = "10000000-0000-4000-8000-000000000002"; -const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; - -describe("Linear XLSX end-to-end", () => { - it("survives return, follow-up, snapshot restore, duplicate and restart undo/redo", async () => { - const databasePath = path.join(tmpdir(), `leistungsbilanz-r10-${randomUUID()}.sqlite`); - let context = createDatabaseContext(databasePath); - try { - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - seedProject(context.db); - const configurationSnapshot = { id: "config-e2e", projectId, configuration }; - context.db.insert(externalModelConfigurations).values(configurationSnapshot).run(); - - const initialBytes = await createRevitXlsxFixture(); - const initial = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs: emptyCatalogs() }); - let sequence = 0; - const initialState = buildExternalXlsxInitialImportTarget({ projectId, expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initial.document, plan: initial.plan, roomDecisions: initial.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: initial.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, createId: () => nextId("3", ++sequence) }); - new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) }); - - let state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; - const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!; - const plannedFirst = { ...first, planningValues: { ...first.planningValues, remark: "Geprüft" }, overriddenFields: [...first.overriddenFields, "remark" as const] }; - new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 1, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: first, target: plannedFirst }]) }); - - const rowBeforeAssignment = currentRow(context.db); - const assignedFirst = { ...plannedFirst, distributionBoardId: boardId, circuitDeviceRowId: rowId }; - new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 2, source: "user", command: createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: rowBeforeAssignment, target: { ...rowBeforeAssignment, quantity: 3 } }], objects: [{ expected: plannedFirst, target: assignedFirst }], confirmedConflictObjectIds: [first.id] }) }); - - state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; - const projected = state.objects.map((object) => projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? { rowId, displayName: "Steckdose", powerPerUnitKw: 0.125, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } : null, circuitMode: configuration.circuitMode, singlePhaseVoltageV: 230, threePhaseVoltageV: 400 })); - const latestBatch = state.importBatches.at(-1)!; - const returned = await createExternalXlsxReturn({ fileName: latestBatch.fileName, bytes: Buffer.from(latestBatch.originalContentBase64!, "base64"), configuration, selectedWorksharingIds: projected.map((entry) => entry.worksharingId), updates: projected.map((entry) => ({ worksharingId: entry.worksharingId, values: entry.values })) }); - const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(returned.bytes); workbook.worksheets[0]!.getCell(3, 6).value = 3; - const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer()); - const returnValues = new Map(projected.map((entry) => [entry.objectId, entry.values])); - const follow = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs: emptyCatalogs(), currentState: state, currentReturnValuesByObjectId: returnValues }); - const built = buildExternalXlsxFollowUpTarget({ projectId, expectedRevision: 3, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: follow.document, plan: follow.plan, currentState: state, sourceChangeDecisions: follow.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: follow.plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: boardId })), familyProjectDeviceDecisions: follow.plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: boardId, rowsById: new Map([[rowId, currentRow(context.db)]]), createId: () => nextId("4", ++sequence) }); - new ExternalFollowUpImportProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 3, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: state, targetState: built.targetState, rows: built.rowTransitions }) }); - assert.equal(currentRow(context.db).quantity, 4); - state = new ExternalModelStateRepository(context.db).getByProject(projectId).state; - assert.equal(state.importBatches.length, 2); - assert.equal(state.importBatches[0]!.originalContentBase64, null); - - const snapshot = new ProjectSnapshotRepository(context.db).createNamed({ projectId, expectedRevision: 4, name: "Nach Folgeimport" }); - assert.ok(snapshot); - const second = state.objects.find((object) => object.worksharingId === secondWorksharingId)!; - const changedSecond = { ...second, planningValues: { ...second.planningValues, simultaneityFactor: 0.55 }, overriddenFields: [...second.overriddenFields, "simultaneityFactor" as const] }; - new ExternalObjectPlanningProjectCommandRepository(context.db).execute({ projectId, expectedRevision: 4, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: second, target: changedSecond }]) }); - const prepared = new ProjectSnapshotRepository(context.db).prepareRestore(projectId, snapshot.id); - assert.ok(prepared); - new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 5, source: "restore", command: prepared.command }); - - const transferRepository = new ProjectTransferRepository(context.db); - const transfer = transferRepository.exportProject(projectId); - assert.ok(transfer); - const duplicate = transferRepository.importDuplicate(transfer); - const duplicateState = readProjectStateSnapshot(context.db, duplicate.projectId)!.state; - const restoredState = readProjectStateSnapshot(context.db, projectId)!.state; - assert.deepEqual(duplicateState.externalModel.objects.map((object) => object.worksharingId).sort(), restoredState.externalModel.objects.map((object) => object.worksharingId).sort()); - assert.notEqual(duplicateState.externalModel.objects[0]!.id, restoredState.externalModel.objects[0]!.id); - assert.equal(duplicateState.externalModel.importBatches.at(-1)!.configurationSnapshot!.deviceTypes[0]!.id, duplicateState.externalModelConfiguration!.configuration.deviceTypes[0]!.id); - - context.close(); - context = createDatabaseContext(databasePath); - const history = new ProjectHistoryRepository(context.db); - const undo = history.getNextCommand(projectId, "undo"); - assert.ok(undo); assertProjectStateRestoreCommand(undo.command); - new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 6, source: "undo", historyTargetChangeSetId: undo.changeSetId, command: undo.command }); - assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.55); - const redo = history.getNextCommand(projectId, "redo"); - assert.ok(redo); assertProjectStateRestoreCommand(redo.command); - new ProjectStateRestoreCommandRepository(context.db).execute({ projectId, expectedRevision: 7, source: "redo", historyTargetChangeSetId: redo.changeSetId, command: redo.command }); - assert.equal(new ExternalModelStateRepository(context.db).getByProject(projectId).state.objects.find((object) => object.worksharingId === secondWorksharingId)!.planningValues.simultaneityFactor, 0.8); - assert.equal(currentRow(context.db).quantity, 4); - } finally { - context.close(); - for (const file of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) if (existsSync(file)) unlinkSync(file); - } - }); -}); - -function seedProject(database: ReturnType["db"]) { - database.insert(projects).values({ id: projectId, name: "R10 Projekt" }).run(); - database.insert(distributionBoards).values({ id: boardId, projectId, name: "UV 1" }).run(); - database.insert(circuitLists).values({ id: listId, projectId, distributionBoardId: boardId, name: "Liste" }).run(); - database.insert(circuitSections).values({ id: sectionId, circuitListId: listId, key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); - database.insert(circuits).values({ id: circuitId, circuitListId: listId, sectionId, equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run(); - database.insert(circuitProtectionDevices).values({ circuitId, type: "LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null }).run(); - database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run(); -} -function currentRow(database: ReturnType["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); } -function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1" }], projectDevices: [] }; } -function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; } diff --git a/tests/revit-xlsx-scale.test.ts b/tests/revit-xlsx-scale.test.ts deleted file mode 100644 index 386916f..0000000 --- a/tests/revit-xlsx-scale.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -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(); } - }); -}); diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index a7d20da..90161b3 100644 --- a/tests/revit-xlsx-settings-api.test.ts +++ b/tests/revit-xlsx-settings-api.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import { applyExternalFollowUpImport, applyExternalInitialImport, createExternalReturnWorkbook, deleteMissingExternalProjectObjects, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalFollowUpImport, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; +import { applyExternalInitialImport, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; describe("Revit XLSX settings", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -53,7 +53,6 @@ describe("Revit XLSX settings", () => { const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8"); const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8"); const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8"); - const followUpWizard = readFileSync("src/frontend/components/revit-follow-up-import-wizard.tsx", "utf8"); const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8"); assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/); assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/); @@ -65,23 +64,10 @@ describe("Revit XLSX settings", () => { assert.match(wizard, /Schritt.*von/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /GZF 1/); - assert.match(revitPage, /Folgeimport/); - assert.match(followUpWizard, /in Revit noch nicht aktualisiert/); - assert.match(followUpWizard, /Fehlende Objekte werden nicht gelöst oder gelöscht/); assert.match(objectTable, /viewportHeight/); assert.match(objectTable, /Sammeländerung anwenden/); assert.match(objectTable, /Rückgabe-Excel herunterladen/); assert.match(objectTable, /Nur geänderte Objekte/); - assert.match(objectTable, /Ausgewählte fehlende löschen/); - }); - - it("deletes missing objects only after explicit confirmation", async () => { - const requests: Array<{ url: string; method: string; body: unknown }> = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 6 } }), { status: 200, headers: { "Content-Type": "application/json" } }); }; - try { await deleteMissingExternalProjectObjects("project-1", 5, ["object-1"]); } - finally { globalThis.fetch = originalFetch; } - assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/delete-missing", method: "POST", body: { expectedRevision: 5, objectIds: ["object-1"], confirmed: true } }]); }); it("loads one project object list and sends revision-safe bulk updates", async () => { @@ -104,21 +90,4 @@ describe("Revit XLSX settings", () => { finally { globalThis.fetch = originalFetch; } assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]); }); - - it("previews and retransmits the complete workbook for follow-up apply", async () => { - const requests: Array<{ url: string; method: string; body: Record | undefined }> = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 10 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } }); }; - try { - const configuration = createDefaultExternalModelConfiguration(); - const file = new File([new Uint8Array([1, 2])], "follow.xlsx"); - await planExternalFollowUpImport("project-1", file, configuration, 9); - await applyExternalFollowUpImport("project-1", file, { expectedRevision: 9, expectedSha256: "a".repeat(64), configuration, objectsWithoutRoomDefaultDistributionBoardId: null, sourceChangeDecisions: [], roomDecisions: [], familyProjectDeviceDecisions: [] }); - } finally { globalThis.fetch = originalFetch; } - assert.equal(requests[0].url, "/api/projects/project-1/external-model/follow-up-import/plan"); - assert.equal(requests[1].url, "/api/projects/project-1/external-model/follow-up-import/apply"); - assert.equal(requests[0].body?.contentBase64, "AQI="); - assert.equal(requests[1].body?.contentBase64, "AQI="); - assert.equal(requests[1].body?.expectedRevision, 9); - }); });