Compare commits

..

No commits in common. "4ff01b2ccdbd3fdcfe7e281cbe98ee481298cd08" and "1a4c8cad8cd9f88044dd3d267616fdd9f58398cd" have entirely different histories.

46 changed files with 267 additions and 1626 deletions

View file

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

View file

@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis 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

View file

@ -31,7 +31,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
- [Deployment und Betrieb](deployment.md)
## 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

View file

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

View file

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

View file

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

View file

@ -626,7 +626,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details:
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 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

View file

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

View file

@ -116,8 +116,8 @@ Implemented automatic-snapshot policy:
Phase 14 is active and follows the
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The 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

View file

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

View file

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

View file

@ -162,7 +162,7 @@ ProjectDevices and device rows (#20).
3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/Linear-XLSX round-trip 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).

View file

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

View file

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

View file

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

View file

@ -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<ExternalInitialImportStatusDto | null>(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<string | null>(null);
@ -43,12 +41,11 @@ export default function RevitPage() {
}
return <main className="container py-4">
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => 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 ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => 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 ? <RevitFollowUpImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => 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}
</main>;
}

View file

@ -1,15 +0,0 @@
import type { AppDatabase } from "../database-context.js";
const defaultInsertBatchSize = 200;
export function insertManyInBatches<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: readonly unknown[],
batchSize = defaultInsertBatchSize
) {
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("Insert batch size must be a positive integer.");
for (let index = 0; index < values.length; index += batchSize) {
database.insert(table).values(values.slice(index, index + batchSize) as never).run();
}
}

View file

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

View file

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

View file

@ -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<ExternalObjectDeleteMissingProjectCommandStore["execute"]>[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)); }

View file

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

View file

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

View file

@ -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<ExternalFollowUpImportPayload> {
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<unknown>): 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<string>();
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<string, unknown> { 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; }

View file

@ -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<unknown>): 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<string>();
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<string>();
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<string, unknown> { 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); }

View file

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

View file

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

View file

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

View file

@ -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<string, ExternalXlsxReturnValues>;
}): Promise<{ plan: ExternalXlsxFollowUpPlan; document: Awaited<ReturnType<typeof createExternalXlsxInitialImportPlan>>["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;
}

View file

@ -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<string, CircuitDeviceRowSnapshot>;
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<string, number>();
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<ExternalSourceDecisionField>(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<keyof ExternalModelObjectSourceValues>(["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<T>(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { const expected = new Set(expectedKeys); const result = new Map<string, T>(); 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; }

View file

@ -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<string, string> = { 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<File | null>(null);
const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration));
const [plan, setPlan] = useState<ExternalFollowUpImportPlanDto | null>(null);
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [roomIds, setRoomIds] = useState<Record<string, string>>({});
const [boardIds, setBoardIds] = useState<Record<string, string>>({});
const [deviceIds, setDeviceIds] = useState<Record<string, string>>({});
const [noRoomBoardId, setNoRoomBoardId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 <FormModal dialogSize="viewport" title="Linear-XLSX-Folgeimport" description={`Schritt ${step + 1} von ${steps.length}: ${steps[step]}`} isSaving={busy} onClose={onClose} onSubmit={(event) => void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Folgeimport übernehmen" : "Weiter"}>
<div className="d-flex gap-2 flex-wrap mb-3">{steps.map((label, index) => <span className={`badge ${index === step ? "text-bg-primary" : index < step ? "text-bg-success" : "text-bg-secondary"}`} key={label}>{index + 1}. {label}</span>)}</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{step === 0 ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="follow-up-xlsx">Vollständige Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="follow-up-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><Count label="Neu" value={plan.newObjectCount} /><Count label="Geändert" value={plan.changedObjectCount} /><Count label="Unverändert" value={plan.unchangedObjectCount} /><Count label="Fehlend" value={plan.missingObjectCount} /><div className="col-12 text-secondary small">{plan.returnDeviationCount} Objekte enthalten Rückgabefelder, die in Revit noch nicht aktualisiert sind.</div>{plan.warnings.length ? <div className="col-12"><div className="alert alert-warning mb-0">{plan.warnings.map((warning) => <div key={warning}>{warning}</div>)}</div></div> : null}</> : null}</div> : null}
{step === 1 && plan ? <div><p className="text-secondary">Nur fachliche Quelländerungen benötigen eine Entscheidung. Abweichende Rückgabefelder werden lediglich als „in Revit noch nicht aktualisiert“ gemeldet.</p>{plan.changes.length ? plan.changes.map((change) => <label className="border rounded p-2 mb-2 d-flex gap-2" key={change.objectId}><input checked={accepted[change.objectId] ?? true} type="checkbox" onChange={(event) => setAccepted({ ...accepted, [change.objectId]: event.target.checked })} /><span><strong>{change.familyAndType}</strong> · {change.worksharingId}<br /><span className="small text-secondary">{change.changedFields.map((field) => fieldLabels[field] ?? field).join(", ")}{change.previousQuantity !== change.suggestedQuantity ? ` · Menge ${change.previousQuantity} → ${change.suggestedQuantity}` : ""}</span></span></label>) : <div className="alert alert-info">Keine fachlichen Quelländerungen.</div>}{plan.missingObjects.length ? <div className="alert alert-warning">{plan.missingObjects.length} fehlende Objekte werden markiert. Links und Mengen bleiben bestehen.</div> : null}</div> : null}
{step === 2 && plan ? <div><p className="text-secondary">Nur neue, tatsächlich übernommene Quellräume benötigen eine Zuordnung.</p>{plan.newSourceRoomKeys.map((key) => { const room = plan.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return <div className="row g-2 mb-2" key={key}><div className="col-md-4"><strong>{room.roomNumber || "–"}</strong> {room.roomName}</div><div className="col-md-4"><select className="form-select" value={roomIds[key] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [key]: event.target.value })}><option value="">Kein Projektraum</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div><div className="col-md-4"><select className="form-select" value={boardIds[key] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [key]: event.target.value })}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div>; })}<label className="form-label">Standardverteiler für neue Objekte ohne Raum<select className="form-select" value={noRoomBoardId} onChange={(event) => setNoRoomBoardId(event.target.value)}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label></div> : null}
{step === 3 && plan ? <div><p className="text-secondary">Unbekannte Familien müssen nur für neue Objekte oder akzeptierte Familienänderungen klassifiziert werden.</p>{relevantUnknownFamilies.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><strong>{family.familyAndType}</strong><div className="d-flex gap-2 flex-wrap mt-2">{configuration.deviceTypes.flatMap((type) => [<button className="btn btn-sm btn-outline-secondary" key={`${type.id}-one`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "one", type.id)}>{type.name} · Menge 1</button>, <button className="btn btn-sm btn-outline-secondary" key={`${type.id}-socket`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "socketCount", type.id)}>{type.name} · XLSX-Anzahl</button>])}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button></div></div>)}{!relevantUnknownFamilies.length ? <div className="alert alert-success">Alle benötigten Familien sind klassifiziert.</div> : null}{relevantInvalidQuantity ? <div className="alert alert-danger">Mindestens eine übernommene Mengenangabe ist ungültig.{invalidQuantityFamilies.map((family) => <button className="btn btn-sm btn-outline-danger ms-2" key={family} type="button" onClick={() => void toggleQuantityRule(family)}>Mengenregel für {family} wechseln</button>)}</div> : null}</div> : null}
{step === 4 && plan ? <div><p className="text-secondary">Neue Revit-Objekte können familienweise mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.newFamilyAndTypes.map((family) => <div className="row g-2 align-items-center mb-2" key={family}><div className="col-md-5">{family}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family]: event.target.value })}><option value="">Kein Projektgerät</option>{plan.existing.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name} · {device.displayName}</option>)}</select></div></div>)}</div> : null}
{step === 5 && plan ? <div className="alert alert-info"><strong>{plan.objectCount} Objekte im vollständigen Import.</strong><br />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.</div> : null}
{step > 0 ? <button className="btn btn-link px-0 mt-3" disabled={busy} onClick={() => setStep(step - 1)} type="button">Zurück</button> : null}
</FormModal>;
}
function Count({ label, value }: { label: string; value: number }) { return <div className="col-6 col-md-3"><strong>{value}</strong><div className="text-secondary small">{label}</div></div>; }

View file

@ -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<T>(rows: readonly T[], scrollTop: number, rowHeight: number, viewportHeight: number, overscan = 5) {
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
return { first, rows: rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + overscan * 2) };
}

View file

@ -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<RevitObjectSort>("family");
const [sort, setSort] = useState<"family" | "room" | "worksharing">("family");
const [selected, setSelected] = useState<Set<string>>(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 <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; const missing = object.presenceStatus === "missing"; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${missing ? "text-bg-warning" : linked ? "text-bg-success" : "text-bg-secondary"}`}>{missing ? "fehlend" : linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(returnSelectable.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><button className="btn btn-sm btn-danger" disabled={saving || selectedMissing.length === 0 || selectionContainsPresentObject} type="button" onClick={() => void deleteMissing()}>Ausgewählte fehlende löschen</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
</div></section>;
}

View file

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

View file

@ -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<ExternalFollowUpImportPlanDto>(`/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<ExternalFollowUpImportResultDto>(`/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<ProjectCommandResultDto>(`/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<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/delete-missing`, { method: "POST", body: JSON.stringify({ expectedRevision, objectIds, confirmed: true }) });
}
export function createExternalReturnWorkbook(
projectId: string,
expectedRevision: number,

View file

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

View file

@ -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<Awaited<ReturnType<typeof buildProjectCircuitTrees>>>) {
const result = new Map<string, { returnContext: ExternalReturnRowContext; snapshot: CircuitDeviceRowSnapshot }>();
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<T>(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; }

View file

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

View file

@ -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<string, number>();
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 {

View file

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

View file

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

View file

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

View file

@ -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<ReturnType<typeof initialState>>) {
const map = new Map<string, ExternalXlsxReturnValues>();
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);
});
});

View file

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

View file

@ -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<typeof createDatabaseContext>["db"]) {
database.insert(projects).values({ id: projectId, name: "R10 Projekt" }).run();
database.insert(distributionBoards).values({ id: boardId, projectId, name: "UV 1" }).run();
database.insert(circuitLists).values({ id: listId, projectId, distributionBoardId: boardId, name: "Liste" }).run();
database.insert(circuitSections).values({ id: sectionId, circuitListId: listId, key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
database.insert(circuits).values({ id: circuitId, circuitListId: listId, sectionId, equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run();
database.insert(circuitProtectionDevices).values({ circuitId, type: "LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null }).run();
database.insert(circuitDeviceRows).values({ id: rowId, circuitId, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 1, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
}
function currentRow(database: ReturnType<typeof createDatabaseContext>["db"]) { return toCircuitDeviceRowSnapshot(database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).get()!); }
function emptyCatalogs() { return { floors: [], rooms: [], distributionBoards: [{ id: boardId, name: "UV 1" }], projectDevices: [] }; }
function nextId(prefix: string, sequence: number) { return `${prefix}0000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`; }

View file

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

View file

@ -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<string, unknown> | 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);
});
});