Compare commits
No commits in common. "4ff01b2ccdbd3fdcfe7e281cbe98ee481298cd08" and "1a4c8cad8cd9f88044dd3d267616fdd9f58398cd" have entirely different histories.
4ff01b2ccd
...
1a4c8cad8c
46 changed files with 267 additions and 1626 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -344,19 +344,6 @@ batch payloads. The Revit page filters and sorts it in the client and renders a
|
||||||
virtualized window. `external-object.update-planning` applies multi-object
|
virtualized window. `external-object.update-planning` applies multi-object
|
||||||
board, ProjectDevice and planning-value changes as one persistent command;
|
board, ProjectDevice and planning-value changes as one persistent command;
|
||||||
linked objects are excluded because they follow their CircuitDeviceRow board.
|
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`
|
The target configuration has no independent version counter; `expectedRevision`
|
||||||
invalidates stale plans, and each import batch stores a configuration copy.
|
invalidates stale plans, and each import batch stores a configuration copy.
|
||||||
|
|
@ -610,7 +597,7 @@ Critical target rules:
|
||||||
selected combined/separate mode block export.
|
selected combined/separate mode block export.
|
||||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||||
- The corrected R2 return workbook was successfully imported into Linear on
|
- 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`,
|
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||||
complete inverse data and the shared project-command transaction boundary.
|
complete inverse data and the shared project-command transaction boundary.
|
||||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
|
||||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||||
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
||||||
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
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;
|
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen;
|
||||||
die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport,
|
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten
|
||||||
virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe.
|
Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen.
|
||||||
Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüft.
|
Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## 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
|
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
||||||
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
||||||
übernehmen
|
übernehmen
|
||||||
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte
|
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
|
||||||
prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an
|
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
|
||||||
Linear zurückgeben
|
Phase-14-Plan in den nächsten Paketen angebunden
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
||||||
- [Deployment und Betrieb](deployment.md)
|
- [Deployment und Betrieb](deployment.md)
|
||||||
|
|
||||||
## Revit / Linear XLSX
|
## Aktive Revit-Umstellung
|
||||||
|
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.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,
|
- [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status,
|
||||||
Paketgrenzen und Linear-Freigabepunkt nach R2
|
Paketgrenzen und Linear-Freigabepunkt nach R2
|
||||||
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
- [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)
|
- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md)
|
||||||
|
|
||||||
## Zukunftsarchitektur
|
## Zukunftsarchitektur
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ bleiben.
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
- 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.
|
Raumzuordnung im Importwizard nicht im Weg.
|
||||||
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
||||||
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,8 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
||||||
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
||||||
importierbar.
|
importierbar.
|
||||||
|
|
||||||
Das aktuelle Projekt-Snapshot-Schema 10 enthält Linear-XLSX-Zustand und die in
|
Das aktuelle Projekt-Snapshot-Schema 9 enthält die logischen Einspeisungsverweise
|
||||||
Schema 9 eingeführten logischen Einspeisungsverweise der Verteilungen;
|
der Verteilungen; Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
|
||||||
Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
|
|
||||||
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
|
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
|
||||||
Snapshot-Versionen 1–7 werden dafür mit
|
Snapshot-Versionen 1–7 werden dafür mit
|
||||||
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
|
`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`.
|
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
|
- `GET /projects/:projectId/external-csv/configuration`
|
||||||
the complete XLSX configuration. PUT requires `expectedRevision`.
|
- returns the complete versioned project configuration or `null`
|
||||||
- `POST /projects/:projectId/external-model/configuration/detect` detects the
|
- `PUT /projects/:projectId/external-csv/configuration`
|
||||||
sheet layout and columns from an uploaded `.xlsx` sample without persisting.
|
- request: `expectedRevision` and the complete validated configuration
|
||||||
- `GET /projects/:projectId/external-model/import-status` returns the latest
|
- executes `external-csv-configuration.update`
|
||||||
batch metadata, object count and current missing-object count.
|
- returns configuration, revision and project history state
|
||||||
- `POST .../initial-import/plan|apply` provides the stateless initial plan and
|
- `POST /projects/:projectId/external-csv/preview`
|
||||||
its confirmed atomic application. Apply retransmits and reparses the XLSX.
|
- request: file name and Base64-encoded CSV content up to 18 MB
|
||||||
- `POST .../follow-up-import/plan|apply` classifies exact Worksharing-ID
|
- requires a stored project configuration
|
||||||
additions, source changes, unchanged and missing objects. Apply requires the
|
- returns transport metadata, SHA-256, row classifications, suspect row
|
||||||
preview hash and source decisions and updates linked row quantities atomically.
|
numbers and mapped source values for every recognized IFC object
|
||||||
- `GET|PUT .../external-model/objects` loads one compact project list and
|
- does not persist a draft, mutate project state or create a revision
|
||||||
applies revision-safe multi-object planning changes.
|
- `POST /projects/:projectId/external-csv/initial-import/plan`
|
||||||
- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row`
|
- reparses the file and returns grouped room/family decisions plus current
|
||||||
changes external-object row assignments through persistent commands.
|
room, distribution-board and ProjectDevice catalogs
|
||||||
- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only
|
- is stateless and rejects an already imported external source
|
||||||
objects marked missing and adjusts linked row quantities in the same command.
|
- `POST /projects/:projectId/external-csv/initial-import/apply`
|
||||||
- `POST .../external-model/return-workbook` creates the structure-preserving
|
- requires the expected revision, configuration version, file hash and one
|
||||||
return XLSX for all, deviating or selected objects.
|
decision for every room and exact family/type group
|
||||||
- `GET .../external-model/export-batches` returns append-only export metadata.
|
- 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
|
Parser errors expose stable codes such as `header-not-found`,
|
||||||
case-sensitive; IfcGUID is optional and non-unique. Undo/Redo uses persisted
|
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
||||||
command states and never reparses workbook bytes.
|
import uploads and parses the file again and verifies its hash.
|
||||||
|
|
||||||
## Document Branding (print view)
|
## Document Branding (print view)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport,
|
- The current Revit/CSV foundation and confirmed initial import are a legacy
|
||||||
Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
|
starting point. The active Phase-14 plan replaces them with the Linear XLSX
|
||||||
Löschen fehlender Objekte verwenden persistente Commands. Die vollständige
|
exchange and exact Worksharing-ID identity.
|
||||||
Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und
|
R2 must pass a real Linear return-import check before database cutover in R3.
|
||||||
Neustarttests geprüft.
|
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.
|
- 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
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
|
|
|
||||||
|
|
@ -626,7 +626,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details:
|
||||||
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||||
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||||
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
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,
|
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||||
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||||
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
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
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
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`
|
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
||||||
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
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
|
BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne
|
||||||
Workbookbytes erneut einzulesen oder zu parsen.
|
Workbookbytes erneut einzulesen oder zu parsen.
|
||||||
|
|
||||||
R6 bis R9 ergänzen die einmal geladene, clientseitig gefilterte und
|
### Historische Ausgangslage vor R3
|
||||||
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.
|
|
||||||
|
|
||||||
R10 weist den Ablauf über Rückgabe, Folgeimport, Snapshot-Restore,
|
Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle
|
||||||
Projektduplikat und Undo/Redo nach Datenbankneustart nach. Persistenzpfade für
|
Laufzeitdokumentation mehr.
|
||||||
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).
|
|
||||||
|
|
||||||
## 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
|
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
||||||
Ausgangslage bleibt ausschließlich im [R0-Audit](spec/revit-excel-r0-audit.md)
|
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
||||||
und in den als abgelöst markierten CSV-Spezifikationen nachvollziehbar. Alte
|
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
||||||
CSV-Snapshots und CSV-History werden weiterhin ausdrücklich abgelehnt.
|
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
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- 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
|
- vollständige elektrische Dimensionierung
|
||||||
- Produktionsdeployment
|
- Produktionsdeployment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,9 +131,8 @@ ihren Wert.
|
||||||
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
|
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
|
||||||
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
||||||
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
||||||
Snapshot-Schema 10 enthält den aktuellen Projektzustand; die in Schema 9
|
Snapshot-Schema 9 enthält zusätzlich die logischen Einspeisungsverweise; die
|
||||||
eingeführten Einspeisungsverweise werden in den unterstützten Versionen 1–8
|
unterstützten Versionen 1–8 werden mit leeren Verweisen hochgestuft. Restore und
|
||||||
mit leeren Verweisen hochgestuft. Restore und
|
|
||||||
Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
|
Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
|
||||||
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
||||||
zum logischen Projektsnapshot oder Projekttransfer.
|
zum logischen Projektsnapshot oder Projekttransfer.
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,8 @@ Implemented automatic-snapshot policy:
|
||||||
|
|
||||||
Phase 14 is active and follows the
|
Phase 14 is active and follows the
|
||||||
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
|
[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
|
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The existing CSV/IFCGUID
|
||||||
been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID.
|
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.
|
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,
|
- Missing objects and duplicate/conflicting Worksharing-IDs are reported,
|
||||||
never silently reassigned.
|
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
|
The project configuration has no separate revision counter. Stateless plans
|
||||||
and apply requests bind to `expectedRevision`; every applied import batch holds
|
and apply requests bind to `expectedRevision`; every applied import batch holds
|
||||||
its configuration copy. Apply reparses the retransmitted workbook. Undo and
|
its configuration copy. Apply reparses the retransmitted workbook. Undo and
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
# LLM-Kontext für die Revit-Integration
|
# LLM-Kontext für die Revit-Integration
|
||||||
|
|
||||||
Die frühere Revit-/CSV-Anforderungsplanung ist abgelöst und Phase 14 ist mit
|
Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und
|
||||||
R10 abgeschlossen. Für Arbeiten an der Linear-XLSX-Anbindung sind vollständig
|
abgelöst. Für Arbeiten an Phase 14 sind vollständig zu lesen:
|
||||||
zu lesen:
|
|
||||||
|
|
||||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
||||||
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
||||||
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
||||||
- [R10-Abnahme](spec/revit-excel-r10-validation.md)
|
|
||||||
- [Aktuelle Systemarchitektur](current-architecture.md)
|
- [Aktuelle Systemarchitektur](current-architecture.md)
|
||||||
- [Projekt-History und externer Modellzustand](project-history-and-external-model-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,
|
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
|
||||||
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad
|
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der derzeit noch im
|
||||||
ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen
|
Code vorhandene CSV-Stand ist ausschließlich die in R1 dokumentierte
|
||||||
Migrationsmeldung abgelehnt.
|
Legacy-Ausgangslage für den schrittweisen Cutover.
|
||||||
|
|
|
||||||
|
|
@ -532,9 +532,6 @@ Acceptance criteria:
|
||||||
|
|
||||||
## Phase 14: External Model Round-Trip
|
## 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
|
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
|
Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
|
||||||
abgelöst und wird nicht fortgeschrieben.
|
abgelöst und wird nicht fortgeschrieben.
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ ProjectDevices and device rows (#20).
|
||||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
4. [x] Distribution-board power summary and project column layout.
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses
|
The Revit/Linear-XLSX round-trip is now the separately planned active Phase 14.
|
||||||
exact Worksharing-ID identity and must not be inferred from these unrelated
|
It uses exact Worksharing-ID identity and must not be inferred from these
|
||||||
backlog tasks. See
|
unrelated backlog tasks. See
|
||||||
[the R0–R10 plan](revit-excel-phase-14-plan.md).
|
[the R0–R10 plan](revit-excel-phase-14-plan.md).
|
||||||
|
|
|
||||||
|
|
@ -430,15 +430,6 @@ Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
|
||||||
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
||||||
auf Metadaten reduziert.
|
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
|
## 15. Commands, Persistenz und Sicherheit
|
||||||
|
|
||||||
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
|
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
|
sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal
|
||||||
geladenen kompakten Liste arbeiten.
|
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:
|
Jedes Paket R1–R10 führt fokussierte Tests sowie aus:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
|
||||||
| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. |
|
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
|
||||||
|
|
||||||
## R0 – Audit
|
## R0 – Audit
|
||||||
|
|
||||||
|
|
@ -190,17 +190,6 @@ Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
|
||||||
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
||||||
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
|
- 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
|
## R10 – Härtung
|
||||||
|
|
||||||
Ende-zu-Ende-Test:
|
Ende-zu-Ende-Test:
|
||||||
|
|
@ -214,15 +203,6 @@ Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI,
|
||||||
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
|
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
|
||||||
alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand.
|
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
|
## Pflichtprüfungen je Paket R1–R10
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconLink } from "../../../../frontend/components/icon-link";
|
import { IconLink } from "../../../../frontend/components/icon-link";
|
||||||
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
|
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 { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
|
||||||
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
||||||
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
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 [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [followUpOpen, setFollowUpOpen] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -43,12 +41,11 @@ export default function RevitPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return <main className="container py-4">
|
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}
|
{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>
|
<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 key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
|
{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}
|
{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}
|
{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>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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); }
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
|
||||||
|
|
||||||
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
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();
|
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
|
||||||
if (!target.source) return;
|
if (!target.source) return;
|
||||||
database.insert(externalModelSources).values(target.source).run();
|
database.insert(externalModelSources).values(target.source).run();
|
||||||
insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") })));
|
database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run();
|
||||||
insertManyInBatches(database, externalRoomMappings, target.roomMappings);
|
if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||||
insertManyInBatches(database, externalModelObjects, target.objects);
|
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; }
|
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; }
|
||||||
|
|
|
||||||
|
|
@ -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)); }
|
|
||||||
|
|
@ -45,7 +45,6 @@ import { executeProjectCommandTransaction } from "./project-command-transaction.
|
||||||
import {
|
import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
|
||||||
|
|
||||||
export class ProjectStateRestoreCommandRepository
|
export class ProjectStateRestoreCommandRepository
|
||||||
implements ProjectStateRestoreCommandStore
|
implements ProjectStateRestoreCommandStore
|
||||||
|
|
@ -429,5 +428,11 @@ function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
table: TTable,
|
table: TTable,
|
||||||
values: unknown[]
|
values: unknown[]
|
||||||
) {
|
) {
|
||||||
insertManyInBatches(database, table, values);
|
if (values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.insert(table)
|
||||||
|
.values(values as never)
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
|
||||||
|
|
||||||
export class ProjectTransferRepository implements ProjectTransferStore {
|
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
@ -257,5 +256,7 @@ function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
table: TTable,
|
table: TTable,
|
||||||
values: unknown[]
|
values: unknown[]
|
||||||
) {
|
) {
|
||||||
insertManyInBatches(database, table, values);
|
if (values.length > 0) {
|
||||||
|
database.insert(table).values(values as never).run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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); }
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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 }; }
|
|
||||||
|
|
@ -182,12 +182,8 @@ import {
|
||||||
externalInitialImportCommandType,
|
externalInitialImportCommandType,
|
||||||
} from "../models/external-initial-import-project-command.model.js";
|
} from "../models/external-initial-import-project-command.model.js";
|
||||||
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.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 { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
|
||||||
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.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 {
|
import {
|
||||||
assertExternalObjectRowAssignmentProjectCommand,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
externalObjectRowAssignmentCommandType,
|
externalObjectRowAssignmentCommandType,
|
||||||
|
|
@ -255,9 +251,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
|
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
|
||||||
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
|
|
||||||
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
|
|
@ -656,21 +650,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).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: {
|
case externalObjectPlanningUpdateCommandType: {
|
||||||
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
||||||
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
|
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: {
|
case externalObjectRowAssignmentCommandType: {
|
||||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||||
if (!this.externalObjectRowAssignmentStore) {
|
if (!this.externalObjectRowAssignmentStore) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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>; }
|
|
||||||
|
|
@ -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) };
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
|
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
|
||||||
import { createExternalReturnWorkbook, deleteMissingExternalProjectObjects, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
||||||
import { createVirtualRevitObjectWindow, filterAndSortRevitObjects, type RevitObjectSort } from "./revit-object-table-model";
|
|
||||||
|
|
||||||
const rowHeight = 44;
|
const rowHeight = 44;
|
||||||
const viewportHeight = 520;
|
const viewportHeight = 520;
|
||||||
|
|
@ -13,7 +12,7 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [boardFilter, setBoardFilter] = useState("all");
|
const [boardFilter, setBoardFilter] = useState("all");
|
||||||
const [assignmentFilter, setAssignmentFilter] = 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 [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
const [bulkBoard, setBulkBoard] = useState("");
|
const [bulkBoard, setBulkBoard] = useState("");
|
||||||
|
|
@ -34,14 +33,22 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!data) return [];
|
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]);
|
}, [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 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 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() {
|
async function applyBulk() {
|
||||||
if (!data || selected.size === 0) return;
|
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>;
|
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>
|
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}
|
{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 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="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(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="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>;
|
</div></section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,6 @@ export interface ExternalInitialImportStatusDto {
|
||||||
imported: boolean;
|
imported: boolean;
|
||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
objectCount: number;
|
objectCount: number;
|
||||||
missingObjectCount: number;
|
|
||||||
importedAtIso: string | null;
|
importedAtIso: string | null;
|
||||||
fileName: 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 {
|
export interface ExternalProjectObjectDto {
|
||||||
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
|
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
|
||||||
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
|
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@ import type {
|
||||||
ExternalModelConfigurationSnapshotDto,
|
ExternalModelConfigurationSnapshotDto,
|
||||||
ExternalInitialImportPlanDto,
|
ExternalInitialImportPlanDto,
|
||||||
ExternalInitialImportResultDto,
|
ExternalInitialImportResultDto,
|
||||||
ExternalFollowUpImportPlanDto,
|
|
||||||
ExternalFollowUpImportResultDto,
|
|
||||||
ApplyExternalFollowUpImportInput,
|
|
||||||
ExternalInitialImportStatusDto,
|
ExternalInitialImportStatusDto,
|
||||||
ApplyExternalInitialImportInput,
|
ApplyExternalInitialImportInput,
|
||||||
ExternalProjectObjectsDto,
|
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) {
|
async function fileToBase64(file: File) {
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
let binary = "";
|
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 }) });
|
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(
|
export function createExternalReturnWorkbook(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.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 { 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 { 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 { 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 { 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";
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
|
|
@ -78,12 +76,8 @@ export const externalModelConfigurationProjectCommandStore =
|
||||||
new ExternalModelConfigurationProjectCommandRepository(db);
|
new ExternalModelConfigurationProjectCommandRepository(db);
|
||||||
export const externalInitialImportProjectCommandStore =
|
export const externalInitialImportProjectCommandStore =
|
||||||
new ExternalInitialImportProjectCommandRepository(db);
|
new ExternalInitialImportProjectCommandRepository(db);
|
||||||
export const externalFollowUpImportProjectCommandStore =
|
|
||||||
new ExternalFollowUpImportProjectCommandRepository(db);
|
|
||||||
export const externalObjectPlanningProjectCommandStore =
|
export const externalObjectPlanningProjectCommandStore =
|
||||||
new ExternalObjectPlanningProjectCommandRepository(db);
|
new ExternalObjectPlanningProjectCommandRepository(db);
|
||||||
export const externalObjectDeleteMissingProjectCommandStore =
|
|
||||||
new ExternalObjectDeleteMissingProjectCommandRepository(db);
|
|
||||||
export const externalObjectRowAssignmentProjectCommandStore =
|
export const externalObjectRowAssignmentProjectCommandStore =
|
||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
|
|
@ -121,7 +115,5 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore,
|
||||||
estimateBalanceProjectCommandStore,
|
estimateBalanceProjectCommandStore,
|
||||||
externalInitialImportProjectCommandStore,
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectPlanningProjectCommandStore,
|
externalObjectPlanningProjectCommandStore
|
||||||
externalFollowUpImportProjectCommandStore,
|
|
||||||
externalObjectDeleteMissingProjectCommandStore
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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[]; }
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) {
|
||||||
const result = externalModelStateRepository.getByProject(projectId);
|
const result = externalModelStateRepository.getByProject(projectId);
|
||||||
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
const batch = result.state.importBatches.at(-1) ?? null;
|
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) {
|
export async function planExternalInitialImport(req: Request, res: Response) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
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 { 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 { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.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); }
|
} 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) {
|
export async function assignExternalObjectsToRow(req: Request, res: Response) {
|
||||||
const projectId = String(req.params.projectId ?? "");
|
const projectId = String(req.params.projectId ?? "");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,8 @@ import {
|
||||||
getExternalInitialImportStatus,
|
getExternalInitialImportStatus,
|
||||||
planExternalInitialImport,
|
planExternalInitialImport,
|
||||||
} from "../controllers/external-initial-import.controller.js";
|
} 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 { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
|
||||||
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
|
|
||||||
|
|
||||||
export const projectRouter = Router();
|
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.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
|
||||||
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
|
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
|
||||||
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
|
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.get("/:projectId/external-model/objects", listExternalObjects);
|
||||||
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
|
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-row", assignExternalObjectsToRow);
|
||||||
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
|
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
|
||||||
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
|
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
26
tests/fixtures/revit-xlsx-fixtures.ts
vendored
26
tests/fixtures/revit-xlsx-fixtures.ts
vendored
|
|
@ -122,32 +122,6 @@ export async function createRevitXlsxFixture(options?: {
|
||||||
return new Uint8Array(await workbook.xlsx.writeBuffer());
|
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(
|
function setRowValues(
|
||||||
worksheet: ExcelJS.Worksheet,
|
worksheet: ExcelJS.Worksheet,
|
||||||
rowNumber: number,
|
rowNumber: number,
|
||||||
|
|
|
||||||
|
|
@ -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")}`; }
|
|
||||||
|
|
@ -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(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
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", () => {
|
describe("Revit XLSX settings", () => {
|
||||||
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
|
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 revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
||||||
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.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 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");
|
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
|
||||||
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
||||||
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
||||||
|
|
@ -65,23 +64,10 @@ describe("Revit XLSX settings", () => {
|
||||||
assert.match(wizard, /Schritt.*von/);
|
assert.match(wizard, /Schritt.*von/);
|
||||||
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
|
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
|
||||||
assert.match(wizard, /GZF 1/);
|
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, /viewportHeight/);
|
||||||
assert.match(objectTable, /Sammeländerung anwenden/);
|
assert.match(objectTable, /Sammeländerung anwenden/);
|
||||||
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
|
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
|
||||||
assert.match(objectTable, /Nur geänderte Objekte/);
|
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 () => {
|
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; }
|
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"] } }]);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue