From ae318a51ac05baadf606a8ec3865df2674376ada Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 29 Sep 2026 21:57:40 +0200 Subject: [PATCH 1/5] Audit Linear Excel integration --- docs/spec/revit-excel-r0-audit.md | 521 ++++++++++++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 docs/spec/revit-excel-r0-audit.md diff --git a/docs/spec/revit-excel-r0-audit.md b/docs/spec/revit-excel-r0-audit.md new file mode 100644 index 0000000..721e402 --- /dev/null +++ b/docs/spec/revit-excel-r0-audit.md @@ -0,0 +1,521 @@ +# R0 – Audit der Linear-Excel-Anbindung + +Stand: 29.09.2026. Geprüfter Code: `cf022b2`. + +**Status: R0 abgeschlossen, Freigabe für R1–R10 ausstehend.** Dieser Bericht +ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten +Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und +die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0 +ändert weder Laufzeitcode noch Datenbank noch Oberfläche. + +## 1. Geprüfter Bestand und Keep/Change/Remove + +Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`, +`docs/project-history-and-external-model-architecture.md`, +`docs/spec/revit-csv-integration-requirements.md` und +`docs/spec/revit-csv-phase-14-audit-and-plan.md`. Der alte Plan widerspricht +sich bereits intern: Sein Status nennt die Erstübernahme noch nicht vorhanden, +während Implementierung und Tests sie nachweisen. Alte Erledigt-Markierungen +werden deshalb nicht übernommen. + +| Bereich / Codebeleg | Entscheidung | Begründung und Änderung | +| --- | --- | --- | +| `src/db/repositories/project-command-transaction.persistence.ts` | Keep | Eine Grenze für Fachänderung, Inverse, Revision und History; auch automatische Snapshots bleiben darin. | +| `src/db/repositories/external-model-state.repository.ts`, `src/db/schema/external-*.ts` | Change | Quelle, Batches, Raum-Mappings und Objekte wiederverwenden; neue Identität, Konfiguration und typisierte Matrix. Schlanke Objekt-Reads von großen Batch-Blobs trennen. | +| `src/domain/models/external-initial-import-project-command.model.ts`, zugehöriger Store | Change | Vollständiger erwarteter/Zielzustand und Row-freier Import bleiben; Worksharing-ID, Gerätearten und atomare Regelübernahme ergänzen. | +| `src/external-model/application/external-initial-import-plan.ts`, `external-initial-import-target.ts` | Change | Raumvorschläge, Kataloge, vollständige Entscheidungen und stabile UUIDs behalten. Objektentscheidungen und Gerätearten ergänzen. | +| `src/db/repositories/external-object-assignment.persistence.ts` und drei Zuordnungs-Stores | Change | Mengeninvariante und exakte Inversen behalten; Geräteart, effektiven Verteiler, Anwesenheit und Übernahme der Row-Werte beim Lösen ergänzen. | +| `CircuitDeviceRow.manualQuantity`, bestehende Mengenprüfungen | Keep | `quantity = manualQuantity + Summe(externer effektiver Mengen)`; Mehrfachsteckdosen bleiben unteilbar. | +| `src/external-model/application/external-circuit-list-objects.ts` | Change | Listen-/Projektprüfung behalten; vollständige Planungswerte, Geräteart, effektiver Verteiler, Status und Row-Anzahlen liefern. | +| `src/domain/models/project-state-snapshot.model.ts`, Snapshot-Persistenz und Transfer | Change | Schema 10; historische CSV-Zustände ausdrücklich ablehnen; alle neuen internen Referenzen remappen. | +| `src/external-model/csv/*`, CSV-Preview, CSV-Validierungen und Controller | Remove/replace | XLSX ersetzt CSV vollständig; kein zweiter aktiver Transportpfad. | +| `src/frontend/components/revit-csv-modal.tsx`, Einbindung auf Projektseite | Remove/replace | Eigene Revit-Seite; Einstellungen nur im Modal, Import in eigenem Wizard. | +| `src/frontend/components/Sidebar.tsx`, `src/frontend/utils/api.ts`, `src/frontend/types.ts` | Change | Route `revit`, neue DTOs und Endpunkte, CSV-Imports entfernen. | +| `src/frontend/components/circuit-tree-editor.tsx`, Grid-Drop-/Projektionsmodule | Change | Separater externer Drag-Payload; Row-, Circuit- und Freiplatz-Ziele bewusst unterscheiden. Bestehende Grid-Bedienung behalten. | +| Raum-/ProjectDevice-Formulare und Commands | Keep | Sichtbar vorbelegte, ausdrücklich bestätigte Anlage als eigener Undo-Schritt. | +| `scripts/verify-revit-reference-csv.ts`, Transporttests | Replace | Pfadargument für lokalen XLSX-Prüfer; ausschließlich synthetische Workbooks in Tests. | + +### Konkrete Lücken gegenüber dem Auftrag + +- `external-model-contracts.ts` importiert heute CSV-Verträge. Die neutralen + Konfigurations-/Matrixverträge gehören künftig in `external-model/domain`; + nur `external-model/xlsx` darf ExcelJS kennen. +- `ifc_guid` ist derzeit NOT NULL und je Quelle eindeutig; Matching, + Snapshots, DTOs und Commands setzen diese Identität voraus. +- `internalDeviceType` ist derzeit ein freier String in Planungswerten. + Es gibt keinen Geräteart-Katalog mit stabilen IDs und Standardwerten. +- Der Erstimport setzt den ProjectDevice-Link, verwendet im Target aber + weiterhin `suggestedPlanningValues`. Die neue Priorität ProjectDevice vor + Geräteart muss ausdrücklich implementiert werden; ein Link allein genügt + nicht. Revit-Leistung wird künftig nicht automatisch zum Planungswert. +- Der Import-Target setzt `distributionBoardId = null`; die Raumvorgabe liegt + separat im Mapping. Projektion und Zuordnungsprüfung vergleichen heute + direkt das Objektfeld. Eine zentrale Auflösung des effektiven Verteilers + fehlt und ist vor der Editor-Nutzung erforderlich. +- Die bisherige Row-Zuordnung darf laut Validator am Objekt ausschließlich + `circuitDeviceRowId` ändern; der Store schreibt auch nur dieses Feld. + Lösen mit aktuellen Row-Planungswerten benötigt eine neue Command-Version. +- Die Kompatibilitätsprüfung vergleicht Raum, Kategorie, Auswahlkenner und + Planungswerte, aber keine stabile Geräteart. Unterschiedliche Gerätearten + dürfen nicht versehentlich in dieselbe Row aggregiert werden. +- Rows speichern `powerPerUnit` in **kW**, externe Planung + `powerPerUnitW` in **W**. Eine zentrale bidirektionale Konvertierung ist + nötig; Exportleistung darf nicht um Faktor 1000 falsch sein. +- Externe FKs auf Row, ProjectDevice, Raum-Mapping und Verteiler verwenden + teilweise `ON DELETE SET NULL`. Bestehende allgemeine Löschpfade sichern + externe Objektübergänge nicht durchgängig in ihren Inversen. Auch + Unterbaum-/History-Löschungen müssen abgesichert werden. +- Ein Editor-Drawer, projektweite Objekttabelle, Exportnachweis und + ausführbarer Folgeimport fehlen. `follow_up` als vorhandener Enumwert ist + kein implementierter Folgeimport. +- Konfigurationsversionen steigen heute nur bei normalen Updates gegenüber + dem aktuellen Wert; Undo setzt alte Versionen zurück. Für eine tatsächlich + monotone Generation muss diese Semantik korrigiert werden. +- XLSX-Parsing ist asynchron. Das heutige erneute synchrone CSV-Parsing + innerhalb der SQLite-Transaktion darf nicht einfach durch `await` ersetzt + werden. + +## 2. Prüfung der lokalen Musterdatei + +`docs/spec/kbp-Stromkreis.xlsx` wurde ausschließlich lesend auf ZIP-/OOXML- +Ebene untersucht; keine Inhalte wurden hochgeladen oder in Fixtures kopiert. +Die Datei ist unversioniert und darf nicht committed werden. + +| Befund | Ergebnis | +| --- | --- | +| Dateigröße / entpackte ZIP-Einträge | 56.765 / 337.740 Bytes | +| Arbeitsblätter / Blattnamenlänge | 1 / 31 Zeichen | +| Zellbereich | A1:O490 | +| Kopfzeile / Leerzeile / Objekte / Abschluss | 1 / 2 / 3–489 / 490 | +| Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate | +| Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte | +| Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname | +| IfcGUID-Duplikate | 2 Gruppen nichtleerer doppelter Werte | +| Formeln | Keine; synthetische Tests dafür erforderlich | +| Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind | + +Das beobachtete Profil aus dem Auftrag wird damit bestätigt. Zusätzliche +Spaltenformatdefinitionen dürfen beim Export nicht auf die gemappten Spalten +gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und +die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie +dürfen nicht ohne Linear-Parameterbindung erfunden werden. + +Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue +Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 prüft +die Struktur, R8/R10 benötigen zusätzlich einen realen Linear-Abnahmelauf. + +## 3. Bibliothek, Adapter und Uploadgrenzen + +**Vorschlag: genau eine neue direkte Abhängigkeit `exceljs`.** Seine +öffentliche API unterstützt XLSX, Zellnotizen, Formate, Spaltenbreiten und +typisierte Werte. Das passt zum beobachteten klassischen Kommentarformat. +Version und transitive Abhängigkeiten werden in R2 festgeschrieben und +geprüft. Die veröffentlichte Version ist laut Paketregister 4.4.0; eine +aktuelle Releasepflege ist damit nicht bewiesen. Dies ist ein ausdrückliches +Wartungsrisiko, kein als erfüllt abgehakter Bibliotheksnachweis. + +Quellen, am 29.09.2026 geprüft: +[ExcelJS-Projekt](https://github.com/exceljs/exceljs), +[offizielle Paketdokumentation](https://www.npmjs.com/package/exceljs). +Keine Bibliothek wird in R0 installiert. R2 muss vor Integration Kommentar- +Roundtrip, Node-24-Verträglichkeit und Abhängigkeitsrisiken prüfen; bei einem +Blocker wird die Bibliotheksentscheidung neu vorgelegt. + +Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet: + +- ausschließlich `.xlsx`, passender OOXML-Inhalt und ZIP-Signatur; kein + `.xlsm`, verschlüsseltes Archiv, VBA, ActiveX oder externe Workbook-Links; +- maximal 10 MiB Upload, 100 MiB tatsächlich entpackte Daten, 2.000 ZIP- + Einträge, 50.000 Zeilen, 256 Spalten und 2 Millionen materialisierte Zellen; +- genau ein Arbeitsblatt; keine Formelausführung, kein Nachladen von URLs; +- ZIP-Vorprüfung vor ExcelJS: Verzeichniseinträge, doppelte Pfade, + Kompressionsverfahren und tatsächliche entpackte Bytezahl prüfen. + Zentralverzeichnisangaben allein schützen nicht vor manipulierten Archiven. + Begrenzte Dekompression mit Node-Bordmitteln, keine zweite direkte Library; +- Parser in begrenztem Worker mit Zeit-/Speichergrenze; Abbruch ohne DB- + Schreibvorgang. API-Body-Limit auf Uploadgrenze einschließlich Base64- + Overhead abstimmen, statt global beliebige Requestgrößen zuzulassen. + +Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten +(Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete +Zeilen mit Originalzeilennummer und Klasse sowie Zellen als diskriminierte +Union `string | number | boolean | empty`. Formeltext und Cache-Status sind +optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert. +Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern +einen Blocker. Originalbytes bleiben unverändert gespeichert. + +Fehlende ID bei sonstigen Objektdaten blockiert; Leer-/eindeutige Summenzeilen +werden ignoriert. Nicht passende nichtleere IDs blockieren ebenfalls. +Worksharing-ID bleibt als Quelltext erhalten, Matching ist exakt; ungültige +Leerzeichen/uneindeutige Schreibweisen werden gemeldet statt still korrigiert. +IfcGUID wird optional gespeichert, doppelte Werte sind nur Hinweise. +Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte +Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein. + +Rückgabe lädt eine Kopie der Originalbytes, bewahrt den Bereich oberhalb der +ersten Datenzeile samt Kommentaren, Formatierung und Spaltenbreiten und +ersetzt den Datenbereich durch gewählte Objektzeilen in Originalreihenfolge. +Alle Spalten bleiben enthalten; Summenzeilen entfallen. Unveränderte Zellen +behalten ihre importierten Werte und Typen. Formeln in Objektzeilen werden +als gecachte Werte ausgegeben, damit Zeilenauswahl keine falschen relativen +Bezüge erzeugt; dies wird vor Export als Hinweis ausgewiesen. Keine Formeln +werden berechnet. Roundtrip bedeutet strukturelle/semantische Gleichheit, +nicht identische ZIP-Bytes. + +## 4. Konfigurationsschema + +Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1: + +```ts +{ + schemaVersion: 1, + layout: { + headerRow: 1, firstDataRow: 3, firstDataColumn: 1, + idColumn: "Worksharing-ID" + }, + circuitMode: "combined" | "separate", + columns: { + worksharingId: string, familyAndType: string, + ifcGuid: string | null, roomNumber: string | null, + roomName: string | null, selectionMarker: string | null, + socketCount: string | null, sourceRemark: string | null, + circuitCombined: string | null, boardName: string | null, + circuitIdentifier: string | null, designation: string | null, + electricalPowerW: string | null, voltageV: string | null, + currentA: string | null + }, + deviceTypes: [{ + id: UUID, name: string, + category: "lighting" | "single_phase" | "three_phase", + defaults: { + powerPerUnitW: number | null, simultaneityFactor: number | null, + cosPhi: number | null, costGroup: string | null + } + }], + familyTypeRules: [{ + id: UUID, exactFamilyAndType: string, deviceTypeId: UUID, + quantityRule: "one" | "socketCount" + }] +} +``` + +Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar. +`layout.idColumn` und `columns.worksharingId` werden gemeinsam gesetzt und +müssen übereinstimmen. Jede gemappte Kopfzeile muss eindeutig auflösbar sein; +doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig, +aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten +Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem +Auftrag. Unbekannte Spalten bleiben ohne Mapping erhalten. + +Geräteart-IDs sind interne UUIDs, keine Namen als Relationsschlüssel. Regeln +sind exakt und je Familien-/Typtext eindeutig; jede importierte Familie muss +eine Geräteart haben. Gerätearten und Regeln werden als ein versioniertes +Konfigurationsaggregat gespeichert. Keine zusätzliche Gerätearttabelle ist +für diesen Umfang nötig; Validatoren erzwingen referenzielle Integrität. +Referenzierte Gerätearten können nicht kommentarlos entfernt werden. + +Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename: +Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt +Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung. +Fehlender GZF bleibt vor Zuordnung leer; verlangt der Row-Vertrag einen Wert, +wird `1` sichtbar vorgeschlagen und ausdrücklich bestätigt. Geräteart- +Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um. + +## 5. Konkretes Persistenzschema + +Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten. +`JSON` bezeichnet validierten JSON-Text in SQLite, nicht ExcelJS-Objekte. + +| Tabelle | Zielspalten / Constraints | +| --- | --- | +| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration_version INTEGER NOT NULL CHECK > 0`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat. | +| `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. | +| `external_import_batches` | Bestehende IDs, Importart, Zeit, Dateiname, SHA-256, `applied_project_revision`, `configuration_version`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; Matrix jetzt typisiert und transportneutral. Unveränderlich innerhalb des aktiven Zustands; Undo/Restore darf ganze Batches entfernen/wiederherstellen. | +| `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. | +| `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. | +| Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. | +| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_version INTEGER`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. | + +`import_batch_id` des Exportnachweises ist absichtlich eine historische UUID +ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis +nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots +und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben. +Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1 +dokumentiert. Importbatches selbst gehören vollständig zu Snapshot/Transfer. + +Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten; +zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`, +`linked_project_device_id`, Export `(project_id, exported_at_iso)`. +Cross-Project-Links und Geräteart-Verweise prüft jeder Command in der +Transaktion; bloße Existenz eines UUID-FK genügt nicht. + +Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften +Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die +Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden; +bei fehlender Vorgabe ist das Objekt ohne Verteiler. Room-/Board- +Sammeländerungen prüfen alle betroffenen Row-Links und lehnen inkonsistente +Umbuchungen ab, bis diese ausdrücklich gelöst/verschoben sind. + +Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind +Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export +verwenden die Row. Beim Lösen werden aktuelle Row-Werte einschließlich +Einheitenkonvertierung in die Objektplanung übernommen und als explizite +Overrides markiert. Effektive Objektmenge wird nicht durch die Row-Gesamtmenge +ersetzt. Die Row und ihr manueller Anteil bleiben erhalten. + +## 6. Commands und Transaktionsgrenze + +Alle öffentlichen Fach-Writes verlangen `expectedRevision`, Anmeldung und +`X-Leistungsbilanz-Request: 1`. `expectedRevision` gehört wie bisher zum +Ausführungsumschlag. Jede Transition enthält vollständige erwartete/Ziel- +Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches. + +| Command | Payload-Skizze und Gegenrichtung | +| --- | --- | +| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht Fachinhalt; Versionsgeneration siehe unten. | +| `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. | +| `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. | +| `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. | +| `external-model-object.update-distribution-board` v1 | `{ objects: transitions }`; explizite Objektabweichung, Projekt-/Row-Konsistenz. | +| `external-model-room-mapping.update` v1 | `{ mappings: transitions }`; interner Raum/Standardverteiler; betroffene Objekt-/Row-Konsistenz mitprüfen. | +| `external-model-object.update-project-device-link` v1 | `{ objects: transitions, confirmedPlanningFields }`; keine Vorlagenänderung; neue Startwerte nur für ausdrücklich bestätigte Felder. Gebundene Objekte werden über ihre Row bearbeitet. | +| `external-object.update-row-assignment` v2 | `{ rows: transitions, objects: transitions, confirmedConflictObjectIds }`; Linkwechsel, materialisierte Mengen, beim Lösen aktuelle Row-Planungswerte; vollständige Inverse. | +| `external-object.assign-to-new-row` v2 | Bestehender vollständiger Row-/Objekt-/Reserveplan mit neuen Objektverträgen; history-only Inverse entfernt nur exakt unveränderte Erzeugnisse. | +| `external-object.assign-to-new-circuit` v2 | Bestehender Circuit-/Protection-/Row-/Objektplan; BMK einmal planen und festschreiben, gleiche UUIDs bei Redo. | +| `external-model-object.delete-missing` v1 | `{ objects: transitionsToNull, rows: quantityTransitions }`; ausschließlich ausdrücklich bestätigte weiterhin fehlende Objekte, kein Row-/Circuit-Löschen; Inverse stellt IDs, Links und Mengen wieder her. | + +Bestehende `project-device.insert`, Room-/Floor-Commands bleiben eigenständige +Undo-Schritte. Familien-/Geräteartentscheidungen im Wizard sind lokale +Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater +Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu, +ohne lokale Entscheidungen zu verlieren. + +Konfigurationsgeneration: `configurationVersion` erhält bei jedem +Konfigurationswechsel einschließlich Undo/Redo/Restore die neu entstehende +Projektrevision. Sie ist damit strikt steigend, aber nicht lückenlos. +Historienbefehle vergleichen den erwarteten Fachinhalt und die aktuelle +Projektversion; die administrative Generation wird serverseitig neu erzeugt. +Die Inverse restauriert alle Fachwerte exakt, nicht einen alten Zählerstand. +Importbatches behalten ihre ursprüngliche Generation und ihren Snapshot. +Ein Restore/Projekttransfer erhält diese Herkunft; die aktive Konfiguration +wird an die Revision des Zielprojekts gebunden. Prüfen: Änderung → Undo → +anderer Zweig darf niemals denselben Vorschau-Token wieder gültig machen. + +XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen +Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und +Entscheidungen werden erneut validiert. Im Store werden Revision, +Konfigurationsinhalt/-generation, erwartete Objekte, Links und vollständiger +vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client +behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht +an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo benutzen +denselben Prüfdienst für persistierte Bytes vor der Transaktion. Keine +asynchronen Callbacks in `better-sqlite3`-Transaktionen. + +Allgemeine Row-/Circuit-/Gruppen-/Board-/Raum-/ProjectDevice-Löschungen +erhalten Guards, solange externe Referenzen betroffen sind. Nutzer lösen die +Referenzen zuvor ausdrücklich über die passenden Commands. Guards gelten +auch in Unterbaum- und History-only-Pfaden. Dadurch werden vorhandene +Commands nicht unbemerkt zu unvollständigen externen Löschcommands. + +## 7. Migration, Snapshot 10, History und Transfer + +Neue additive Migration nach `0013_users` (voraussichtlich `0014`, Nummer +beim Beginn von R3 erneut prüfen). Bestehende SQL-/Metadateien unverändert. + +1. Vor R3-Anwendung API-Schreibzugriffe stoppen und `npm run db:backup` + ausführen; verifiziertes Backup samt Wiederherstellungsmöglichkeit sichern. + R0 verändert keine DB und benötigt daher kein Migrationsbackup. +2. Vorhandene externe CSV-Entwicklungsdaten dürfen gemäß Auftrag verworfen + werden. Zuerst je verknüpfter Row `quantity = manual_quantity` setzen; + sonst bliebe nach Entfernen der externen Objekte eine Phantomlast. + Rows, Circuits, manuelle Anteile, UUIDs und BMKs bleiben bestehen. +3. Externe Objekte → Mappings → Batches → Quellen → CSV-Konfiguration in + FK-sicherer Reihenfolge entfernen. Tabellen gezielt neu aufbauen, wo + SQLite NOT-NULL-/Unique-Änderungen dies erfordern. Neue Zieltabellen und + Indices erstellen. Keine leeren Worksharing-IDs als Backfill erfinden. +4. Generiertes SQL und Drizzle-Metadaten prüfen; saubere und bestehende + Test-DBs migrieren; FK-/Integritätsprüfung und Mengenabgleich ausführen. + Der Reset ist eine dokumentierte einmalige Entwicklungsmigration, + kein stiller Laufzeit-Import. +5. Alte Revisionsdaten nicht umschreiben, keine CSV-History heimlich + überspringen. An der Ausführungsgrenze alte CSV-Konfigurationscommands, + Initial-/Zuordnungscommands v1 einschließlich ihrer History-Inversen und + Restores mit CSV-Inhalt vor jedem Write ablehnen. + +Fehlermeldung für History: +„Dieser Verlaufsschritt verwendet die frühere CSV-/IfcGUID-Anbindung und +kann mit der Excel-Anbindung nicht ausgeführt werden.“ +Der History-Status sollte den blockierten obersten Schritt samt Grund zeigen; +keine automatisch geleerten Stacks und keine Änderung bei Ablehnung. Frühere +allgemeine Commands können nach dem Reset zusätzlich an ihren exakten +Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung. + +Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle, +alle Batches mit Originalbytes/Matrix, Raum-Mappings, Objekte und Links. +Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn +`externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer +ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration +allein zählt als nicht leerer CSV-Zustand und wird nicht still verworfen. +Snapshot-Checksumme am gespeicherten Original vor Upgrade prüfen. + +Fehlermeldung: +„Dieser Projektstand enthält Daten der früheren CSV-/IfcGUID-Anbindung. +Eine automatische Übernahme in die Excel-Anbindung ist nicht möglich. +Bitte verwenden Sie einen Projektstand ohne CSV-Daten.“ + +Der aktuelle portable Transfer ist bereits **Format 2** (Grobbilanzen), +nicht Format 1 wie im alten Audit. Envelope 2 und dessen Format-1-Lesepfad +bleiben; enthaltenes Projektzustandsschema wird 10. Duplizieren remappt +Projekt-, Konfigurations-, Geräteart-, Regel-, Quellen-, Batch-, Mapping-, +Objekt-UUIDs und alle internen Links, auch in Konfigurationssnapshots der +Batches. Worksharing-IDs, optionale IfcGUIDs und Originalbytes bleiben +unverändert. Grobbilanzen und globale Office-/Auth-Daten behalten ihre +bisherige Abgrenzung. Exportnachweise werden nicht als neue Projektexporte +dupliziert. + +## 8. Read-Modelle, Oberfläche und Rückgabe + +Vorgeschlagene API-Basis `/api/projects/:projectId/revit`: +`configuration` (GET/PUT), `profile/preview` (POST, zustandslos), +`initial-import/plan|apply`, `follow-up-import/plan|apply` (POST), +`status` und `objects` (GET), `return-workbook` (POST mit Export-Audit). +Der bestehende listenbezogene External-Objects-GET bleibt erhalten. +Objektmutationen verwenden den vorhandenen Project-Command-Endpunkt mit +neuen typisierten Verträgen. CSV-Endpunkte werden entfernt, keine Aliasse. + +Export verwendet `expectedRevision` für einen konsistent gelesenen Stand; +die Datei-Erzeugung erfolgt außerhalb des kurzen Read-Snapshots. Danach +separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt. +Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung, +nicht einen tatsächlich abgeschlossenen Browserdownload. + +Die Revit-Seite zeigt Status, Aktionen und eine paginierte Objekttabelle +(Vorschlag: 100 Zeilen pro Seite). Filter-/Sortierzustand, UUID-Auswahl und +Sammelaktionen sind unabhängig vom DOM. „Alle gefilterten“ verwendet eine +explizit aufgelöste Auswahl zur bekannten Revision; keine Mutation anhand +eines inzwischen veränderten Filters. Große Auswahlmengen werden nicht in +getrennte Undo-Schritte aufgeteilt. Kein vollständiges Batch-Blob im DTO. + +Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen, +Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln +entstehen im Wizard und sind auf der Seite einklappbar nachbearbeitbar. +Fehlende Raumangaben bleiben zulässig, Board-Zuordnung ist auch ohne Raum +möglich. ProjectDevice-Zuordnung bleibt optional. + +Rechter Editor-Drawer: nur vorhandene, ungebundene konfliktfreie Objekte des +effektiven Verteilers. Gruppierung nach internem Raum, Auswahlkenner, +Geräteart-ID, Kategorie und identischen Planungswerten; unterschiedliche +ProjectDevice-Links bleiben ebenfalls getrennt, da eine Row nur einen Link +haben kann. Effektive Einzelmengen sind kein Gleichheitsfeld: Einfach- und +Mehrfachsteckdosen sollen gemeinsam gruppierbar sein. Anzeige enthält +Mengensumme und Objektanzahl. Fachlich heterogene Auswahl darf nicht +willkürlich auf eine einzelne Row zusammenfallen; Konfliktanzeige verlangt +eine kompatible Auswahl. Row-Indikator öffnet Objekte und explizites Lösen. + +Eine Funktion `getRevitBoardName(board)` liefert ausschließlich `board.name`. +Gemeinsam: Name + unverändertes `equipmentIdentifier`, ohne Separator. +Getrennt: Name und BMK in eigene Parameter. Ohne Circuit bleiben beide +Stromkreiswerte leer, auch wenn ein Planungsverteiler bekannt ist. + +Exportprojektion und Abweichungsanzeige teilen dieselbe reine Funktion: +Row-Anzeigename/-Leistung bei Link, sonst Objektplanung; Objektleistung in W += effektive Menge × Einzelleistung (Row-kW vorher × 1000), ohne GZF. +Spannung vom Circuit, sonst Projektstandard passend zur Kategorie. +Strom aus Objektleistung, U, cosPhi und Phasenart; Drehstrom mit Faktor √3, +auf 0,01 A. Fehlende/ungültige Werte ergeben leer plus Hinweis. Nullleistung +bleibt bei gültigem Nenner numerisch 0. Kein still angenommenes cosPhi. +Abweichungsvergleich normalisiert Zahlpräzision, Leere und skalare Typen +gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand. + +Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite +Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden +nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene +Parameter behalten importierte Werte. Fehlende benötigte Zielspalten bzw. +Linear-Bindungen blockieren die betreffende Exportkonfiguration mit Hinweis +auf einen neuen vollständigen Linear-Export. Dateiname gemäß Auftrag mit +bereinigtem Projektnamen und Tagesdatum. + +Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert +immer den vollständigen letzten Quellstand. Unveränderte Objekte werden +nicht allein zum Fortschreiben von `lastSeenImportBatchId` neu geschrieben; +aktuelle Sichtbarkeit/Quellwerte kommen aus Batch/Matrix. Akzeptierte +Planungsgrundlage und letzte importierte Beobachtung sind getrennt. +Raum-/Familien-/Marker-/Mengenänderungen brauchen Entscheidungen. Bei +unvereinbarer Row-Zuordnung blockiert die Übernahme bis zum ausdrücklichen +Lösen/Umordnen, statt Rows automatisch zu erzeugen. Übernommene Mengen +ändern alle betroffenen Row-Summen im selben Importcommand. Fehlend setzt +nur den Status, erhält vorerst Link und Menge. Rückgabefeld-Abweichungen +sind Hinweise, keine Übernahmeentscheidungen. Ein völlig unveränderter +Folgeimport erzeugt einen nachvollziehbaren Batch, aber keine unnötigen +Objekt-/Row-Updates. + +## 9. Pakete, Verifikation und Risiken + +R0 liefert nur diesen Bericht und einen eigenen Commit. R1–R10 starten erst +nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste. + +| Paket | Arbeit und entscheidende Abnahme | +| --- | --- | +| R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. | +| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss; ZIP-/Formelgrenzen. Keine DB-Änderung. | +| R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. | +| R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. | +| R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. | +| R6 | Objekt-Reads, Paging/Filter, Sammel-Commands und Overrides; verwandte Lösch-/Referenzguards vollständig absichern. | +| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele und Reload/Undo/Redo. | +| R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. | +| R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. | +| R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. | + +Die temporäre Grenze zwischen R3 und R4 wird ausdrücklich behandelt: +Buildfähige, nicht aktive Kompatibilitätstypen für das alte Modal dürfen bis +R4 bestehen, aber kein aktiver CSV-Backendpfad. Veraltete Projektaktion bis +zur neuen Seite deaktivieren/entfernen, keinen funktionslosen Upload anbieten. +Die Dokumentation darf Zwischenstände nicht als fertigen Workflow ausweisen. + +Pflicht je R1–R10: fokussierte Tests, `npm test`, `npm run build:api`, +`npm run build:web`, `npm run typecheck:scripts`, +`npx tsc --noEmit -p tsconfig.next.json`, je ein knapper imperativer Commit. +R0: 31 vorhandene Tests aus sechs Dateien erfolgreich ausgeführt: +`external-model-foundation`, `external-initial-import-plan`, +`external-initial-import-project-command.repository`, +`external-object-row-assignment-project-command.repository`, +`external-circuit-list-objects`, +`external-csv-configuration-project-command.repository` (je `.test.ts`). +Diese decken vorhandene Commit-/Rollback-, Undo-/Redo-, Snapshot-/Transfer- +Grundlagen ab, nicht die neue Excel-Funktion. Der erste Start scheiterte an +Sandbox-`spawn EPERM`; derselbe Lauf außerhalb der Sandbox bestand. +Vollständige Builds/Tests sind für diesen reinen R0-Bericht nicht ausgeführt. + +Zusätzlich erforderliche neue Prüfungen: + +- Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs. +- v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen + und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen. +- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne; Apply mit anderem + Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig + zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück. +- Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte. +- Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische + Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung. +- Löschen über alle Unterbaum-/History-Pfade kann externe FKs nicht still + nullen. Fremde Verteilungen/Räume/Kategorien und Gerätearten blockieren. +- Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt + den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise. +- 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße, + Command-/Snapshot-Dauer und UI-Reaktionszeit messen; höchstens eine + paginierte Seite im DOM, stabile Auswahl über Seitenwechsel. + +Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des +Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu +belegen. Große Originaldateien vervielfachen sich in History/Snapshots; +Limits und Messungen müssen reale Speichergrößen berücksichtigen. Die +CSV-Entwicklungsbereinigung ist absichtlich verlustbehaftet, aber auf den +externen Zustand begrenzt und nur nach verifiziertem Backup zulässig. +Fehlende Zielparameter im Muster verlangen einen neuen vollständigen +Linear-Export, keine geratenen GUID-Kommentare. + +**Freigabepunkt:** Der Nutzerauftrag verlangt wörtlich +„R0. Audit (danach stoppen und Freigabe abwarten)“. +Mit Freigabe dieses Vorschlags beginnt R1; R0 selbst autorisiert keine +Installation, Migration oder Implementierung von R1–R10. From d4803a18d28b7dd929a76c1638e49756bb3129e5 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 29 Sep 2026 22:28:16 +0200 Subject: [PATCH 2/5] Document Linear Excel integration --- .gitignore | 1 + AGENTS.md | 184 +-- README.md | 19 +- docs/README.md | 15 +- docs/building-structure.md | 4 +- docs/circuit-list-editor-known-limitations.md | 7 +- docs/current-architecture.md | 38 +- ...history-and-external-model-architecture.md | 45 +- docs/revit-requirements-llm-context.md | 282 +--- docs/spec/07-implementation-phases-todo.md | 48 +- docs/spec/08-current-product-backlog.md | 8 +- .../revit-csv-integration-requirements.md | 1390 +---------------- .../spec/revit-csv-phase-14-audit-and-plan.md | 410 +---- .../revit-excel-integration-requirements.md | 463 ++++++ docs/spec/revit-excel-phase-14-plan.md | 173 ++ docs/spec/revit-excel-r0-audit.md | 161 +- 16 files changed, 932 insertions(+), 2316 deletions(-) create mode 100644 docs/spec/revit-excel-integration-requirements.md create mode 100644 docs/spec/revit-excel-phase-14-plan.md diff --git a/.gitignore b/.gitignore index b237969..911c16f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ data/*.db data/backups/*.db .codex/*.log +/docs/spec/kbp-Stromkreis.xlsx diff --git a/AGENTS.md b/AGENTS.md index b01e6b5..7c0a120 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,29 +320,21 @@ supported versions contain circuit-group identity, distribution-board components separate circuit/component protection records. Portable duplicate imports remap component ids and protection-owner references together with the existing project graph. -Project-wide Revit CSV configuration uses -`external-csv-configuration.update`. Its complete expected/target snapshot is -persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 5-9, restore and portable -project duplication preserve the configuration; duplication remaps its internal -id and project link. -The project page exposes the Revit CSV modal. It edits the complete -transport/column/family-rule configuration through the revision-safe endpoint, -requests a stateless preview and then shows the Phase 14.2 initial-import -decisions. Preview responses include all mapped objects, while the UI renders -only the first 25. No preview or plan creates external objects, rows, revisions -or server-side drafts. -Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, -immutable import batches with original bytes plus the classified cell matrix, -normalized source-room mappings and external objects unique by -`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. -The general external-model repository is read-only; runtime writes belong only -to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-9 -capture the complete external state and remap every internal UUID and link while -preserving IFCGUIDs, source values, original bytes and the classified matrix. -Import batches additionally store the monotonic project CSV configuration -version introduced by additive migration `0004`; it is distinct from the CSV -configuration JSON schema version. +The current runtime still contains the legacy Revit-CSV/IfcGUID foundation: +project configuration, preview, confirmed initial import, external objects and +the three row-assignment commands. It remains the audited starting point, not +the Phase-14 target. Do not extend its CSV transport or IFCGUID identity model. +The approved cutover is Linear XLSX with exact case-sensitive Worksharing-ID +identity. See `docs/spec/revit-excel-integration-requirements.md` and +`docs/spec/revit-excel-phase-14-plan.md`. + +During R1/R2 no database or runtime schema changes are made. R3 will replace +the legacy configuration and transport additively, reject non-empty legacy CSV +snapshot/history state with a German error, and introduce snapshot schema 10. +The target configuration has no independent version counter; `expectedRevision` +invalidates stale plans, and each import batch stores a configuration copy. +Only the newest import batch retains workbook bytes and the full matrix; older +batches retain date, filename, SHA-256 and object count. Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills existing rows from `quantity` and keeps `quantity` as the materialized total. Normal manual row inserts and quantity edits update both values together. @@ -363,37 +355,12 @@ protection and its external object links. Its history-only inverse requires the complete unchanged circuit snapshot and exact link set. Redo never recalculates the BMK. All three external assignment stores share `external-object-assignment.persistence.ts` for compatibility and link safety. -The read-only +The current read-only `GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` -projection resolves the board through the owned CircuitList and returns only -that board's compact external objects, unassigned first. It exposes no original -CSV bytes or command payloads and returns an empty list before optional import. -Confirmed initial state is written only through -`external-import.apply-initial`. The command rechecks configuration version, -original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit -planning overrides and internal project links in the shared transaction. It -never creates or links a CircuitDeviceRow. Its exact inverse removes the whole -unchanged external state, and Redo restores the same UUIDs and bytes. -`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless -read path for the first wizard stage. It rejects projects with an existing -external source and returns source-room groups, exact room-number suggestions, -exact family/type groups, projected object values, issue counts and the current -floor/room/board/ProjectDevice catalogs. It creates no draft, revision or -domain row. The wide modal renders these groups and catalogs as explicit -decisions and identifies exact, ambiguous and new room matches. Missing rooms -and ProjectDevices are created through the reused project forms with mapped -Revit values as visible defaults. Each catalog creation remains its own project -command; the wizard adds and selects the result without discarding its plan. -The dedicated -`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint -retransmits and replans the file against the expected hash, configuration -version and project revision. It requires one explicit decision per source-room -and exact family/type group, blocks unclassified families, creates all stable -external UUIDs server-side and invokes `external-import.apply-initial`. -Room/default-board and optional ProjectDevice links are supported; CSV circuit -values remain source-only and every `circuitDeviceRowId` remains null. The UI -blocks unclassified families, requires a final confirmation and applies the -whole import as one persistent Undo/Redo step. +projection resolves the board through the owned CircuitList and returns compact +legacy external objects. The target Revit page will load one compact project +list and filter/sort it client-side in a virtualized table, without server +pagination or batch blobs in the DTO. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. @@ -575,70 +542,58 @@ Required operations: Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo is served with `nosniff` and a restrictive CSP. -## Revit / CSV External Model Work +## Revit / Linear Excel External Model Work -Status: further Revit work (editor assignment UI, follow-up import, return -export) is deferred and will be tackled later. When it resumes, the exchange -stays CSV-based and uses the CSV export/import of the Linear plugin in Revit. -The internal Revit/CAD steering committee prefers this over manipulating the -model directly through Dynamo. Do not start Dynamo, Revit API or JSON -transport work. The Linear CSV layout is not yet known: check a sample export -against the existing project CSV configuration before changing the parser. -Earlier Dynamo considerations are archived in -`docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md`; the -removed diagnostic scripts remain reachable through the git tag -`archive/dynamo-vorueberlegungen`. +Phase 14 is active and follows +`docs/spec/revit-excel-integration-requirements.md` plus +`docs/spec/revit-excel-phase-14-plan.md`. The old CSV requirements and phases +14.0–14.6 are superseded. Dynamo, Revit API and JSON transport remain excluded. -When explicitly working on Phase 14, use -`docs/spec/revit-csv-integration-requirements.md` as the detailed source of -truth together with the current architecture documents. +Critical target rules: -Critical rules: +- Exchange uses only `.xlsx` files produced and consumed by the Linear plugin. +- Revit integration remains optional. Import never creates a CircuitDeviceRow, + Circuit or equipment identifier. +- Worksharing-ID is the external key and is compared exactly and + case-sensitively. It stays separate from internal UUIDs and circuit BMKs. + IfcGUID is optional redundancy, never a key, and duplicate values are only a + warning. +- A row without Worksharing-ID is suspicious only when `Familie und Typ` is + populated. Show a warning and skip it; do not block. Other ID-less rows, + including the sample summary in column B, are skipped silently. +- Source, planning and override values remain separate. Existing ProjectDevices + remain canonical and are never silently changed. +- Multi-socket objects remain indivisible. Row quantity stays + `manualQuantity + sum(effectiveQuantity)`. +- The project configuration has no independent version counter. Preview and + apply use `expectedRevision`; import batches store a configuration copy. +- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they + apply only stored before/after states. +- Only the newest import batch retains original bytes and the full matrix; + older batches retain date, filename, SHA-256 and object count. +- The Revit object table loads one compact list, filters/sorts in the client and + renders it virtually; do not add server pagination. +- Linked objects always follow their row's distribution board. Changing a + room's default board affects only unassigned objects and is not blocked. +- Deleting a row, circuit or group warns that X Revit objects become unassigned, + detaches them in the same command and restores links on Undo. Only deletion + of referenced rooms and distribution boards is blocked. +- Missing optional return columns for designation, power, voltage or current + produce warnings and are skipped. Only missing circuit columns for the + selected combined/separate mode block export. +- Missing GZF defaults visibly to 1 without separate confirmation. +- R2 must generate a changed return workbook from the local sample. The user + tests it in Linear before R3. If Linear rejects it, propose direct OOXML + modification of the original workbook before starting R3. +- All confirmed writes use typed project commands with `expectedRevision`, + complete inverse data and the shared project-command transaction boundary. +- Do not add automatic circuit planning, Revit circuit adoption, multi-source + management, partial imports, PostgreSQL, background jobs or new permissions. -- Revit integration is optional. Do not make imported external data mandatory - for projects, rooms, project devices, circuits or device rows. -- Parse imports into staging first. Never write CSV rows directly into circuits - or device rows. -- Keep external object identity separate from internal UUIDs and circuit BMKs. - `IfcGUID` identifies the external Revit object and must never replace an - internal UUID or `equipmentIdentifier`. -- One imported Revit object may exist without a `CircuitDeviceRow`. - Import, room assignment, distribution-board assignment, classification and - ProjectDevice linking do not create a row. -- Create or change a `CircuitDeviceRow` only after an explicit user action in - the circuit-list editor. -- Several external objects may link to one `CircuitDeviceRow`, while every - external object remains individually traceable for follow-up imports and - object-specific export. -- Multi-socket Revit objects are indivisible. Their effective quantity may be - greater than one, but one external object must never be split across circuits. -- Existing ProjectDevices are canonical project templates and must not be - overwritten from Revit values. When creating a new ProjectDevice, mapped - Revit values may prefill the creation form. -- Store imported source values separately from local planning values and local - overrides. Imports never silently overwrite local values. -- Repeated imports match deterministically by project/source and `IfcGUID`. - New, changed, missing and conflicting objects require preview and explicit - user decisions. -- The first implementation assumes one complete Revit model export per project. - Do not add visible multi-source, linked-model or partial-import management. -- CSV configuration is project-scoped. Preserve title, header, blank, subtotal - and unknown rows and columns for the return export. -- Treat rows without `IfcGUID` as passthrough when they contain no object data. - Do not report Revit subtotal rows as missing-identity objects. -- Apply each confirmed import diff through a typed project command with - `expectedRevision`, complete inverse data and the shared project command - transaction boundary. -- External-model state must be added to logical project snapshots and portable - project transfer when it becomes part of the supported runtime. -- Keep CSV parsing/serialization in a transport adapter. Domain services must - not depend on CSV syntax, React, `better-sqlite3` or concrete repositories. -- Do not implement automatic circuit planning, Dynamo/API exchange, - PostgreSQL, background jobs or permissions as part of the initial CSV phase. - -Before implementation, audit the current ProjectDevice, CircuitDeviceRow, -command, snapshot, transfer and editor drag-and-drop paths. Propose concrete -domain objects and commands before adding migrations. +The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is +gitignored and must never be embedded in tests or committed. Use synthetic +workbooks for automated tests and accept the real file only as an explicit +local path in the reference checker. ## Cable Sizing Module @@ -733,9 +688,6 @@ for the early-phase power balance by DIN 276 cost group (see ## Current Deferred Work -- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never - Dynamo), except when Phase 14 is explicitly requested and - `docs/spec/revit-csv-integration-requirements.md` is being followed - full electrical sizing - multi-user/PostgreSQL operation - supported production deployment diff --git a/README.md b/README.md index f64ae04..3490332 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind -vorhanden. Revit-CSV-Konfiguration, Vorschau -und bestätigter Erstimport sind implementiert; die Editoroberfläche zur -Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere -Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import -des Linear-Plugins in Revit. +vorhanden. Die bisherige Revit-CSV-/IfcGUID-Grundlage mit Vorschau und +bestätigtem Erstimport ist noch im Code vorhanden. Der freigegebene +Phase-14-Plan ersetzt sie schrittweise durch den XLSX-Austausch des +Linear-Plugins mit Worksharing-ID als externem Schlüssel. R0 ist abgeschlossen; +R1 aktualisiert die Dokumentation. Editorzuordnung, Folgeimport und Rückexport +sind noch nicht als neuer Excel-Pfad ausgeliefert. ## Unterstützter Arbeitsablauf @@ -40,9 +41,9 @@ des Linear-Plugins in Revit. - grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät übernehmen -- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von - Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen - noch keine Stromkreise oder Gerätezeilen +- den vorhandenen Legacy-Revit-CSV-Erstimport verwenden; der Zielpfad über + Linear-XLSX, eigene Revit-Seite, Editorzuordnung, Rückgabe und Folgeimport + wird gemäß Phase-14-Plan umgesetzt ## Technik @@ -177,6 +178,8 @@ vor der Baseline werden nicht unterstützt. - [Anmeldung und Benutzerverwaltung](docs/authentication.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) +- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md) +- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md) Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen diff --git a/docs/README.md b/docs/README.md index 3569b42..90c5fc3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,13 +31,18 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Deployment und Betrieb](deployment.md) -## Zukunftsarchitektur +## Aktive Revit-Umstellung - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) -- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md) -- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) – - zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins -- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md) +- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) – + verbindliche Anforderungen für XLSX und Worksharing-ID +- [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status, + Paketgrenzen und Linear-Freigabepunkt nach R2 +- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md) +- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md) + +## Zukunftsarchitektur + - [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md) - [Roadmap und Phasen](spec/07-implementation-phases-todo.md) - [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md) diff --git a/docs/building-structure.md b/docs/building-structure.md index 21059b4..9db2c91 100644 --- a/docs/building-structure.md +++ b/docs/building-structure.md @@ -101,8 +101,8 @@ bleiben. ## Offen - Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die - Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die - Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg. + aktive Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten + Raumzuordnung im Importwizard nicht im Weg. - Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau und ausdrücklicher Bestätigung einhalten. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index fdc463a..ee2d470 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,9 +9,10 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. -- Further Revit work is deferred; it will later use the CSV export/import of - the Linear plugin in Revit, not Dynamo. -- The Revit/CSV foundation and confirmed initial import are implemented. +- The current Revit/CSV foundation and confirmed initial import are a legacy + starting point. The active Phase-14 plan replaces them with the Linear XLSX + exchange and exact Worksharing-ID identity. + R2 must pass a real Linear return-import check before database cutover in R3. Existing-row assignment, move and detach plus creation of an external-only row in an existing or newly created circuit have atomic persistent commands. Their API planning and editor UI, follow-up import, conflict review and return diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 20c7ccd..8993974 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -640,11 +640,15 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. -## Revit-CSV-Grundlage +## Revit-Anbindung: Legacy-Ausgangslage und freigegebener Excel-Cutover -**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann -über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. -Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md). +Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen +Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte +Ausgangslage für den schrittweisen Cutover; er darf nicht erweitert werden. +Das verbindliche Ziel ist der XLSX-Austausch des Linear-Plugins mit exakter, +case-sensitiver Worksharing-ID. Details: +[Excel-Anforderungen](spec/revit-excel-integration-requirements.md) und +[R0–R10-Plan](spec/revit-excel-phase-14-plan.md). Der reine Adapter unter `src/external-model/csv` validiert die projektweite Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine @@ -774,11 +778,33 @@ Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert ihn für die betroffene Gruppe. +### Freigegebener Zielpfad ab R2 + +R2 ersetzt zunächst nur den Transportadapter und endet mit einem realen +Linear-Rückimporttest. R3 bleibt bis zu dessen Freigabe gesperrt. Scheitert der +ExcelJS-Roundtrip, wird vor R3 ein Adapter vorgeschlagen, der die Original-XLSX +direkt auf OOXML-Ebene ändert. + +Der Zielzustand verwendet Worksharing-ID als exakten, case-sensitiven externen +Schlüssel; IfcGUID bleibt optional. Die Konfiguration hat keinen eigenen +Versionszähler. `expectedRevision` bindet Vorschau und Apply, Importbatches +speichern eine Konfigurationskopie. Nur der letzte Batch hält Workbook und +Matrix vollständig, ältere Batches nur Metadaten. Apply parst die erneut +übertragene Datei, Undo/Redo arbeitet nur auf persistierten Zuständen. + +Die eigene Route `/projects/:projectId/revit` lädt eine kompakte Objektliste +einmal und stellt sie clientseitig gefiltert, sortiert und virtualisiert dar. +Verknüpfte Objekte folgen dem Verteiler ihrer Row. Row-, Circuit- und +Gruppenlöschungen lösen externe Links nach Warnung im selben Command; Undo +stellt sie wieder her. Raum- und Verteilerlöschungen bleiben bei externen +Referenzen gesperrt. + ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und - strukturtreuer Revit-Rückexport +- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die + Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung + folgt dem R0–R10-Plan - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index f8ed263..91b82ca 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -9,7 +9,8 @@ This document records architectural direction for future work. It is not a final - persist undo/redo across page reloads and application restarts - provide a project-wide version history with named restore points - preserve an auditable sequence of user, import, migration and restore operations -- support future Revit/CSV round-trips using stable IFCGUID-based links +- support the Revit/Linear-XLSX round-trip using stable, exact + Worksharing-ID-based external identity - keep the persistence boundary replaceable so PostgreSQL can be adopted later ## Separate Concerns @@ -111,11 +112,12 @@ Implemented automatic-snapshot policy: - automatic capture and retention run inside the same transaction as the triggering project revision -## Revit / CSV / IFCGUID Round-Trip Direction +## Revit / Linear Excel Round-Trip Direction -**Deferred:** further Revit work will be tackled later, then through the -CSV export/import of the Linear plugin in Revit instead of Dynamo. Details: -[Revit-CSV requirements, section 1](spec/revit-csv-integration-requirements.md). +Phase 14 is active and follows the +[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and +[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The existing CSV/IFCGUID +runtime is a legacy starting point for the cutover, not the target model. External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. @@ -124,9 +126,12 @@ Conceptual entities: - `ExternalModelSource` - identifies a Revit/IFC model or another external source within a project - `ExternalImportBatch` - - file name, checksum, import time, format/version and processing status + - file name, checksum, import time, object count and processing status + - only the latest batch keeps original workbook bytes, matrix and the + configuration copy; older batches keep date, filename, checksum and count - `ExternalModelObject` - - stable IFCGUID and optional Revit element identifiers + - exact case-sensitive Worksharing-ID as external identity + - optional IfcGUID as non-unique source redundancy - source attributes and last-seen import batch - `ExternalObjectLink` - explicit relationship to a project device, circuit device row or another future domain entity @@ -137,13 +142,27 @@ Conceptual entities: Required behavior: -- IFCGUID uniqueness is scoped to project and external model source. +- Worksharing-ID uniqueness is scoped to the external source and uses exact, + case-sensitive comparison. IfcGUID is not a matching key. - Parsing writes to staging first. - Users preview additions, changes, missing objects and conflicts before applying them. - Applying an import creates one project revision and one auditable change set. - Imported source values and locally assigned planning values remain distinguishable. -- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically. -- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned. +- Export retains Worksharing-ID so Linear can match rows deterministically. +- Missing objects and duplicate/conflicting Worksharing-IDs are reported, + never silently reassigned. + +The project configuration has no separate revision counter. Stateless plans +and apply requests bind to `expectedRevision`; every applied import batch holds +its configuration copy. Apply reparses the retransmitted workbook. Undo and +Redo operate only on persisted complete before/after states and never parse an +Excel file. + +Deleting a linked row, circuit or group detaches affected Revit objects in the +same typed command after an explicit warning; its inverse restores all links. +Only deleting referenced rooms and distribution boards is blocked. Linked +objects always derive their board from their row, while a room default-board +change affects only unassigned objects. ## SQLite and PostgreSQL Direction @@ -292,7 +311,8 @@ Remaining constraints before completing project version history: boundary; extend it whenever further project mutations are introduced - project creation remains initialization at revision zero, while global device templates remain outside any single project history -- do not model IFCGUID as an overloaded circuit equipment identifier +- do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment + identifier or an internal relation id - keep database backup/restore checks separate from project history tests ## Deferred Decisions @@ -300,5 +320,6 @@ Remaining constraints before completing project version history: - whether large snapshot payloads remain in PostgreSQL or move to object storage - branch visualization after undo followed by new edits - user/role model and actor attribution -- supported Revit CSV dialects and parameter mapping UI +- Linear-XLSX compatibility beyond the observed sample and any future + project-independent mapping profiles - PostgreSQL deployment topology and migration date diff --git a/docs/revit-requirements-llm-context.md b/docs/revit-requirements-llm-context.md index cf10324..3782150 100644 --- a/docs/revit-requirements-llm-context.md +++ b/docs/revit-requirements-llm-context.md @@ -1,273 +1,15 @@ -# Kontext für die Revit-Anforderungsplanung +# LLM-Kontext für die Revit-Integration -**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann -über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. -Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md). +Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und +abgelöst. Für Arbeiten an Phase 14 sind vollständig zu lesen: -## Zweck dieser Datei +- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) +- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md) +- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md) +- [Aktuelle Systemarchitektur](current-architecture.md) +- [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md) -Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei -helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen -für einen zukünftigen Revit-Datenaustausch zu klären. - -Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst -den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und -daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein -Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen. - -## Produkt und fachliches Modell - -Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. -Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen. - -Das unterstützte Laufzeitmodell ist: - -```text -Project -├── Floor -│ └── Room -├── ProjectDevice -└── DistributionBoard - └── CircuitList - └── CircuitSection - └── Circuit - └── CircuitDeviceRow -``` - -Wichtige fachliche Grenzen: - -- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile. -- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten. -- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis. -- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe - und Kategorie gehören zur Gerätezeile. -- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage. -- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber - lokale Werte und ausdrücklich erfasste Abweichungen. -- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals - automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl. -- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär - erlaubt. -- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden. -- Neunummerierung ist immer eine ausdrückliche Benutzeraktion. -- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet - werden. - -Für einen späteren Revit-Austausch besonders relevante Daten sind: - -- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung, - Kennzeichnung als öffentliches Gebäude und Projektspannungen. -- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und - Raumname. -- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor. -- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-, - Spannungs-, Steuerungs-, Status- und Bemerkungsdaten. -- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name, - Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss, - Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung. -- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte. - -Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus -und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im -aktuellen Produkt nicht vorgesehen. - -## Aktuelle technische Architektur - -- Frontend: Next.js App Router, React und TypeScript unter `src/app` und - `src/frontend`. -- API: Express und Zod unter `src/server`. -- Fachregeln und typisierte Commands: `src/domain`. -- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`. -- Laufzeitkomposition konkreter Repositories: - `src/server/composition`. -- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000, - SQLite-Datei unter `data/leistungsbilanz.db`. -- Docker Compose wird für die lokale Entwicklung unterstützt. -- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt. - -Der zentrale Editor liegt unter: - -```text -src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx -src/frontend/components/circuit-tree-editor.tsx -``` - -Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln -liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in -Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von -`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen. - -## Revisionen, Undo/Redo und Sicherungspunkte - -Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame -Command- und Transaktionsgrenze: - -```text -Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang -``` - -Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt. -Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle -geben die erwartete Revision mit, damit veraltete Clients keine neueren -Änderungen überschreiben. - -Undo/Redo ist projektweit persistent und funktioniert nach Reload oder -Anwendungsneustart. Zusätzlich gibt es: - -- eine unveränderliche Revisions-Timeline; -- benannte und automatische logische Projektsnapshots; -- wiederherstellbare Snapshots mit Prüfsumme; -- einen portablen, versionierten JSON-Projekttransfer. - -Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden: -Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command -beziehungsweise als ein fachlich definierter Änderungssatz atomar, -revisioniert und rückgängig machbar sein. - -## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer - -Der vorhandene JSON-Export/-Import überträgt den vollständigen internen -Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines -Projekts innerhalb von Leistungsbilanz. - -Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne -Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage -für CSV-Dateien oder Revit-Parameter verwendet werden. - -Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler -externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und -Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten -zuweisen kann. - -## Bereits vereinbarte Leitplanken für den externen Modellaustausch - -Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges -Implementierungsdesign: - -- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder - Gerätezeilen. -- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich. -- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und - widersprüchliche Objekte angezeigt. -- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle - und einer stabilen Identität, voraussichtlich IFCGUID, erkennen. -- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe - Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären. -- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen - unterscheidbar bleiben. -- Lokale Planungswerte dürfen niemals still überschrieben werden. -- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen - ausdrücklich modelliert werden. -- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals - automatisch einem anderen Objekt zugeordnet. -- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit - die Werte deterministisch zurückschreiben kann. -- Ein Export bezieht sich auf eine bekannte Projektrevision. -- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu - PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem - Serverbetrieb oder unabhängigen Hintergrundjobs. - -Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt: - -- `ExternalModelSource` -- `ExternalImportBatch` -- `ExternalModelObject` -- `ExternalObjectLink` -- `ExternalParameterMapping` -- `ExternalExportBatch` - -Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie -hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene -Tabellen behandeln. - -## Auftrag an das andere LLM - -Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem -realen Ablauf in Revit und kläre danach schrittweise mindestens: - -1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert? -2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus - (CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen, - Revit-Versionen)? -3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`, - Typ-ID, Modell-/Dateikennung oder weitere Schlüssel? -4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und - aktualisierte Revit-Dateien unterschieden? -5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in - Leistungsbilanz geplant? -6. Welche internen Ziele können externe Objekte erhalten: Projektgerät, - Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten? -7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis, - Projektgerät, Raum oder einer Verteilung zugeordnet? -8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen - Einheiten und unter welchen Revit-Parameternamen? -9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten, - Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue - Rückgabedatei erzeugt werden? -10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert, - wiederverwendet und zwischen Projekten geteilt werden? -11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene, - doppelte oder nicht mehr sichtbare Revit-Objekte? -12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung? - Welche Konflikte dürfen automatisch gelöst werden und welche benötigen - eine Auswahl? -13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der - Anwender vor der Übernahme? -14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme - verhindern oder nur eine Warnung erzeugen? -15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch? -16. Müssen Import und Export synchron im Browser laufen oder werden später - Hintergrundjobs benötigt? -17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo, - Versionshistorie und Support gespeichert werden? -18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als - Abnahmekriterien? - -Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen -Notizen ausdrücklich: - -- vom Anwender bestätigte Anforderungen; -- sinnvolle Vorschläge; -- offene Entscheidungen; -- technische Folgerungen; -- bewusst nicht betrachtete spätere Ausbaustufen. - -## Erwartetes Ergebnis der Anforderungsrunde - -Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie -soll mindestens enthalten: - -- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen; -- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und - Rückexport; -- vereinbartes Dateiformat mit einem anonymisierten Beispiel; -- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit; -- Identitäts-, Matching- und Quellmodell; -- Regeln für neue, geänderte, fehlende und doppelte Objekte; -- Konflikt-, Validierungs- und Freigaberegeln; -- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung; -- Anforderungen an Revision, Atomarität, Undo/Redo und Audit; -- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende - Architekturregeln zu umgehen; -- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien; -- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden - müssen. - -Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren, -welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile -nur Architekturvorschläge des LLM sind. - -## Weiterführende Projektdokumente - -- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln -- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und - Command-Pfad -- `docs/project-history-and-external-model-architecture.md` – bisherige - Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL -- `docs/spec/01-domain-context.md` – fachlicher Kontext -- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept -- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und - kontrollierte Synchronisierung -- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14 -- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte - Funktionen +Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel, +Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der derzeit noch im +Code vorhandene CSV-Stand ist ausschließlich die in R1 dokumentierte +Legacy-Ausgangslage für den schrittweisen Cutover. diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index f36d60d..0043ef2 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -532,36 +532,34 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip -**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann -über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. -Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md). +Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf +Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist +abgelöst und wird nicht fortgeschrieben. -Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist -implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe -[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) -und die verbindlichen -[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md). +Verbindliche Quellen: -Goal: +- [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md) +- [R0–R10-Plan](revit-excel-phase-14-plan.md) +- [R0-Bestandsaudit](revit-excel-r0-audit.md) -Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import). +Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit +einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie +erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird +zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative +vorgelegt. -Tasks: +Kernabnahme der Phase: -- model external sources, import batches and stable IFCGUID objects -- stage and validate imports before applying them -- show additions, changes, missing objects and conflicts -- link external objects explicitly to project-domain entities -- apply an import as one auditable project revision -- configure inbound and outbound parameter mappings -- export against a known project revision while preserving IFCGUID - -Acceptance criteria: - -- repeated imports match objects deterministically by source and IFCGUID -- imports never silently overwrite local planning values -- users approve the diff before it changes the project -- exported data can be assigned back to the correct Revit objects +- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel; +- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim + Rückexport erhalten; +- Import erzeugt keine Rows und überschreibt keine lokale Planung still; +- Zuordnung, Löschung, Folgeimport und Undo/Redo sind atomar und nach Neustart + reproduzierbar; +- Undo/Redo liest keine Excel-Datei; +- nur der letzte Importbatch enthält Workbook und Matrix vollständig; +- mehrere Tausend Objekte bleiben über eine clientseitig gefilterte, + virtualisierte Liste bedienbar. PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration. diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index c0a2720..6d1eae4 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -162,7 +162,7 @@ ProjectDevices and device rows (#20). 3. [x] Snapshot-to-revision descriptions and history presentation. 4. [x] Distribution-board power summary and project column layout. -The Revit/CSV/IFCGUID round-trip follows the distribution-board component -foundation as a separate jointly planned phase and must not be inferred from -these tasks. It is currently deferred and will later use the CSV -export/import of the Linear plugin in Revit, not Dynamo. +The Revit/Linear-XLSX round-trip is now the separately planned active Phase 14. +It uses exact Worksharing-ID identity and must not be inferred from these +unrelated backlog tasks. See +[the R0–R10 plan](revit-excel-phase-14-plan.md). diff --git a/docs/spec/revit-csv-integration-requirements.md b/docs/spec/revit-csv-integration-requirements.md index 4ce65ee..7889566 100644 --- a/docs/spec/revit-csv-integration-requirements.md +++ b/docs/spec/revit-csv-integration-requirements.md @@ -1,1386 +1,8 @@ -# Revit-CSV-Integration – Anforderungen und Implementierungsanweisung +# Abgelöst: Revit-CSV-Anforderungen -## 1. Status und Verbindlichkeit +Diese CSV-/IfcGUID-Spezifikation ist seit 29.09.2026 abgelöst. Die verbindliche +Quelle ist [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md). -> **Zurückgestellt (September 2026).** Die weitere Revit-Arbeit -> (Editor-Oberfläche zur Objektzuordnung, Folgeimport, Rückexport) wird später -> angegangen. Wenn sie wieder aufgenommen wird, bleibt der Austausch -> CSV-basiert: Export und Import laufen über das Linear-Plugin in Revit. Das -> entspricht der Vorgabe des internen Revit/CAD-Lenkungskreises, weil dabei -> nicht direkt per Dynamo im Modell gearbeitet wird. Eine Dynamo- oder -> API-Anbindung ist nicht vorgesehen; die früheren Vorüberlegungen liegen unter -> [docs/archive](../archive/revit-dynamo-leistungsbilanz-integration-konzept.md). -> Der Aufbau der Linear-CSV ist noch offen. Vor der Wiederaufnahme wird ein -> Musterexport gegen die bestehende projektweite CSV-Konfiguration und den -> Parser geprüft; erst danach wird entschieden, was anzupassen ist. Die bereits -> umgesetzten Schritte bleiben unverändert nutzbar. - -Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**. - -Es unterscheidet ausdrücklich zwischen: - -- **Bestätigte Anforderung:** vom Anwender festgelegtes Verhalten. -- **Bestehende Architekturvorgabe:** bereits geltende Regel des Projekts. -- **Implementierungsvorschlag:** empfohlene technische Aufteilung, noch kein unveränderliches Datenbankschema. -- **Offene Entscheidung:** darf erst in der jeweils genannten Phase konkretisiert werden. - -Codex darf aus diesem Dokument kein vollständiges Event-Sourcing, keine PostgreSQL-Migration, keine automatische Stromkreisplanung und keine direkte Dynamo-Anbindung ableiten. - -Vor Implementierungsbeginn sind insbesondere zu lesen: - -- `AGENTS.md` -- `docs/current-architecture.md` -- `docs/spec/03-data-model-concept.md` -- `docs/spec/05-linked-devices-and-sync.md` -- `docs/project-history-and-external-model-architecture.md` -- `docs/spec/07-implementation-phases-todo.md` -- `docs/circuit-list-editor-known-limitations.md` - -## 2. Ziel - -Die Anwendung soll Revit-Objekte der Kategorie **Elektroinstallation** über eine CSV-Datei einlesen, dauerhaft anhand ihrer `IfcGUID` erkennen und mit der manuellen Elektroplanung in Leistungsbilanz verbinden. - -Der Workflow soll insbesondere vermeiden, dass Stromkreiskennzeichnungen und Leistungen für jedes Revit-Objekt händisch nachgetragen werden müssen. - -Der Nutzer soll: - -1. Revit-Objekte importieren, -2. Räume aus dem Import anlegen oder vorhandenen Räumen zuordnen, -3. Räume und einzelne Objekte Verteilungen zuordnen, -4. Objekte klassifizieren und optional mit `ProjectDevice`-Vorlagen verknüpfen, -5. noch nicht verschaltete Objekte im Stromkreislisteneditor manuell Stromkreisen und Gerätezeilen zuweisen, -6. Stromkreiskennzeichnung und Leistung in eine strukturgleiche Rückgabe-CSV schreiben, -7. denselben vollständigen Modellstand später erneut importieren und Änderungen kontrolliert abgleichen. - -## 3. Grundlegende Produktgrenzen - -### 3.1 Revit bleibt optional - -**Bestätigte Anforderung** - -Leistungsbilanz muss vollständig ohne Revit-Integration nutzbar bleiben. - -- Projekte, Ebenen, Räume, Verteilungen, Projektgeräte, Stromkreise und Gerätezeilen bleiben manuell anlegbar. -- Ein Projekt darf ausschließlich manuelle Daten, ausschließlich verknüpfte Revit-Daten oder eine Mischung enthalten. -- Revit ist nur für die Existenz und die Quellattribute der tatsächlich verknüpften Modellobjekte führend. -- Revit ist nicht die alleinige Quelle des gesamten Leistungsbilanzprojekts. - -### 3.2 Circuit-First-Modell bleibt unverändert - -**Bestehende Architekturvorgabe** - -```text -Project -├── Floor -│ └── Room -├── ProjectDevice -└── DistributionBoard - └── CircuitList - └── CircuitSection - └── Circuit - └── CircuitDeviceRow -``` - -- Ein `Circuit` ist nicht dasselbe wie eine `CircuitDeviceRow`. -- BMK, Schutz- und Kabeldaten gehören zum Stromkreis. -- Menge, Leistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe und Kategorie gehören zur Gerätezeile. -- Ein importiertes Revit-Objekt darf vor seiner Stromkreiszuordnung eigenständig existieren. -- Der Import legt nicht automatisch Stromkreise oder Gerätezeilen an. - -### 3.3 Stable IDs und BMK - -**Bestehende Architekturvorgabe** - -- `IfcGUID` ersetzt niemals eine interne UUID. -- `IfcGUID` wird niemals als Stromkreis-BMK verwendet. -- Bestehende interne UUIDs und BMKs dürfen nicht automatisch geändert werden. -- Neunummerierung bleibt eine ausdrückliche Benutzeraktion. -- Import, Sortierung, Verschieben und Folgeabgleich führen nicht automatisch zu einer Neunummerierung. - -## 4. Umfang der ersten Ausbaustufe - -### 4.1 Enthalten - -**Bestätigte Anforderungen** - -- projektweite CSV- und Mappingkonfiguration; -- Import vollständiger CSV-Dateien; -- Import von Revit-Objekten der Kategorie `Elektroinstallation`; -- deterministische Identifikation über `IfcGUID`; -- Import beziehungsweise Verknüpfung von Räumen; -- manuelle Verknüpfung von Räumen mit Ebenen; -- manuelle Anlage der Verteilungen; -- Standardverteilung je Raum und abweichende Verteilung je Objekt; -- exakte Klassifizierung über den CSV-Wert `Familie und Typ`; -- konfigurierbare Behandlung von Einzel- und Mehrfachsteckdosen; -- optionale Verknüpfung importierter Objekte mit vorhandenen `ProjectDevice`-Vorlagen; -- Erstellen neuer `ProjectDevice`-Vorlagen aus ausgewählten Importobjekten; -- lokale Planungswerte und Abweichungen bereits vor der Stromkreiszuordnung; -- projektweites Import-, Staging- und Abgleichs-UI; -- Bereich für unzugeordnete externe Objekte im Stromkreislisteneditor; -- manuelle Zuordnung zu bestehenden Gerätezeilen, bestehenden Stromkreisen oder neuen Stromkreisen; -- Rückexport von vollständiger Stromkreiskennzeichnung und elektrischer Leistung; -- Erhaltung der ursprünglichen CSV-Struktur; -- kontrollierter Folgeimport mit Einzel- und Sammelentscheidungen; -- atomare, revisionierte und rückgängig machbare Übernahmen. - -### 4.2 Ausdrücklich nicht enthalten - -**Bestätigte Anforderungen** - -- automatische Stromkreisbildung in der ersten Ausbaustufe; -- automatische Übernahme späterer Stromkreisvorschläge; -- Import von Verteilungen aus Revit; -- Import einer separaten MEP-Raumliste; -- Teilimporte einzelner Ansichten oder Geschosse; -- Verwaltung mehrerer sichtbarer Modellquellen; -- direkte Dynamo- oder API-Synchronisation; -- PostgreSQL- oder Mehrbenutzerbetrieb; -- automatische Reservekreisbildung; -- automatische elektrische Dimensionierung; -- automatische Konfliktentscheidung; -- automatische Freigabe oder Wiederverwendung leer gewordener Stromkreisnummern; -- Platzhalter, Teiltextsuche oder reguläre Ausdrücke in der ersten Familie-und-Typ-Klassifizierung. - -## 5. Begriffe - -### 5.1 Externes Modellobjekt - -Ein importiertes Revit-Exemplar mit eigener `IfcGUID`. - -Es ist weder automatisch ein `ProjectDevice` noch automatisch eine `CircuitDeviceRow`. - -### 5.2 Quellwert - -Ein Wert, der aus der zuletzt akzeptierten Revit-CSV stammt. - -Beispiele: - -- Revit-Raumnummer; -- Revit-Raumname; -- Familie und Typ; -- CAx-Auswahlkenner; -- vorhandene Stromkreiskennzeichnung; -- vorhandene elektrische Leistung. - -### 5.3 Lokaler Planungswert - -Ein in Leistungsbilanz bestätigter beziehungsweise bearbeiteter Wert. - -Lokale Planungswerte dürfen durch Importe niemals still überschrieben werden. - -### 5.4 Wirksame Menge - -Die fachliche Menge, die für Gerätezeile, Leistungsberechnung und Steckdosenbegrenzung zählt. - -Beispiel: - -```text -2 Doppelsteckdosen + 2 Einzelsteckdosen -= 4 Revit-Objekte -= 6 physische Steckdosen -= wirksame Menge 6 -``` - -## 6. Revit-CSV-Vertrag - -### 6.1 Konfigurierbare Transporteigenschaften - -**Bestätigte Anforderung** - -Die Konfiguration gilt projektweit und muss über eine GUI bearbeitbar sein. - -Mindestens konfigurierbar: - -- Encoding; -- Trennzeichen; -- Dezimaltrennzeichen; -- Zahlenformat; -- Einheit und Umrechnungsregel für Leistung; -- Spaltenname für `IfcGUID`; -- Spaltenname für Raumnummer; -- Spaltenname für Raumname; -- Spaltenname für `Familie und Typ`; -- Spaltenname für `CAx_Auswahlkenner`; -- Spaltenname für Stromkreiskennzeichnung; -- Spaltenname für elektrische Leistung; -- Spaltenname für den Mengenparameter einer Mehrfachsteckdose; -- weitere Quellspalten-zu-internen-Feld-Mappings. - -Erste Standardwerte: - -```text -Encoding: UTF-8 -Trennzeichen: ; -Leistung in Revit: W -``` - -### 6.2 Tatsächlich beobachtetes CSV-Profil - -Die bereitgestellte Referenzdatei besitzt: - -```text -Encoding: UTF-8 mit BOM -Zeilenende: CRLF -Trennzeichen: Semikolon -Quotierung: alle Felder in doppelten Anführungszeichen -Zeile 1: Titel-/Metadatenzeile -Zeile 2: Kopfzeile mit 12 Spalten -Zeile 3: vollständig leere Zeile -Objektzeilen mit IfcGUID: 897 -Summen-/Gruppierungszeilen ohne IfcGUID: 211 -Nichtleere doppelte IfcGUIDs: keine -``` - -Beispiel einer nicht als Objekt zu importierenden Summenzeile: - -```text -"UV_AV_DG_01-1F1: 8";"";"";"";"";"";"";"";"";"";"";"" -``` - -Die Implementierung darf daher nicht annehmen, dass: - -- die Kopfzeile immer die erste Dateizeile ist; -- jede Zeile nach der Kopfzeile ein Objekt ist; -- jede fehlende `IfcGUID` automatisch ein fehlerhaftes Objekt darstellt. - -### 6.3 Erkennen der Kopfzeile - -**Implementierungsvorschlag** - -Die Kopfzeile wird anhand der projektweit konfigurierten erforderlichen Spaltennamen gesucht. - -- Genau eine passende Kopfzeile: verwenden. -- Keine passende Kopfzeile: Import blockieren. -- Mehrere passende Kopfzeilen: Import blockieren und Auswahl beziehungsweise Konfiguration verlangen. -- Zeilen vor und nach der Kopfzeile bleiben als Transportbestandteil erhalten. - -### 6.4 Zeilenklassen - -**Implementierungsvorschlag** - -Jede CSV-Zeile wird genau einer Klasse zugeordnet: - -1. `metadata` - Zeile vor der Kopfzeile oder sonstige nicht tabellarische Metadaten. - -2. `header` - Erkannte Kopfzeile. - -3. `passthrough` - Leer-, Summen-, Gruppierungs- oder sonstige Zeile ohne externe Objektidentität, die nicht wie ein unvollständiges Objekt wirkt. - -4. `object` - Zeile mit nichtleerer, syntaktisch akzeptierter `IfcGUID`. - -5. `suspect-object` - Zeile ohne `IfcGUID`, aber mit befüllten objekttragenden Feldern wie Raum, Familie und Typ oder Auswahlkenner. - -`passthrough`-Zeilen werden unverändert erhalten und erzeugen keine externen Objekte. - -`suspect-object`-Zeilen werden als Konflikt angezeigt und nicht automatisch verworfen. - -### 6.5 Strukturtreuer Rückexport - -**Bestätigte Anforderung** - -Der Rückexport basiert auf der zuletzt akzeptierten vollständigen CSV und muss erhalten: - -- identische Anzahl und Reihenfolge der Zeilen; -- identische Anzahl und Reihenfolge der Spalten; -- Titel-, Leer-, Summen- und sonstige Passthrough-Zeilen; -- unbekannte beziehungsweise nicht gemappte Spalten; -- unveränderte Werte in nicht zu schreibenden Zellen; -- `IfcGUID` jeder Objektzeile; -- vorhandene Transportmerkmale, soweit technisch zuverlässig erkennbar. - -Mindestens zu erhalten sind: - -```text -BOM-Status -Encoding -Zeilenende -Trennzeichen -Spaltenreihenfolge -Zeilenreihenfolge -Titelzeilen -Leerzeilen -Summenzeilen -``` - -Nur explizit als Ausgangsparameter gemappte Zellen von erkannten Objektzeilen dürfen ergänzt oder überschrieben werden. - -Eine vollständig neu zusammengestellte Tabelle mit nur den fachlich verwendeten Spalten ist unzulässig. - -### 6.6 Anonymisiertes Beispiel - -```csv -"Projekt-Export";"";"";"";"" -"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"Auswahlkenner";"Leistung";"IfcGUID" -"";"";"";"";"";"";"" -"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Doppelsteckdose";"Arbeitsplatz";"";"0AbCdEfGhIjKlMnOpQrStu" -"UV_AV_01-2F1: 2";"";"";"";"";"";"" -``` - -Die letzte Zeile ist eine Passthrough-Summenzeile, kein externes Modellobjekt. - -## 7. Identität und Matching - -### 7.1 Externe Identität - -**Bestätigte Anforderung** - -- `IfcGUID` ist der stabile Schlüssel für ein Revit-Objekt. -- Ein Objekt bleibt auch bei geänderten Quellwerten über seine `IfcGUID` bekannt. -- Doppelte nichtleere `IfcGUIDs` innerhalb eines Imports sind ein blockierender Konflikt. -- Eine fehlende `IfcGUID` in einer echten Objektzeile verhindert die automatische Verknüpfung. - -### 7.2 Quellmodell - -**Bestätigte Anforderung plus Architekturfolgerung** - -Für die erste Ausbaustufe gibt es pro Leistungsbilanzprojekt genau einen vollständigen Revit-Modellstrom. - -Die technische Architektur darf trotzdem eine implizite externe Quelle vorsehen, damit `IfcGUID` nicht global oder projektübergreifend eindeutig sein muss. - -Empfohlene Semantik: - -```text -Project -└── implicit ExternalModelSource - └── ExternalModelObject identified by IfcGUID -``` - -Es gibt in der ersten Ausbaustufe keine sichtbare Verwaltung von Hauptmodellen, Teilmodellen oder verknüpften Modellen. - -### 7.3 Interne Identität - -Jedes externe Objekt erhält zusätzlich eine stabile interne UUID. - -Diese UUID wird für interne Relationen, Commands, Snapshots und Undo/Redo verwendet. - -## 8. Datenhoheit - -### 8.1 Revit-geführt - -Revit ist zunächst führend für: - -- Existenz des externen Objekts; -- `IfcGUID`; -- Familie und Typ; -- Revit-Raumnummer und Revit-Raumname als Quellwerte; -- CAx-Auswahlkenner als Quellwert; -- sonstige ausschließlich aus Revit importierte Attribute. - -### 8.2 Leistungsbilanz-geführt - -Leistungsbilanz ist nach Bestätigung führend für: - -- interne Raum- und Ebenenverknüpfung; -- Standard- und Objektverteilung; -- Klassifizierung; -- `ProjectDevice`-Verknüpfung; -- lokale Planungswerte; -- Stromkreiszuordnung; -- `CircuitDeviceRow`-Zuordnung; -- Stromkreis-BMK; -- elektrische Planungsleistung; -- GZF, cosPhi, Kostengruppe, Kategorie und weitere lokale Gerätewerte. - -### 8.3 Konfliktregel - -Kein Folgeimport darf lokale Planungswerte automatisch überschreiben. - -Der Nutzer entscheidet je Konflikt oder per Sammelaktion zwischen: - -- Revit-Änderung übernehmen; -- lokalen Wert beibehalten; -- manuell einen dritten Wert festlegen. - -## 9. Projektweite Revit-Konfiguration - -### 9.1 Mapping - -Die Projektkonfiguration muss die CSV-Spalten auf interne Bedeutungen abbilden. - -Der CAx-Auswahlkenner muss mindestens als sichtbarer, filterbarer und gruppierbarer Wert verfügbar sein. Er kann als Vorschlag für einen Anzeigenamen verwendet werden. - -### 9.2 Familie-und-Typ-Regeln - -**Bestätigte Anforderung** - -In der ersten Ausbaustufe gilt ausschließlich exakter Textvergleich. - -Eine Regel kann mindestens festlegen: - -- exakter CSV-Wert `Familie und Typ`; -- interner Gerätetyp beziehungsweise fachliche Kategorie; -- Anschlussart; -- ProjectDevice-Kategorie `lighting`, `single_phase` oder `three_phase`; -- Mengenregel: - - feste Menge `1`; - - Menge aus gemappter CSV-Spalte; -- optional vorgeschlagener Anzeigename beziehungsweise Mappingquelle. - -Mehrere unterschiedliche Revit-Familien und -Typen dürfen demselben internen Gerätetyp zugeordnet werden. - -Beispiele: - -```text -_E_CAx Steckdose_AP: Steckdose Aufputz -→ interner Gerätetyp Steckdose -→ single_phase -→ Menge 1 -``` - -```text -_E_CAx Mehrfachsteckdose: Doppelsteckdose -→ interner Gerätetyp Steckdose -→ single_phase -→ Menge aus konfigurierter Anzahl-Spalte -``` - -### 9.3 Zugriff - -Der Konfigurationsbereich ist zunächst für alle Nutzer zugänglich, da noch kein Rollen- oder Rechtesystem existiert. - -## 10. Externes Objekt und Planungszustand - -### 10.1 Notwendige fachliche Informationen - -**Implementierungsvorschlag, kein finales Schema** - -Ein persistiertes externes Objekt benötigt mindestens: - -- interne UUID; -- Projekt- und implizite Quellenzugehörigkeit; -- `IfcGUID`; -- Referenz auf die aktuelle beziehungsweise zuletzt akzeptierte Quellzeile; -- importierte Quellwerte; -- zuletzt akzeptierte Quellwerte für den Folgeabgleich; -- verknüpften internen Raum, optional; -- Standardverteilung des Raums; -- abweichende Objektverteilung, optional; -- Klassifizierung; -- wirksame Menge; -- lokale Planungswerte; -- lokale Override-Metadaten; -- optionales `linkedProjectDeviceId`; -- optionalen Link zu einer `CircuitDeviceRow`; -- letzten Sichtbarkeits-/Importstatus; -- letzten gesehenen Importbatch; -- Konfliktstatus. - -### 10.2 Kein automatischer Row-Lebenszyklus - -Folgende Aktionen erzeugen keine `CircuitDeviceRow`: - -- CSV einlesen; -- Raum zuordnen; -- Verteilung zuordnen; -- klassifizieren; -- ProjectDevice verknüpfen; -- lokale Planungswerte bearbeiten. - -Eine Gerätezeile entsteht ausschließlich durch ausdrückliche Zuordnung im Stromkreislisteneditor. - -## 11. ProjectDevice-Verknüpfung - -### 11.1 Vorhandenes ProjectDevice - -Importierte Objekte dürfen mit einem vorhandenen `ProjectDevice` verknüpft werden. - -Das vorhandene `ProjectDevice` wird durch Revit-Werte niemals automatisch verändert. - -Die bestehende Regel bleibt verbindlich: - -- `ProjectDevice` ist die kanonische Projektvorlage; -- lokale Werte und Overrides bleiben möglich; -- Änderungen am ProjectDevice werden nie still synchronisiert; -- Synchronisierung benötigt Vorschau und Auswahl. - -### 11.2 Neues ProjectDevice aus Importobjekten - -**Bestätigte Anforderung** - -Der Nutzer darf im Importwizard ein neues `ProjectDevice` erstellen. - -Das Erstellformular darf anhand der gemappten Werte der ausgewählten Revit-Objekte vorausgefüllt werden. - -Mögliche Vorbelegungen: - -- `name`; -- `displayName`; -- Kategorie; -- Anschlussart; -- Kostengruppe; -- Einzelleistung; -- Gleichzeitigkeitsfaktor; -- cosPhi; -- Bemerkung. - -Die Kategorie des ProjectDevices bestimmt gemäß bestehendem Modell die Phasenart. Es darf kein paralleles, widersprüchliches Phasenmodell eingeführt werden. - -Bei einer Mehrfachauswahl mit unterschiedlichen Quellwerten muss die UI Abweichungen anzeigen. Sie darf nicht willkürlich einen Wert wählen. - -### 11.3 Lokale Werte vor der Stromkreiszuordnung - -Importierte Objekte dürfen bereits vor der Stromkreiszuordnung lokale Planungswerte und lokale Abweichungen vom verknüpften ProjectDevice besitzen. - -Beim späteren Erzeugen einer Gerätezeile werden diese bestätigten Objektplanungswerte als Ausgangswerte verwendet. - -## 12. Erstimport-Workflow - -### 12.1 Öffnen und Konfiguration - -1. Nutzer öffnet die projektweite Revit-Integration. -2. Falls keine gültige Mappingkonfiguration existiert, wird zuerst die Konfiguration geöffnet. -3. Nutzer lädt eine vollständige CSV hoch. -4. Parser erkennt Transportparameter und Kopfzeile. -5. Datei wird ohne fachliche Projektschreibvorgänge geparst und validiert. - -### 12.2 Vorschau - -Die Vorschau zeigt mindestens: - -- Anzahl erkannter Objektzeilen; -- Anzahl Passthrough-Zeilen; -- Anzahl verdächtiger Objektzeilen; -- fehlende und doppelte `IfcGUIDs`; -- unbekannte Familie-und-Typ-Werte; -- neue und bereits bekannte Räume; -- vorhandene Stromkreiswerte; -- nicht parsebare Leistungen oder Mengen. - -### 12.3 Raumwizard - -Räume werden aus den importierten Objektzeilen abgeleitet. - -Der Nutzer kann: - -- einen neuen internen Raum anlegen; -- einen importierten Raum mit einem vorhandenen internen Raum verknüpfen; -- mehrere Räume sortiert nach Raumnummer bearbeiten; -- Räume einer Ebene zuordnen. - -Die Raumnummer ist zunächst das primäre Matchingmerkmal. - -Eine eigene Revit-Raum-GUID ist nicht erforderlich. - -### 12.4 Verteilungen - -Verteilungen werden manuell angelegt. - -Der Nutzer kann: - -- einem Raum eine Standardverteilung zuweisen; -- mehrere Räume gesammelt einer Verteilung zuweisen; -- einzelne Objekte abweichend einer anderen Verteilung zuweisen; -- Objekte ohne MEP-Raum manuell einer Verteilung zuweisen. - -Neue Objekte eines bereits bekannten Raums erhalten die bestehende Standardverteilung als Vorschlag. - -### 12.5 Klassifizierung und ProjectDevices - -Der Nutzer kann: - -- Objekte nach Raum, Familie und Typ, CAx-Auswahlkenner und Status filtern; -- Familie-und-Typ-Werte klassifizieren; -- mehrere Objekte mit einem vorhandenen ProjectDevice verknüpfen; -- unmittelbar ein neues ProjectDevice erstellen; -- lokale Werte bearbeiten. - -### 12.6 Bereits gefüllte Stromkreiskennzeichnung - -Enthält eine Objektzeile bereits eine Stromkreiskennzeichnung, muss der Wizard fragen, wie sie behandelt wird. - -Möglichkeiten: - -- vorhandenen Wert nur als Quellwert behalten; -- vorhandene Zuordnung übernehmen; -- vorhandenen Wert ignorieren. - -Eine Übernahme ist nur zulässig, wenn: - -- die Präfix-Verteilung eindeutig vorhanden ist; -- die Zuordnung zur Raum- beziehungsweise Objektverteilung passt oder ausdrücklich bestätigt wird; -- der Stromkreis eindeutig vorhanden oder bewusst anzulegen ist; -- keine widersprüchliche Zuordnung entsteht. - -Keine Variante darf still gewählt werden. - -### 12.7 Übernahme - -Die bestätigte Erstübernahme schreibt mindestens: - -- Importbatch; -- vollständige Transportgrundlage für den Rückexport; -- externe Objekte; -- Quellwerte; -- Raum- und Verteilungslinks; -- Klassifizierungen; -- ProjectDevice-Links; -- lokale Planungswerte; -- Konfliktentscheidungen. - -Die Übernahme ist ein atomarer Projekt-Command mit `expectedRevision`. - -Sie erzeugt genau eine Projektrevision und einen Undo-Schritt. - -Nach erfolgreicher Übernahme erscheinen ausreichend klassifizierte, einer Verteilung zugeordnete und noch nicht verschaltete Objekte im Stromkreislisteneditor. - -## 13. UI 1 – Import-, Staging- und Abgleichswizard - -### 13.1 Pflichtfunktionen - -- sortierbare tabellarische Darstellung für mehrere Tausend Objekte; -- Volltextsuche; -- Filter nach Raum, Raumname, Familie und Typ, CAx-Auswahlkenner, Verteilung, ProjectDevice, Stromkreis und Status; -- Mehrfachauswahl; -- Shift-Bereichsauswahl; -- Auswahl aller gefilterten Treffer; -- Auswahl vollständiger Räume oder Gruppen; -- Sammelzuordnung zu Raum, Ebene, Verteilung, Klassifizierung und ProjectDevice; -- Erstellen eines ProjectDevices; -- Bearbeiten lokaler Planungswerte; -- Vergleich Quellwert gegen lokalen Wert; -- Konfliktentscheidungen einzeln und gesammelt; -- Vorschau des Änderungssatzes vor Übernahme. - -### 13.2 Sichtbare Objektinformationen - -Mindestens: - -- `IfcGUID`; -- Familie und Typ; -- CAx-Auswahlkenner; -- Revit-Raumnummer und Raumname; -- interner Raum und Ebene; -- Standardverteilung; -- tatsächliche Objektverteilung; -- Klassifizierung; -- wirksame Menge; -- verknüpftes ProjectDevice; -- lokaler Anzeigename; -- lokale Leistung; -- zugeordneter Stromkreis; -- zugeordnete CircuitDeviceRow; -- Import- und Konfliktstatus. - -### 13.3 Statusfilter - -Mindestens: - -- neu; -- bekannt und unverändert; -- geändert; -- Raum geändert; -- im vollständigen Folgeimport nicht enthalten; -- ohne Raum; -- ohne Verteilung; -- ohne Klassifizierung; -- ohne ProjectDevice; -- ohne Stromkreis; -- vollständig zugeordnet; -- konfliktbehaftet. - -`Ohne ProjectDevice` ist kein Fehler. - -## 14. UI 2 – Unzugeordnete Objekte im Stromkreislisteneditor - -### 14.1 Sichtbarkeit - -Der Editor zeigt einen separaten Bereich beziehungsweise Drawer mit Objekten, die: - -- der aktuellen Verteilung zugeordnet sind; -- noch mit keiner `CircuitDeviceRow` verknüpft sind; -- ausreichend klassifiziert sind; -- keinen blockierenden Konflikt besitzen. - -### 14.2 Darstellung und Filter - -Mindestens sichtbar: - -- Raum; -- Familie und Typ; -- CAx-Auswahlkenner; -- interner Gerätetyp; -- ProjectDevice; -- wirksame Menge; -- Einzelleistung; -- Objektgesamtleistung; -- Kategorie beziehungsweise Phasenart; -- Anschlussart; -- Status. - -Filter und Mehrfachauswahl müssen auch hier für große Mengen geeignet sein. - -### 14.3 Drag-and-drop-Semantik - -**Bestätigte Anforderung** - -Der Drop-Zielbereich bestimmt die fachliche Aktion. - -#### Drop auf bestehende CircuitDeviceRow - -- ausgewählte externe Objekte werden mit genau dieser Zeile verknüpft; -- Menge und Gesamtleistung werden neu berechnet; -- die Anwendung wählt niemals selbst eine andere passende Zeile. - -#### Drop auf bestehenden Circuit - -- in diesem Stromkreis wird eine neue CircuitDeviceRow angelegt; -- dies gilt auch, wenn bereits eine ähnliche Zeile vorhanden ist. - -#### Drop auf freien Zielbereich für neuen Circuit - -- ein neuer Circuit wird über den bestehenden Circuit-Insert-/Move-Mechanismus angelegt; -- mindestens eine neue CircuitDeviceRow wird erzeugt; -- BMK-Erzeugung folgt den bestehenden expliziten Regeln des Editors; -- keine Nachbarstromkreise werden neu nummeriert. - -Die gesamte jeweilige Aktion einschließlich externer Links, Mengenänderung, Circuit-/Row-Erzeugung, Revision und History-Transition ist atomar. - -### 14.4 Keine automatische Zusammenführung - -Gleiche Eigenschaften dürfen Hinweise und Vorschläge erzeugen. - -Die Anwendung darf externe Objekte aber nicht ohne Benutzeraktion automatisch: - -- zusammenführen; -- einer bestehenden Gerätezeile hinzufügen; -- in einen Stromkreis verschieben; -- auf mehrere Gerätezeilen verteilen. - -## 15. Gerätezeilen, Aggregation und Mehrfachsteckdosen - -### 15.1 Einzelobjekte bleiben erhalten - -Jedes Revit-Objekt bleibt intern separat anhand seiner `IfcGUID` nachvollziehbar, auch wenn mehrere Objekte in einer Gerätezeile aggregiert dargestellt werden. - -### 15.2 Mehrfachsteckdose - -**Bestätigte Anforderungen** - -- Eine Mehrfachsteckdose ist ein Revit-Objekt. -- Ihr Mengenparameter enthält die Anzahl physischer Schukosteckdosen. -- Eine Doppelsteckdose zählt als zwei Steckdosen. -- Das Objekt ist unteilbar und darf nicht auf mehrere Stromkreise verteilt werden. -- Die Leistung wird je physischer Steckdose angesetzt. -- Die Objektgesamtleistung ist `wirksame Menge × powerPerUnit`. - -### 15.3 Gerätezeilenmenge - -Die Menge einer CircuitDeviceRow ist die Summe der wirksamen Mengen aller mit ihr verknüpften externen Objekte plus gegebenenfalls manuell geführter Anteile, sofern das vorhandene Datenmodell dies eindeutig unterscheiden kann. - -Beispiel: - -```text -2 Doppelsteckdosen + 2 Einzelsteckdosen -→ 4 ExternalModelObjects -→ CircuitDeviceRow.quantity = 6 -``` - -### 15.4 Getrennte Gerätezeilen - -**Bestätigte Anforderungen** - -Unterschiedliche Räume werden in getrennten Gerätezeilen dargestellt. - -Unterschiedliche CAx-Auswahlkenner werden in getrennten Gerätezeilen dargestellt. - -Beispiel: - -```text -Circuit UV_AV_01-2F1 -- Raum 01/101: 4 × Arbeitsplatzsteckdose -- Raum 01/101: 2 × Mediensteckdose -- Raum 01/102: 1 × Arbeitsplatzsteckdose -``` - -Der Nutzer darf trotz gleicher Werte bewusst mehrere getrennte Gerätezeilen erzeugen. - -### 15.5 Raumübergreifende Stromkreise - -Raumübergreifende Stromkreise bleiben manuell zulässig und werden über mehrere Gerätezeilen übersichtlich dargestellt. - -Die spätere automatische Stromkreisvorschlagsfunktion darf nur raumbezogene Vorschläge erzeugen. - -## 16. Folgeimport - -### 16.1 Annahme - -Jeder Folgeimport enthält erneut den vollständigen relevanten Modellstand der Kategorie `Elektroinstallation`. - -Teilimporte sind in der ersten Ausbaustufe nicht zulässig beziehungsweise werden nicht als Löschgrundlage verwendet. - -### 16.2 Vergleichszustände - -Mindestens zu erkennen: - -- bekannte `IfcGUID`, unveränderte Quellwerte; -- neue `IfcGUID`; -- bekannte `IfcGUID` mit geänderten Quellwerten; -- geänderter Revit-Raum; -- geänderte Stromkreiskennzeichnung; -- geänderte Leistung; -- zuvor bekannte `IfcGUID` fehlt; -- doppelte `IfcGUID`; -- verdächtige Objektzeile ohne `IfcGUID`. - -### 16.3 Fehlende Objekte - -Fehlt eine bekannte `IfcGUID`, wird das Objekt nur als `nicht mehr im aktuellen vollständigen Export enthalten` markiert. - -Es wird nicht automatisch: - -- gelöscht; -- aus seiner Gerätezeile entfernt; -- aus seinem Stromkreis entfernt; -- von seinem ProjectDevice getrennt. - -Erst nach ausdrücklicher Bestätigung wird das externe Objekt entfernt beziehungsweise als gelöscht übernommen. - -Danach: - -- wird sein Link zur CircuitDeviceRow entfernt; -- werden Menge und Leistung der Zeile neu berechnet; -- bleibt ein dadurch leer gewordener Circuit bestehen; -- wird seine BMK nicht automatisch freigegeben oder neu vergeben. - -### 16.4 Raumänderung - -Eine Raumänderung wird anhand derselben `IfcGUID` erkannt und als Konflikt angezeigt. - -Sie wird nicht automatisch übernommen. - -### 16.5 Alle Änderungen sind nutzergesteuert - -Der Nutzer kann Konflikte: - -- einzeln behandeln; -- gruppiert behandeln; -- Revit-Werte übernehmen; -- lokale Werte behalten; -- manuell überschreiben. - -Die bestätigte Übernahme eines Folgeimports ist ein atomarer Projekt-Command und eine Projektrevision. - -## 17. Rückexport - -### 17.1 Grundlage - -Der Export verwendet den zuletzt akzeptierten vollständigen Importbatch und eine bekannte aktuelle Projektrevision. - -Ein Export verändert die Projektrevision nicht. - -### 17.2 Stromkreiskennzeichnung - -Für jedes einem Stromkreis zugeordnete externe Objekt wird geschrieben: - -```text - -``` - -Beispiel: - -```text -UV_AV_DG_01-2F1 -``` - -Die genaue Trennlogik folgt dem tatsächlich im Projekt verwendeten Verteilerpräfix und Stromkreis-BMK; es darf kein zweites unabhängiges Nummerierungssystem eingeführt werden. - -### 17.3 Leistung - -Die Rückgabe erfolgt in Watt. - -Für eine Mehrfachsteckdose wird die objektbezogene Gesamtleistung geschrieben: - -```text -Objektleistung = wirksame Menge des Revit-Objekts × powerPerUnit -``` - -Bei normalen Einzelobjekten ist die wirksame Menge `1`. - -### 17.4 Objektbezogene Ausgabe - -Sind mehrere Revit-Objekte mit einer gemeinsamen CircuitDeviceRow verknüpft: - -- erhält jedes Objekt seine eigene ursprüngliche CSV-Zeile; -- erhält jedes Objekt dieselbe Stromkreiskennzeichnung; -- erhält jedes Objekt seine eigene Objektgesamtleistung; -- bleibt seine `IfcGUID` unverändert. - -### 17.5 Exportnachweis - -**Implementierungsvorschlag** - -Ein Exportbatch speichert mindestens: - -- Projekt-ID; -- Projektrevision; -- zugrunde liegenden Importbatch; -- Zeitstempel; -- Dateiname; -- Prüfsumme; -- verwendete Mappingkonfiguration beziehungsweise deren Version. - -Dies ist Auditmetadaten und kein Ersatz für die Projekt-Revisionshistorie. - -## 18. Validierungen - -### 18.1 Blockierende Fehler - -Mindestens: - -- CSV kann mit der Konfiguration nicht geparst werden; -- erforderliche Kopfzeile ist nicht eindeutig; -- erforderliche Spalte fehlt; -- doppelte nichtleere `IfcGUID` unter Objektzeilen; -- ungültige oder nicht serialisierbare Projektwerte; -- stale `expectedRevision`; -- Verknüpfung zu einem anderen Projekt; -- Drop-Ziel gehört nicht zur zugeordneten Verteilung beziehungsweise Liste; -- Mehrfachsteckdose würde intern auf mehrere Stromkreise aufgeteilt; -- atomare Command-Vorbedingungen stimmen nicht mehr. - -### 18.2 Warnungen beziehungsweise aufzulösende Konflikte - -Mindestens: - -- unbekannter Familie-und-Typ-Wert; -- Objekt ohne Raum; -- Raumänderung; -- bestehende abweichende Stromkreiskennzeichnung; -- abweichende Revit-Leistung; -- Objekt im Folgeimport nicht enthalten; -- ausgewählte Objekte besitzen unterschiedliche lokale Planungswerte; -- vorhandene Stromkreiszuordnung passt nicht zur Verteilung; -- verdächtige Objektzeile ohne `IfcGUID`. - -## 19. Revision, Undo/Redo und Audit - -### 19.1 Gemeinsame Transaktionsgrenze - -**Bestehende Architekturvorgabe** - -Alle bestätigten projektbezogenen Änderungen laufen über: - -```text -domain mutation -+ inverse command -+ project revision -+ undo/redo transition -``` - -Sie werden gemeinsam über die bestehende Projekt-Command-Transaktionsgrenze committed oder vollständig zurückgerollt. - -Neue Revit-Fachlogik darf nicht direkt von React, `better-sqlite3`, globalen Datenbankinstanzen oder konkreten SQLite-Repositories abhängen. - -### 19.2 Neue logische Commands - -**Implementierungsvorschlag** - -Mindestens getrennte Command-Verantwortlichkeiten: - -- Revit-Projektkonfiguration ändern; -- akzeptierten Erstimport anwenden; -- akzeptierten Folgeimport anwenden; -- externe Objektplanungswerte ändern; -- externe Objekte mit ProjectDevices verknüpfen oder trennen; -- externe Objekte einer bestehenden CircuitDeviceRow zuweisen; -- externe Objekte als neue CircuitDeviceRow einem Circuit zuweisen; -- externe Objekte mit neuem Circuit und neuer Row zuweisen; -- externe Objektzuordnung aus einer Row lösen; -- externes Objekt nach bestätigtem Fehlen löschen. - -Die konkreten Command-Namen und Payloads sind beim Implementierungsaudit festzulegen. - -### 19.3 Undo - -Undo muss insbesondere wiederherstellen: - -- externe Objekte und Quellstände; -- Raum-, Verteilungs- und ProjectDevice-Links; -- lokale Planungswerte und Overrides; -- CircuitDeviceRow-Verknüpfungen; -- Mengen und Leistungen; -- erzeugte Row- und Circuit-UUIDs; -- Importstatus. - -Undo darf keine BMKs neu berechnen. - -## 20. Snapshots und portabler Projekttransfer - -**Bestehende Architekturfolgerung** - -Sobald die Revit-Integration unterstützt wird, gehört ihr bestätigter Projektzustand zum vollständigen logischen Projektzustand. - -Snapshot- und JSON-Transfer-Schema müssen deshalb nach einem bewussten Schema-Versionswechsel mindestens erhalten: - -- projektweite Revit-Konfiguration; -- Mappingregeln; -- Familie-und-Typ-Regeln; -- externe Objekte; -- Quell- und Planungswerte; -- Raum-, Verteilungs-, ProjectDevice- und CircuitDeviceRow-Links; -- relevante Importbatch-Grundlagen für den Rückexport; -- Konflikt- und Sichtbarkeitsstatus. - -Beim Duplizieren eines Projekts: - -- werden alle internen UUIDs remapped; -- bleiben externe `IfcGUIDs` als Quellidentitäten erhalten; -- werden interne Links auf die remappten Projektobjekte aktualisiert. - -Vorhandene Snapshot-Migrationen dürfen nicht still verändert werden. Es ist eine additive neue Schema-Version erforderlich. - -## 21. Modul- und Persistenzgrenzen - -### 21.1 Verbindliche Grenzen - -- Parsing und Serialisierung sind keine React-Aufgabe. -- CSV-Dialektlogik ist kein Bestandteil von Circuit-Domain-Services. -- Domain-Services kennen keine konkreten SQLite-Repositories. -- Controller importieren keinen globalen Datenbankclient. -- Writes erfolgen ausschließlich über typisierte Commands. -- Reads verwenden schmale, fachlich benannte Repositories. -- Server-Komposition erzeugt und injiziert konkrete Adapter. -- SQLite bleibt der unterstützte Adapter. -- PostgreSQL wird nicht in dieser Phase eingeführt. - -### 21.2 Empfohlene Verantwortlichkeiten - -**Implementierungsvorschlag** - -```text -CSV transport adapter -- dialect detection -- header and row classification -- lossless-enough matrix preservation -- mapped value parsing -- return serialization - -External model domain -- identity and matching -- source versus planning values -- conflict calculation -- object lifecycle -- aggregation invariants - -Application services / commands -- import preview -- first import apply -- follow import apply -- link and unlink operations -- export projection - -Persistence adapters -- project configuration -- import/export batches -- external objects -- links and accepted source state - -Frontend -- project configuration UI -- staging wizard -- unassigned-object editor drawer -``` - -Die Namen sind Verantwortlichkeiten, keine vorab beschlossenen Tabellen- oder Ordnernamen. - -## 22. Performanceanforderungen - -**Bestätigte Anforderung** - -Das UI muss mehrere Tausend Objektzeilen praktisch bearbeitbar machen. - -Mindestens: - -- keine vollständige DOM-Darstellung aller Zeilen, falls dies die Bedienbarkeit beeinträchtigt; -- server- oder clientseitige effiziente Sortierung und Filterung; -- stabile Mehrfachauswahl; -- Sammelaktionen auf gefilterte Treffer; -- keine fachliche Änderung beim bloßen Filtern und Sortieren; -- keine automatische Neunummerierung; -- Import und Export dürfen zunächst synchron laufen, solange reale Projektdateien zuverlässig verarbeitet werden. - -Die Referenzdatei mit rund 900 Objektzeilen und über 1.100 Gesamtzeilen ist als Mindest-Integrationstest zu verwenden. - -## 23. Implementierungsphasen - -### Phase 14.0 – Audit und Verträge - -Ziel: - -- aktuelle ProjectDevice-, Row-, Command-, Snapshot- und Editorpfade prüfen; -- konkretes Datenmodell und Command-Schnittstellen vorschlagen; -- keine Implementierung vor Freigabe des Auditberichts. - -Lieferung: - -- Keep/Refactor/Add-Liste; -- vorgeschlagene Domainobjekte; -- vorgeschlagene Commands; -- Snapshot-/Transfer-Auswirkungen; -- Risiken bei Row-Mengen und lokalen Objektwerten. - -Abnahme: - -- keine bestehende Architekturregel wird umgangen; -- offene Punkte sind klar als solche markiert. - -### Phase 14.1 – CSV-Konfiguration und Parser - -Ziel: - -- projektweite Mappingkonfiguration; -- Parser und Serializer für das beobachtete CSV-Profil; -- reine Vorschau ohne Projektschreibvorgang. - -Abnahme: - -- Referenzdatei wird mit 897 Objektzeilen und 211 Passthrough-Summenzeilen erkannt; -- Titel-, Leer- und Summenzeilen bleiben erhalten; -- nichtleere `IfcGUIDs` sind eindeutig; -- Rückserialisierung ohne fachliche Änderungen erhält Struktur und Zellenwerte; -- Parser besitzt fokussierte Tests für BOM, CRLF, Semikolon, Quotierung und Kopfzeile in Zeile 2. - -### Phase 14.2 – Externe Objekte und Erstimport - -Ziel: - -- persistente externe Objekte; -- Raum-, Ebenen-, Verteilungs-, Klassifizierungs- und ProjectDevice-Verknüpfung; -- atomare Importübernahme. - -Abnahme: - -- Import erzeugt keine CircuitDeviceRow; -- Raum- und Verteilungszuordnung kann gespeichert werden; -- neues ProjectDevice kann vorausgefüllt erstellt werden; -- vorhandenes ProjectDevice wird nicht durch Revit-Werte verändert; -- Import ist ein Undo-Schritt; -- Undo entfernt den vollständigen bestätigten Importzustand. - -### Phase 14.3 – Stromkreislisteneditor - -Ziel: - -- unzugeordnete externe Objekte je Verteilung; -- Drag-and-drop zu Row, Circuit oder neuem Circuit; -- Aggregation und Mehrfachsteckdosen. - -Abnahme: - -- Drop auf Row ergänzt genau diese Row; -- Drop auf Circuit erzeugt eine neue Row; -- Drop auf neuen Zielbereich erzeugt Circuit und Row atomar; -- 2 Doppel- plus 2 Einzelsteckdosen ergeben Menge 6; -- keine Mehrfachsteckdose kann geteilt werden; -- verschiedene Räume und Auswahlkenner bleiben in getrennten Rows; -- Undo stellt externe Links, Mengen, Row-/Circuit-UUIDs und BMKs exakt wieder her. - -### Phase 14.4 – Rückexport - -Ziel: - -- strukturtreue Revit-Rückgabe-CSV. - -Abnahme: - -- Zeilen- und Spaltenzahl sowie Reihenfolge entsprechen der Ausgangsdatei; -- unbekannte Spalten und Passthrough-Zeilen bleiben erhalten; -- Stromkreis und Leistung werden ausschließlich in gemappte Objektzellen geschrieben; -- jedes Objekt behält seine `IfcGUID`; -- Mehrfachsteckdosen erhalten ihre Objektgesamtleistung in Watt; -- Export referenziert eine bekannte Projektrevision. - -### Phase 14.5 – Folgeimport und Konflikte - -Ziel: - -- additions, changes, missing objects and conflicts; -- Einzel- und Sammelentscheidungen. - -Abnahme: - -- neue Objekte werden erkannt; -- Raumänderungen werden konfliktbehaftet angezeigt; -- fehlende Objekte werden nicht automatisch entfernt; -- lokale Werte werden nicht automatisch überschrieben; -- bestätigte Änderungen sind ein atomarer Undo-Schritt; -- unveränderte Objekte erzeugen keine unnötigen Domainänderungen. - -### Phase 14.6 – Snapshot, Transfer und Härtung - -Ziel: - -- vollständige Projektportabilität; -- Performance und End-to-End-Tests. - -Abnahme: - -- Snapshot/Restore erhält sämtliche externe Links und Exportgrundlagen; -- Projektduplikat remapped interne UUIDs und behält `IfcGUIDs`; -- Undo/Redo funktioniert nach Reload; -- Referenz-CSV durchläuft Erstimport, Row-Zuordnung, Export, Folgeimport und Restore; -- vollständige Test- und Build-Suite läuft erfolgreich. - -## 24. End-to-End-Abnahmeszenarien - -### Szenario A – Gemischte Steckdosen - -Aus Revit: - -```text -Raum 01/101 -- 2 Doppelsteckdosen, jeweils Menge 2 -- 2 Einzelsteckdosen, jeweils Menge 1 -- gleicher CAx-Auswahlkenner -``` - -Erwartung: - -- 4 externe Objekte; -- wirksame Menge 6; -- Nutzer zieht alle Objekte auf eine neue Row; -- Row zeigt `6 × Steckdose`; -- alle vier CSV-Zeilen erhalten denselben Stromkreis; -- Doppelsteckdosen erhalten jeweils `2 × powerPerUnit` als Objektleistung; -- Einzelsteckdosen erhalten jeweils `1 × powerPerUnit`. - -### Szenario B – Gleiches ProjectDevice in mehreren Räumen - -Aus Revit: - -- gleicher Leuchtentyp in drei Technikräumen. - -Erwartung: - -- Nutzer wählt alle Leuchten raumübergreifend; -- erstellt ein neues, aus Revit-Werten vorausgefülltes ProjectDevice; -- alle Objekte werden damit verknüpft; -- es wird noch keine CircuitDeviceRow erzeugt; -- später entstehen je Raum getrennte Rows, sofern die Objekte verschiedenen Räumen zugeordnet werden. - -### Szenario C – Raumübergreifender Putzsteckdosenstromkreis - -- mehrere Räume mit jeweils wenigen Putzsteckdosen; -- Nutzer ordnet sie manuell demselben Circuit zu; -- pro Raum entsteht eine getrennte Row; -- alle Objekte erhalten dieselbe Stromkreiskennzeichnung. - -### Szenario D – Folgeimport mit Raumänderung - -- bekannte `IfcGUID`; -- Revit-Raumnummer ist geändert. - -Erwartung: - -- Objekt wird nicht neu angelegt; -- Raumänderung wird als Konflikt angezeigt; -- bestehende interne Raum-, Verteilungs- und Stromkreiszuordnung bleibt unverändert; -- Nutzer kann die Änderung einzeln oder gesammelt übernehmen. - -### Szenario E – Fehlendes Objekt - -- eine zuvor bekannte `IfcGUID` fehlt im vollständigen Folgeimport. - -Erwartung: - -- Objekt wird als fehlend markiert; -- Row-Menge und Leistung bleiben zunächst unverändert; -- erst bestätigte Löschung entfernt Link und Mengenanteil; -- Circuit bleibt auch bei leerer Row beziehungsweise nach Row-Entfernung bestehen; -- BMK wird nicht automatisch wiederverwendet. - -### Szenario F – Bestehende Stromkreiskennzeichnung beim Erstimport - -- Revit-Zeile enthält bereits `UV_AV_01-2F3`. - -Erwartung: - -- Wizard zeigt den Wert; -- Nutzer kann Quellwert behalten, Zuordnung übernehmen oder ignorieren; -- keine Option wird automatisch gewählt; -- unpassende Verteilung oder fehlender Circuit erzeugt Konflikt beziehungsweise bewusste Anlageentscheidung. - -### Szenario G – CSV-Passthrough - -- Titelzeile; -- Kopfzeile in Zeile 2; -- Leerzeile; -- Objektzeilen; -- Summenzeile ohne `IfcGUID`. - -Erwartung: - -- nur Objektzeilen erzeugen externe Objekte; -- Summenzeile erzeugt keine Fehlermeldung; -- Rückexport enthält alle Zeilen an derselben Position. - -## 25. Noch offene, phasengebundene Entscheidungen - -Diese Punkte blockieren die Erstellung dieser Spezifikation nicht. - -### Vor Phase 14.2 zu entscheiden - -- konkrete Benennung der neuen Domainobjekte und Tabellen; -- ob Importentwürfe vor der endgültigen Übernahme serverseitig gespeichert werden; -- genaue Versionierung der Mappingkonfiguration; -- genaue Speicherung der Original-CSV: vollständige Rohdatei, logische Zellmatrix oder Kombination. - -### Vor Phase 14.3 zu entscheiden - -- visuelle Position des Objekt-Drawers; -- Verhalten einer Mehrfachauswahl mit widersprüchlichen Planungswerten; -- Warnungsdialog bei fachlich unpassender Row; -- Verhalten, wenn eine Row manuelle Mengenanteile und externe Objekte kombiniert; -- genaue UI für das Lösen einer einzelnen externen Objektverknüpfung aus einer aggregierten Row. - -### Vor Phase 14.4 zu entscheiden - -- exaktes Revit-Zahlenformat für Dezimalwerte; -- genaue Formatierung von Wattwerten, zum Beispiel `1000 W` oder `1000`; -- Umgang mit Objekten ohne Stromkreis beim Rückexport: leeres Feld oder unveränderter Quellwert; -- Dateinamenschema der Rückgabe. - -### Spätere Ausbaustufen - -- automatische raumbezogene Stromkreisvorschläge; -- projektübergreifend wiederverwendbare Mappingprofile; -- Teilmodelle und mehrere externe Quellen; -- Dynamo/API/JSON-Transportadapter (nicht vorgesehen, siehe Abschnitt 1); -- Rollen und Berechtigungen; -- Hintergrundjobs; -- PostgreSQL. - -## 26. Arbeitsweise für Codex - -Vor jeder Phase: - -1. relevante bestehende Module auditieren; -2. angeben, was erhalten, erweitert oder ersetzt wird; -3. betroffene Commands, Snapshots und Transferformate nennen; -4. Migration und Rückwärtsverträglichkeit erläutern; -5. fokussierte Tests festlegen. - -Während der Implementierung: - -- kleine reviewbare Arbeitspakete; -- keine fachlichen Writes außerhalb der Command-Grenze; -- keine automatische BMK-Änderung; -- keine stillen Revit- oder ProjectDevice-Synchronisationen; -- keine ungefragte PostgreSQL- oder Dynamo-Implementierung; -- keine Kopplung von Domainlogik an CSV, React oder SQLite. - -Nach jedem Arbeitspaket: - -- fokussierte Tests; -- `npm test`; -- `npm run build:api`; -- `npm run build:web`; -- `npm run typecheck:scripts`; -- `npx tsc --noEmit -p tsconfig.next.json`; -- Dokumentation des neuen unterstützten Laufzeitpfads aktualisieren. +Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit +`ae318a5` erhalten. Er darf nicht mehr als Implementierungsanweisung oder +Statusquelle verwendet werden. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index a4c21e4..2d116b2 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -1,406 +1,8 @@ -# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung +# Abgelöst: alter Phase-14-CSV-Plan -## Status +Die Phasen 14.0–14.6 und ihre Erledigt-Markierungen sind abgelöst. Für die +Umstellung auf Linear-Excel gelten der [R0–R10-Plan](revit-excel-phase-14-plan.md) +und die [neuen Anforderungen](revit-excel-integration-requirements.md). -**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann -über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. -Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md). - -Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den -gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen. -Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem -additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert -noch nicht. - -Fachliche Quelle bleibt -[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). -Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die -Reihenfolge kleiner Arbeitspakete. - -## Ergebnis des Bestandsaudits - -### Beibehalten - -- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis - viele `CircuitDeviceRow`-Datensätze. -- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf - diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht - ausdrücklich bestätigt. -- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung - zwischen einer Gerätezeile und genau einer Projektgerätevorlage. -- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte, - Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen - vierten, fachlich eigenen Pfad. -- Alle bestätigten Änderungen verwenden typisierte Commands mit - `expectedRevision`, vollständiger Gegenrichtung und - `project-command-transaction.persistence.ts` als gemeinsamer - Transaktionsgrenze. -- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit - fester Fremdschlüsselreihenfolge und UUID-Remapping. -- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte - Laufzeitadapter. - -### Erweitern oder gezielt refaktorieren - -- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den - bestätigten externen Projektzustand erweitert. Bereits veröffentlichte - Snapshot-Versionen werden nicht verändert. -- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration, - Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine - konkreten SQLite-Abhängigkeiten. -- Der Stromkreislisteneditor erhält später einen separaten Drawer für - unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht - dafür zweckentfremdet. -- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer - `CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“. - -### Hinzufügen - -- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain; -- projektweite, versionierte Revit-CSV-Konfiguration; -- eine zunächst implizite externe Modellquelle je Projekt; -- Importbatches einschließlich Originaltransport und akzeptiertem - Konfigurationsstand; -- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und - Planungswerten und einzelnem Lebenszyklus; -- Quellraum-Mappings auf interne Räume und Standardverteilungen; -- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler - abweichender Verteilung; -- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices; -- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und - End-to-End-Tests. - -### Entfernen - -Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14 -kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt. - -## Vorgeschlagenes Domänenmodell - -Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die -jeweilige englische Pluralform in `snake_case`. - -### `ExternalModelSource` - -- stabile interne UUID; -- `projectId`; -- technischer Name und Quelltyp `revit_csv`; -- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie - abgeleitet und nicht als redundanter Zeiger gespeichert. - -In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch -angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die -Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an -`(sourceId, ifcGuid)`. - -### `ExternalCsvConfiguration` - -- stabile UUID und `projectId` mit genau einer aktiven Konfiguration; -- Konfigurationsschema-Version; -- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen, - Dezimalformat und Kopfzeilenerkennung; -- exakte Spaltenzuordnung; -- geordnete, exakte Familie-und-Typ-Regeln; -- monotoner Konfigurationsstand. - -Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in -einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei -verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar, -ohne eine zweite veränderbare Historientabelle einzuführen. - -### `ExternalImportBatch` - -- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel; -- Dateiname, SHA-256 und zugehörige Projektrevision; -- verwendete monotone Konfigurationsversion und vollständiger - Konfigurationssnapshot; -- Originaldatei als Bytes; -- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten. - -Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes -belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten -Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten, -Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren. - -### `ExternalRoomMapping` - -- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel; -- Quellgeschoss-, Raumnummer- und Raumname-Snapshot; -- optionales `roomId`; -- optionale `defaultDistributionBoardId`. - -Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält -das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung. - -### `ExternalModelObject` - -- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`; -- letzter gesehener und letzter akzeptierter Importbatch; -- akzeptierte Quellwerte getrennt als strukturierter Snapshot; -- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame - Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe - und Bemerkung; -- explizite Override-Metadaten; -- optionales `externalRoomMappingId`; -- optionale abweichende `distributionBoardId`; -- optionales `linkedProjectDeviceId`; -- optionales `circuitDeviceRowId`; -- Anwesenheitsstatus für bestätigte fehlende Objekte. - -Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele -einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet -sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre -wirksame Menge größer eins. - -Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem -Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter -Anwesenheits- oder Planungszustand wird persistiert. - -## Mengenmodell der Gerätezeile - -Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller -Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines -Objekts manuelle Mengen löschen oder doppelt zählen. - -Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt: - -```text -quantity = manualQuantity - + Summe(effectiveQuantity aller verknüpften ExternalModelObjects) -``` - -- Bei der Migration erhält jede bestehende Row - `manualQuantity = quantity`; ihr Ergebnis bleibt unverändert. -- Eine ausschließlich aus externen Objekten erstellte Row startet mit - `manualQuantity = 0`. -- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende - Berechnungen, Filter und Exporte und wird im selben Command aktualisiert. -- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet - sein. -- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte - übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht - automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine - ausdrückliche Nutzerentscheidung. -- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim - Lösen bleibt der manuelle Anteil erhalten. - -Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere -Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller -Row-Lebenszyklus ist ausgeschlossen. - -## Command-Schnittstellen - -Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte -Snapshots und werden mit der aktuellen Projektrevision geplant. Große -Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch -bleiben und dürfen spätere Quellstände nicht erneut auswerten. - -- `external-csv-configuration.update`: vollständige projektweite - Konfiguration ändern. -- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe - Objekte und bestätigte Links atomar einfügen. -- `external-import.apply-follow-up`: neuen Batch und die vollständigen - erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte - atomar übernehmen. -- `external-model-object.update-planning`: lokale Werte und Overrides eines - oder mehrerer Objekte ändern. -- `external-model-object.update-project-device-link`: ProjectDevice-Links - explizit setzen oder lösen, ohne das ProjectDevice zu verändern. -- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer - bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen - und alle betroffenen materialisierten Mengen atomar aktualisieren. -- `external-object.assign-to-new-row`: stabile neue Row einfügen, - Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren. -- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz, - Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im - Command gespeichert und bei Redo nicht neu berechnet. -- `external-model-object.delete-missing`: nur ausdrücklich bestätigte, - weiterhin unveränderte fehlende Objekte löschen. - -Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen -`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare -Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und -Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern -keine Projektrevision und laufen über einen eigenen Audit-Store. - -## Snapshot-, Transfer- und Migrationsfolgen - -- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das - Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2 - ergänzt `externalCsvConfiguration = null`. -- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und - Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt - leere externe Collections. -- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende - Werte werden einmalig aus `quantity` übernommen. -- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte - Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge. -- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links - remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte - bleiben unverändert. -- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene - Project-State-Schema-Version wird additiv erhöht. -- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert. - -## Implementierungsfolge - -### 14.1 – CSV-Konfiguration und reine Vorschau - -1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine - synthetische Fixtures ergänzen. **Erledigt.** -2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM, - CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie - Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für - synthetische Testdaten; die Referenzdatei steht noch aus.** -3. Konfiguration über Migration, Snapshot v3, Transfer und den Command - `external-csv-configuration.update` persistieren. **Erledigt.** -4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:** - Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das - deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei - Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash - gegen die Vorschau geprüft. - -**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme -erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2. - -Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen -Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit -`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere -Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die -Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in -einen Commit aufgenommen. - -### 14.2 – Persistente Objekte und Erstimport - -1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen. - **Erledigt:** Migration `0003`, vollständiger externer Read-State sowie - exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt - weiterhin keinen Import-Write außerhalb eines Commands. -2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen. - **Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft; - Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes, - Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs, - lassen IFCGUID und Quelltransport jedoch unverändert. -3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. - **Erledigt:** `external-import.apply-initial` wechselt ausschließlich - zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft - Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige - IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links. - Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt - und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt - die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` - bleibt unverändert. -4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard - ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:** - Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit - exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge, - Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und - ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und - schreibt weder Entwurf noch Projektzustand. Der dedizierte - Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die - Planung, vollständige Entscheidungen für alle Quellräume und exakten - Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht - klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale - ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben - ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen, - bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und - verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende - Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten - Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese - Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen. - -Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung -statt serverseitiger Entwürfe, die Konfigurationsversionierung und die -Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. - -### 14.3 – Editor und explizite Zuordnung - -1. `manualQuantity` und Mengeninvarianten migrieren und testen. - **Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile - `manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen - beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4 - verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert - als manuellen Anteil plus Summe der unteilbaren externen Objektmengen. -2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen. - **Teilweise erledigt:** `external-object.update-row-assignment` deckt - Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und - Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum, - Selektionskennzeichen und Planungswerte, verlangt für bestätigbare - Planungsabweichungen eine explizite Objektliste und aktualisiert Links und - Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist - dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen - Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die - Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit. - Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer - Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig - unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist - als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt - den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK, - Standardschutz und genau einer externen Row wird gemeinsam mit den - Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen - Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt. - BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente - Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen. -3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. - **Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene - GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers, - unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker, - Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden; - Drawer, Filter und Mengenwirkung folgen. -4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload - testen. - -Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl, -Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt. - -### 14.4 – Strukturtreuer Rückexport - -1. Exportprojektion gegen eine bekannte Projektrevision ergänzen. -2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen. -3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten. -4. Download und append-only Exportnachweis ergänzen. - -Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich -festgelegt. - -### 14.5 – Folgeimport und Konflikte - -1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen. -2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren. -3. Einzel- und Sammelentscheidungen im Wizard ergänzen. -4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo - übernehmen. - -### 14.6 – Härtung und End-to-End-Abnahme - -1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren. -2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport, - Snapshot/Restore und Projektduplikat prüfen. -3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen. -4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich - ausgelieferten Stand nachziehen. - -## Risiken und Schutzmaßnahmen - -- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile; - Commands ändern Links und materialisierte Menge gemeinsam. -- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind - getrennt; jede Übernahme benötigt eine Nutzerentscheidung. -- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage; - deren bestehende Commands bleiben alleinige Schreibgrenze. -- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix, - Dialekt, Konfiguration, Hash und Projektrevision. -- **Sehr große Undo-Payloads:** Importcommands speichern vollständige - erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen, - nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert. -- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im - Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit. -- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die - bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu - klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist. - -## Freigabepunkt - -Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine -Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket -ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus. +Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit +`ae318a5` erhalten und darf nicht fortgeschrieben werden. diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md new file mode 100644 index 0000000..ff580ec --- /dev/null +++ b/docs/spec/revit-excel-integration-requirements.md @@ -0,0 +1,463 @@ +# Revit-Integration über Linear-Excel – verbindliche Anforderungen + +Stand: 29.09.2026. Dieses Dokument ist die fachliche und technische +Arbeitsgrundlage für die neue Phase 14. Es ersetzt die frühere CSV-/IfcGUID- +Spezifikation. Der Umsetzungsstand steht im +[R0–R10-Plan](revit-excel-phase-14-plan.md); der geprüfte Ausgangszustand im +[R0-Audit](revit-excel-r0-audit.md). + +## 1. Ziel und Grenzen + +Der Austausch mit Revit erfolgt ausschließlich über den XLSX-Export/-Import +des Linear-Plugins. Eine vollständige Revit-Bauteilliste wird importiert, in +Leistungsbilanz geplant und mit gleichem Tabellenaufbau an Linear +zurückgegeben. Linear ordnet enthaltene Zeilen anhand der Worksharing-ID zu. + +Nicht Teil dieser Phase sind Dynamo, Revit-API, JSON-Transport, automatische +Stromkreisbildung, Übernahme vorhandener Revit-Stromkreiswerte, +Teilimporte, mehrere Modellquellen, PostgreSQL und neue Rollen/Rechte. + +Die Integration bleibt optional. Manuelle Projekte, Räume, ProjectDevices, +Stromkreise und Gerätezeilen funktionieren ohne externes Modell. Der +Circuit-First-Aufbau, stabile interne UUIDs und BMKs, ausdrückliche +Neunummerierung sowie die gemeinsame Project-Command-Transaktionsgrenze +bleiben unverändert. + +## 2. Verbindliche fachliche Regeln + +- Import erzeugt keine `CircuitDeviceRow`, keinen `Circuit` und kein BMK. +- Quellwerte, lokale Planungswerte und Overrides bleiben getrennt. +- Ein Folgeimport überschreibt keine lokale Planung still. +- Fehlende Objekte werden markiert und erst nach ausdrücklicher Bestätigung + entfernt. +- Mehrfachsteckdosen sind unteilbare externe Objekte. Ihre wirksame Menge kann + größer als eins sein. +- Die Row-Menge ist + `manualQuantity + Summe(effectiveQuantity der verknüpften Objekte)`. +- Nach einer Row-Zuordnung ist die `CircuitDeviceRow` die Planungsebene für + alle verknüpften Objekte. +- Bestehende ProjectDevices bleiben kanonische Vorlagen und werden durch + Importe nie verändert. +- Sortieren, Filtern, Importieren, Zuordnen und Verschieben nummeriert keine + Stromkreise automatisch neu. + +## 3. Linear-XLSX-Profil + +Die lokale Referenzdatei `docs/spec/kbp-Stromkreis.xlsx` enthält vertrauliche +Projektdaten. Sie wird nicht committed und nicht in Tests eingebettet. Tests +erzeugen synthetische Workbooks. Der lokale Referenzprüfer erhält den Pfad als +Argument. + +Beobachtetes Profil: + +- genau ein Arbeitsblatt; Blattname nach Excel-Regel maximal 31 Zeichen; +- Kopfzeile in Zeile 1, Zeile 2 leer, Daten ab Zeile 3, erste Datenspalte A; +- Spalte A `Worksharing-ID` im Format `-<8 Hex>`; +- Parameterbindungen stehen als klassische Excel-Kommentare in den übrigen + Kopfzellen; +- Zahlen sind typisierte Excel-Zahlen; importierte Werte bleiben unverändert, + fachlich verwendete Leistung wird auf 0,01 W projiziert; +- Objektzeilen dürfen keinen MEP-Raum besitzen; +- eine Schluss-/Summenzeile ohne ID kann vorkommen und wird weder importiert + noch zurückgegeben. + +Die Referenzdatei enthält 487 Objektzeilen, eindeutige Worksharing-IDs und +keine doppelten nichtleeren IfcGUIDs bei exaktem, case-sensitivem Vergleich. +Ihre Summenzeile steht in Spalte B (`CAx_Auswahlkenner`), während +`Familie und Typ` leer ist. + +### Zeilen ohne Worksharing-ID + +Nur eine Zeile ohne Worksharing-ID, aber mit befülltem Feld +`Familie und Typ`, gilt als verdächtige Objektzeile. Sie erzeugt einen Hinweis +und wird übersprungen; sie blockiert den Import nicht. Andere Zeilen ohne ID, +einschließlich einer nur in Spalte B befüllten Summenzeile, werden ohne Hinweis +übersprungen. Eine Objektzeile ist für den Import eine Zeile mit nichtleerer +Worksharing-ID. + +Worksharing-ID und IfcGUID werden stets exakt, ordinal und mit +Groß-/Kleinschreibung verglichen. Es findet kein Trimmen, Normalisieren oder +Case-Folding für die Identität statt. Eine nichtleere Worksharing-ID mit +unerlaubten äußeren Leerzeichen oder falschem Format blockiert. Doppelte +Worksharing-IDs blockieren. IfcGUID ist optional, kein Schlüssel und darf +doppelt vorkommen; Duplikate erzeugen lediglich einen Hinweis. + +## 4. Upload und Transportadapter + +Der Server akzeptiert ausschließlich Dateien mit Endung `.xlsx` bis 10 MiB. +`.xls` und `.xlsm` werden abgelehnt. Parsing findet serverseitig statt. Nach +dem Einlesen gelten feste Zeilen-, Spalten- und Zellgrenzen, die in R2 anhand +synthetischer Grenztests festgelegt und zentral definiert werden. Es gibt in +dieser Phase keinen eigenen ZIP-Prüfer und keinen Parser-Worker. + +Der Adapter unter `src/external-model/xlsx` liest eine transportneutrale +Zellmatrix. Eine Zelle ist `string`, `number`, `boolean` oder `empty`. +Zusätzlich werden Blattname, Originalzeilennummer, Kopfzeilenkommentare, +Spaltenbreiten und die für Hinweise nötigen Formelmetadaten erfasst. +Domain-Services kennen weder ExcelJS noch XLSX, React oder SQLite. + +Formeln werden niemals ausgeführt. Ist ein gecachter skalarer Wert vorhanden, +wird er verwendet und als Hinweis ausgewiesen. Fehlt er oder ist er ein +Fehlerwert, bleibt das Feld leer; bei der Worksharing-ID oder einem anderen +Pflichtfeld blockiert dies die betreffende Objektzeile beziehungsweise den +Import. + +Genau eine gepflegte Bibliothek wird ergänzt. Vorgesehen ist `exceljs`, sofern +R2 die Node-24-Verträglichkeit und den Roundtrip von Kopfkommentaren, +Blattname, Spaltenbreiten, Formaten und Zelltypen bestätigt. + +## 5. Projektkonfiguration + +Die Konfiguration ist projektweit, revisionssicher und besitzt keine eigene +Versionszählung. Jeder Plan und jedes Apply tragen `expectedRevision`. +Da die Projektrevision auch bei Undo/Redo strikt steigt, wird ein alter Plan +automatisch ungültig. Jeder Importbatch speichert eine vollständige Kopie der +beim Import verwendeten Konfiguration. + +Das Einstellungsmodal auf der Revit-Seite erkennt aus einer zustandslos +hochgeladenen Musterdatei Kopfzeile, erste Datenzeile und erste Datenspalte. +Diese Werte bleiben editierbar; Standard ist 1 / 3 / A. Es gibt nur ein Feld +für die Identitätsspalte: `columns.worksharingId`. Ein separates +`layout.idColumn` existiert nicht. + +Zielvertrag: + +```ts +interface ExternalModelConfiguration { + schemaVersion: 1; + layout: { + headerRow: number; + firstDataRow: number; + firstDataColumn: number; + }; + circuitMode: "combined" | "separate"; + columns: { + worksharingId: string; + ifcGuid: string | null; + roomNumber: string | null; + roomName: string | null; + familyAndType: string; + selectionMarker: string | null; + socketCount: string | null; + sourceRemark: string | null; + circuitCombined: string | null; + boardName: string | null; + circuitIdentifier: string | null; + designation: string | null; + electricalPowerW: string | null; + voltageV: string | null; + currentA: string | null; + }; + deviceTypes: DeviceTypeConfiguration[]; + familyTypeRules: FamilyTypeRule[]; +} +``` + +Standardzuordnung: + +| Schlüssel | Bedeutung | Standardspalte | +| --- | --- | --- | +| `worksharingId` | externe Identität, Pflicht | `Worksharing-ID` | +| `ifcGuid` | optionale Redundanz | `IfcGUID` | +| `roomNumber` | Raumnummer | `MEP-Raum: Nummer` | +| `roomName` | Raumname | `MEP-Raum: Name` | +| `familyAndType` | Familie und Typ, Pflicht | `Familie und Typ` | +| `selectionMarker` | CAx-Auswahlkenner | `CAx_Auswahlkenner` | +| `socketCount` | Anzahl Einzelsteckdosen | `CAX_AnzahlObjekte` | +| `sourceRemark` | Anmerkung | `CAx_Anmerkung` | +| `circuitCombined` | Stromkreis gesamt | `kbp_Stromkreisnummer` | +| `boardName` | Verteiler | `kbp-E-Verteiler` | +| `circuitIdentifier` | Stromkreis-BMK | `kbp-E-Stromkreis` | +| `designation` | Bezeichnung | `kbp-E-Bezeichnung` | +| `electricalPowerW` | elektrische Leistung [W] | `kbp-E-Elektrische Leistung` | +| `voltageV` | Spannung [V] | `kbp-E-Spannung` | +| `currentA` | Stromstärke [A] | `kbp-E-Stromstärke` | + +`worksharingId` und `familyAndType` sind Pflichtmappings. Im Modus +`combined` ist `circuitCombined` für den Rückexport Pflicht; im Modus +`separate` sind `boardName` und `circuitIdentifier` Pflicht. Fehlende optionale +Rückgabespalten `designation`, `electricalPowerW`, `voltageV` und `currentA` +werden mit Hinweis übersprungen und blockieren den Export nicht. + +Jedes interne Feld besitzt im Modal ein Dropdown aus den Kopfzellen und eine +freie Eingabe. Exakte kbp-Standardnamen werden vorbelegt. Typ und Einheit aus +dem Linear-Kommentar werden angezeigt. Nicht gemappte Spalten bleiben beim +Rückexport unverändert. + +## 6. Gerätearten und Familie-und-Typ-Regeln + +Eine Geräteart besitzt stabile interne UUID, Name, Kategorie +`lighting | single_phase | three_phase` sowie optionale Standardwerte für +Leistung je Einheit in W, GZF, cosPhi und Kostengruppe. Jede exakte +`Familie und Typ`-Regel verweist auf genau eine Geräteart und verwendet die +Mengenregel `1` oder `socketCount`. + +Mehrere Revit-Familien dürfen auf dieselbe Geräteart zeigen. Jedes Revit- +Objekt bleibt trotzdem einzeln über seine Worksharing-ID nachverfolgbar. +Unbekannte Familien müssen im Importwizard zugeordnet oder über eine neue +Geräteart klassifiziert werden; andernfalls blockiert Apply. + +Planungsstartwerte folgen dieser Reihenfolge: + +1. ausdrücklich verknüpftes ProjectDevice; +2. Geräteart-Standard; +3. leer. + +Der Anzeigename wird aus CAx-Auswahlkenner, dann Geräteartname, dann +`Familie und Typ` vorgeschlagen. Ein fehlender GZF wird mit `1` vorbelegt, +ohne zusätzliche Bestätigung. Standardwerte sind Startwerte, keine dauerhafte +Synchronisierung. + +## 7. Persistenter externer Zustand + +Pro Projekt existiert genau eine implizite Revit-XLSX-Quelle. Ein externes +Objekt besitzt eine interne UUID und ist je `(sourceId, worksharingId)` +eindeutig. `worksharingId` ist NOT NULL; `ifcGuid` ist nullable und nicht +eindeutig. Interne Relationen verwenden ausschließlich interne UUIDs. + +Gespeichert werden: + +- akzeptierte Quellwerte einschließlich aller Rückgabefelder; +- lokale Planungswerte und Override-Felder; +- Geräteart; +- optionales Raum-Mapping, abweichender Verteiler, ProjectDevice und + `CircuitDeviceRow`; +- Anwesenheitsstatus und Verweis auf den zuletzt relevanten Import; +- effektive Menge als fachlicher Wert des unteilbaren Objekts. + +Der effektive Verteiler eines verknüpften Objekts ist immer der Verteiler +seiner Row. Für ein unzugeordnetes Objekt gilt zuerst die explizite +Objektabweichung, danach der Standardverteiler seines Raum-Mappings. Ändert +sich der Standardverteiler eines Raums, betrifft das nur unzugeordnete +Objekte; verknüpfte Objekte bleiben über ihre Row unverändert und die Änderung +wird deshalb nicht gesperrt. + +## 8. Importbatch-Aufbewahrung, Snapshot und Transfer + +Der aktive Projektzustand enthält den letzten Importbatch vollständig: +Originalbytes, klassifizierte Matrix, Dateiname, SHA-256, Zeitpunkt, +Objektzahl, Importart, Projektrevision und Konfigurationskopie. Ältere Batches +werden zu Metadaten reduziert: Datum, Dateiname, SHA-256 und Objektzahl. +Sie enthalten keine Originalbytes, Matrix oder Konfigurationskopie mehr. + +Snapshot-Schema 10 und portabler Projekttransfer übernehmen genau diese +Aufteilung. Beim Duplizieren werden alle internen UUIDs und Links remapped; +Worksharing-IDs, optionale IfcGUIDs, Quellwerte und das letzte Workbook bleiben +unverändert. + +Versionen 1–9 ohne externe CSV-Daten werden verlustfrei auf Version 10 +hochgestuft. Enthält ein alter Stand eine CSV-Konfiguration oder einen nicht +leeren CSV-Zustand, wird er mit klarer deutscher Fehlermeldung abgelehnt. +Alte CSV-History-Commands werden ebenfalls vor jedem Write abgelehnt und nie +stillschweigend übersprungen. + +## 9. Erstimport + +Der Ablauf ist Upload → zustandslose Vorschau/Planung → erneute +Dateiübertragung bei Apply. Apply prüft SHA-256, `expectedRevision`, +Konfigurationskopie, Matrix und sämtliche Entscheidungen erneut. Nur Apply +liest die Excel-Datei erneut ein. + +Undo und Redo lesen keine Excel-Datei und parsen keine gespeicherten Bytes. +Sie arbeiten ausschließlich auf den im Command gespeicherten vollständigen +Vorher-/Nachher-Zuständen. Dadurch bleiben sie nach Neustart deterministisch +und unabhängig vom Parser. + +Der Wizard umfasst: + +1. Vorschau mit Objektzahl, übersprungenen und verdächtigen Zeilen, + doppelten Worksharing-IDs, doppelten IfcGUID-Hinweisen, unbekannten + Familien, nicht lesbaren Zahlen und Formeln; +2. Räume anlegen oder vorhandenen Räumen/Ebenen zuordnen; +3. Standardverteiler je Raum, Sammelzuordnung, Objektabweichungen und Objekte + ohne Raum; +4. Familien exakt Gerätearten und einer Mengenregel zuordnen; +5. optional vorhandene oder neu angelegte ProjectDevices verknüpfen; +6. Zusammenfassung und ausdrückliche Bestätigung. + +Die Übernahme ist ein atomarer Command und ein Undo-Schritt. Im Import +entstehen keine Rows. Neu angelegte Räume und ProjectDevices verwenden ihre +vorhandenen Commands und bleiben eigene Undo-Schritte. + +## 10. Objektplanung und Revit-Seite + +Route: `/projects/:projectId/revit`, erreichbar aus der Projektnavigation. +Der Revit-Button und das CSV-Modal auf der Projektübersicht entfallen. + +Die Seite besitzt: + +- Statuskarte mit letztem Import, Objektzahl, ohne Verteiler, ohne + Stromkreis, fehlend und Abweichung zu Revit; +- Aktionen `Import`, `Rückgabe-Excel`, `Einstellungen`; +- einklappbare Geräteart-/Familienregel-Übersicht; +- kompakte Objekttabelle mit Suche, Spaltenfiltern, Sortierung, + Mehrfachauswahl und Sammelzuordnung für Verteiler, ProjectDevice und + Planungswerte. + +Die kompakte Liste wird einmal geladen, vollständig im Client gefiltert und +sortiert und virtualisiert dargestellt. Es gibt keine Server-Pagination. +Originalbytes, Matrix und Command-Payloads gehören nicht in dieses DTO. + +Vor Row-Zuordnung lassen sich Leistung je Einheit, GZF, cosPhi, +Kostengruppe, Anzeigename und Bemerkung einzeln oder gesammelt ändern. +Geänderte Felder werden Overrides. Nach Row-Zuordnung werden diese Werte im +Grid an der Row bearbeitet. Beim ausdrücklichen Lösen erhält das Objekt die +aktuellen Row-Werte als Planungswerte und entsprechende Overrides. + +## 11. Editor-Zuordnung und Löschen + +Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie +Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum, +Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler +ProjectDevice-Link. Die Anzeige nennt Summe der wirksamen Mengen und Zahl der +externen Objekte. Gruppen sind aufklappbar; Einzel- und Mehrfachauswahl sowie +Filter bleiben möglich. + +Drag-and-drop: + +- auf vorhandene Row: genau diese Row; +- auf Circuit: neue Row mit `manualQuantity = 0`; +- auf freien Bereich: neuer Circuit, Schutz und Row in einem Command. + +Neue IDs und BMK werden einmal geplant; Redo rechnet sie nicht neu. Neue Rows +übernehmen den bestätigten Gruppenzustand und die Summe der effektiven Mengen. +An Rows zeigt ein Hinweis die Zahl verknüpfter Revit-Objekte; einzelne Objekte +können ausdrücklich gelöst werden. + +Wird eine Row, ein Circuit oder eine Gruppe mit verknüpften Revit-Objekten +gelöscht, zeigt die UI vor Bestätigung: +`X Revit-Objekte werden wieder unzugeordnet`. Derselbe Löschcommand löst die +Objekte, entfernt die Struktur und speichert alle Links in seiner Inversen. +Undo stellt Struktur und Links exakt wieder her. Diese Löschungen werden nicht +gesperrt. + +Das Löschen eines Verteilers oder Raums mit referenzierten externen Objekten +bleibt gesperrt. Der Nutzer muss die Verknüpfungen beziehungsweise Zuordnungen +zuvor ausdrücklich ändern. Die Regel gilt auch für Unterbaum- und History- +Pfade. + +## 12. Rückgabe-Excel + +Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only +Nachweis speichert Projektrevision, zugrunde liegenden letzten Import, +Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash +und Objektzahl. + +Umfang: + +- alle im letzten Import vorhandenen Objekte (Standard); +- nur Objekte mit Abweichung zum zuletzt importierten Revit-Wert; +- Auswahl aus der Objekttabelle. + +Die Rückgabe basiert auf einer Kopie des zuletzt importierten Workbooks. +Zeilen oberhalb der ersten Datenzeile, Kopfkommentare, Formate, +Spaltenbreiten, Blattname und Spaltenreihenfolge bleiben erhalten. Danach +folgen nur die gewählten Objektzeilen in ihrer Originalreihenfolge. Alle +Spalten bleiben enthalten; Summenzeilen entfallen. + +Stromkreiswerte: + +- `combined`: `DistributionBoard.name + Circuit.equipmentIdentifier`, ohne + Trennzeichen; +- `separate`: Verteilername und unverändertes BMK in getrennten Spalten; +- ohne Circuit: Stromkreisfeld beziehungsweise beide Felder leer. + +Die Wahl des Verteilerwerts liegt in genau einer Funktion und verwendet +zunächst `DistributionBoard.name`. + +Weitere Rückgabewerte: + +- Bezeichnung = Row-`displayName`, ohne Row Planungs-Anzeigename; +- Leistung [W] = effektive Menge × Einzelleistung, ohne GZF; +- Spannung = Circuit-Spannung, sonst Projektstandard der Kategorie; +- Strom = `P / (U × cosPhi)` beziehungsweise + `P / (sqrt(3) × U × cosPhi)`, auf 0,01 A; +- nicht berechenbar = leere Zelle plus Hinweis. + +Zahlen werden als Excel-Zahlen geschrieben. Fehlende optionale Zielspalten +werden mit Hinweis ausgelassen. Nur fehlende Stromkreisspalten des gewählten +Modus blockieren. Dateiname: +`_Revit-Rueckgabe_.xlsx`. + +## 13. Verbindliche R2-Abnahme mit Linear + +R2 endet mit einer Rückgabedatei, die aus der lokalen realen Musterdatei +erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese +Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach +erfolgreichem Linear-Rückimport beginnen. + +Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe +vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die +erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die +Alternative bleibt hinter demselben transportneutralen Adaptervertrag. + +## 14. Folgeimport + +Jeder Import ist eine vollständige Projektbauteilliste. Matching erfolgt +ausschließlich über exakt und case-sensitiv verglichene Worksharing-ID. + +- neu: Wizard-Schritte nur für neue Objekte und unbekannte Familien; +- geänderte Quellwerte: Einzel- oder Sammelentscheidung; +- akzeptierte Mengenänderung: verknüpfte Row-Menge im selben Command; +- fehlend: markieren, Link und Menge zunächst beibehalten; +- Löschen: erst nach gesonderter Bestätigung; +- abweichende Rückgabefelder: Hinweis `in Revit noch nicht aktualisiert`, + keine Übernahmeentscheidung. + +`external-import.apply-follow-up` ist atomar und ein Undo-Schritt. +Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen. +Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird +auf Metadaten reduziert. + +## 15. Commands, Persistenz und Sicherheit + +Projektbezogene Writes verwenden typisierte Commands mit vollständiger +Gegenrichtung, `expectedRevision`, Anmeldung, CSRF-Header und +`project-command-transaction.persistence.ts`. Dazu gehören mindestens: + +- `external-model-configuration.update`; +- `external-import.apply-initial`; +- `external-import.apply-follow-up`; +- `external-model-object.update-planning`; +- `external-model-object.update-distribution-board`; +- `external-model-room-mapping.update`; +- `external-model-object.update-project-device-link`; +- `external-object.update-row-assignment`; +- `external-object.assign-to-new-row`; +- `external-object.assign-to-new-circuit`; +- `external-model-object.delete-missing`; +- die erweiterten Row-/Circuit-/Gruppen-Löschcommands mit Objekt-Link- + Transitionen. + +Die Konfiguration selbst ist Teil der erwarteten/gewünschten Snapshots, aber +hat keinen eigenen monotonen Zähler. Importbatches halten eine unveränderliche +Konfigurationskopie. Preview/Plan sind zustandslos. Apply überträgt und parst +die Datei erneut; Undo/Redo führt nur gespeicherte Zustandsübergänge aus. + +Neue Migrationen sind additiv. Vor Anwendung wird die Datenbank gesichert und +das generierte SQL geprüft. Da nur Entwicklungsdaten existieren, darf die +Migration alte externe CSV-Daten verwerfen. Vor dem Löschen externer Objekte +werden betroffene Row-Mengen auf `manualQuantity` zurückgesetzt. Der Verlust +des alten externen Entwicklungszustands wird dokumentiert. + +## 16. Abnahme und Leistung + +Die Oberfläche muss mehrere Tausend Objekte flüssig bedienen. R10 misst +Import, Command-/Snapshot-Größe, Speicherbedarf und Client-Interaktion mit +synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den +sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal +geladenen kompakten Liste arbeiten. + +Jedes Paket R1–R10 führt fokussierte Tests sowie aus: + +```text +npm test +npm run build:api +npm run build:web +npm run typecheck:scripts +npx tsc --noEmit -p tsconfig.next.json +``` + +Jedes Paket erhält einen eigenen knappen imperativen Commit. Die konkrete +Paketfolge und ihre Freigabepunkte stehen im +[R0–R10-Plan](revit-excel-phase-14-plan.md). diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md new file mode 100644 index 0000000..46b4fba --- /dev/null +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -0,0 +1,173 @@ +# Phase 14 neu – Plan für die Linear-Excel-Anbindung + +Verbindliche Fachgrundlage: +[Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md). +Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. + +## Status + +| Paket | Status | Ergebnis / Freigabepunkt | +| --- | --- | --- | +| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. | +| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | +| R2 | offen | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei. Danach zwingende Linear-Abnahme durch den Nutzer. | +| R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. | +| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | +| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | +| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | +| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | +| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | +| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | +| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | + +## R0 – Audit + +Ergebnis: [R0-Audit](revit-excel-r0-audit.md). Die Freigabe vom 29.09.2026 +enthält 13 Korrekturen. Bei Abweichungen zwischen dem historischen Audit und +der aktuellen Anforderung gilt das neue Anforderungsdokument. + +## R1 – Dokumentation + +Lieferumfang: + +- neues verbindliches Excel-Anforderungsdokument; +- dieser R0–R10-Plan; +- alte CSV-Anforderungen und alter Plan als kurze Ablösungshinweise; +- LLM-Kontext auf die neue Quelle der Wahrheit kürzen; +- `AGENTS.md`, aktuelle Architektur, History-/External-Model-Architektur, + Roadmap, README, Dokumentationsindex und bekannte Einschränkungen anpassen; +- reale Musterdatei per `.gitignore` vom Commit ausschließen; +- alle internen Links auf die neuen Dokumente umstellen. + +Keine Laufzeit-, Schema- oder UI-Änderung. Abnahme: keine aktive Dokumentation +nennt CSV/IfcGUID als künftigen Transport beziehungsweise Schlüssel; der +aktuell noch vorhandene Legacy-Code bleibt als solcher klar benannt. + +## R2 – XLSX-Transportadapter + +Dateien/Module: neues `src/external-model/xlsx`, synthetische Testfixtures und +ein auf XLSX umgestellter Referenzprüfer mit Dateipfadargument. Eine neue +direkte Bibliothek (`exceljs`, vorbehaltlich der Tests), keine DB-Änderung. + +Abnahme: + +- Kopfzeilenkommentare, Blattname, Spaltenbreiten, Formate und Zelltypen + überleben einen strukturellen Roundtrip; +- erste Datenzeile/-spalte und Mappings funktionieren ohne separates + `layout.idColumn`; +- Worksharing-ID/IfcGUID werden exakt und case-sensitiv behandelt; +- Zeile ohne ID und ohne `Familie und Typ` wird übersprungen, die Muster- + Summenzeile B490 blockiert nicht; +- Zeile ohne ID mit `Familie und Typ` erzeugt Hinweis und wird übersprungen; +- maximal 10 MiB, nur `.xlsx`, Zeilen-/Spaltengrenzen nach dem Einlesen; +- keine Formelausführung; +- lokale Referenzprüfung bestätigt 487 Objekte und keine ID-Duplikate; +- aus der realen Musterdatei entsteht lokal eine nicht versionierte + Rückgabedatei mit einigen geänderten Werten. + +Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein +erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter +OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert. + +## R3 – Datenmodell und Backend-Cutover + +- Konfiguration ohne eigene Versionszählung; +- `worksharing_id NOT NULL`, Unique je Quelle; `ifc_guid` nullable ohne Unique; +- additive Migration nach vorherigem DB-Backup; alte externe Entwicklungsdaten + verwerfen und Row-Mengen vorher auf `manualQuantity` zurücksetzen; +- Batch-Aufbewahrung: nur letzter Batch vollständig, ältere nur Metadaten; +- Snapshot 10, Restore und Transfer; alte CSV-Zustände/History klar ablehnen; +- Backend-CSV-Parser, Endpunkte und Verträge entfernen; +- Apply parst erneut; Undo/Redo verwenden ausschließlich gespeicherte + Vorher-/Nachher-Zustände. + +Risiken: Migration mit verknüpften Row-Mengen, alte History-Spitzen und +vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests. + +## R4 – Revit-Seite und Einstellungen + +- Route `/projects/:projectId/revit` und Navigationseintrag; +- Status-/Aktionsgrundgerüst; +- Einstellungsmodal mit Musterdateierkennung, Layout und Feldzuordnung; +- Stromkreismodus `combined | separate`; +- Projektseiten-Button und `revit-csv-modal.tsx` entfernen; +- nur deutsche UI-Texte und bestehende Icon-/Kartenregeln. + +## R5 – Erstimport + +- Vorschau und Plan mit erneuter Dateiübertragung beim Apply; +- Räume, Verteiler, Gerätearten, exakte Familienregeln und optionale + ProjectDevices; +- fehlender GZF als sichtbarer Startwert `1`, ohne Zusatzbestätigung; +- Warn-/Überspringregel für ID-lose Zeilen; +- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung; +- Undo/Redo aus Commandzuständen ohne Workbook-Parsing. + +## R6 – Objekttabelle und Sammelaktionen + +- kompakte Objektliste einmal laden; +- clientseitig suchen, filtern und sortieren; +- virtualisierte Darstellung ohne Server-Pagination; +- stabile Mehrfachauswahl und Sammelaktionen für Verteiler, ProjectDevice und + Objektplanungswerte; +- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte + Objekte folgen unverändert ihrer Row. + +## R7 – Stromkreislisteneditor + +- gruppierter rechter Revit-Bereich mit Anzahl-Badge; +- Drop auf Row, Circuit oder freien Bereich über vorhandene, angepasste + Commands; +- Row-Indikator, Objektanzeige und Lösen mit Übernahme aktueller Row-Werte; +- Row-/Circuit-/Gruppen-Löschcommands lösen verknüpfte Objekte atomar und + zeigen `X Revit-Objekte werden wieder unzugeordnet`; +- Undo stellt Objektlinks exakt wieder her; +- nur Raum-/Verteilerlöschung mit externen Referenzen bleibt gesperrt. + +## R8 – Rückgabe-Excel + +- alle / abweichende / ausgewählte Objekte; +- zuletzt importiertes Workbook als strukturelle Grundlage; +- Stromkreisfelder des gewählten Modus sind Pflicht; +- fehlende optionale Bezeichnung-/Leistung-/Spannung-/Stromspalten nur als + Hinweis überspringen; +- typisierte Zahlen, gemeinsame Export-/Abweichungsprojektion; +- append-only Exportnachweis ohne Projektrevision. + +R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf. + +## R9 – Folgeimport + +- exaktes, case-sensitives Matching nach Worksharing-ID; +- neu, geändert, unverändert, fehlend und Rückgabefeldabweichungen; +- Entscheidungen nur für fachliche Quelländerungen; +- akzeptierte Mengenänderungen und Row-Summen atomar; +- fehlende Objekte nicht automatisch lösen oder löschen; +- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren; +- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo. + +## R10 – Härtung + +Ende-zu-Ende-Test: + +```text +Import → Objektplanung → Row-Zuordnung → Rückgabe → Folgeimport + → Snapshot/Restore → Projektduplikat → Undo/Redo nach Neustart +``` + +Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI, +Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen, +alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand. + +## Pflichtprüfungen je Paket R1–R10 + +```text +fokussierte Tests +npm test +npm run build:api +npm run build:web +npm run typecheck:scripts +npx tsc --noEmit -p tsconfig.next.json +``` + +Jedes Paket wird mit einer knappen imperativen Commit-Message abgeschlossen. diff --git a/docs/spec/revit-excel-r0-audit.md b/docs/spec/revit-excel-r0-audit.md index 721e402..84acc5a 100644 --- a/docs/spec/revit-excel-r0-audit.md +++ b/docs/spec/revit-excel-r0-audit.md @@ -2,12 +2,17 @@ Stand: 29.09.2026. Geprüfter Code: `cf022b2`. -**Status: R0 abgeschlossen, Freigabe für R1–R10 ausstehend.** Dieser Bericht +**Status: R0 abgeschlossen und mit Korrekturen für R1 freigegeben.** Dieser Bericht ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0 ändert weder Laufzeitcode noch Datenbank noch Oberfläche. +Die 13 Freigabekorrekturen vom 29.09.2026 sind in den +[verbindlichen Anforderungen](revit-excel-integration-requirements.md) und im +[R0–R10-Plan](revit-excel-phase-14-plan.md) eingearbeitet. Bei Abweichungen +gelten diese beiden R1-Dokumente. + ## 1. Geprüfter Bestand und Keep/Change/Remove Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`, @@ -90,7 +95,7 @@ Die Datei ist unversioniert und darf nicht committed werden. | Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate | | Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte | | Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname | -| IfcGUID-Duplikate | 2 Gruppen nichtleerer doppelter Werte | +| IfcGUID-Duplikate | Keine bei exaktem, case-sensitivem Vergleich | | Formeln | Keine; synthetische Tests dafür erforderlich | | Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind | @@ -100,9 +105,15 @@ gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie dürfen nicht ohne Linear-Parameterbindung erfunden werden. +Die frühere Angabe von zwei IfcGUID-Duplikatgruppen war falsch: PowerShells +standardmäßiges Gruppieren verglich ohne Beachtung der Groß-/Kleinschreibung. +Ein erneuter ordinaler, case-sensitiver Vergleich ergab keine Duplikate. Die +Summenzeile hat nur in B490 einen Wert; `Familie und Typ` ist leer. + Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue -Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 prüft -die Struktur, R8/R10 benötigen zusätzlich einen realen Linear-Abnahmelauf. +Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 endet +deshalb mit einer aus der Musterdatei erzeugten Rückgabedatei und einem +verbindlichen Linear-Test durch den Nutzer vor R3. ## 3. Bibliothek, Adapter und Uploadgrenzen @@ -123,18 +134,12 @@ Blocker wird die Bibliotheksentscheidung neu vorgelegt. Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet: -- ausschließlich `.xlsx`, passender OOXML-Inhalt und ZIP-Signatur; kein - `.xlsm`, verschlüsseltes Archiv, VBA, ActiveX oder externe Workbook-Links; -- maximal 10 MiB Upload, 100 MiB tatsächlich entpackte Daten, 2.000 ZIP- - Einträge, 50.000 Zeilen, 256 Spalten und 2 Millionen materialisierte Zellen; -- genau ein Arbeitsblatt; keine Formelausführung, kein Nachladen von URLs; -- ZIP-Vorprüfung vor ExcelJS: Verzeichniseinträge, doppelte Pfade, - Kompressionsverfahren und tatsächliche entpackte Bytezahl prüfen. - Zentralverzeichnisangaben allein schützen nicht vor manipulierten Archiven. - Begrenzte Dekompression mit Node-Bordmitteln, keine zweite direkte Library; -- Parser in begrenztem Worker mit Zeit-/Speichergrenze; Abbruch ohne DB- - Schreibvorgang. API-Body-Limit auf Uploadgrenze einschließlich Base64- - Overhead abstimmen, statt global beliebige Requestgrößen zuzulassen. +- ausschließlich `.xlsx`, kein `.xls` oder `.xlsm`; +- maximal 10 MiB Upload; +- genau ein Arbeitsblatt und keine Formelausführung; +- feste Zeilen-, Spalten- und Zellgrenzen nach dem Einlesen. + +Es gibt keinen eigenen ZIP-Prüfer und keinen Parser-Worker. Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten (Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete @@ -144,11 +149,14 @@ optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert. Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern einen Blocker. Originalbytes bleiben unverändert gespeichert. -Fehlende ID bei sonstigen Objektdaten blockiert; Leer-/eindeutige Summenzeilen -werden ignoriert. Nicht passende nichtleere IDs blockieren ebenfalls. -Worksharing-ID bleibt als Quelltext erhalten, Matching ist exakt; ungültige -Leerzeichen/uneindeutige Schreibweisen werden gemeldet statt still korrigiert. -IfcGUID wird optional gespeichert, doppelte Werte sind nur Hinweise. +Eine Zeile ohne Worksharing-ID ist nur verdächtig, wenn `Familie und Typ` +gefüllt ist. Sie erzeugt einen Hinweis und wird übersprungen, ohne zu +blockieren. Andere ID-lose Zeilen, auch die Summenzeile mit Inhalt in B, +werden still übersprungen. Nicht passende nichtleere IDs blockieren. +Worksharing-ID und IfcGUID bleiben als Quelltext erhalten und werden exakt, +ordinal und case-sensitiv verglichen; ungültige äußere Leerzeichen werden +gemeldet statt still korrigiert. IfcGUID wird optional gespeichert, doppelte +Werte sind nur Hinweise. Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein. @@ -170,8 +178,7 @@ Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1: { schemaVersion: 1, layout: { - headerRow: 1, firstDataRow: 3, firstDataColumn: 1, - idColumn: "Worksharing-ID" + headerRow: 1, firstDataRow: 3, firstDataColumn: 1 }, circuitMode: "combined" | "separate", columns: { @@ -200,8 +207,8 @@ Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1: ``` Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar. -`layout.idColumn` und `columns.worksharingId` werden gemeinsam gesetzt und -müssen übereinstimmen. Jede gemappte Kopfzeile muss eindeutig auflösbar sein; +Es gibt nur `columns.worksharingId` und kein separates `layout.idColumn`. +Jede gemappte Kopfzeile muss eindeutig auflösbar sein; doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig, aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem @@ -217,8 +224,8 @@ Referenzierte Gerätearten können nicht kommentarlos entfernt werden. Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename: Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung. -Fehlender GZF bleibt vor Zuordnung leer; verlangt der Row-Vertrag einen Wert, -wird `1` sichtbar vorgeschlagen und ausdrücklich bestätigt. Geräteart- +Fehlender GZF wird sichtbar mit `1` vorbelegt, ohne zusätzliche Bestätigung. +Geräteart- Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um. ## 5. Konkretes Persistenzschema @@ -228,20 +235,21 @@ Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten. | Tabelle | Zielspalten / Constraints | | --- | --- | -| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration_version INTEGER NOT NULL CHECK > 0`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat. | +| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat; keine eigene Versionszählung. | | `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. | -| `external_import_batches` | Bestehende IDs, Importart, Zeit, Dateiname, SHA-256, `applied_project_revision`, `configuration_version`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; Matrix jetzt typisiert und transportneutral. Unveränderlich innerhalb des aktiven Zustands; Undo/Restore darf ganze Batches entfernen/wiederherstellen. | +| `external_import_batches` | Letzter Batch vollständig mit IDs, Importart, Zeit, Dateiname, SHA-256, Objektzahl, `applied_project_revision`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; ältere Batches nur als Metadaten Datum, Dateiname, SHA-256 und Objektzahl. | | `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. | | `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. | | Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. | -| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_version INTEGER`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. | +| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_snapshot/hash`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. | `import_batch_id` des Exportnachweises ist absichtlich eine historische UUID ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben. Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1 -dokumentiert. Importbatches selbst gehören vollständig zu Snapshot/Transfer. +dokumentiert. Nur der letzte Importbatch gehört mit Bytes und Matrix zu +Snapshot/Transfer; ältere Batches werden dort nur als Metadaten erhalten. Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten; zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`, @@ -252,9 +260,9 @@ Transaktion; bloße Existenz eines UUID-FK genügt nicht. Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden; -bei fehlender Vorgabe ist das Objekt ohne Verteiler. Room-/Board- -Sammeländerungen prüfen alle betroffenen Row-Links und lehnen inkonsistente -Umbuchungen ab, bis diese ausdrücklich gelöst/verschoben sind. +bei fehlender Vorgabe ist das Objekt ohne Verteiler. Eine Änderung des +Raum-Standardverteilers betrifft nur unzugeordnete Objekte; verknüpfte Objekte +folgen immer der Row-Verteilung und blockieren diese Änderung nicht. Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export @@ -272,7 +280,7 @@ Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches. | Command | Payload-Skizze und Gegenrichtung | | --- | --- | -| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht Fachinhalt; Versionsgeneration siehe unten. | +| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht den vollständigen Fachinhalt; keine eigene Konfigurationsversion. | | `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. | | `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. | | `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. | @@ -290,32 +298,29 @@ Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu, ohne lokale Entscheidungen zu verlieren. -Konfigurationsgeneration: `configurationVersion` erhält bei jedem -Konfigurationswechsel einschließlich Undo/Redo/Restore die neu entstehende -Projektrevision. Sie ist damit strikt steigend, aber nicht lückenlos. -Historienbefehle vergleichen den erwarteten Fachinhalt und die aktuelle -Projektversion; die administrative Generation wird serverseitig neu erzeugt. -Die Inverse restauriert alle Fachwerte exakt, nicht einen alten Zählerstand. -Importbatches behalten ihre ursprüngliche Generation und ihren Snapshot. -Ein Restore/Projekttransfer erhält diese Herkunft; die aktive Konfiguration -wird an die Revision des Zielprojekts gebunden. Prüfen: Änderung → Undo → -anderer Zweig darf niemals denselben Vorschau-Token wieder gültig machen. +Die Konfiguration hat keine eigene Versionszählung. Vorschau und Apply binden +sich an `expectedRevision`; weil auch Undo/Redo neue, strikt steigende +Projektrevisionen erzeugen, wird ein veralteter Plan eindeutig ungültig. +Importbatches speichern eine vollständige Konfigurationskopie. Prüfen: +Änderung → Undo → anderer Zweig darf niemals denselben Plan wieder gültig +machen. XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und Entscheidungen werden erneut validiert. Im Store werden Revision, -Konfigurationsinhalt/-generation, erwartete Objekte, Links und vollständiger +Konfigurationsinhalt, erwartete Objekte, Links und vollständiger vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht -an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo benutzen -denselben Prüfdienst für persistierte Bytes vor der Transaktion. Keine -asynchronen Callbacks in `better-sqlite3`-Transaktionen. +an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo lesen und +parsen keine Excel-Datei; sie wenden ausschließlich die gespeicherten +Vorher-/Nachher-Zustände an. Keine asynchronen Callbacks in +`better-sqlite3`-Transaktionen. -Allgemeine Row-/Circuit-/Gruppen-/Board-/Raum-/ProjectDevice-Löschungen -erhalten Guards, solange externe Referenzen betroffen sind. Nutzer lösen die -Referenzen zuvor ausdrücklich über die passenden Commands. Guards gelten -auch in Unterbaum- und History-only-Pfaden. Dadurch werden vorhandene -Commands nicht unbemerkt zu unvollständigen externen Löschcommands. +Row-/Circuit-/Gruppen-Löschcommands erfassen verknüpfte Objekte vollständig. +Nach der Warnung `X Revit-Objekte werden wieder unzugeordnet` lösen sie die +Links im selben Command; ihre Inverse stellt sie exakt wieder her. Sperren +bleiben nur für das Löschen referenzierter Verteiler und Räume und gelten auch +in Unterbaum- und History-only-Pfaden. ## 7. Migration, Snapshot 10, History und Transfer @@ -351,7 +356,8 @@ allgemeine Commands können nach dem Reset zusätzlich an ihren exakten Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung. Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle, -alle Batches mit Originalbytes/Matrix, Raum-Mappings, Objekte und Links. +den letzten Batch mit Originalbytes/Matrix, ältere Batches nur als Metadaten +sowie Raum-Mappings, Objekte und Links. Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn `externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration @@ -389,12 +395,13 @@ separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt. Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung, nicht einen tatsächlich abgeschlossenen Browserdownload. -Die Revit-Seite zeigt Status, Aktionen und eine paginierte Objekttabelle -(Vorschlag: 100 Zeilen pro Seite). Filter-/Sortierzustand, UUID-Auswahl und -Sammelaktionen sind unabhängig vom DOM. „Alle gefilterten“ verwendet eine -explizit aufgelöste Auswahl zur bekannten Revision; keine Mutation anhand -eines inzwischen veränderten Filters. Große Auswahlmengen werden nicht in -getrennte Undo-Schritte aufgeteilt. Kein vollständiges Batch-Blob im DTO. +Die Revit-Seite lädt einmal eine kompakte Objektliste. Filterung und Sortierung +laufen im Client; die Tabelle wird virtualisiert und verwendet keine Server- +Pagination. Filter-/Sortierzustand, UUID-Auswahl und Sammelaktionen sind +unabhängig vom DOM. „Alle gefilterten“ wird vor einer Mutation zu einer +expliziten ID-Auswahl an der aktuellen Revision aufgelöst. Große Auswahlmengen +werden nicht in getrennte Undo-Schritte aufgeteilt. Kein vollständiges +Batch-Blob im DTO. Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen, Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln @@ -430,10 +437,10 @@ gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand. Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene -Parameter behalten importierte Werte. Fehlende benötigte Zielspalten bzw. -Linear-Bindungen blockieren die betreffende Exportkonfiguration mit Hinweis -auf einen neuen vollständigen Linear-Export. Dateiname gemäß Auftrag mit -bereinigtem Projektnamen und Tagesdatum. +Parameter behalten importierte Werte. Fehlende optionale Spalten für +Bezeichnung, Leistung, Spannung und Strom werden mit Hinweis übersprungen. +Nur fehlende Stromkreisspalten des gewählten Modus blockieren. Dateiname gemäß +Auftrag mit bereinigtem Projektnamen und Tagesdatum. Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert immer den vollständigen letzten Quellstand. Unveränderte Objekte werden @@ -457,12 +464,12 @@ nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste. | Paket | Arbeit und entscheidende Abnahme | | --- | --- | | R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. | -| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss; ZIP-/Formelgrenzen. Keine DB-Änderung. | +| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss. Aus der lokalen Musterdatei entsteht eine Rückgabedatei mit geänderten Werten. Danach Nutzerabnahme in Linear; R3 bleibt bis zum Erfolg gesperrt. Bei Fehlschlag vor R3 OOXML-Direktänderung als Alternative vorlegen. Keine DB-Änderung. | | R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. | | R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. | | R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. | -| R6 | Objekt-Reads, Paging/Filter, Sammel-Commands und Overrides; verwandte Lösch-/Referenzguards vollständig absichern. | -| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele und Reload/Undo/Redo. | +| R6 | Kompakte Objekt-Reads, clientseitige Filter/Sortierung, Virtualisierung, Sammel-Commands und Overrides. | +| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele, atomare Objektlösung beim Row-/Circuit-/Gruppen-Löschen und Reload/Undo/Redo. | | R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. | | R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. | | R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. | @@ -492,19 +499,19 @@ Zusätzlich erforderliche neue Prüfungen: - Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs. - v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen. -- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne; Apply mit anderem +- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne über `expectedRevision`; Apply mit anderem Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück. - Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte. - Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung. -- Löschen über alle Unterbaum-/History-Pfade kann externe FKs nicht still - nullen. Fremde Verteilungen/Räume/Kategorien und Gerätearten blockieren. +- Row-/Circuit-/Gruppen-Löschen löst externe Links im Command und stellt sie + per Undo wieder her. Raum-/Verteilerlöschen mit Referenzen blockiert. - Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise. - 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße, - Command-/Snapshot-Dauer und UI-Reaktionszeit messen; höchstens eine - paginierte Seite im DOM, stabile Auswahl über Seitenwechsel. + Command-/Snapshot-Dauer und UI-Reaktionszeit messen; nur sichtbare Zeilen im + DOM, stabile Auswahl über virtuelle Bereiche. Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu @@ -515,7 +522,7 @@ externen Zustand begrenzt und nur nach verifiziertem Backup zulässig. Fehlende Zielparameter im Muster verlangen einen neuen vollständigen Linear-Export, keine geratenen GUID-Kommentare. -**Freigabepunkt:** Der Nutzerauftrag verlangt wörtlich -„R0. Audit (danach stoppen und Freigabe abwarten)“. -Mit Freigabe dieses Vorschlags beginnt R1; R0 selbst autorisiert keine -Installation, Migration oder Implementierung von R1–R10. +**Freigabepunkt:** R0 wurde am 29.09.2026 mit den in R1 dokumentierten +Korrekturen freigegeben. R2 besitzt einen neuen verbindlichen Freigabepunkt: +Vor Beginn von R3 muss der Nutzer die in R2 erzeugte Rückgabedatei erfolgreich +in Linear getestet haben. From 52d3b1a2708d6f350676533b46e555d2a0eaf2b0 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 07:33:06 +0200 Subject: [PATCH 3/5] Add Linear Excel transport --- .gitignore | 1 + docs/spec/revit-excel-phase-14-plan.md | 20 +- package-lock.json | 811 ++++++++++++++++++ package.json | 3 +- scripts/verify-revit-reference-csv.ts | 81 -- scripts/verify-revit-reference-xlsx.ts | 162 ++++ .../xlsx/external-xlsx-contracts.ts | 153 ++++ .../xlsx/external-xlsx-transport.ts | 582 +++++++++++++ tests/external-xlsx-transport.test.ts | 267 ++++++ tests/fixtures/revit-xlsx-fixtures.ts | 134 +++ 10 files changed, 2131 insertions(+), 83 deletions(-) delete mode 100644 scripts/verify-revit-reference-csv.ts create mode 100644 scripts/verify-revit-reference-xlsx.ts create mode 100644 src/external-model/xlsx/external-xlsx-contracts.ts create mode 100644 src/external-model/xlsx/external-xlsx-transport.ts create mode 100644 tests/external-xlsx-transport.test.ts create mode 100644 tests/fixtures/revit-xlsx-fixtures.ts diff --git a/.gitignore b/.gitignore index 911c16f..acbb157 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ data/*.db data/backups/*.db .codex/*.log /docs/spec/kbp-Stromkreis.xlsx +/data/revit-reference/*.xlsx diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 46b4fba..bb00053 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -10,7 +10,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | --- | --- | --- | | R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. | | R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | -| R2 | offen | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei. Danach zwingende Linear-Abnahme durch den Nutzer. | +| R2 | implementiert, Linear-Abnahme ausstehend | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt. R3 bleibt bis zum erfolgreichen Linear-Test gesperrt. | | R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. | | R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | | R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | @@ -69,6 +69,24 @@ Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert. +Implementierungsstand vom 30.09.2026: Der Adapter liegt unter +`src/external-model/xlsx`, der Referenzprüfer unter +`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei +die ignorierte Datei +`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei +enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte +Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen, +Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte +ID-Vergleiche und Grenzen ab. + +Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der +Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete +Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer; +ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten +Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete +Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im +fachlich begrenzten R2-Paket aktualisiert. + ## R3 – Datenmodell und Backend-Cutover - Konfiguration ohne eigene Versionszählung; diff --git a/package-lock.json b/package-lock.json index 417421b..9d27840 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "better-sqlite3": "^12.9.0", "bootstrap": "^5.3.8", "drizzle-orm": "^0.45.2", + "exceljs": "^4.4.0", "express": "^5.2.1", "lucide-react": "^1.14.0", "next": "^16.2.4", @@ -875,6 +876,47 @@ "node": ">=18" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1587,6 +1629,87 @@ "node": ">= 0.6" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1630,6 +1753,28 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1648,6 +1793,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1689,6 +1840,16 @@ "@popperjs/core": "^2.11.8" } }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1712,12 +1873,38 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1772,6 +1959,18 @@ } ] }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1782,6 +1981,27 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1818,12 +2038,49 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2030,6 +2287,45 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2132,6 +2428,26 @@ "node": ">= 0.6" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2182,6 +2498,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2228,6 +2557,12 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2242,6 +2577,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2302,6 +2653,27 @@ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2313,6 +2685,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2388,6 +2766,23 @@ } ] }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2411,6 +2806,190 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/lucide-react": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", @@ -2480,6 +3059,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2488,6 +3079,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -2591,6 +3194,15 @@ "node": ">=10" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2621,6 +3233,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2629,6 +3247,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -2696,6 +3323,12 @@ "node": ">=10" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2799,6 +3432,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2808,6 +3471,19 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2847,6 +3523,18 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2906,6 +3594,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3165,6 +3859,15 @@ "node": ">=6" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3173,6 +3876,15 @@ "node": ">=0.6" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3706,11 +4418,69 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3724,6 +4494,47 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", diff --git a/package.json b/package.json index 414eb53..1c035ba 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", - "revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts" + "revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts" }, "keywords": [], "author": "", @@ -36,6 +36,7 @@ "better-sqlite3": "^12.9.0", "bootstrap": "^5.3.8", "drizzle-orm": "^0.45.2", + "exceljs": "^4.4.0", "express": "^5.2.1", "lucide-react": "^1.14.0", "next": "^16.2.4", diff --git a/scripts/verify-revit-reference-csv.ts b/scripts/verify-revit-reference-csv.ts deleted file mode 100644 index 07d605b..0000000 --- a/scripts/verify-revit-reference-csv.ts +++ /dev/null @@ -1,81 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; -import { - parseExternalCsv, - serializeExternalCsv, -} from "../src/external-model/csv/external-csv-transport.js"; -import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; - -const filePath = resolve( - process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv" -); -const source = readFileSync(filePath); -const configuration = createDefaultExternalCsvConfiguration({ - ifcGuid: "IfcGUID", - roomNumber: "MEP-Raum: Nummer", - roomName: "MEP-Raum: Name", - familyAndType: "Familie und Typ", - selectionMarker: "CAx_Auswahlkenner", - circuitIdentifier: "kbp_Stromkreisnummer", - power: "kbp-E-Elektrische Leistung", - quantity: null, -}); -configuration.additionalSourceMappings = [ - { sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" }, - { sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" }, - { sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" }, - { sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" }, - { sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" }, -]; - -const document = parseExternalCsv(source, configuration); -const counts = Object.fromEntries( - ["metadata", "header", "passthrough", "object", "suspect-object"].map( - (classification) => [ - classification, - document.rows.filter((row) => row.classification === classification).length, - ] - ) -); -const nonEmptyPassthroughRows = document.rows.filter( - (row) => - row.classification === "passthrough" && - row.cells.some((cell) => cell.value !== "") -).length; -const serialized = serializeExternalCsv(document); -const preview = createExternalCsvPreview({ - fileName: filePath, - bytes: source, - configuration, -}); - -assert.equal(counts.object, 897, "Expected 897 object rows."); -assert.equal(preview.objectCount, 897, "Expected 897 preview objects."); -assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows."); -assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows."); -assert.equal( - Buffer.compare(Buffer.from(serialized), source), - 0, - "Expected a byte-identical CSV round-trip." -); - -const sha256 = createHash("sha256").update(source).digest("hex"); -process.stdout.write( - `${JSON.stringify( - { - file: filePath, - bytes: source.length, - sha256, - dialect: document.dialect, - rows: counts, - nonEmptyPassthroughRows, - roundTripByteIdentical: true, - previewObjectCount: preview.objectCount, - }, - null, - 2 - )}\n` -); diff --git a/scripts/verify-revit-reference-xlsx.ts b/scripts/verify-revit-reference-xlsx.ts new file mode 100644 index 0000000..ed568d4 --- /dev/null +++ b/scripts/verify-revit-reference-xlsx.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import ExcelJS from "exceljs"; +import { + createDefaultExternalXlsxTransportConfiguration, + type ExternalXlsxScalar, +} from "../src/external-model/xlsx/external-xlsx-contracts.js"; +import { + createExternalXlsxReturn, + parseExternalXlsx, +} from "../src/external-model/xlsx/external-xlsx-transport.js"; + +async function main() { +const inputPath = resolve( + process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx" +); +const outputPath = resolve( + process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx" +); +assert.notEqual( + inputPath, + outputPath, + "Eingabe- und Ausgabedatei müssen verschieden sein." +); + +const source = readFileSync(inputPath); +const configuration = createDefaultExternalXlsxTransportConfiguration(); +const document = await parseExternalXlsx({ + fileName: inputPath, + bytes: source, + configuration, +}); +const objectRows = document.rows.filter((row) => row.classification === "object"); +const suspectRows = document.rows.filter( + (row) => row.classification === "suspect-object" +); +const worksharingIdColumn = document.columns.find( + (column) => column.header === configuration.columns.worksharingId +)?.columnNumber; +assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst."); + +assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet."); +assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet."); +assert.equal( + document.columns.filter((column) => column.comment !== null).length, + 14, + "Es werden 14 Kopfzeilenkommentare erwartet." +); +assert.equal( + document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length, + 0, + "Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten." +); + +const testValues = [ + { + circuitCombined: "UV_R2_TEST-2F1", + electricalPowerW: 111, + voltageV: 230, + currentA: 0.48, + }, + { + circuitCombined: "UV_R2_TEST-2F2", + electricalPowerW: 222, + voltageV: 230, + currentA: 0.97, + }, + { + circuitCombined: "UV_R2_TEST-3F1", + electricalPowerW: 333, + voltageV: 400, + currentA: 0.48, + }, +] satisfies Record[]; + +const updates = testValues.map((values, index) => { + const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value; + if (typeof value !== "string") { + throw new Error("Worksharing-ID muss Text sein."); + } + return { worksharingId: value, values }; +}); +const result = await createExternalXlsxReturn({ + fileName: inputPath, + bytes: source, + configuration, + updates, +}); +assert.equal(result.exportedObjectCount, 487); +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, result.bytes); + +const outputDocument = await parseExternalXlsx({ + fileName: outputPath, + bytes: result.bytes, + configuration, +}); +assert.equal( + outputDocument.rows.filter((row) => row.classification === "object").length, + 487 +); +assert.equal( + outputDocument.rows.some((row) => row.classification !== "object"), + false, + "Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten." +); +assert.equal(outputDocument.sheetName, document.sheetName); +assert.deepEqual( + outputDocument.columns, + document.columns, + "Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben." +); +assert.deepEqual( + outputDocument.rows.slice(updates.length).map((row) => row.cells), + objectRows.slice(updates.length).map((row) => row.cells), + "Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten." +); +const outputWorkbook = new ExcelJS.Workbook(); +await outputWorkbook.xlsx.load(result.bytes as never); +const commentedCells: string[] = []; +outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => { + row.eachCell({ includeEmpty: true }, (cell) => { + if (cell.note !== undefined) commentedCells.push(cell.address); + }); +}); +assert.equal( + commentedCells.length, + 14, + "Nur die 14 Kopfzellen dürfen Kommentare enthalten." +); +assert.equal( + commentedCells.every((address) => address.endsWith("1")), + true, + "Datenzellen dürfen keine Kommentare enthalten." +); + +process.stdout.write( + `${JSON.stringify( + { + input: inputPath, + output: outputPath, + inputBytes: source.length, + outputBytes: result.bytes.length, + inputSha256: createHash("sha256").update(source).digest("hex"), + outputSha256: createHash("sha256").update(result.bytes).digest("hex"), + sheetName: document.sheetName, + objectCount: objectRows.length, + headerCommentCount: document.columns.filter( + (column) => column.comment !== null + ).length, + exactDuplicateIfcGuidCount: 0, + changedObjectCount: updates.length, + }, + null, + 2 + )}\n` +); +} + +void main(); diff --git a/src/external-model/xlsx/external-xlsx-contracts.ts b/src/external-model/xlsx/external-xlsx-contracts.ts new file mode 100644 index 0000000..94c7b45 --- /dev/null +++ b/src/external-model/xlsx/external-xlsx-contracts.ts @@ -0,0 +1,153 @@ +export const MAX_EXTERNAL_XLSX_BYTES = 10 * 1024 * 1024; + +export const DEFAULT_EXTERNAL_XLSX_LIMITS = { + maxRows: 50_000, + maxColumns: 256, + maxCells: 2_000_000, +} as const; + +export type ExternalXlsxScalar = string | number | boolean | null; + +export interface ExternalXlsxCell { + columnNumber: number; + value: ExternalXlsxScalar; + valueType: "empty" | "string" | "number" | "boolean"; + numberFormat: string | null; + formula: string | null; + usedCachedFormulaValue: boolean; +} + +export type ExternalXlsxRowClassification = + | "object" + | "suspect-object" + | "passthrough"; + +export interface ExternalXlsxRow { + rowNumber: number; + classification: ExternalXlsxRowClassification; + cells: ExternalXlsxCell[]; +} + +export interface ExternalXlsxColumn { + columnNumber: number; + header: string; + comment: string | null; + width: number | null; +} + +export type ExternalXlsxIssueCode = + | "cached-formula-value-used" + | "formula-without-cached-value" + | "suspect-object-skipped" + | "duplicate-ifc-guid" + | "optional-return-column-missing"; + +export interface ExternalXlsxIssue { + code: ExternalXlsxIssueCode; + message: string; + rowNumber?: number; + columnNumber?: number; + field?: ExternalXlsxColumnKey; +} + +export interface ExternalXlsxDocument { + sheetName: string; + headerRowNumber: number; + firstDataRowNumber: number; + firstDataColumnNumber: number; + columns: ExternalXlsxColumn[]; + rows: ExternalXlsxRow[]; + issues: ExternalXlsxIssue[]; +} + +export interface ExternalXlsxLayoutConfiguration { + headerRow: number; + firstDataRow: number; + firstDataColumn: number; +} + +export type ExternalXlsxCircuitMode = "combined" | "separate"; + +export interface ExternalXlsxColumnConfiguration { + worksharingId: string; + ifcGuid: string | null; + roomNumber: string | null; + roomName: string | null; + familyAndType: string; + selectionMarker: string | null; + socketCount: string | null; + sourceRemark: string | null; + circuitCombined: string | null; + boardName: string | null; + circuitIdentifier: string | null; + designation: string | null; + electricalPowerW: string | null; + voltageV: string | null; + currentA: string | null; +} + +export type ExternalXlsxColumnKey = keyof ExternalXlsxColumnConfiguration; + +export interface ExternalXlsxTransportConfiguration { + layout: ExternalXlsxLayoutConfiguration; + circuitMode: ExternalXlsxCircuitMode; + columns: ExternalXlsxColumnConfiguration; +} + +export interface ExternalXlsxLimits { + maxRows: number; + maxColumns: number; + maxCells: number; +} + +export type ExternalXlsxReturnValues = Partial< + Record< + | "circuitCombined" + | "boardName" + | "circuitIdentifier" + | "designation" + | "electricalPowerW" + | "voltageV" + | "currentA", + ExternalXlsxScalar + > +>; + +export interface ExternalXlsxReturnUpdate { + worksharingId: string; + values: ExternalXlsxReturnValues; +} + +export interface ExternalXlsxReturnResult { + bytes: Uint8Array; + exportedObjectCount: number; + issues: ExternalXlsxIssue[]; +} + +export function createDefaultExternalXlsxTransportConfiguration(): ExternalXlsxTransportConfiguration { + return { + layout: { + headerRow: 1, + firstDataRow: 3, + firstDataColumn: 1, + }, + circuitMode: "combined", + columns: { + worksharingId: "Worksharing-ID", + ifcGuid: "IfcGUID", + roomNumber: "MEP-Raum: Nummer", + roomName: "MEP-Raum: Name", + familyAndType: "Familie und Typ", + selectionMarker: "CAx_Auswahlkenner", + socketCount: "CAX_AnzahlObjekte", + sourceRemark: "CAx_Anmerkung", + circuitCombined: "kbp_Stromkreisnummer", + boardName: "kbp-E-Verteiler", + circuitIdentifier: "kbp-E-Stromkreis", + designation: "kbp-E-Bezeichnung", + electricalPowerW: "kbp-E-Elektrische Leistung", + voltageV: "kbp-E-Spannung", + currentA: "kbp-E-Stromstärke", + }, + }; +} diff --git a/src/external-model/xlsx/external-xlsx-transport.ts b/src/external-model/xlsx/external-xlsx-transport.ts new file mode 100644 index 0000000..31cc707 --- /dev/null +++ b/src/external-model/xlsx/external-xlsx-transport.ts @@ -0,0 +1,582 @@ +import ExcelJS from "exceljs"; +import type { CellValue, Worksheet } from "exceljs"; +import { + DEFAULT_EXTERNAL_XLSX_LIMITS, + MAX_EXTERNAL_XLSX_BYTES, + type ExternalXlsxCell, + type ExternalXlsxColumnKey, + type ExternalXlsxDocument, + type ExternalXlsxIssue, + type ExternalXlsxLimits, + type ExternalXlsxReturnResult, + type ExternalXlsxReturnUpdate, + type ExternalXlsxScalar, + type ExternalXlsxTransportConfiguration, +} from "./external-xlsx-contracts.js"; + +export type ExternalXlsxErrorCode = + | "invalid-file-extension" + | "file-too-large" + | "invalid-workbook" + | "worksheet-count" + | "worksheet-too-large" + | "invalid-layout" + | "header-not-found" + | "ambiguous-header" + | "required-formula-result-missing" + | "invalid-worksharing-id" + | "duplicate-worksharing-id" + | "return-circuit-column-missing" + | "unknown-worksharing-id"; + +export class ExternalXlsxError extends Error { + constructor( + public readonly code: ExternalXlsxErrorCode, + message: string + ) { + super(message); + this.name = "ExternalXlsxError"; + } +} + +export interface ParseExternalXlsxInput { + fileName: string; + bytes: Uint8Array; + configuration: ExternalXlsxTransportConfiguration; + limits?: ExternalXlsxLimits; +} + +export interface CreateExternalXlsxReturnInput extends ParseExternalXlsxInput { + selectedWorksharingIds?: readonly string[]; + updates?: readonly ExternalXlsxReturnUpdate[]; +} + +export async function parseExternalXlsx( + input: ParseExternalXlsxInput +): Promise { + assertUpload(input.fileName, input.bytes); + assertConfiguration(input.configuration); + const workbook = await loadWorkbook(input.bytes); + const worksheet = getOnlyWorksheet(workbook); + assertWorksheetLimits( + worksheet, + input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS + ); + return parseWorksheet(worksheet, input.configuration); +} + +export async function createExternalXlsxReturn( + input: CreateExternalXlsxReturnInput +): Promise { + assertUpload(input.fileName, input.bytes); + assertConfiguration(input.configuration); + const workbook = await loadWorkbook(input.bytes); + const worksheet = getOnlyWorksheet(workbook); + assertWorksheetLimits( + worksheet, + input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS + ); + const document = parseWorksheet(worksheet, input.configuration); + const headerLookup = createHeaderLookup(worksheet, input.configuration); + const issues = [...document.issues]; + const returnColumns = resolveReturnColumns( + headerLookup, + input.configuration, + issues + ); + const worksharingIdColumn = headerLookup.get( + input.configuration.columns.worksharingId + )!; + const objectRows = document.rows.filter((row) => row.classification === "object"); + const objectRowById = new Map( + objectRows.map((row) => [ + scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null), + row.rowNumber, + ]) + ); + const selectedIds = input.selectedWorksharingIds + ? new Set(input.selectedWorksharingIds) + : new Set(objectRowById.keys()); + for (const worksharingId of selectedIds) { + if (!objectRowById.has(worksharingId)) { + throw new ExternalXlsxError( + "unknown-worksharing-id", + `Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}` + ); + } + } + const updates = new Map( + (input.updates ?? []).map((update) => [update.worksharingId, update.values]) + ); + for (const worksharingId of updates.keys()) { + if (!objectRowById.has(worksharingId)) { + throw new ExternalXlsxError( + "unknown-worksharing-id", + `Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}` + ); + } + } + + const selectedRows = objectRows.filter((row) => { + const id = scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null); + return selectedIds.has(id); + }); + const lastColumn = worksheet.columnCount; + const snapshots = selectedRows.map((row) => + captureRow(worksheet, row.rowNumber, lastColumn) + ); + snapshots.forEach((snapshot, index) => { + restoreRow( + worksheet, + input.configuration.layout.firstDataRow + index, + snapshot, + lastColumn + ); + const destinationRow = worksheet.getRow( + input.configuration.layout.firstDataRow + index + ); + const id = scalarToIdentity(destinationRow.getCell(worksharingIdColumn).value); + const values = updates.get(id); + if (!values) return; + for (const [field, value] of Object.entries(values) as [ + keyof typeof values, + ExternalXlsxScalar, + ][]) { + const columnNumber = returnColumns.get(field); + if (columnNumber !== undefined) { + destinationRow.getCell(columnNumber).value = value; + } + } + }); + + const firstRemovedRow = + input.configuration.layout.firstDataRow + snapshots.length; + const removeCount = Math.max(0, worksheet.rowCount - firstRemovedRow + 1); + // ExcelJS 4.4 does not shrink its internal row array when a multi-row + // splice removes the complete tail. Removing the tail one row at a time + // produces an actual OOXML deletion instead of serialized empty rows. + for (let index = 0; index < removeCount; index += 1) { + worksheet.spliceRows(worksheet.rowCount, 1); + } + const buffer = await workbook.xlsx.writeBuffer(); + return { + bytes: new Uint8Array(buffer), + exportedObjectCount: snapshots.length, + issues, + }; +} + +function parseWorksheet( + worksheet: Worksheet, + configuration: ExternalXlsxTransportConfiguration +): ExternalXlsxDocument { + const headerLookup = createHeaderLookup(worksheet, configuration); + const issues: ExternalXlsxIssue[] = []; + const worksharingIdColumn = headerLookup.get(configuration.columns.worksharingId)!; + const familyAndTypeColumn = headerLookup.get(configuration.columns.familyAndType)!; + const ifcGuidColumn = configuration.columns.ifcGuid + ? headerLookup.get(configuration.columns.ifcGuid) + : undefined; + const seenWorksharingIds = new Set(); + const seenIfcGuids = new Set(); + const rows = [] as ExternalXlsxDocument["rows"]; + + for ( + let rowNumber = configuration.layout.firstDataRow; + rowNumber <= worksheet.rowCount; + rowNumber += 1 + ) { + const row = worksheet.getRow(rowNumber); + const cells: ExternalXlsxCell[] = []; + for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) { + cells.push( + toExternalCell(row.getCell(columnNumber), rowNumber, columnNumber, issues) + ); + } + const worksharingIdCell = cells[worksharingIdColumn - 1]; + const familyAndTypeCell = cells[familyAndTypeColumn - 1]; + for (const [field, requiredCell] of [ + ["Worksharing-ID", worksharingIdCell], + ["Familie und Typ", familyAndTypeCell], + ] as const) { + if (requiredCell?.formula != null && !requiredCell.usedCachedFormulaValue) { + throw new ExternalXlsxError( + "required-formula-result-missing", + `Das Pflichtfeld ${field} hat in Zeile ${rowNumber} kein gespeichertes Formelergebnis.` + ); + } + } + const worksharingId = scalarToIdentity(worksharingIdCell?.value ?? null); + const familyAndType = scalarToIdentity(familyAndTypeCell?.value ?? null); + if (!worksharingId) { + const classification = familyAndType ? "suspect-object" : "passthrough"; + if (classification === "suspect-object") { + issues.push({ + code: "suspect-object-skipped", + message: `Zeile ${rowNumber} enthält Familie und Typ, aber keine Worksharing-ID, und wird übersprungen.`, + rowNumber, + }); + } + rows.push({ rowNumber, classification, cells }); + continue; + } + if (!isWorksharingId(worksharingId)) { + throw new ExternalXlsxError( + "invalid-worksharing-id", + `Zeile ${rowNumber} enthält eine ungültige Worksharing-ID.` + ); + } + if (seenWorksharingIds.has(worksharingId)) { + throw new ExternalXlsxError( + "duplicate-worksharing-id", + `Die Worksharing-ID kommt mehrfach vor: ${worksharingId}` + ); + } + seenWorksharingIds.add(worksharingId); + if (ifcGuidColumn !== undefined) { + const ifcGuid = scalarToIdentity(cells[ifcGuidColumn - 1]?.value ?? null); + if (ifcGuid) { + if (seenIfcGuids.has(ifcGuid)) { + issues.push({ + code: "duplicate-ifc-guid", + message: `IfcGUID kommt mehrfach vor: ${ifcGuid}`, + rowNumber, + columnNumber: ifcGuidColumn, + field: "ifcGuid", + }); + } + seenIfcGuids.add(ifcGuid); + } + } + rows.push({ rowNumber, classification: "object", cells }); + } + + const headerRow = worksheet.getRow(configuration.layout.headerRow); + const columns = [] as ExternalXlsxDocument["columns"]; + for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) { + const cell = headerRow.getCell(columnNumber); + columns.push({ + columnNumber, + header: scalarToIdentity(toScalar(cell.value).value), + comment: getCommentText(cell.note), + width: worksheet.getColumn(columnNumber).width ?? null, + }); + } + return { + sheetName: worksheet.name, + headerRowNumber: configuration.layout.headerRow, + firstDataRowNumber: configuration.layout.firstDataRow, + firstDataColumnNumber: configuration.layout.firstDataColumn, + columns, + rows, + issues, + }; +} + +function createHeaderLookup( + worksheet: Worksheet, + configuration: ExternalXlsxTransportConfiguration +) { + const headers = new Map(); + const row = worksheet.getRow(configuration.layout.headerRow); + for ( + let columnNumber = configuration.layout.firstDataColumn; + columnNumber <= worksheet.columnCount; + columnNumber += 1 + ) { + const header = scalarToIdentity(toScalar(row.getCell(columnNumber).value).value); + if (!header) continue; + headers.set(header, [...(headers.get(header) ?? []), columnNumber]); + } + const configured = Object.entries(configuration.columns) as [ + ExternalXlsxColumnKey, + string | null, + ][]; + const required = new Set(["worksharingId", "familyAndType"]); + const lookup = new Map(); + for (const [field, header] of configured) { + if (header === null) continue; + const matches = headers.get(header) ?? []; + if (matches.length > 1) { + throw new ExternalXlsxError( + "ambiguous-header", + `Die konfigurierte Kopfzelle ist nicht eindeutig: ${header}` + ); + } + if (matches.length === 0 && required.has(field)) { + throw new ExternalXlsxError( + "header-not-found", + `Die konfigurierte Pflichtspalte fehlt: ${header}` + ); + } + if (matches.length === 1) lookup.set(header, matches[0]); + } + return lookup; +} + +function resolveReturnColumns( + headerLookup: Map, + configuration: ExternalXlsxTransportConfiguration, + issues: ExternalXlsxIssue[] +) { + const result = new Map(); + const circuitFields: ("circuitCombined" | "boardName" | "circuitIdentifier")[] = + configuration.circuitMode === "combined" + ? ["circuitCombined"] + : ["boardName", "circuitIdentifier"]; + for (const field of circuitFields) { + const header = configuration.columns[field]; + const columnNumber = header === null ? undefined : headerLookup.get(header); + if (columnNumber === undefined) { + throw new ExternalXlsxError( + "return-circuit-column-missing", + `Die Stromkreisspalte des gewählten Modus fehlt: ${field}` + ); + } + result.set(field, columnNumber); + } + const optionalFields = [ + "designation", + "electricalPowerW", + "voltageV", + "currentA", + ] as const; + for (const field of optionalFields) { + const header = configuration.columns[field]; + const columnNumber = header === null ? undefined : headerLookup.get(header); + if (columnNumber === undefined) { + issues.push({ + code: "optional-return-column-missing", + message: `Die optionale Rückgabespalte fehlt und wird übersprungen: ${field}`, + field, + }); + } else { + result.set(field, columnNumber); + } + } + return result; +} + +function toExternalCell( + cell: ExcelJS.Cell, + rowNumber: number, + columnNumber: number, + issues: ExternalXlsxIssue[] +): ExternalXlsxCell { + const converted = toScalar(cell.value); + if (converted.formula !== null) { + issues.push({ + code: converted.usedCachedFormulaValue + ? "cached-formula-value-used" + : "formula-without-cached-value", + message: converted.usedCachedFormulaValue + ? `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} wird mit ihrem gespeicherten Ergebnis gelesen.` + : `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} hat kein nutzbares gespeichertes Ergebnis und bleibt leer.`, + rowNumber, + columnNumber, + }); + } + return { + columnNumber, + value: converted.value, + valueType: getScalarValueType(converted.value), + numberFormat: cell.numFmt || null, + formula: converted.formula, + usedCachedFormulaValue: converted.usedCachedFormulaValue, + }; +} + +function toScalar(value: CellValue): { + value: ExternalXlsxScalar; + formula: string | null; + usedCachedFormulaValue: boolean; +} { + if (value === null || value === undefined) { + return { value: null, formula: null, usedCachedFormulaValue: false }; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return { value, formula: null, usedCachedFormulaValue: false }; + } + if (value instanceof Date) { + return { value: value.toISOString(), formula: null, usedCachedFormulaValue: false }; + } + if ("formula" in value || "sharedFormula" in value) { + const formula = + ("formula" in value ? value.formula : value.sharedFormula) ?? ""; + const result = "result" in value ? value.result : undefined; + if ( + typeof result === "string" || + typeof result === "number" || + typeof result === "boolean" + ) { + return { value: result, formula, usedCachedFormulaValue: true }; + } + return { value: null, formula, usedCachedFormulaValue: false }; + } + if ("error" in value) { + return { value: null, formula: null, usedCachedFormulaValue: false }; + } + if ("richText" in value) { + return { + value: value.richText.map((part) => part.text).join(""), + formula: null, + usedCachedFormulaValue: false, + }; + } + if ("text" in value) { + return { value: value.text, formula: null, usedCachedFormulaValue: false }; + } + return { value: null, formula: null, usedCachedFormulaValue: false }; +} + +function captureRow(worksheet: Worksheet, rowNumber: number, lastColumn: number) { + const row = worksheet.getRow(rowNumber); + return { + height: row.height, + cells: Array.from({ length: lastColumn }, (_, index) => { + const cell = row.getCell(index + 1); + const converted = toScalar(cell.value); + return { + value: converted.formula === null ? cloneValue(cell.value) : converted.value, + style: structuredClone(cell.style), + note: cell.note === undefined ? undefined : structuredClone(cell.note), + }; + }), + }; +} + +function restoreRow( + worksheet: Worksheet, + rowNumber: number, + snapshot: ReturnType, + lastColumn: number +) { + const row = worksheet.getRow(rowNumber); + row.height = snapshot.height; + for (let columnNumber = 1; columnNumber <= lastColumn; columnNumber += 1) { + const cell = row.getCell(columnNumber); + const source = snapshot.cells[columnNumber - 1]; + cell.value = source.value; + cell.style = structuredClone(source.style); + if (source.note === undefined) { + // ExcelJS has no public comment-removal API. Its note setter creates a + // Note even for undefined, which would add an empty comment to the cell. + (cell as unknown as { _comment?: unknown })._comment = undefined; + } else { + cell.note = source.note; + } + } +} + +function cloneValue(value: CellValue): CellValue { + if (value instanceof Date) return new Date(value); + if (value && typeof value === "object") return structuredClone(value); + return value; +} + +function getCommentText(note: ExcelJS.Cell["note"]): string | null { + if (note === undefined) return null; + if (typeof note === "string") return note; + return note.texts?.map((part) => part.text).join("") ?? null; +} + +function getScalarValueType(value: ExternalXlsxScalar): ExternalXlsxCell["valueType"] { + if (value === null) return "empty"; + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + return "boolean"; +} + +function scalarToIdentity(value: ExternalXlsxScalar | CellValue): string { + const scalar = + typeof value === "object" && value !== null ? toScalar(value).value : value; + return typeof scalar === "string" ? scalar : scalar === null ? "" : String(scalar); +} + +function isWorksharingId(value: string) { + return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/.test( + value + ); +} + +function assertUpload(fileName: string, bytes: Uint8Array) { + if (!fileName.toLocaleLowerCase("en-US").endsWith(".xlsx")) { + throw new ExternalXlsxError( + "invalid-file-extension", + "Es werden ausschließlich .xlsx-Dateien unterstützt." + ); + } + if (bytes.byteLength > MAX_EXTERNAL_XLSX_BYTES) { + throw new ExternalXlsxError( + "file-too-large", + "Die XLSX-Datei darf höchstens 10 MiB groß sein." + ); + } +} + +async function loadWorkbook(bytes: Uint8Array) { + const workbook = new ExcelJS.Workbook(); + try { + await workbook.xlsx.load( + Buffer.from(bytes) as unknown as Parameters[0] + ); + } catch (error) { + throw new ExternalXlsxError( + "invalid-workbook", + `Die XLSX-Datei konnte nicht gelesen werden: ${error instanceof Error ? error.message : "Unbekannter Fehler"}` + ); + } + return workbook; +} + +function getOnlyWorksheet(workbook: ExcelJS.Workbook) { + if (workbook.worksheets.length !== 1) { + throw new ExternalXlsxError( + "worksheet-count", + "Die XLSX-Datei muss genau ein Arbeitsblatt enthalten." + ); + } + return workbook.worksheets[0]; +} + +function assertWorksheetLimits(worksheet: Worksheet, limits: ExternalXlsxLimits) { + const rowCount = worksheet.rowCount; + const columnCount = worksheet.columnCount; + if ( + rowCount > limits.maxRows || + columnCount > limits.maxColumns || + rowCount * columnCount > limits.maxCells + ) { + throw new ExternalXlsxError( + "worksheet-too-large", + `Das Arbeitsblatt überschreitet die Grenzen (${rowCount} Zeilen, ${columnCount} Spalten).` + ); + } +} + +function assertConfiguration(configuration: ExternalXlsxTransportConfiguration) { + const { headerRow, firstDataRow, firstDataColumn } = configuration.layout; + if ( + !Number.isInteger(headerRow) || + headerRow < 1 || + !Number.isInteger(firstDataRow) || + firstDataRow <= headerRow || + !Number.isInteger(firstDataColumn) || + firstDataColumn < 1 + ) { + throw new ExternalXlsxError( + "invalid-layout", + "Kopfzeile, erste Datenzeile oder erste Datenspalte sind ungültig." + ); + } + if (!configuration.columns.worksharingId || !configuration.columns.familyAndType) { + throw new ExternalXlsxError( + "invalid-layout", + "Worksharing-ID und Familie und Typ müssen konfiguriert sein." + ); + } +} diff --git a/tests/external-xlsx-transport.test.ts b/tests/external-xlsx-transport.test.ts new file mode 100644 index 0000000..32cd784 --- /dev/null +++ b/tests/external-xlsx-transport.test.ts @@ -0,0 +1,267 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import ExcelJS from "exceljs"; +import { + createExternalXlsxReturn, + ExternalXlsxError, + parseExternalXlsx, +} from "../src/external-model/xlsx/external-xlsx-transport.js"; +import { + createRevitXlsxFixture, + externalXlsxTestConfiguration, + firstWorksharingId, + secondWorksharingId, +} from "./fixtures/revit-xlsx-fixtures.js"; + +describe("external XLSX transport", () => { + it("reads typed cells and classifies ID-less rows without blocking summaries", async () => { + const document = await parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture(), + configuration: externalXlsxTestConfiguration, + }); + + assert.equal(document.sheetName, "Linear Export"); + assert.equal(document.headerRowNumber, 1); + assert.equal(document.firstDataRowNumber, 3); + assert.equal(document.firstDataColumnNumber, 1); + assert.deepEqual( + document.rows.map((row) => row.classification), + ["object", "object", "suspect-object", "passthrough"] + ); + assert.equal(document.rows[0].cells[5].value, 2); + assert.equal(document.rows[0].cells[15].value, true); + assert.equal(document.columns[0].comment, "Kommentar Worksharing-ID"); + assert.equal(document.columns[0].width, 12); + assert.deepEqual( + document.issues.map((issue) => issue.code), + ["suspect-object-skipped"] + ); + }); + + it("resolves editable layout positions without a separate ID column", async () => { + const configuration = { + ...externalXlsxTestConfiguration, + layout: { headerRow: 2, firstDataRow: 5, firstDataColumn: 2 }, + }; + const document = await parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture(configuration.layout), + configuration, + }); + + assert.equal(document.headerRowNumber, 2); + assert.equal(document.firstDataRowNumber, 5); + assert.equal(document.firstDataColumnNumber, 2); + assert.equal(document.columns[1].header, "Worksharing-ID"); + assert.equal(document.rows[0].cells[1].value, firstWorksharingId); + assert.deepEqual( + document.rows.map((row) => row.classification), + ["object", "object", "suspect-object", "passthrough"] + ); + }); + + it("compares Worksharing-ID and IfcGUID exactly and case-sensitively", async () => { + const lowerCaseId = secondWorksharingId.toLowerCase(); + const document = await parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture({ + secondWorksharingId: lowerCaseId, + firstIfcGuid: "IfcGUID", + secondIfcGuid: "IFCGUID", + }), + configuration: externalXlsxTestConfiguration, + }); + assert.equal( + document.rows.filter((row) => row.classification === "object").length, + 2 + ); + assert.equal( + document.issues.some((issue) => issue.code === "duplicate-ifc-guid"), + false + ); + + await assert.rejects( + parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture({ duplicateWorksharingId: true }), + configuration: externalXlsxTestConfiguration, + }), + isXlsxError("duplicate-worksharing-id") + ); + + const duplicateIfcDocument = await parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture({ + firstIfcGuid: "same", + secondIfcGuid: "same", + }), + configuration: externalXlsxTestConfiguration, + }); + assert.equal( + duplicateIfcDocument.issues.filter( + (issue) => issue.code === "duplicate-ifc-guid" + ).length, + 1 + ); + }); + + it("uses cached formula values without evaluating formulas", async () => { + const document = await parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture({ includeFormula: true }), + configuration: externalXlsxTestConfiguration, + }); + const powerCell = document.rows[0].cells[11]; + assert.equal(powerCell.value, 250); + assert.equal(powerCell.formula, "125*2"); + assert.equal(powerCell.usedCachedFormulaValue, true); + assert.equal( + document.issues.some( + (issue) => issue.code === "cached-formula-value-used" + ), + true + ); + }); + + it("blocks a required formula without a cached result", async () => { + await assert.rejects( + parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: await createRevitXlsxFixture({ + includeRequiredFormulaWithoutResult: true, + }), + configuration: externalXlsxTestConfiguration, + }), + isXlsxError("required-formula-result-missing") + ); + }); + + it("writes selected object rows in source order and preserves workbook structure", async () => { + const source = await createRevitXlsxFixture({ includeFormula: true }); + const result = await createExternalXlsxReturn({ + fileName: "linear.xlsx", + bytes: source, + configuration: externalXlsxTestConfiguration, + selectedWorksharingIds: [secondWorksharingId, firstWorksharingId], + updates: [ + { + worksharingId: firstWorksharingId, + values: { + circuitCombined: "UV99-3F7", + electricalPowerW: 321.5, + voltageV: 400, + currentA: 0.46, + }, + }, + ], + }); + + assert.equal(result.exportedObjectCount, 2); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(result.bytes as never); + const worksheet = workbook.worksheets[0]; + assert.equal(worksheet.name, "Linear Export"); + assert.equal(worksheet.rowCount, 4); + assert.equal(worksheet.getCell("A3").value, firstWorksharingId); + assert.equal(worksheet.getCell("A4").value, secondWorksharingId); + assert.equal(worksheet.getCell("H3").value, "UV99-3F7"); + assert.equal(worksheet.getCell("L3").value, 321.5); + assert.equal(worksheet.getCell("M3").value, 400); + assert.equal(worksheet.getCell("N3").value, 0.46); + assert.equal(worksheet.getCell("P4").value, "bleibt"); + assert.equal(worksheet.getCell("L3").numFmt, "0.00"); + assert.equal(worksheet.getColumn(1).width, 12); + assert.equal( + typeof worksheet.getCell("A1").note === "string" + ? worksheet.getCell("A1").note + : worksheet.getCell("A1").note?.texts?.[0]?.text, + "Kommentar Worksharing-ID" + ); + const commentedCells: string[] = []; + worksheet.eachRow({ includeEmpty: true }, (row) => { + row.eachCell({ includeEmpty: true }, (cell) => { + if (cell.note !== undefined) commentedCells.push(cell.address); + }); + }); + assert.deepEqual( + commentedCells, + Array.from({ length: 16 }, (_, index) => + worksheet.getCell(1, index + 1).address + ) + ); + }); + + it("warns for missing optional return columns and blocks missing circuit columns", async () => { + const source = await createRevitXlsxFixture(); + const optionalMissing = { + ...externalXlsxTestConfiguration, + columns: { + ...externalXlsxTestConfiguration.columns, + designation: "Nicht vorhanden", + }, + }; + const result = await createExternalXlsxReturn({ + fileName: "linear.xlsx", + bytes: source, + configuration: optionalMissing, + }); + assert.equal( + result.issues.some( + (issue) => + issue.code === "optional-return-column-missing" && + issue.field === "designation" + ), + true + ); + + await assert.rejects( + createExternalXlsxReturn({ + fileName: "linear.xlsx", + bytes: source, + configuration: { + ...externalXlsxTestConfiguration, + columns: { + ...externalXlsxTestConfiguration.columns, + circuitCombined: "Nicht vorhanden", + }, + }, + }), + isXlsxError("return-circuit-column-missing") + ); + }); + + it("enforces extension, byte size and post-read sheet limits", async () => { + const source = await createRevitXlsxFixture(); + await assert.rejects( + parseExternalXlsx({ + fileName: "linear.xlsm", + bytes: source, + configuration: externalXlsxTestConfiguration, + }), + isXlsxError("invalid-file-extension") + ); + await assert.rejects( + parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: new Uint8Array(10 * 1024 * 1024 + 1), + configuration: externalXlsxTestConfiguration, + }), + isXlsxError("file-too-large") + ); + await assert.rejects( + parseExternalXlsx({ + fileName: "linear.xlsx", + bytes: source, + configuration: externalXlsxTestConfiguration, + limits: { maxRows: 3, maxColumns: 100, maxCells: 1_000 }, + }), + isXlsxError("worksheet-too-large") + ); + }); +}); + +function isXlsxError(code: ExternalXlsxError["code"]) { + return (error: unknown) => + error instanceof ExternalXlsxError && error.code === code; +} diff --git a/tests/fixtures/revit-xlsx-fixtures.ts b/tests/fixtures/revit-xlsx-fixtures.ts new file mode 100644 index 0000000..48e3cbd --- /dev/null +++ b/tests/fixtures/revit-xlsx-fixtures.ts @@ -0,0 +1,134 @@ +import ExcelJS from "exceljs"; +import { + createDefaultExternalXlsxTransportConfiguration, + type ExternalXlsxTransportConfiguration, +} from "../../src/external-model/xlsx/external-xlsx-contracts.js"; + +export const firstWorksharingId = + "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000001"; +export const secondWorksharingId = + "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000002"; + +export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration = + createDefaultExternalXlsxTransportConfiguration(); + +export async function createRevitXlsxFixture(options?: { + duplicateWorksharingId?: boolean; + secondWorksharingId?: string; + firstIfcGuid?: string; + secondIfcGuid?: string; + includeFormula?: boolean; + includeRequiredFormulaWithoutResult?: boolean; + headerRow?: number; + firstDataRow?: number; + firstDataColumn?: number; +}) { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Linear Export"); + const headerRow = options?.headerRow ?? 1; + const firstDataRow = options?.firstDataRow ?? 3; + const firstDataColumn = options?.firstDataColumn ?? 1; + const headers = [ + "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", + "Unbekannt", + ]; + headers.forEach((header, index) => { + const columnNumber = firstDataColumn + index; + const cell = worksheet.getCell(headerRow, columnNumber); + cell.value = header; + cell.note = `Kommentar ${header}`; + cell.font = { bold: true }; + worksheet.getColumn(columnNumber).width = 12 + index; + }); + + setRowValues(worksheet, firstDataRow, firstDataColumn, [ + firstWorksharingId, + "Steckdose", + "01.101", + "Büro", + "Steckdose: Standard", + 2, + "Quelle A", + "UV01-2F1", + "UV01", + "-2F1", + "Arbeitsplatz", + 250, + 230, + 1.09, + options?.firstIfcGuid ?? "IfcCaseSensitive", + true, + ]); + worksheet.getCell(firstDataRow, firstDataColumn + 11).numFmt = "0.00"; + worksheet.getCell(firstDataRow, firstDataColumn + 12).numFmt = "0"; + worksheet.getCell(firstDataRow, firstDataColumn + 13).numFmt = "0.00"; + if (options?.includeFormula) { + worksheet.getCell(firstDataRow, firstDataColumn + 11).value = { + formula: "125*2", + result: 250, + }; + } + if (options?.includeRequiredFormulaWithoutResult) { + worksheet.getCell(firstDataRow, firstDataColumn).value = { formula: "\"\"" }; + } + + setRowValues(worksheet, firstDataRow + 1, firstDataColumn, [ + options?.duplicateWorksharingId + ? firstWorksharingId + : options?.secondWorksharingId ?? secondWorksharingId, + "Leuchte", + null, + null, + "Leuchte: Decke", + 1, + "Quelle B", + "UV01-1F1", + "UV01", + "-1F1", + "Beleuchtung", + 42.5, + 230, + 0.18, + options?.secondIfcGuid ?? "IfcCaseSensitive2", + "bleibt", + ]); + worksheet.getCell(firstDataRow + 1, firstDataColumn + 11).numFmt = "0.00"; + setRowValues(worksheet, firstDataRow + 2, firstDataColumn, [ + null, + "verdächtig", + null, + null, + "Gerät: Ohne ID", + ]); + setRowValues(worksheet, firstDataRow + 3, firstDataColumn, [ + null, + "Summe: 292,5 W", + ]); + + return new Uint8Array(await workbook.xlsx.writeBuffer()); +} + +function setRowValues( + worksheet: ExcelJS.Worksheet, + rowNumber: number, + firstColumn: number, + values: ExcelJS.CellValue[] +) { + values.forEach((value, index) => { + worksheet.getCell(rowNumber, firstColumn + index).value = value; + }); +} From 1cfeb06ec780da9efe02fe484ba30ee9dec12e7f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 08:46:07 +0200 Subject: [PATCH 4/5] Replace Revit CSV backend with Linear XLSX --- AGENTS.md | 32 +- README.md | 17 +- docs/current-architecture.md | 38 +- .../revit-excel-integration-requirements.md | 3 + docs/spec/revit-excel-phase-14-plan.md | 4 +- package.json | 2 +- scripts/run-migrations.js | 64 +- .../0014_linear_xlsx_external_model.sql | 97 + src/db/migrations/meta/0014_snapshot.json | 3100 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + ...onfiguration-project-command.repository.ts | 105 - ...itial-import-project-command.repository.ts | 252 -- ...onfiguration-project-command.repository.ts | 79 + ...xternal-model-configuration.repository.ts} | 17 +- .../external-model-state.repository.ts | 10 +- .../external-object-assignment.persistence.ts | 8 +- ...roject-state-restore-command.repository.ts | 19 +- .../project-state-snapshot.persistence.ts | 10 +- .../project-transfer.repository.ts | 13 +- src/db/schema/external-csv-configurations.ts | 22 - src/db/schema/external-import-batches.ts | 18 +- .../schema/external-model-configurations.ts | 21 + src/db/schema/external-model-objects.ts | 18 +- src/db/schema/external-model-sources.ts | 7 +- ...csv-configuration-project-command.model.ts | 94 - ...al-initial-import-project-command.model.ts | 134 - ...del-configuration-project-command.model.ts | 88 + .../models/project-state-snapshot.model.ts | 295 +- src/domain/models/project-transfer.model.ts | 64 +- ...csv-configuration-project-command.store.ts | 19 - .../external-csv-configuration.reader.ts | 10 - ...al-initial-import-project-command.store.ts | 19 - ...del-configuration-project-command.store.ts | 19 + .../external-model-configuration.reader.ts | 10 + .../services/project-command.service.ts | 51 +- .../external-circuit-list-objects.ts | 88 - .../application/external-csv-preview.ts | 91 - .../external-initial-import-plan.ts | 164 - .../external-initial-import-target.ts | 130 - .../external-model-configuration.ts | 246 ++ .../csv/external-csv-transport.ts | 356 -- .../domain/external-model-contracts.ts | 45 +- .../domain/external-model-matching.ts | 147 - src/frontend/types.ts | 78 +- .../composition/application-repositories.ts | 6 +- .../composition/project-command-stores.ts | 12 +- .../controllers/external-csv.controller.ts | 298 -- src/server/routes/project.routes.ts | 23 - src/shared/validation/external-csv.schemas.ts | 40 - tests/external-circuit-list-objects.test.ts | 105 - tests/external-csv-api-contract.test.ts | 83 - ...uration-project-command.repository.test.ts | 244 -- tests/external-csv-preview.test.ts | 56 - tests/external-csv-transport.test.ts | 146 - tests/external-initial-import-plan.test.ts | 149 - ...-import-project-command.repository.test.ts | 297 -- tests/external-model-configuration.test.ts | 132 + tests/external-model-foundation.test.ts | 431 --- tests/external-model-xlsx-foundation.test.ts | 87 + ...ignment-project-command.repository.test.ts | 25 +- ...near-xlsx-external-model-migration.test.ts | 115 + tests/project-command.service.test.ts | 56 +- ...ow-sync-project-command.repository.test.ts | 25 +- tests/project-snapshot.repository.test.ts | 2 + tests/project-state-snapshot.test.ts | 38 +- 65 files changed, 4764 insertions(+), 3717 deletions(-) create mode 100644 src/db/migrations/0014_linear_xlsx_external_model.sql create mode 100644 src/db/migrations/meta/0014_snapshot.json delete mode 100644 src/db/repositories/external-csv-configuration-project-command.repository.ts delete mode 100644 src/db/repositories/external-initial-import-project-command.repository.ts create mode 100644 src/db/repositories/external-model-configuration-project-command.repository.ts rename src/db/repositories/{external-csv-configuration.repository.ts => external-model-configuration.repository.ts} (51%) delete mode 100644 src/db/schema/external-csv-configurations.ts create mode 100644 src/db/schema/external-model-configurations.ts delete mode 100644 src/domain/models/external-csv-configuration-project-command.model.ts delete mode 100644 src/domain/models/external-initial-import-project-command.model.ts create mode 100644 src/domain/models/external-model-configuration-project-command.model.ts delete mode 100644 src/domain/ports/external-csv-configuration-project-command.store.ts delete mode 100644 src/domain/ports/external-csv-configuration.reader.ts delete mode 100644 src/domain/ports/external-initial-import-project-command.store.ts create mode 100644 src/domain/ports/external-model-configuration-project-command.store.ts create mode 100644 src/domain/ports/external-model-configuration.reader.ts delete mode 100644 src/external-model/application/external-circuit-list-objects.ts delete mode 100644 src/external-model/application/external-csv-preview.ts delete mode 100644 src/external-model/application/external-initial-import-plan.ts delete mode 100644 src/external-model/application/external-initial-import-target.ts create mode 100644 src/external-model/configuration/external-model-configuration.ts delete mode 100644 src/external-model/csv/external-csv-transport.ts delete mode 100644 src/external-model/domain/external-model-matching.ts delete mode 100644 src/server/controllers/external-csv.controller.ts delete mode 100644 src/shared/validation/external-csv.schemas.ts delete mode 100644 tests/external-circuit-list-objects.test.ts delete mode 100644 tests/external-csv-api-contract.test.ts delete mode 100644 tests/external-csv-configuration-project-command.repository.test.ts delete mode 100644 tests/external-csv-preview.test.ts delete mode 100644 tests/external-csv-transport.test.ts delete mode 100644 tests/external-initial-import-plan.test.ts delete mode 100644 tests/external-initial-import-project-command.repository.test.ts create mode 100644 tests/external-model-configuration.test.ts delete mode 100644 tests/external-model-foundation.test.ts create mode 100644 tests/external-model-xlsx-foundation.test.ts create mode 100644 tests/linear-xlsx-external-model-migration.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7c0a120..c95a271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,10 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 9 additionally stores +Current snapshot schema version 10 stores the Linear-XLSX configuration and +external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded; +versions containing legacy CSV configuration or external CSV state are rejected +with a German migration error. Version 9 additionally stores `feedingDistributionBoardId` and `feedingCircuitId` on distribution boards; versions 1 to 8 are upgraded with both references set to `null`. Version 8 stores the optional net room area `rooms[].areaM2` (see @@ -307,8 +310,8 @@ distribution-board info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`; all older supported versions are upgraded with `null` for these fields. -Stored named and automatic snapshots of every supported version (1 to 9) -remain restorable; they are upgraded while parsing. Version 5 stores +Stored named and automatic snapshots of versions 1 to 9 remain restorable when +they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed external-model source, import batches, room mappings and objects. Version 3 @@ -320,17 +323,17 @@ supported versions contain circuit-group identity, distribution-board components separate circuit/component protection records. Portable duplicate imports remap component ids and protection-owner references together with the existing project graph. -The current runtime still contains the legacy Revit-CSV/IfcGUID foundation: -project configuration, preview, confirmed initial import, external objects and -the three row-assignment commands. It remains the audited starting point, not -the Phase-14 target. Do not extend its CSV transport or IFCGUID identity model. -The approved cutover is Linear XLSX with exact case-sensitive Worksharing-ID -identity. See `docs/spec/revit-excel-integration-requirements.md` and +R3 replaced the runtime configuration and persistence foundation with Linear +XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops +the old development-only external CSV data after resetting linked row totals to +their manual share. Snapshot schema 10, restore and project transfer use the +new state; non-empty legacy CSV snapshots and legacy CSV history entries are +rejected with a German error. The backend CSV parser and routes are removed. +The old project-page frontend remains only until its replacement in R4; do not +reconnect it to runtime CSV endpoints. See +`docs/spec/revit-excel-integration-requirements.md` and `docs/spec/revit-excel-phase-14-plan.md`. -During R1/R2 no database or runtime schema changes are made. R3 will replace -the legacy configuration and transport additively, reject non-empty legacy CSV -snapshot/history state with a German error, and introduce snapshot schema 10. The target configuration has no independent version counter; `expectedRevision` invalidates stale plans, and each import batch stores a configuration copy. Only the newest import batch retains workbook bytes and the full matrix; older @@ -582,9 +585,8 @@ Critical target rules: produce warnings and are skipped. Only missing circuit columns for the selected combined/separate mode block export. - Missing GZF defaults visibly to 1 without separate confirmation. -- R2 must generate a changed return workbook from the local sample. The user - tests it in Linear before R3. If Linear rejects it, propose direct OOXML - modification of the original workbook before starting R3. +- The corrected R2 return workbook was successfully imported into Linear on + 2026-09-30; R3 is complete and R4 is the next package. - All confirmed writes use typed project commands with `expectedRevision`, complete inverse data and the shared project-command transaction boundary. - Do not add automatic circuit planning, Revit circuit adoption, multi-source diff --git a/README.md b/README.md index 3490332..b8b9c3e 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,11 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind -vorhanden. Die bisherige Revit-CSV-/IfcGUID-Grundlage mit Vorschau und -bestätigtem Erstimport ist noch im Code vorhanden. Der freigegebene -Phase-14-Plan ersetzt sie schrittweise durch den XLSX-Austausch des -Linear-Plugins mit Worksharing-ID als externem Schlüssel. R0 ist abgeschlossen; -R1 aktualisiert die Dokumentation. Editorzuordnung, Folgeimport und Rückexport -sind noch nicht als neuer Excel-Pfad ausgeliefert. +vorhanden. Die Backend- und Persistenzgrundlage der bisherigen +Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R3 sind abgeschlossen; +die eigene Revit-Seite, Erstimport, Editorzuordnung, Rückgabe und Folgeimport +folgen in R4 bis R10. ## Unterstützter Arbeitsablauf @@ -41,9 +40,9 @@ sind noch nicht als neuer Excel-Pfad ausgeliefert. - grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät übernehmen -- den vorhandenen Legacy-Revit-CSV-Erstimport verwenden; der Zielpfad über - Linear-XLSX, eigene Revit-Seite, Editorzuordnung, Rückgabe und Folgeimport - wird gemäß Phase-14-Plan umgesetzt +- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite, + Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß + Phase-14-Plan in den nächsten Paketen angebunden ## Technik diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 8993974..846debb 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -510,6 +510,9 @@ Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie. +Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den +externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden +hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt. Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche @@ -520,9 +523,9 @@ Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des Projekts; ältere Versionen werden mit `null` hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und -enthält bereits den vollständigen externen Modellzustand -aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die -projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, +enthielt bereits den damaligen externen CSV-Modellzustand +aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die +damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide @@ -640,7 +643,34 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. -## Revit-Anbindung: Legacy-Ausgangslage und freigegebener Excel-Cutover +## Revit-Anbindung: Linear-XLSX-Grundlage nach R3 + +R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014` +entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte +Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue +`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler; +Änderungen laufen als Projekt-Commands über `expectedRevision`. + +Externe Objekte werden je Quelle durch `worksharing_id` exakt und +case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig. +Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf +Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur +Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und +Projekttransfer verwenden diesen Zustand und remappen alle internen +Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9 +werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem +CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung +abgelehnt. + +Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser, +seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte +Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch +die Route `/projects/:projectId/revit` ersetzt. + +### Historische Ausgangslage vor R3 + +Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle +Laufzeitdokumentation mehr. Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md index ff580ec..29a4aaf 100644 --- a/docs/spec/revit-excel-integration-requirements.md +++ b/docs/spec/revit-excel-integration-requirements.md @@ -386,6 +386,9 @@ erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach erfolgreichem Linear-Rückimport beginnen. +Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear +eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben. + Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index bb00053..4004eb3 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -10,8 +10,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | --- | --- | --- | | R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. | | R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | -| R2 | implementiert, Linear-Abnahme ausstehend | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt. R3 bleibt bis zum erfolgreichen Linear-Test gesperrt. | -| R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. | +| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. | +| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. | | R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | | R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | | R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | diff --git a/package.json b/package.json index 1c035ba..17837f1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test": "node scripts/run-tests.mjs", "test:watch": "node scripts/run-tests.mjs --watch", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", + "db:migrate": "node scripts/run-migrations.js", "db:backup": "tsx scripts/db-backup.ts", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts" diff --git a/scripts/run-migrations.js b/scripts/run-migrations.js index ebbed8a..3a6fc84 100644 --- a/scripts/run-migrations.js +++ b/scripts/run-migrations.js @@ -4,13 +4,65 @@ const Database = require('better-sqlite3'); const fs = require('node:fs'); const path = require('node:path'); -const dataDir = path.resolve('data'); +const dataDir = path.resolve( + process.env.LEISTUNGSBILANZ_DATA_DIR || 'data' +); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); -const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db')); -const db = drizzle(sqlite); +const databasePath = path.join(dataDir, 'leistungsbilanz.db'); -migrate(db, { migrationsFolder: path.resolve('src/db/migrations') }); -sqlite.close(); +async function run() { + const sqlite = new Database(databasePath); + try { + if (requiresLinearXlsxBackup(sqlite)) { + const backupDir = path.join(dataDir, 'backups'); + fs.mkdirSync(backupDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = path.join( + backupDir, + `leistungsbilanz-before-linear-xlsx-${timestamp}.db` + ); + await sqlite.backup(backupPath); + verifyBackup(backupPath); + console.log(`Verified pre-migration backup created: ${backupPath}`); + } -console.log('Migrations applied'); + migrate(drizzle(sqlite), { + migrationsFolder: path.resolve('src/db/migrations'), + }); + } finally { + sqlite.close(); + } + console.log('Migrations applied'); +} + +function requiresLinearXlsxBackup(sqlite) { + const tables = new Set( + sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name) + ); + return tables.has('external_csv_configurations') && + !tables.has('external_model_configurations'); +} + +function verifyBackup(backupPath) { + const backup = new Database(backupPath, { readonly: true, fileMustExist: true }); + try { + const integrity = backup.pragma('integrity_check', { simple: true }); + const foreignKeyViolations = backup.pragma('foreign_key_check'); + if (integrity !== 'ok' || foreignKeyViolations.length > 0) { + throw new Error( + `Pre-migration backup verification failed: integrity=${integrity}, ` + + `foreign-key violations=${foreignKeyViolations.length}` + ); + } + } finally { + backup.close(); + } +} + +run().catch((error) => { + console.error('Migration failed:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/db/migrations/0014_linear_xlsx_external_model.sql b/src/db/migrations/0014_linear_xlsx_external_model.sql new file mode 100644 index 0000000..cb8546b --- /dev/null +++ b/src/db/migrations/0014_linear_xlsx_external_model.sql @@ -0,0 +1,97 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +UPDATE `circuit_device_rows` +SET `quantity` = `manual_quantity` +WHERE EXISTS ( + SELECT 1 + FROM `external_model_objects` + WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id` +);--> statement-breakpoint +DROP TABLE `external_model_objects`;--> statement-breakpoint +DROP TABLE `external_room_mappings`;--> statement-breakpoint +DROP TABLE `external_import_batches`;--> statement-breakpoint +DROP TABLE `external_model_sources`;--> statement-breakpoint +DROP TABLE `external_csv_configurations`;--> statement-breakpoint +CREATE TABLE `external_model_configurations` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `configuration` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint +CREATE TABLE `external_model_sources` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `source_type` text DEFAULT 'revit_xlsx' NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint +CREATE TABLE `external_import_batches` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `import_kind` text NOT NULL, + `imported_at_iso` text NOT NULL, + `file_name` text NOT NULL, + `sha256` text NOT NULL, + `object_count` integer NOT NULL, + `applied_project_revision` integer, + `configuration_snapshot` text, + `original_bytes` blob, + `document` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint +CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint +CREATE TABLE `external_room_mappings` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `normalized_source_room_key` text NOT NULL, + `source_floor_name` text, + `source_room_number` text NOT NULL, + `source_room_name` text NOT NULL, + `room_id` text, + `default_distribution_board_id` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null +);--> statement-breakpoint +CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint +CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint +CREATE TABLE `external_model_objects` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `worksharing_id` text NOT NULL, + `ifc_guid` text, + `device_type_id` text NOT NULL, + `last_seen_import_batch_id` text NOT NULL, + `last_accepted_import_batch_id` text NOT NULL, + `accepted_source_values` text NOT NULL, + `planning_values` text NOT NULL, + `overridden_fields` text NOT NULL, + `external_room_mapping_id` text, + `distribution_board_id` text, + `linked_project_device_id` text, + `circuit_device_row_id` text, + `presence_status` text DEFAULT 'present' NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null +);--> statement-breakpoint +CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint +CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/src/db/migrations/meta/0014_snapshot.json b/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..dc4c096 --- /dev/null +++ b/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,3100 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b443af16-e0aa-459a-9443-62a7f8c6b6dd", + "prevId": "a967fdfb-171b-4f1d-aa0c-f7124e28fba3", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feeding_distribution_board_id": { + "name": "feeding_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feeding_circuit_id": { + "name": "feeding_circuit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "distribution_boards_feeding_board_idx": { + "name": "distribution_boards_feeding_board_idx", + "columns": [ + "feeding_distribution_board_id" + ], + "isUnique": false + }, + "distribution_boards_feeding_circuit_idx": { + "name": "distribution_boards_feeding_circuit_idx", + "columns": [ + "feeding_circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk": { + "name": "distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "distribution_boards", + "columnsFrom": [ + "feeding_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "distribution_boards_feeding_circuit_id_circuits_id_fk": { + "name": "distribution_boards_feeding_circuit_id_circuits_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "circuits", + "columnsFrom": [ + "feeding_circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_count": { + "name": "object_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_configurations": { + "name": "external_model_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_model_configurations_project_id_unique": { + "name": "external_model_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_configurations_project_id_projects_id_fk": { + "name": "external_model_configurations_project_id_projects_id_fk", + "tableFrom": "external_model_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worksharing_id": { + "name": "worksharing_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_type_id": { + "name": "device_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_worksharing_id_unique": { + "name": "external_model_objects_source_worksharing_id_unique", + "columns": [ + "source_id", + "worksharing_id" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_project_device_type_idx": { + "name": "external_model_objects_project_device_type_idx", + "columns": [ + "project_id", + "device_type_id" + ], + "isUnique": false + }, + "external_model_objects_external_room_mapping_idx": { + "name": "external_model_objects_external_room_mapping_idx", + "columns": [ + "external_room_mapping_id" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_linked_project_device_idx": { + "name": "external_model_objects_linked_project_device_idx", + "columns": [ + "linked_project_device_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_xlsx'" + } + }, + "indexes": { + "external_model_sources_project_unique": { + "name": "external_model_sources_project_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 2a2e076..0f6c303 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1790624623732, "tag": "0013_users", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1790748107566, + "tag": "0014_linear_xlsx_external_model", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/external-csv-configuration-project-command.repository.ts b/src/db/repositories/external-csv-configuration-project-command.repository.ts deleted file mode 100644 index c7d7bf7..0000000 --- a/src/db/repositories/external-csv-configuration-project-command.repository.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { eq } from "drizzle-orm"; -import { - assertExternalCsvConfigurationUpdateProjectCommand, - createExternalCsvConfigurationUpdateProjectCommand, - type ExternalCsvConfigurationSnapshot, -} from "../../domain/models/external-csv-configuration-project-command.model.js"; -import type { - ExecuteExternalCsvConfigurationCommandInput, - ExternalCsvConfigurationProjectCommandStore, -} from "../../domain/ports/external-csv-configuration-project-command.store.js"; -import type { AppDatabase } from "../database-context.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; -import { projects } from "../schema/projects.js"; -import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; - -export class ExternalCsvConfigurationProjectCommandRepository - implements ExternalCsvConfigurationProjectCommandStore -{ - constructor(private readonly database: AppDatabase) {} - - execute(input: ExecuteExternalCsvConfigurationCommandInput) { - assertExternalCsvConfigurationUpdateProjectCommand(input.command); - return executeProjectCommandTransaction(this.database, input, (tx) => { - const project = tx - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.id, input.projectId)) - .get(); - if (!project) { - throw new Error("Project not found."); - } - const current = tx - .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, input.projectId)) - .get() ?? null; - assertSameSnapshot(current, input.command.payload.expected); - const target = input.command.payload.target; - if (target !== null && target.projectId !== input.projectId) { - throw new Error("External CSV configuration belongs to a different project."); - } - if (sameSnapshot(current, target)) { - throw new Error("External CSV configuration did not change."); - } - if ( - input.source === "user" && - target !== null && - target.configurationVersion !== (current?.configurationVersion ?? 0) + 1 - ) { - throw new Error("External CSV configuration version must advance by one."); - } - - if (target === null) { - tx.delete(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, input.projectId)) - .run(); - } else if (current === null) { - tx.insert(externalCsvConfigurations).values(target).run(); - } else { - const updated = tx - .update(externalCsvConfigurations) - .set({ - configurationVersion: target.configurationVersion, - configuration: target.configuration, - }) - .where(eq(externalCsvConfigurations.id, current.id)) - .run(); - if (updated.changes !== 1) { - throw new Error("External CSV configuration changed before update."); - } - } - return createExternalCsvConfigurationUpdateProjectCommand(target, current); - }); - } -} - -function assertSameSnapshot( - current: ExternalCsvConfigurationSnapshot | null, - expected: ExternalCsvConfigurationSnapshot | null -) { - if (!sameSnapshot(current, expected)) { - throw new Error("External CSV configuration changed before update."); - } -} - -function sameSnapshot( - left: ExternalCsvConfigurationSnapshot | null, - right: ExternalCsvConfigurationSnapshot | null -) { - return canonicalJson(left) === canonicalJson(right); -} - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(",")}]`; - } - if (value !== null && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value); -} diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts deleted file mode 100644 index 1f2281e..0000000 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ /dev/null @@ -1,252 +0,0 @@ -import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; -import { - assertExternalInitialImportProjectCommand, - createExternalInitialImportProjectCommand, - isEmptyExternalModelState, -} from "../../domain/models/external-initial-import-project-command.model.js"; -import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js"; -import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js"; -import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js"; -import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; -import { - createExternalRoomKey, - indexExternalObjectsByIfcGuid, - projectInitialExternalObject, -} from "../../external-model/domain/external-model-matching.js"; -import type { AppDatabase } from "../database-context.js"; -import { distributionBoards } from "../schema/distribution-boards.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; -import { externalImportBatches } from "../schema/external-import-batches.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; -import { ExternalModelStateRepository } from "./external-model-state.repository.js"; - -export class ExternalInitialImportProjectCommandRepository - implements ExternalInitialImportProjectCommandStore -{ - constructor(private readonly database: AppDatabase) {} - - execute(input: Parameters[0]) { - assertExternalInitialImportProjectCommand(input.command); - return executeProjectCommandTransaction(this.database, input, (tx) => { - const currentResult = new ExternalModelStateRepository(tx) - .getByProject(input.projectId); - if (!currentResult.projectExists) throw new Error("Project not found."); - const expected = input.command.payload.expected; - if (!sameState(currentResult.state, expected)) { - throw new Error("External model state changed before initial import."); - } - const target = input.command.payload.target; - if (!isEmptyExternalModelState(target)) { - validatePopulatedTarget(tx, input, target); - } - - replaceExternalState(tx, input.projectId, target); - const persisted = new ExternalModelStateRepository(tx) - .getByProject(input.projectId).state; - if (!sameState(persisted, target)) { - throw new Error("Persisted external initial import differs from its target."); - } - return createExternalInitialImportProjectCommand(target, currentResult.state); - }); - } -} - -function validatePopulatedTarget( - database: AppDatabase, - input: Parameters[0], - target: ExternalModelStateSnapshot -) { - const source = target.source!; - const batch = target.importBatches[0]; - if (source.projectId !== input.projectId) { - throw new Error("External initial import belongs to a different project."); - } - if ( - input.source === "user" && - batch.appliedProjectRevision !== input.expectedRevision + 1 - ) { - throw new Error("External initial import revision is invalid."); - } - const configuration = database - .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, input.projectId)) - .get(); - if ( - !configuration || - configuration.configurationVersion !== batch.configurationVersion || - canonicalJson(configuration.configuration) !== - canonicalJson(batch.configurationSnapshot) - ) { - throw new Error("External initial import configuration changed before apply."); - } - - const bytes = decodeCanonicalBase64(batch.originalContentBase64); - const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); - if (sha256 !== batch.sha256) { - throw new Error("External initial import checksum is invalid."); - } - const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot); - if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) { - throw new Error("External initial import document differs from the original bytes."); - } - const preview = createExternalCsvPreview({ - fileName: batch.fileName, - bytes, - configuration: batch.configurationSnapshot, - }); - const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects); - if (targetByIfcGuid.size !== preview.objects.length) { - throw new Error("External initial import must contain every CSV object exactly once."); - } - const mappingsById = new Map( - target.roomMappings.map((mapping) => [mapping.id, mapping]) - ); - const expectedRoomKeys = new Set(); - for (const candidate of preview.objects) { - const object = targetByIfcGuid.get(candidate.ifcGuid); - if (!object) { - throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`); - } - const projected = projectInitialExternalObject( - candidate, - batch.configurationSnapshot - ); - if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) { - throw new Error("External initial import source values differ from the CSV."); - } - for (const field of Object.keys(projected.planningValues) as Array) { - if ( - !object.overriddenFields.includes(field) && - canonicalJson(object.planningValues[field]) !== - canonicalJson(projected.planningValues[field]) - ) { - throw new Error(`External initial planning field ${field} changed without an override.`); - } - } - const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); - if (roomKey === null) { - if (object.externalRoomMappingId !== null) { - throw new Error("External object without a source room must not use a room mapping."); - } - } else { - expectedRoomKeys.add(roomKey); - const mapping = object.externalRoomMappingId === null - ? null - : mappingsById.get(object.externalRoomMappingId); - if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) { - throw new Error("External object source room mapping is incomplete."); - } - } - } - if ( - target.roomMappings.length !== expectedRoomKeys.size || - target.roomMappings.some( - (mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey) - ) - ) { - throw new Error("External initial import room mappings do not match CSV rooms."); - } - validateInternalLinks(database, input.projectId, target); -} - -function validateInternalLinks( - database: AppDatabase, - projectId: string, - target: ExternalModelStateSnapshot -) { - const roomIds = new Set( - database.select({ id: rooms.id }).from(rooms) - .where(eq(rooms.projectId, projectId)).all().map((row) => row.id) - ); - const boardIds = new Set( - database.select({ id: distributionBoards.id }).from(distributionBoards) - .where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id) - ); - const deviceIds = new Set( - database.select({ id: projectDevices.id }).from(projectDevices) - .where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id) - ); - for (const mapping of target.roomMappings) { - if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) { - throw new Error("External room mapping references another project."); - } - if ( - mapping.defaultDistributionBoardId !== null && - !boardIds.has(mapping.defaultDistributionBoardId) - ) { - throw new Error("External room mapping distribution board belongs to another project."); - } - } - for (const object of target.objects) { - if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) { - throw new Error("External object distribution board belongs to another project."); - } - if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) { - throw new Error("External object project device belongs to another project."); - } - } -} - -function replaceExternalState( - database: AppDatabase, - projectId: string, - target: ExternalModelStateSnapshot -) { - database.delete(externalModelSources) - .where(eq(externalModelSources.projectId, projectId)).run(); - if (target.source === null) return; - database.insert(externalModelSources).values(target.source).run(); - database.insert(externalImportBatches).values( - target.importBatches.map(({ originalContentBase64, ...batch }) => ({ - ...batch, - originalBytes: Buffer.from(originalContentBase64, "base64"), - })) - ).run(); - if (target.roomMappings.length) { - database.insert(externalRoomMappings).values(target.roomMappings).run(); - } - if (target.objects.length) { - database.insert(externalModelObjects).values(target.objects).run(); - } -} - -function decodeCanonicalBase64(value: string) { - const bytes = Buffer.from(value, "base64"); - if (bytes.toString("base64") !== value) { - throw new Error("External initial import bytes are not canonical base64."); - } - return bytes; -} - -function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) { - return canonicalJson(sortState(left)) === canonicalJson(sortState(right)); -} - -function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot { - const byId = (entries: readonly T[]) => - [...entries].sort((left, right) => left.id.localeCompare(right.id)); - return { - source: state.source, - importBatches: byId(state.importBatches), - roomMappings: byId(state.roomMappings), - objects: byId(state.objects), - }; -} - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (value !== null && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record).sort().map( - (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}` - ).join(",")}}`; - } - return JSON.stringify(value); -} diff --git a/src/db/repositories/external-model-configuration-project-command.repository.ts b/src/db/repositories/external-model-configuration-project-command.repository.ts new file mode 100644 index 0000000..881cc8a --- /dev/null +++ b/src/db/repositories/external-model-configuration-project-command.repository.ts @@ -0,0 +1,79 @@ +import { eq } from "drizzle-orm"; +import { + assertExternalModelConfigurationUpdateProjectCommand, + createExternalModelConfigurationUpdateProjectCommand, + type ExternalModelConfigurationSnapshot, +} from "../../domain/models/external-model-configuration-project-command.model.js"; +import type { + ExecuteExternalModelConfigurationCommandInput, + ExternalModelConfigurationProjectCommandStore, +} from "../../domain/ports/external-model-configuration-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; +import { projects } from "../schema/projects.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class ExternalModelConfigurationProjectCommandRepository + implements ExternalModelConfigurationProjectCommandStore { + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteExternalModelConfigurationCommandInput) { + assertExternalModelConfigurationUpdateProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const project = tx.select({ id: projects.id }).from(projects) + .where(eq(projects.id, input.projectId)).get(); + if (!project) throw new Error("Project not found."); + const current = tx.select().from(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null; + assertSameSnapshot(current, input.command.payload.expected); + const target = input.command.payload.target; + if (target !== null && target.projectId !== input.projectId) { + throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt."); + } + if (sameSnapshot(current, target)) { + throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert."); + } + if (target === null) { + tx.delete(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, input.projectId)).run(); + } else if (current === null) { + tx.insert(externalModelConfigurations).values(target).run(); + } else { + const updated = tx.update(externalModelConfigurations) + .set({ configuration: target.configuration }) + .where(eq(externalModelConfigurations.id, current.id)).run(); + if (updated.changes !== 1) { + throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert."); + } + } + return createExternalModelConfigurationUpdateProjectCommand(target, current); + }); + } +} + +function assertSameSnapshot( + current: ExternalModelConfigurationSnapshot | null, + expected: ExternalModelConfigurationSnapshot | null +) { + if (!sameSnapshot(current, expected)) { + throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert."); + } +} + +function sameSnapshot( + left: ExternalModelConfigurationSnapshot | null, + right: ExternalModelConfigurationSnapshot | null +) { + return canonicalJson(left) === canonicalJson(right); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map( + (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/db/repositories/external-csv-configuration.repository.ts b/src/db/repositories/external-model-configuration.repository.ts similarity index 51% rename from src/db/repositories/external-csv-configuration.repository.ts rename to src/db/repositories/external-model-configuration.repository.ts index 2b39a65..7cbacab 100644 --- a/src/db/repositories/external-csv-configuration.repository.ts +++ b/src/db/repositories/external-model-configuration.repository.ts @@ -1,12 +1,11 @@ import { eq } from "drizzle-orm"; -import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js"; +import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js"; import type { AppDatabase } from "../database-context.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; import { projects } from "../schema/projects.js"; -export class ExternalCsvConfigurationRepository - implements ExternalCsvConfigurationReader -{ +export class ExternalModelConfigurationRepository + implements ExternalModelConfigurationReader { constructor(private readonly database: AppDatabase) {} getByProject(projectId: string) { @@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository .from(projects) .where(eq(projects.id, projectId)) .get(); - if (!project) { - return { projectExists: false, configuration: null }; - } + if (!project) return { projectExists: false, configuration: null }; const configuration = this.database .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, projectId)) + .from(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, projectId)) .get() ?? null; return { projectExists: true, configuration }; } diff --git a/src/db/repositories/external-model-state.repository.ts b/src/db/repositories/external-model-state.repository.ts index 36079c9..c2adb7c 100644 --- a/src/db/repositories/external-model-state.repository.ts +++ b/src/db/repositories/external-model-state.repository.ts @@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader { .select() .from(externalModelObjects) .where(eq(externalModelObjects.sourceId, source.id)) - .orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id)) + .orderBy( + asc(externalModelObjects.worksharingId), + asc(externalModelObjects.id) + ) .all(); return { @@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader { source, importBatches: batches.map(({ originalBytes, ...batch }) => ({ ...batch, - originalContentBase64: Buffer.from(originalBytes).toString("base64"), + originalContentBase64: + originalBytes === null + ? null + : Buffer.from(originalBytes).toString("base64"), })), roomMappings, objects, diff --git a/src/db/repositories/external-object-assignment.persistence.ts b/src/db/repositories/external-object-assignment.persistence.ts index ddc9891..1111730 100644 --- a/src/db/repositories/external-object-assignment.persistence.ts +++ b/src/db/repositories/external-object-assignment.persistence.ts @@ -51,7 +51,7 @@ export function assertExternalObjectsCompatibleWithRow(input: { const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId])); const markers = new Set( input.allLinkedObjects.map((object) => - object.acceptedSourceValues.selectionMarker.trim() + scalarText(object.acceptedSourceValues.selectionMarker).trim() ) ); if (markers.size > 1) { @@ -118,13 +118,17 @@ function matchesRowPlanningValues( row.category === planning.category && row.connectionKind === planning.connectionKind && (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && - row.simultaneityFactor === planning.simultaneityFactor && + row.simultaneityFactor === (planning.simultaneityFactor ?? 1) && row.cosPhi === planning.cosPhi && row.costGroup === planning.costGroup && row.remark === planning.remark ); } +function scalarText(value: string | number | boolean | null) { + return value === null ? "" : String(value); +} + function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value !== null && typeof value === "object") { diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 386d805..006b742 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -31,7 +31,7 @@ import { projectDevices } from "../schema/project-devices.js"; import { projectChangeSets } from "../schema/project-change-sets.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; @@ -241,7 +241,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) { buildings: byId(state.buildings), floors: byId(state.floors), rooms: byId(state.rooms), - externalCsvConfiguration: state.externalCsvConfiguration, + externalModelConfiguration: state.externalModelConfiguration, externalModel: { source: state.externalModel.source, importBatches: byId(state.externalModel.importBatches), @@ -283,8 +283,8 @@ function replaceProjectState( .where(eq(externalModelSources.projectId, state.project.id)) .run(); database - .delete(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, state.project.id)) + .delete(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, state.project.id)) .run(); database .delete(distributionBoards) @@ -330,10 +330,10 @@ function replaceProjectState( insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); - if (state.externalCsvConfiguration !== null) { + if (state.externalModelConfiguration !== null) { database - .insert(externalCsvConfigurations) - .values(state.externalCsvConfiguration) + .insert(externalModelConfigurations) + .values(state.externalModelConfiguration) .run(); } insertMany(database, rooms, state.rooms); @@ -387,7 +387,10 @@ function replaceProjectState( state.externalModel.importBatches.map( ({ originalContentBase64, ...batch }) => ({ ...batch, - originalBytes: Buffer.from(originalContentBase64, "base64"), + originalBytes: + originalContentBase64 === null + ? null + : Buffer.from(originalContentBase64, "base64"), }) ) ); diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index a11cd58..431241b 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; import { ExternalModelStateRepository } from "./external-model-state.repository.js"; export interface PersistedProjectStateSnapshot { @@ -66,10 +66,10 @@ export function readProjectStateSnapshot( .where(eq(distributionBoards.projectId, projectId)) .orderBy(asc(distributionBoards.name), asc(distributionBoards.id)) .all(); - const externalCsvConfiguration = database + const externalModelConfiguration = database .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, projectId)) + .from(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, projectId)) .get() ?? null; const externalModel = new ExternalModelStateRepository(database) .getByProject(projectId).state; @@ -266,7 +266,7 @@ export function readProjectStateSnapshot( projectEditor: project.projectEditor, }, distributionBoards: boardRows, - externalCsvConfiguration, + externalModelConfiguration, externalModel, circuitLists: listRows, circuitSections: sectionRows, diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 1d93afd..fc73db5 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -23,7 +23,7 @@ import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; -import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalModelConfigurations } from "../schema/external-model-configurations.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; @@ -163,10 +163,10 @@ function insertProjectState( .insert(projects) .values({ ...state.project, currentRevision: 0 }) .run(); - if (state.externalCsvConfiguration !== null) { + if (state.externalModelConfiguration !== null) { database - .insert(externalCsvConfigurations) - .values(state.externalCsvConfiguration) + .insert(externalModelConfigurations) + .values(state.externalModelConfiguration) .run(); } insertMany(database, buildings, state.buildings); @@ -240,7 +240,10 @@ function insertProjectState( state.externalModel.importBatches.map( ({ originalContentBase64, ...batch }) => ({ ...batch, - originalBytes: Buffer.from(originalContentBase64, "base64"), + originalBytes: + originalContentBase64 === null + ? null + : Buffer.from(originalContentBase64, "base64"), }) ) ); diff --git a/src/db/schema/external-csv-configurations.ts b/src/db/schema/external-csv-configurations.ts deleted file mode 100644 index 2cbcd06..0000000 --- a/src/db/schema/external-csv-configurations.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; -import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; -import { projects } from "./projects.js"; - -export const externalCsvConfigurations = sqliteTable( - "external_csv_configurations", - { - id: text("id").primaryKey(), - projectId: text("project_id") - .notNull() - .references(() => projects.id, { onDelete: "cascade" }), - configurationVersion: integer("configuration_version").notNull(), - configuration: text("configuration", { mode: "json" }) - .$type() - .notNull(), - }, - (table) => [ - uniqueIndex("external_csv_configurations_project_id_unique").on( - table.projectId - ), - ] -); diff --git a/src/db/schema/external-import-batches.ts b/src/db/schema/external-import-batches.ts index 125077e..0919891 100644 --- a/src/db/schema/external-import-batches.ts +++ b/src/db/schema/external-import-batches.ts @@ -1,9 +1,7 @@ import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import type { - ExternalCsvConfiguration, - ExternalCsvDocument, -} from "../../external-model/csv/external-csv-contracts.js"; +import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js"; +import type { ExternalXlsxDocument } from "../../external-model/xlsx/external-xlsx-contracts.js"; import { externalModelSources } from "./external-model-sources.js"; import { projects } from "./projects.js"; @@ -21,15 +19,13 @@ export const externalImportBatches = sqliteTable( importedAtIso: text("imported_at_iso").notNull(), fileName: text("file_name").notNull(), sha256: text("sha256").notNull(), - appliedProjectRevision: integer("applied_project_revision").notNull(), - configurationVersion: integer("configuration_version").notNull(), + objectCount: integer("object_count").notNull(), + appliedProjectRevision: integer("applied_project_revision"), configurationSnapshot: text("configuration_snapshot", { mode: "json" }) - .$type() - .notNull(), - originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(), + .$type(), + originalBytes: blob("original_bytes", { mode: "buffer" }), document: text("document", { mode: "json" }) - .$type() - .notNull(), + .$type(), }, (table) => [ index("external_import_batches_project_revision_idx").on( diff --git a/src/db/schema/external-model-configurations.ts b/src/db/schema/external-model-configurations.ts new file mode 100644 index 0000000..e28ced7 --- /dev/null +++ b/src/db/schema/external-model-configurations.ts @@ -0,0 +1,21 @@ +import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; +import { projects } from "./projects.js"; + +export const externalModelConfigurations = sqliteTable( + "external_model_configurations", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + configuration: text("configuration", { mode: "json" }) + .$type() + .notNull(), + }, + (table) => [ + uniqueIndex("external_model_configurations_project_id_unique").on( + table.projectId + ), + ] +); diff --git a/src/db/schema/external-model-objects.ts b/src/db/schema/external-model-objects.ts index fbd50b7..2de80cb 100644 --- a/src/db/schema/external-model-objects.ts +++ b/src/db/schema/external-model-objects.ts @@ -23,7 +23,9 @@ export const externalModelObjects = sqliteTable( sourceId: text("source_id") .notNull() .references(() => externalModelSources.id, { onDelete: "cascade" }), - ifcGuid: text("ifc_guid").notNull(), + worksharingId: text("worksharing_id").notNull(), + ifcGuid: text("ifc_guid"), + deviceTypeId: text("device_type_id").notNull(), lastSeenImportBatchId: text("last_seen_import_batch_id") .notNull() .references(() => externalImportBatches.id, { onDelete: "restrict" }), @@ -62,17 +64,27 @@ export const externalModelObjects = sqliteTable( .default("present"), }, (table) => [ - uniqueIndex("external_model_objects_source_ifc_guid_unique").on( + uniqueIndex("external_model_objects_source_worksharing_id_unique").on( table.sourceId, - table.ifcGuid + table.worksharingId ), index("external_model_objects_project_presence_idx").on( table.projectId, table.presenceStatus ), + index("external_model_objects_project_device_type_idx").on( + table.projectId, + table.deviceTypeId + ), + index("external_model_objects_external_room_mapping_idx").on( + table.externalRoomMappingId + ), index("external_model_objects_distribution_board_idx").on( table.distributionBoardId ), + index("external_model_objects_linked_project_device_idx").on( + table.linkedProjectDeviceId + ), index("external_model_objects_circuit_device_row_idx").on( table.circuitDeviceRowId ), diff --git a/src/db/schema/external-model-sources.ts b/src/db/schema/external-model-sources.ts index a4f9ded..7dfb3d1 100644 --- a/src/db/schema/external-model-sources.ts +++ b/src/db/schema/external-model-sources.ts @@ -12,12 +12,9 @@ export const externalModelSources = sqliteTable( name: text("name").notNull(), sourceType: text("source_type", { enum: externalModelSourceTypes }) .notNull() - .default("revit_csv"), + .default("revit_xlsx"), }, (table) => [ - uniqueIndex("external_model_sources_project_type_unique").on( - table.projectId, - table.sourceType - ), + uniqueIndex("external_model_sources_project_unique").on(table.projectId), ] ); diff --git a/src/domain/models/external-csv-configuration-project-command.model.ts b/src/domain/models/external-csv-configuration-project-command.model.ts deleted file mode 100644 index bb87342..0000000 --- a/src/domain/models/external-csv-configuration-project-command.model.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; -import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; -import type { SerializedProjectCommand } from "./project-command.model.js"; - -export const externalCsvConfigurationUpdateCommandType = - "external-csv-configuration.update" as const; -export const externalCsvConfigurationCommandSchemaVersion = 1 as const; - -export interface ExternalCsvConfigurationSnapshot { - id: string; - projectId: string; - configurationVersion: number; - configuration: ExternalCsvConfiguration; -} - -export interface ExternalCsvConfigurationUpdatePayload { - expected: ExternalCsvConfigurationSnapshot | null; - target: ExternalCsvConfigurationSnapshot | null; -} - -export interface ExternalCsvConfigurationUpdateProjectCommand - extends SerializedProjectCommand { - schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion; - type: typeof externalCsvConfigurationUpdateCommandType; -} - -export function createExternalCsvConfigurationUpdateProjectCommand( - expected: ExternalCsvConfigurationSnapshot | null, - target: ExternalCsvConfigurationSnapshot | null -): ExternalCsvConfigurationUpdateProjectCommand { - const command: ExternalCsvConfigurationUpdateProjectCommand = { - schemaVersion: externalCsvConfigurationCommandSchemaVersion, - type: externalCsvConfigurationUpdateCommandType, - payload: { expected, target }, - }; - assertExternalCsvConfigurationUpdateProjectCommand(command); - return command; -} - -export function assertExternalCsvConfigurationUpdateProjectCommand( - command: SerializedProjectCommand -): asserts command is ExternalCsvConfigurationUpdateProjectCommand { - if ( - command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion || - command.type !== externalCsvConfigurationUpdateCommandType || - !isRecord(command.payload) - ) { - throw new Error("Unsupported external CSV configuration update command."); - } - assertSnapshot(command.payload.expected, "expected"); - assertSnapshot(command.payload.target, "target"); - if (command.payload.expected === null && command.payload.target === null) { - throw new Error("External CSV configuration command must change state."); - } - if ( - command.payload.expected !== null && - command.payload.target !== null && - (command.payload.expected.id !== command.payload.target.id || - command.payload.expected.projectId !== command.payload.target.projectId) - ) { - throw new Error("External CSV configuration identity must remain stable."); - } -} - -function assertSnapshot( - value: unknown, - field: string -): asserts value is ExternalCsvConfigurationSnapshot | null { - if (value === null) { - return; - } - if (!isRecord(value)) { - throw new Error(`${field} external CSV configuration must be an object or null.`); - } - assertId(value.id, `${field}.id`); - assertId(value.projectId, `${field}.projectId`); - if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) { - throw new Error(`${field}.configurationVersion must be a positive integer.`); - } - assertExternalCsvConfiguration(value.configuration); - if (Object.keys(value).length !== 4) { - throw new Error(`${field} external CSV configuration contains unknown fields.`); - } -} - -function assertId(value: unknown, field: string) { - if (typeof value !== "string" || !value.trim()) { - throw new Error(`${field} must be a non-empty string.`); - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/src/domain/models/external-initial-import-project-command.model.ts b/src/domain/models/external-initial-import-project-command.model.ts deleted file mode 100644 index 2004cfc..0000000 --- a/src/domain/models/external-initial-import-project-command.model.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; -import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; -import type { SerializedProjectCommand } from "./project-command.model.js"; - -export const externalInitialImportCommandType = - "external-import.apply-initial" as const; -export const externalInitialImportCommandSchemaVersion = 1 as const; - -export interface ExternalInitialImportPayload { - expected: ExternalModelStateSnapshot; - target: ExternalModelStateSnapshot; -} - -export interface ExternalInitialImportProjectCommand - extends SerializedProjectCommand { - schemaVersion: typeof externalInitialImportCommandSchemaVersion; - type: typeof externalInitialImportCommandType; -} - -export function createExternalInitialImportProjectCommand( - expected: ExternalModelStateSnapshot, - target: ExternalModelStateSnapshot -): ExternalInitialImportProjectCommand { - const command: ExternalInitialImportProjectCommand = { - schemaVersion: externalInitialImportCommandSchemaVersion, - type: externalInitialImportCommandType, - payload: { expected, target }, - }; - assertExternalInitialImportProjectCommand(command); - return command; -} - -export function assertExternalInitialImportProjectCommand( - command: SerializedProjectCommand -): asserts command is ExternalInitialImportProjectCommand { - if ( - command.schemaVersion !== externalInitialImportCommandSchemaVersion || - command.type !== externalInitialImportCommandType || - !isRecord(command.payload) || - Object.keys(command.payload).length !== 2 - ) { - throw new Error("Unsupported external initial import command."); - } - const expected = parseExternalModelStateSnapshot(command.payload.expected); - const target = parseExternalModelStateSnapshot(command.payload.target); - const expectedIsEmpty = isEmptyExternalModelState(expected); - const targetIsEmpty = isEmptyExternalModelState(target); - if (expectedIsEmpty === targetIsEmpty) { - throw new Error("External initial import must transition between empty and populated state."); - } - assertPopulatedInitialState(expectedIsEmpty ? target : expected); -} - -export function createEmptyExternalModelState(): ExternalModelStateSnapshot { - return { source: null, importBatches: [], roomMappings: [], objects: [] }; -} - -export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) { - return ( - state.source === null && - state.importBatches.length === 0 && - state.roomMappings.length === 0 && - state.objects.length === 0 - ); -} - -function assertPopulatedInitialState(state: ExternalModelStateSnapshot) { - if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) { - throw new Error("External initial import requires one source, one batch and at least one object."); - } - const source = state.source; - const batch = state.importBatches[0]; - if ( - batch.importKind !== "initial" || - batch.projectId !== source.projectId || - batch.sourceId !== source.id - ) { - throw new Error("External initial import batch identity is invalid."); - } - const mappingIds = uniqueIds(state.roomMappings, "room mapping"); - const roomKeys = new Set(); - for (const mapping of state.roomMappings) { - if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) { - throw new Error("External initial room mapping identity is invalid."); - } - if (roomKeys.has(mapping.normalizedSourceRoomKey)) { - throw new Error("External initial import contains duplicate room keys."); - } - roomKeys.add(mapping.normalizedSourceRoomKey); - } - uniqueIds(state.objects, "object"); - const ifcGuids = new Set(); - for (const object of state.objects) { - if ( - object.projectId !== source.projectId || - object.sourceId !== source.id || - object.lastSeenImportBatchId !== batch.id || - object.lastAcceptedImportBatchId !== batch.id - ) { - throw new Error("External initial object identity is invalid."); - } - if (ifcGuids.has(object.ifcGuid)) { - throw new Error("External initial import contains duplicate IFCGUIDs."); - } - ifcGuids.add(object.ifcGuid); - if ( - object.externalRoomMappingId !== null && - !mappingIds.has(object.externalRoomMappingId) - ) { - throw new Error("External initial object room mapping is invalid."); - } - if (object.circuitDeviceRowId !== null) { - throw new Error("External initial import must not assign circuit device rows."); - } - if (object.presenceStatus !== "present") { - throw new Error("External initial import objects must be present."); - } - } -} - -function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) { - const ids = new Set(); - for (const entry of entries) { - if (ids.has(entry.id)) { - throw new Error(`External initial import contains duplicate ${label} ids.`); - } - ids.add(entry.id); - } - return ids; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/src/domain/models/external-model-configuration-project-command.model.ts b/src/domain/models/external-model-configuration-project-command.model.ts new file mode 100644 index 0000000..e606b6a --- /dev/null +++ b/src/domain/models/external-model-configuration-project-command.model.ts @@ -0,0 +1,88 @@ +import { + assertExternalModelConfiguration, + type ExternalModelConfiguration, +} from "../../external-model/configuration/external-model-configuration.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const externalModelConfigurationUpdateCommandType = + "external-model-configuration.update" as const; +export const externalModelConfigurationCommandSchemaVersion = 1 as const; + +export interface ExternalModelConfigurationSnapshot { + id: string; + projectId: string; + configuration: ExternalModelConfiguration; +} + +export interface ExternalModelConfigurationUpdatePayload { + expected: ExternalModelConfigurationSnapshot | null; + target: ExternalModelConfigurationSnapshot | null; +} + +export interface ExternalModelConfigurationUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof externalModelConfigurationCommandSchemaVersion; + type: typeof externalModelConfigurationUpdateCommandType; +} + +export function createExternalModelConfigurationUpdateProjectCommand( + expected: ExternalModelConfigurationSnapshot | null, + target: ExternalModelConfigurationSnapshot | null +): ExternalModelConfigurationUpdateProjectCommand { + const command: ExternalModelConfigurationUpdateProjectCommand = { + schemaVersion: externalModelConfigurationCommandSchemaVersion, + type: externalModelConfigurationUpdateCommandType, + payload: { expected, target }, + }; + assertExternalModelConfigurationUpdateProjectCommand(command); + return command; +} + +export function assertExternalModelConfigurationUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ExternalModelConfigurationUpdateProjectCommand { + if ( + command.schemaVersion !== externalModelConfigurationCommandSchemaVersion || + command.type !== externalModelConfigurationUpdateCommandType || + !isRecord(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Nicht unterstützter Revit-XLSX-Konfigurationscommand."); + } + assertSnapshot(command.payload.expected, "expected"); + assertSnapshot(command.payload.target, "target"); + if (command.payload.expected === null && command.payload.target === null) { + throw new Error("Der Konfigurationscommand muss den Zustand ändern."); + } + if ( + command.payload.expected !== null && + command.payload.target !== null && + (command.payload.expected.id !== command.payload.target.id || + command.payload.expected.projectId !== command.payload.target.projectId) + ) { + throw new Error("Die Identität der Revit-XLSX-Konfiguration muss stabil bleiben."); + } +} + +function assertSnapshot( + value: unknown, + field: string +): asserts value is ExternalModelConfigurationSnapshot | null { + if (value === null) return; + if (!isRecord(value) || Object.keys(value).length !== 3) { + throw new Error(`${field} muss eine vollständige Konfiguration oder null sein.`); + } + assertId(value.id, `${field}.id`); + assertId(value.projectId, `${field}.projectId`); + assertExternalModelConfiguration(value.configuration); +} + +function assertId(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} muss eine nichtleere ID sein.`); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index ea3aba7..2296bc1 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -28,6 +28,10 @@ import { } from "../services/project-voltage.service.js"; import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; +import { + validateExternalModelConfiguration, + type ExternalModelConfiguration, +} from "../../external-model/configuration/external-model-configuration.js"; import { externalImportKinds, externalModelSourceTypes, @@ -35,7 +39,8 @@ import { type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 9 as const; +export const projectStateSnapshotSchemaVersion = 10 as const; +const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const; const roomAreaProjectStateSnapshotSchemaVersion = 8 as const; const buildingProjectStateSnapshotSchemaVersion = 7 as const; const coverProjectStateSnapshotSchemaVersion = 6 as const; @@ -423,14 +428,14 @@ const externalCsvDocumentSchema = z.object({ }); }); -const externalModelSourceSchema = z.object({ +const legacyExternalModelSourceSchema = z.object({ id: idSchema, projectId: idSchema, name: z.string().trim().min(1), - sourceType: z.enum(externalModelSourceTypes), + sourceType: z.literal("revit_csv"), }).strict(); -const externalImportBatchSchema = z.object({ +const legacyExternalImportBatchSchema = z.object({ id: idSchema, projectId: idSchema, sourceId: idSchema, @@ -461,7 +466,7 @@ const externalRoomMappingSchema = z.object({ defaultDistributionBoardId: idSchema.nullable(), }).strict(); -const externalPlanningFieldNames = [ +const legacyExternalPlanningFieldNames = [ "displayName", "internalDeviceType", "category", @@ -474,7 +479,7 @@ const externalPlanningFieldNames = [ "remark", ] as const; -const externalModelObjectSchema = z.object({ +const legacyExternalModelObjectSchema = z.object({ id: idSchema, projectId: idSchema, sourceId: idSchema, @@ -504,6 +509,170 @@ const externalModelObjectSchema = z.object({ costGroup: nullableStringSchema, remark: nullableStringSchema, }).strict(), + overriddenFields: z.array(z.enum(legacyExternalPlanningFieldNames)).refine( + (values) => new Set(values).size === values.length + ), + externalRoomMappingId: idSchema.nullable(), + distributionBoardId: idSchema.nullable(), + linkedProjectDeviceId: idSchema.nullable(), + circuitDeviceRowId: idSchema.nullable(), + presenceStatus: z.enum(externalObjectPresenceStatuses), +}).strict(); + +const legacyExternalModelStateSchema = z.object({ + source: legacyExternalModelSourceSchema.nullable(), + importBatches: z.array(legacyExternalImportBatchSchema), + roomMappings: z.array(externalRoomMappingSchema), + objects: z.array(legacyExternalModelObjectSchema), +}).strict(); + +const externalModelConfigurationSnapshotSchema = z.object({ + id: idSchema, + projectId: idSchema, + configuration: z.custom( + (value) => validateExternalModelConfiguration(value).success + ), +}).strict(); + +const externalXlsxScalarSchema = z.union([ + z.string(), + finiteNumberSchema, + z.boolean(), + z.null(), +]); + +const externalXlsxDocumentSchema = z.object({ + sheetName: z.string().min(1).max(31), + headerRowNumber: z.number().int().positive(), + firstDataRowNumber: z.number().int().positive(), + firstDataColumnNumber: z.number().int().positive(), + columns: z.array(z.object({ + columnNumber: z.number().int().positive(), + header: z.string(), + comment: z.string().nullable(), + width: finiteNumberSchema.positive().nullable(), + }).strict()), + rows: z.array(z.object({ + rowNumber: z.number().int().positive(), + classification: z.enum(["object", "suspect-object", "passthrough"]), + cells: z.array(z.object({ + columnNumber: z.number().int().positive(), + value: externalXlsxScalarSchema, + valueType: z.enum(["empty", "string", "number", "boolean"]), + numberFormat: z.string().nullable(), + formula: z.string().nullable(), + usedCachedFormulaValue: z.boolean(), + }).strict()), + }).strict()), + issues: z.array(z.object({ + code: z.enum([ + "cached-formula-value-used", + "formula-without-cached-value", + "suspect-object-skipped", + "duplicate-ifc-guid", + "optional-return-column-missing", + ]), + message: z.string().min(1), + rowNumber: z.number().int().positive().optional(), + columnNumber: z.number().int().positive().optional(), + field: z.enum([ + "worksharingId", "ifcGuid", "roomNumber", "roomName", + "familyAndType", "selectionMarker", "socketCount", "sourceRemark", + "circuitCombined", "boardName", "circuitIdentifier", "designation", + "electricalPowerW", "voltageV", "currentA", + ]).optional(), + }).strict()), +}).strict(); + +const externalModelSourceSchema = z.object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + sourceType: z.enum(externalModelSourceTypes), +}).strict(); + +const externalImportBatchSchema = z.object({ + id: idSchema, + projectId: idSchema, + sourceId: idSchema, + importKind: z.enum(externalImportKinds), + importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))), + fileName: z.string().trim().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + objectCount: z.number().int().nonnegative(), + appliedProjectRevision: z.number().int().nonnegative().nullable(), + configurationSnapshot: z.custom( + (value) => value === null || validateExternalModelConfiguration(value).success + ).nullable(), + originalContentBase64: z.string().min(1).regex( + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + ).nullable(), + document: externalXlsxDocumentSchema.nullable(), +}).strict().superRefine((batch, context) => { + const complete = [ + batch.appliedProjectRevision, + batch.configurationSnapshot, + batch.originalContentBase64, + batch.document, + ].map((value) => value !== null); + if (!complete.every((value) => value === complete[0])) { + context.addIssue({ + code: "custom", + message: "Importbatch muss vollständig oder auf Metadaten reduziert sein.", + }); + } +}); + +const externalPlanningFieldNames = [ + "displayName", + "category", + "connectionKind", + "effectiveQuantity", + "powerPerUnitW", + "simultaneityFactor", + "cosPhi", + "costGroup", + "remark", +] as const; + +const externalModelObjectSchema = z.object({ + id: idSchema, + projectId: idSchema, + sourceId: idSchema, + worksharingId: z.string().regex( + /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/ + ), + ifcGuid: externalIfcGuidSchema.nullable(), + deviceTypeId: idSchema, + lastSeenImportBatchId: idSchema, + lastAcceptedImportBatchId: idSchema, + acceptedSourceValues: z.object({ + rowNumber: z.number().int().positive(), + roomNumber: externalXlsxScalarSchema, + roomName: externalXlsxScalarSchema, + familyAndType: z.string().min(1), + selectionMarker: externalXlsxScalarSchema, + socketCount: externalXlsxScalarSchema, + sourceRemark: externalXlsxScalarSchema, + circuitCombined: externalXlsxScalarSchema, + boardName: externalXlsxScalarSchema, + circuitIdentifier: externalXlsxScalarSchema, + designation: externalXlsxScalarSchema, + electricalPowerW: externalXlsxScalarSchema, + voltageV: externalXlsxScalarSchema, + currentA: externalXlsxScalarSchema, + }).strict(), + planningValues: z.object({ + displayName: nullableStringSchema, + category: z.enum(circuitGroupCategories).nullable(), + connectionKind: nullableStringSchema, + effectiveQuantity: finiteNumberSchema.positive(), + powerPerUnitW: finiteNumberSchema.nonnegative().nullable(), + simultaneityFactor: finiteNumberSchema.min(0).max(1).nullable(), + cosPhi: finiteNumberSchema.positive().max(1).nullable(), + costGroup: nullableStringSchema, + remark: nullableStringSchema, + }).strict(), overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine( (values) => new Set(values).size === values.length ), @@ -519,7 +688,21 @@ const externalModelStateSchema = z.object({ importBatches: z.array(externalImportBatchSchema), roomMappings: z.array(externalRoomMappingSchema), objects: z.array(externalModelObjectSchema), -}).strict(); +}).strict().superRefine((state, context) => { + const fullBatchIndexes = state.importBatches.flatMap((batch, index) => + batch.originalContentBase64 === null ? [] : [index] + ); + if ( + state.importBatches.length > 0 && + (fullBatchIndexes.length !== 1 || + fullBatchIndexes[0] !== state.importBatches.length - 1) + ) { + context.addIssue({ + code: "custom", + message: "Nur der letzte Importbatch darf vollständig gespeichert sein.", + }); + } +}); export function parseExternalModelStateSnapshot( value: unknown @@ -565,19 +748,30 @@ export const projectStateSnapshotSchema = z schemaVersion: z.literal(projectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), - externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, ...projectStateSnapshotContents, }) .strict(); +const legacyCsvProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: legacyExternalModelStateSchema, + ...projectStateSnapshotContents, + }) + .strict(); + const roomAreaProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardWithoutFeedingSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - externalModel: externalModelStateSchema, + externalModel: legacyExternalModelStateSchema, ...projectStateSnapshotContents, }) .strict(); @@ -588,7 +782,7 @@ const buildingProjectStateSnapshotSchema = z project: projectSchema, distributionBoards: z.array(distributionBoardWithoutFeedingSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - externalModel: externalModelStateSchema, + externalModel: legacyExternalModelStateSchema, ...buildingProjectStateSnapshotContents, }) .strict(); @@ -599,7 +793,7 @@ const coverProjectStateSnapshotSchema = z project: projectSchema, distributionBoards: z.array(distributionBoardWithoutFeedingSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - externalModel: externalModelStateSchema, + externalModel: legacyExternalModelStateSchema, ...previousProjectStateSnapshotContents, }) .strict(); @@ -610,7 +804,7 @@ const boardInfoProjectStateSnapshotSchema = z project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - externalModel: externalModelStateSchema, + externalModel: legacyExternalModelStateSchema, ...previousProjectStateSnapshotContents, }) .strict(); @@ -621,7 +815,7 @@ const previousProjectStateSnapshotSchema = z project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - externalModel: externalModelStateSchema, + externalModel: legacyExternalModelStateSchema, ...previousProjectStateSnapshotContents, }) .strict(); @@ -684,6 +878,39 @@ export function parseProjectStateSnapshot( } function upgradePreviousProjectStateSnapshot(value: unknown): unknown { + const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value); + if ( + legacyCsv !== null && + typeof legacyCsv === "object" && + !Array.isArray(legacyCsv) && + (legacyCsv as { schemaVersion?: unknown }).schemaVersion === + legacyCsvProjectStateSnapshotSchemaVersion + ) { + const previous = legacyCsvProjectStateSnapshotSchema.parse(legacyCsv); + if ( + previous.externalCsvConfiguration !== null || + !isEmptyExternalModelState(previous.externalModel) + ) { + throw new Error( + "Dieser alte Projektstand enthält Revit-CSV-Daten und kann nicht auf die Revit-XLSX-Struktur aktualisiert werden." + ); + } + const { + externalCsvConfiguration: _externalCsvConfiguration, + externalModel: _legacyExternalModel, + ...remaining + } = previous; + return { + ...remaining, + schemaVersion: projectStateSnapshotSchemaVersion, + externalModelConfiguration: null, + externalModel: emptyExternalModelState(), + }; + } + return legacyCsv; +} + +function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown { const roomArea = upgradeToRoomAreaProjectStateSnapshot(value); if ( roomArea !== null && @@ -695,7 +922,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion, distributionBoards: withEmptyDistributionBoardFeeding( previous.distributionBoards ), @@ -830,6 +1057,20 @@ function emptyExternalModelState() { }; } +function isEmptyExternalModelState(state: { + source: unknown; + importBatches: unknown[]; + roomMappings: unknown[]; + objects: unknown[]; +}) { + return ( + state.source === null && + state.importBatches.length === 0 && + state.roomMappings.length === 0 && + state.objects.length === 0 + ); +} + function normalizeSnapshotPhaseTypes( snapshot: ProjectStateSnapshot ): ProjectStateSnapshot { @@ -891,10 +1132,10 @@ function assertProjectStateSnapshotRelations( ) { const projectId = snapshot.project.id; if ( - snapshot.externalCsvConfiguration !== null && - snapshot.externalCsvConfiguration.projectId !== projectId + snapshot.externalModelConfiguration !== null && + snapshot.externalModelConfiguration.projectId !== projectId ) { - throw new Error("Snapshot external CSV configuration belongs to a different project."); + throw new Error("Snapshot Revit-XLSX configuration belongs to a different project."); } const boardIds = uniqueIds( snapshot.distributionBoards, @@ -1235,6 +1476,12 @@ function assertExternalModelRelations( } return; } + if (snapshot.externalModelConfiguration === null) { + throw new Error("Snapshot external model requires a Revit-XLSX configuration."); + } + if (importBatches.length === 0) { + throw new Error("Snapshot external model source requires an import batch."); + } assertProjectOwnership(source.projectId, references.projectId, "external model source"); const batchIds = uniqueIds(importBatches, "external import batch"); const mappingIds = uniqueIds(roomMappings, "external room mapping"); @@ -1270,17 +1517,23 @@ function assertExternalModelRelations( } } - const ifcGuids = new Set(); + const worksharingIds = new Set(); + const deviceTypeIds = new Set( + snapshot.externalModelConfiguration.configuration.deviceTypes.map( + (deviceType) => deviceType.id + ) + ); for (const object of objects) { assertProjectOwnership(object.projectId, references.projectId, "external model object"); if (object.sourceId !== source.id) { throw new Error("Snapshot external model object belongs to a different source."); } assertUniqueKey( - ifcGuids, - object.ifcGuid, - "Snapshot contains duplicate external IFCGUIDs." + worksharingIds, + object.worksharingId, + "Snapshot contains duplicate external Worksharing-IDs." ); + assertReference(deviceTypeIds, object.deviceTypeId, "external object device type"); assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch"); assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch"); if (object.externalRoomMappingId !== null) { diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index d68b29e..e067896 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -146,16 +146,35 @@ export function remapProjectState( createId ); const externalObjectIds = idMap(source.externalModel.objects, createId); + const configurations = [ + source.externalModelConfiguration?.configuration ?? null, + ...source.externalModel.importBatches.map( + (batch) => batch.configurationSnapshot + ), + ].filter((configuration) => configuration !== null); + const deviceTypeIds = uniqueIdMap( + configurations.flatMap((configuration) => configuration.deviceTypes), + createId + ); + const familyRuleIds = uniqueIdMap( + configurations.flatMap((configuration) => configuration.familyTypeRules), + createId + ); return parseProjectStateSnapshot({ ...source, project: { ...source.project, id: targetProjectId, name }, - externalCsvConfiguration: - source.externalCsvConfiguration === null + externalModelConfiguration: + source.externalModelConfiguration === null ? null : { - ...source.externalCsvConfiguration, + ...source.externalModelConfiguration, id: createId(), projectId: targetProjectId, + configuration: remapExternalModelConfiguration( + source.externalModelConfiguration.configuration, + deviceTypeIds, + familyRuleIds + ), }, externalModel: { source: source.externalModel.source === null @@ -170,6 +189,14 @@ export function remapProjectState( id: requiredId(externalBatchIds, batch.id), projectId: targetProjectId, sourceId: externalSourceId!, + configurationSnapshot: + batch.configurationSnapshot === null + ? null + : remapExternalModelConfiguration( + batch.configurationSnapshot, + deviceTypeIds, + familyRuleIds + ), })), roomMappings: source.externalModel.roomMappings.map((mapping) => ({ ...mapping, @@ -190,6 +217,7 @@ export function remapProjectState( id: requiredId(externalObjectIds, object.id), projectId: targetProjectId, sourceId: externalSourceId!, + deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId), lastSeenImportBatchId: requiredId( externalBatchIds, object.lastSeenImportBatchId @@ -322,6 +350,36 @@ export function remapProjectState( }); } +function remapExternalModelConfiguration( + configuration: NonNullable< + ProjectStateSnapshot["externalModelConfiguration"] + >["configuration"], + deviceTypeIds: ReadonlyMap, + familyRuleIds: ReadonlyMap +) { + return { + ...configuration, + deviceTypes: configuration.deviceTypes.map((deviceType) => ({ + ...deviceType, + id: requiredId(deviceTypeIds, deviceType.id), + })), + familyTypeRules: configuration.familyTypeRules.map((rule) => ({ + ...rule, + id: requiredId(familyRuleIds, rule.id), + deviceTypeId: requiredId(deviceTypeIds, rule.deviceTypeId), + })), + }; +} + +function uniqueIdMap( + entries: ReadonlyArray<{ id: string }>, + createId: () => string +) { + return new Map( + [...new Set(entries.map((entry) => entry.id))].map((id) => [id, createId()]) + ); +} + function idMap( entries: ReadonlyArray<{ id: string }>, createId: () => string diff --git a/src/domain/ports/external-csv-configuration-project-command.store.ts b/src/domain/ports/external-csv-configuration-project-command.store.ts deleted file mode 100644 index 0bc74b4..0000000 --- a/src/domain/ports/external-csv-configuration-project-command.store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js"; -import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; - -export interface ExecuteExternalCsvConfigurationCommandInput { - projectId: string; - expectedRevision: number; - source: ProjectRevisionSource; - description?: string; - actorId?: string; - historyTargetChangeSetId?: string; - command: ExternalCsvConfigurationUpdateProjectCommand; -} - -export interface ExternalCsvConfigurationProjectCommandStore { - execute(input: ExecuteExternalCsvConfigurationCommandInput): { - revision: AppendedProjectRevision; - inverse: ExternalCsvConfigurationUpdateProjectCommand; - }; -} diff --git a/src/domain/ports/external-csv-configuration.reader.ts b/src/domain/ports/external-csv-configuration.reader.ts deleted file mode 100644 index a50144b..0000000 --- a/src/domain/ports/external-csv-configuration.reader.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js"; - -export interface ExternalCsvConfigurationReadResult { - projectExists: boolean; - configuration: ExternalCsvConfigurationSnapshot | null; -} - -export interface ExternalCsvConfigurationReader { - getByProject(projectId: string): ExternalCsvConfigurationReadResult; -} diff --git a/src/domain/ports/external-initial-import-project-command.store.ts b/src/domain/ports/external-initial-import-project-command.store.ts deleted file mode 100644 index db1917e..0000000 --- a/src/domain/ports/external-initial-import-project-command.store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js"; -import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; - -export interface ExecuteExternalInitialImportCommandInput { - projectId: string; - expectedRevision: number; - source: ProjectRevisionSource; - description?: string; - actorId?: string; - historyTargetChangeSetId?: string; - command: ExternalInitialImportProjectCommand; -} - -export interface ExternalInitialImportProjectCommandStore { - execute(input: ExecuteExternalInitialImportCommandInput): { - revision: AppendedProjectRevision; - inverse: ExternalInitialImportProjectCommand; - }; -} diff --git a/src/domain/ports/external-model-configuration-project-command.store.ts b/src/domain/ports/external-model-configuration-project-command.store.ts new file mode 100644 index 0000000..d6dce8c --- /dev/null +++ b/src/domain/ports/external-model-configuration-project-command.store.ts @@ -0,0 +1,19 @@ +import type { ExternalModelConfigurationUpdateProjectCommand } from "../models/external-model-configuration-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; + +export interface ExecuteExternalModelConfigurationCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ExternalModelConfigurationUpdateProjectCommand; +} + +export interface ExternalModelConfigurationProjectCommandStore { + execute(input: ExecuteExternalModelConfigurationCommandInput): { + revision: AppendedProjectRevision; + inverse: ExternalModelConfigurationUpdateProjectCommand; + }; +} diff --git a/src/domain/ports/external-model-configuration.reader.ts b/src/domain/ports/external-model-configuration.reader.ts new file mode 100644 index 0000000..1b54f77 --- /dev/null +++ b/src/domain/ports/external-model-configuration.reader.ts @@ -0,0 +1,10 @@ +import type { ExternalModelConfigurationSnapshot } from "../models/external-model-configuration-project-command.model.js"; + +export interface ExternalModelConfigurationReadResult { + projectExists: boolean; + configuration: ExternalModelConfigurationSnapshot | null; +} + +export interface ExternalModelConfigurationReader { + getByProject(projectId: string): ExternalModelConfigurationReadResult; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 35dd9e8..603a4be 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -173,15 +173,10 @@ import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js"; import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js"; import { - assertExternalCsvConfigurationUpdateProjectCommand, - externalCsvConfigurationUpdateCommandType, -} from "../models/external-csv-configuration-project-command.model.js"; -import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js"; -import { - assertExternalInitialImportProjectCommand, - externalInitialImportCommandType, -} from "../models/external-initial-import-project-command.model.js"; -import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js"; + assertExternalModelConfigurationUpdateProjectCommand, + externalModelConfigurationUpdateCommandType, +} from "../models/external-model-configuration-project-command.model.js"; +import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js"; import { assertExternalObjectRowAssignmentProjectCommand, externalObjectRowAssignmentCommandType, @@ -243,8 +238,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly historyStore: ProjectHistoryStore, - private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, - private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, + private readonly externalModelConfigurationStore?: ExternalModelConfigurationProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, @@ -308,6 +302,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { ); } + assertNoLegacyExternalCsvHistoryCommand(step.command); + return this.dispatch({ ...input, source: direction, @@ -625,22 +621,12 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } - case externalCsvConfigurationUpdateCommandType: { - assertExternalCsvConfigurationUpdateProjectCommand(input.command); - if (!this.externalCsvConfigurationStore) { - throw new Error("External CSV configuration store is not available."); + case externalModelConfigurationUpdateCommandType: { + assertExternalModelConfigurationUpdateProjectCommand(input.command); + if (!this.externalModelConfigurationStore) { + throw new Error("External model configuration store is not available."); } - return this.externalCsvConfigurationStore.execute({ - ...input, - command: input.command, - }).revision; - } - case externalInitialImportCommandType: { - assertExternalInitialImportProjectCommand(input.command); - if (!this.externalInitialImportStore) { - throw new Error("External initial import store is not available."); - } - return this.externalInitialImportStore.execute({ + return this.externalModelConfigurationStore.execute({ ...input, command: input.command, }).revision; @@ -720,3 +706,16 @@ function assertExpectedRevision(expectedRevision: number) { ); } } + +function assertNoLegacyExternalCsvHistoryCommand( + command: SerializedProjectCommand +) { + if ( + command.type === "external-csv-configuration.update" || + command.type === "external-import.apply-initial" + ) { + throw new Error( + "Dieser Verlaufseintrag stammt aus der alten Revit-CSV-Anbindung und kann nach der Umstellung auf Linear-XLSX nicht mehr ausgef\u00fchrt werden." + ); + } +} diff --git a/src/external-model/application/external-circuit-list-objects.ts b/src/external-model/application/external-circuit-list-objects.ts deleted file mode 100644 index 43f57c5..0000000 --- a/src/external-model/application/external-circuit-list-objects.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { - ExternalModelStateSnapshot, -} from "../domain/external-model-contracts.js"; - -export interface ExternalCircuitListObjectDto { - id: string; - ifcGuid: string; - selectionMarker: string; - familyAndType: string; - sourceCircuitIdentifier: string; - displayName: string | null; - category: string | null; - connectionKind: string | null; - effectiveQuantity: number; - powerPerUnitW: number | null; - sourceRoomNumber: string; - sourceRoomName: string; - roomId: string | null; - linkedProjectDeviceId: string | null; - circuitDeviceRowId: string | null; - presenceStatus: "present" | "missing"; -} - -export interface ExternalCircuitListObjectsDto { - sourceName: string | null; - objectCount: number; - unassignedObjectCount: number; - objects: ExternalCircuitListObjectDto[]; -} - -export function createExternalCircuitListObjects(input: { - state: ExternalModelStateSnapshot; - distributionBoardId: string; -}): ExternalCircuitListObjectsDto { - const mappingById = new Map( - input.state.roomMappings.map((mapping) => [mapping.id, mapping]) - ); - const objects = input.state.objects - .filter((object) => object.distributionBoardId === input.distributionBoardId) - .map((object) => { - const mapping = object.externalRoomMappingId === null - ? null - : mappingById.get(object.externalRoomMappingId) ?? null; - return { - id: object.id, - ifcGuid: object.ifcGuid, - selectionMarker: object.acceptedSourceValues.selectionMarker, - familyAndType: object.acceptedSourceValues.familyAndType, - sourceCircuitIdentifier: object.acceptedSourceValues.circuitIdentifier, - displayName: object.planningValues.displayName, - category: object.planningValues.category, - connectionKind: object.planningValues.connectionKind, - effectiveQuantity: object.planningValues.effectiveQuantity, - powerPerUnitW: object.planningValues.powerPerUnitW, - sourceRoomNumber: - mapping?.sourceRoomNumber ?? object.acceptedSourceValues.roomNumber, - sourceRoomName: - mapping?.sourceRoomName ?? object.acceptedSourceValues.roomName, - roomId: mapping?.roomId ?? null, - linkedProjectDeviceId: object.linkedProjectDeviceId, - circuitDeviceRowId: object.circuitDeviceRowId, - presenceStatus: object.presenceStatus, - } satisfies ExternalCircuitListObjectDto; - }) - .sort(compareExternalObjects); - return { - sourceName: input.state.source?.name ?? null, - objectCount: objects.length, - unassignedObjectCount: objects.filter( - (object) => object.circuitDeviceRowId === null - ).length, - objects, - }; -} - -function compareExternalObjects( - left: ExternalCircuitListObjectDto, - right: ExternalCircuitListObjectDto -) { - const assignmentOrder = Number(left.circuitDeviceRowId !== null) - - Number(right.circuitDeviceRowId !== null); - if (assignmentOrder !== 0) return assignmentOrder; - return left.sourceRoomNumber.localeCompare(right.sourceRoomNumber, "de", { - numeric: true, - }) || left.selectionMarker.localeCompare(right.selectionMarker, "de", { - numeric: true, - }) || left.ifcGuid.localeCompare(right.ifcGuid); -} diff --git a/src/external-model/application/external-csv-preview.ts b/src/external-model/application/external-csv-preview.ts deleted file mode 100644 index 57aa480..0000000 --- a/src/external-model/application/external-csv-preview.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { createHash } from "node:crypto"; -import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js"; -import { parseExternalCsv } from "../csv/external-csv-transport.js"; - -export interface ExternalCsvPreviewObject { - rowNumber: number; - ifcGuid: string; - roomNumber: string; - roomName: string; - familyAndType: string; - selectionMarker: string; - circuitIdentifier: string; - power: string; - quantity: string | null; - additionalSourceValues: Record; -} - -export interface ExternalCsvPreview { - fileName: string; - byteLength: number; - sha256: string; - dialect: ReturnType["dialect"]; - headerRowNumber: number; - headerColumns: string[]; - rowCount: number; - objectCount: number; - passthroughCount: number; - nonEmptyPassthroughCount: number; - suspectObjectCount: number; - objects: ExternalCsvPreviewObject[]; - suspectRowNumbers: number[]; -} - -export function createExternalCsvPreview(input: { - fileName: string; - bytes: Uint8Array; - configuration: ExternalCsvConfiguration; -}): ExternalCsvPreview { - const document = parseExternalCsv(input.bytes, input.configuration); - const header = document.rows[document.headerRowIndex]; - const headerIndex = new Map( - header.cells.map((cell, index) => [cell.value, index]) - ); - const valueAt = (row: (typeof document.rows)[number], column: string) => - row.cells[headerIndex.get(column)!]?.value ?? ""; - const objects = document.rows - .filter((row) => row.classification === "object") - .map((row): ExternalCsvPreviewObject => ({ - rowNumber: row.index + 1, - ifcGuid: valueAt(row, input.configuration.columns.ifcGuid), - roomNumber: valueAt(row, input.configuration.columns.roomNumber), - roomName: valueAt(row, input.configuration.columns.roomName), - familyAndType: valueAt(row, input.configuration.columns.familyAndType), - selectionMarker: valueAt(row, input.configuration.columns.selectionMarker), - circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier), - power: valueAt(row, input.configuration.columns.power), - quantity: - input.configuration.columns.quantity === null - ? null - : valueAt(row, input.configuration.columns.quantity), - additionalSourceValues: Object.fromEntries( - input.configuration.additionalSourceMappings.map((mapping) => [ - mapping.targetField, - valueAt(row, mapping.sourceColumn), - ]) - ), - })); - const passthroughRows = document.rows.filter( - (row) => row.classification === "passthrough" - ); - const suspectRows = document.rows.filter( - (row) => row.classification === "suspect-object" - ); - return { - fileName: input.fileName, - byteLength: input.bytes.byteLength, - sha256: createHash("sha256").update(input.bytes).digest("hex"), - dialect: document.dialect, - headerRowNumber: document.headerRowIndex + 1, - headerColumns: header.cells.map((cell) => cell.value), - rowCount: document.rows.length, - objectCount: objects.length, - passthroughCount: passthroughRows.length, - nonEmptyPassthroughCount: passthroughRows.filter((row) => - row.cells.some((cell) => cell.value !== "") - ).length, - suspectObjectCount: suspectRows.length, - objects, - suspectRowNumbers: suspectRows.map((row) => row.index + 1), - }; -} diff --git a/src/external-model/application/external-initial-import-plan.ts b/src/external-model/application/external-initial-import-plan.ts deleted file mode 100644 index bc916f8..0000000 --- a/src/external-model/application/external-initial-import-plan.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js"; -import { - createExternalRoomKey, - normalizeExternalRoomPart, - projectInitialExternalObject, -} from "../domain/external-model-matching.js"; -import { createExternalCsvPreview } from "./external-csv-preview.js"; - -interface ExistingRoom { - id: string; - floorId: string | null; - roomNumber: string; - roomName: string; -} - -interface ExistingFloor { - id: string; - name: string; - sortOrder: number; -} - -interface ExistingDistributionBoard { - id: string; - name: string; - floorId: string | null; -} - -interface ExistingProjectDevice { - id: string; - name: string; - displayName: string; - category: string | null; - connectionKind: string | null; - powerPerUnit: number; -} - -export function createExternalInitialImportPlan(input: { - fileName: string; - bytes: Uint8Array; - configurationVersion: number; - configuration: ExternalCsvConfiguration; - rooms: ExistingRoom[]; - floors: ExistingFloor[]; - distributionBoards: ExistingDistributionBoard[]; - projectDevices: ExistingProjectDevice[]; -}) { - const preview = createExternalCsvPreview(input); - const roomCandidatesByNumber = new Map(); - for (const room of input.rooms) { - const key = normalizeExternalRoomPart(room.roomNumber); - if (!key) continue; - const candidates = roomCandidatesByNumber.get(key) ?? []; - candidates.push(room); - roomCandidatesByNumber.set(key, candidates); - } - - const objects = preview.objects.map((object) => { - const projected = projectInitialExternalObject(object, input.configuration); - return { - ifcGuid: projected.ifcGuid, - sourceRoomKey: createExternalRoomKey(object.roomNumber, object.roomName), - sourceValues: projected.sourceValues, - suggestedPlanningValues: projected.planningValues, - issues: projected.issues, - }; - }); - const sourceRooms = new Map(); - for (const object of objects) { - if (object.sourceRoomKey === null) continue; - const existing = sourceRooms.get(object.sourceRoomKey); - if (existing) { - existing.objectCount += 1; - } else { - sourceRooms.set(object.sourceRoomKey, { - sourceRoomKey: object.sourceRoomKey, - roomNumber: object.sourceValues.roomNumber, - roomName: object.sourceValues.roomName, - objectCount: 1, - }); - } - } - const roomPlans = [...sourceRooms.values()] - .map((room) => { - const candidates = roomCandidatesByNumber.get( - normalizeExternalRoomPart(room.roomNumber) - ) ?? []; - return { - ...room, - suggestedRoomId: candidates.length === 1 ? candidates[0].id : null, - matchStatus: - candidates.length === 1 - ? "exact-number" - : candidates.length > 1 - ? "ambiguous-number" - : "new", - candidateRoomIds: candidates.map((candidate) => candidate.id), - } as const; - }) - .sort((left, right) => - left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) || - left.roomName.localeCompare(right.roomName, "de") - ); - - const familyGroups = new Map(); - for (const object of objects) { - const key = object.sourceValues.familyAndType; - const group = familyGroups.get(key); - if (group) { - group.objectCount += 1; - } else { - familyGroups.set(key, { - familyAndType: key, - objectCount: 1, - classified: !object.issues.includes("unknown-family-and-type"), - category: object.suggestedPlanningValues.category, - connectionKind: object.suggestedPlanningValues.connectionKind, - internalDeviceType: object.suggestedPlanningValues.internalDeviceType, - }); - } - } - - return { - configurationVersion: input.configurationVersion, - fileName: preview.fileName, - sha256: preview.sha256, - byteLength: preview.byteLength, - rowCount: preview.rowCount, - objectCount: preview.objectCount, - passthroughCount: preview.passthroughCount, - suspectObjectCount: preview.suspectObjectCount, - suspectRowNumbers: preview.suspectRowNumbers, - sourceRooms: roomPlans, - familyGroups: [...familyGroups.values()].sort((left, right) => - left.familyAndType.localeCompare(right.familyAndType, "de") - ), - issueCounts: { - unknownFamilyAndType: objects.filter((object) => - object.issues.includes("unknown-family-and-type") - ).length, - invalidPower: objects.filter((object) => object.issues.includes("invalid-power")).length, - invalidQuantity: objects.filter((object) => object.issues.includes("invalid-quantity")).length, - missingRoom: objects.filter((object) => object.issues.includes("missing-room")).length, - }, - objects, - existing: { - floors: input.floors, - rooms: input.rooms, - distributionBoards: input.distributionBoards, - projectDevices: input.projectDevices, - }, - }; -} diff --git a/src/external-model/application/external-initial-import-target.ts b/src/external-model/application/external-initial-import-target.ts deleted file mode 100644 index be0925e..0000000 --- a/src/external-model/application/external-initial-import-target.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { ExternalCsvConfiguration, ExternalCsvDocument } from "../csv/external-csv-contracts.js"; -import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js"; -import type { createExternalInitialImportPlan } from "./external-initial-import-plan.js"; - -type ExternalInitialImportPlan = ReturnType; - -export interface ExternalRoomDecision { - sourceRoomKey: string; - roomId: string | null; - defaultDistributionBoardId: string | null; -} - -export interface ExternalFamilyProjectDeviceDecision { - familyAndType: string; - projectDeviceId: string | null; -} - -export function buildExternalInitialImportTarget(input: { - projectId: string; - expectedRevision: number; - sourceName: string; - importedAtIso: string; - configurationVersion: number; - configuration: ExternalCsvConfiguration; - originalContentBase64: string; - document: ExternalCsvDocument; - plan: ExternalInitialImportPlan; - roomDecisions: ExternalRoomDecision[]; - familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[]; - createId: () => string; -}): ExternalModelStateSnapshot { - if (input.plan.familyGroups.some((group) => !group.classified)) { - throw new Error("Alle Familie-und-Typ-Werte müssen vor dem Erstimport klassifiziert sein."); - } - const roomDecisionByKey = exactDecisionMap( - input.plan.sourceRooms.map((room) => room.sourceRoomKey), - input.roomDecisions, - (decision) => decision.sourceRoomKey, - "Quellraum" - ); - const familyDecisionByName = exactDecisionMap( - input.plan.familyGroups.map((group) => group.familyAndType), - input.familyProjectDeviceDecisions, - (decision) => decision.familyAndType, - "Familie und Typ" - ); - const sourceId = input.createId(); - const batchId = input.createId(); - const roomMappingIdByKey = new Map( - input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()]) - ); - return { - source: { - id: sourceId, - projectId: input.projectId, - name: input.sourceName.trim(), - sourceType: "revit_csv", - }, - importBatches: [{ - id: batchId, - projectId: input.projectId, - sourceId, - importKind: "initial", - importedAtIso: input.importedAtIso, - fileName: input.plan.fileName, - sha256: input.plan.sha256, - appliedProjectRevision: input.expectedRevision + 1, - configurationVersion: input.configurationVersion, - configurationSnapshot: input.configuration, - originalContentBase64: input.originalContentBase64, - document: input.document, - }], - roomMappings: input.plan.sourceRooms.map((room) => { - const decision = roomDecisionByKey.get(room.sourceRoomKey)!; - return { - id: roomMappingIdByKey.get(room.sourceRoomKey)!, - projectId: input.projectId, - sourceId, - normalizedSourceRoomKey: room.sourceRoomKey, - sourceFloorName: null, - sourceRoomNumber: room.roomNumber, - sourceRoomName: room.roomName, - roomId: decision.roomId, - defaultDistributionBoardId: decision.defaultDistributionBoardId, - }; - }), - objects: input.plan.objects.map((object) => ({ - id: input.createId(), - projectId: input.projectId, - sourceId, - ifcGuid: object.ifcGuid, - lastSeenImportBatchId: batchId, - lastAcceptedImportBatchId: batchId, - acceptedSourceValues: object.sourceValues, - planningValues: object.suggestedPlanningValues, - overriddenFields: [], - externalRoomMappingId: - object.sourceRoomKey === null - ? null - : roomMappingIdByKey.get(object.sourceRoomKey)!, - distributionBoardId: null, - linkedProjectDeviceId: - familyDecisionByName.get(object.sourceValues.familyAndType)! - .projectDeviceId, - circuitDeviceRowId: null, - presenceStatus: "present", - })), - }; -} - -function exactDecisionMap( - expectedKeys: string[], - decisions: T[], - keyOf: (decision: T) => string, - label: string -) { - const expected = new Set(expectedKeys); - const result = new Map(); - for (const decision of decisions) { - const key = keyOf(decision); - if (!expected.has(key) || result.has(key)) { - throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); - } - result.set(key, decision); - } - if (result.size !== expected.size) { - throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); - } - return result; -} diff --git a/src/external-model/configuration/external-model-configuration.ts b/src/external-model/configuration/external-model-configuration.ts new file mode 100644 index 0000000..b3dc20e --- /dev/null +++ b/src/external-model/configuration/external-model-configuration.ts @@ -0,0 +1,246 @@ +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; +import { + createDefaultExternalXlsxTransportConfiguration, + type ExternalXlsxColumnConfiguration, + type ExternalXlsxTransportConfiguration, +} from "../xlsx/external-xlsx-contracts.js"; + +export const externalModelConfigurationSchemaVersion = 1 as const; +export const externalQuantityRules = ["one", "socketCount"] as const; +export type ExternalQuantityRule = (typeof externalQuantityRules)[number]; + +export interface ExternalDeviceTypeDefaults { + powerPerUnitW: number | null; + simultaneityFactor: number | null; + cosPhi: number | null; + costGroup: string | null; +} + +export interface ExternalDeviceTypeConfiguration { + id: string; + name: string; + category: CircuitGroupCategory; + defaults: ExternalDeviceTypeDefaults; +} + +export interface ExternalFamilyTypeRule { + id: string; + exactFamilyAndType: string; + deviceTypeId: string; + quantityRule: ExternalQuantityRule; +} + +export interface ExternalModelConfiguration + extends ExternalXlsxTransportConfiguration { + schemaVersion: typeof externalModelConfigurationSchemaVersion; + deviceTypes: ExternalDeviceTypeConfiguration[]; + familyTypeRules: ExternalFamilyTypeRule[]; +} + +export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration { + return { + schemaVersion: externalModelConfigurationSchemaVersion, + ...createDefaultExternalXlsxTransportConfiguration(), + deviceTypes: [], + familyTypeRules: [], + }; +} + +export function assertExternalModelConfiguration( + value: unknown +): asserts value is ExternalModelConfiguration { + if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) { + throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration."); + } + assertExactKeys(value, [ + "schemaVersion", + "layout", + "circuitMode", + "columns", + "deviceTypes", + "familyTypeRules", + ], "Revit-XLSX-Konfiguration"); + assertLayout(value.layout); + if (value.circuitMode !== "combined" && value.circuitMode !== "separate") { + throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig."); + } + assertColumns(value.columns, value.circuitMode); + if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) { + throw new Error("Gerätearten und Familienregeln müssen Listen sein."); + } + + const deviceTypeIds = new Set(); + const deviceTypeNames = new Set(); + for (const [index, deviceType] of value.deviceTypes.entries()) { + assertDeviceType(deviceType, index); + assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID"); + assertUnique(deviceTypeNames, deviceType.name, "Geräteartname"); + } + const ruleIds = new Set(); + const familyNames = new Set(); + for (const [index, rule] of value.familyTypeRules.entries()) { + assertFamilyRule(rule, index); + assertUnique(ruleIds, rule.id, "Familienregel-ID"); + assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ"); + if (!deviceTypeIds.has(rule.deviceTypeId)) { + throw new Error( + `Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.` + ); + } + } +} + +export function validateExternalModelConfiguration(value: unknown): + | { success: true; data: ExternalModelConfiguration } + | { success: false; error: string } { + try { + assertExternalModelConfiguration(value); + return { success: true, data: value }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Ungültige Konfiguration.", + }; + } +} + +function assertLayout(value: unknown) { + if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt."); + assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout"); + assertPositiveInteger(value.headerRow, "Kopfzeile"); + assertPositiveInteger(value.firstDataRow, "erste Datenzeile"); + assertPositiveInteger(value.firstDataColumn, "erste Datenspalte"); + if ((value.firstDataRow as number) <= (value.headerRow as number)) { + throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen."); + } +} + +function assertColumns( + value: unknown, + circuitMode: ExternalModelConfiguration["circuitMode"] +) { + if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt."); + const keys = [ + "worksharingId", + "ifcGuid", + "roomNumber", + "roomName", + "familyAndType", + "selectionMarker", + "socketCount", + "sourceRemark", + "circuitCombined", + "boardName", + "circuitIdentifier", + "designation", + "electricalPowerW", + "voltageV", + "currentA", + ] satisfies (keyof ExternalXlsxColumnConfiguration)[]; + assertExactKeys(value, keys, "XLSX-Spaltenzuordnung"); + const mappedNames = new Set(); + for (const key of keys) { + const columnName = value[key]; + const required = + key === "worksharingId" || + key === "familyAndType" || + (circuitMode === "combined" && key === "circuitCombined") || + (circuitMode === "separate" && + (key === "boardName" || key === "circuitIdentifier")); + if (columnName === null && !required) continue; + assertNonEmptyString(columnName, `Spalte ${key}`); + assertUnique(mappedNames, columnName, "Spaltenzuordnung"); + } +} + +function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration { + if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`); + assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`); + assertUuid(value.id, `Geräteart ${index + 1}.id`); + assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`); + if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) { + throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`); + } + if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`); + assertExactKeys( + value.defaults, + ["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"], + `Geräteart ${index + 1}.defaults` + ); + assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung"); + assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF"); + assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi"); + if (value.defaults.costGroup !== null) { + assertNonEmptyString(value.defaults.costGroup, "Kostengruppe"); + } +} + +function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule { + if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`); + assertExactKeys( + value, + ["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"], + `Familienregel ${index + 1}` + ); + assertUuid(value.id, `Familienregel ${index + 1}.id`); + assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`); + assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`); + if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) { + throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`); + } +} + +function assertPositiveInteger(value: unknown, label: string) { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`${label} muss eine positive Ganzzahl sein.`); + } +} + +function assertNullableNumber( + value: unknown, + minimum: number, + maximum: number, + label: string +) { + if (value === null) return; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < minimum || + value > maximum + ) { + throw new Error(`${label} ist ungültig.`); + } +} + +function assertNonEmptyString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.length === 0 || value !== value.trim()) { + throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`); + } +} + +function assertUuid(value: unknown, label: string): asserts value is string { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) + ) { + throw new Error(`${label} muss eine UUID sein.`); + } +} + +function assertUnique(values: Set, value: string, label: string) { + if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`); + values.add(value); +} + +function assertExactKeys(value: Record, keys: readonly string[], label: string) { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error(`${label} enthält unbekannte oder fehlende Felder.`); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/external-model/csv/external-csv-transport.ts b/src/external-model/csv/external-csv-transport.ts deleted file mode 100644 index 3446d24..0000000 --- a/src/external-model/csv/external-csv-transport.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { assertExternalCsvConfiguration } from "./external-csv-configuration.js"; -import type { - ExternalCsvCell, - ExternalCsvConfiguration, - ExternalCsvDocument, - ExternalCsvLineEnding, - ExternalCsvRow, -} from "./external-csv-contracts.js"; - -export type ExternalCsvParseErrorCode = - | "invalid-encoding" - | "invalid-csv" - | "header-not-found" - | "ambiguous-header" - | "invalid-ifc-guid" - | "duplicate-ifc-guid"; - -export class ExternalCsvParseError extends Error { - constructor( - public readonly code: ExternalCsvParseErrorCode, - message: string - ) { - super(message); - this.name = "ExternalCsvParseError"; - } -} - -export function parseExternalCsv( - input: Uint8Array, - configuration: ExternalCsvConfiguration -): ExternalCsvDocument { - assertExternalCsvConfiguration(configuration); - const hasBom = hasUtf8Bom(input); - const contentBytes = hasBom ? input.subarray(3) : input; - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(contentBytes); - } catch { - throw new ExternalCsvParseError( - "invalid-encoding", - "CSV is not valid UTF-8." - ); - } - - const parsed = parseRows(text, configuration.delimiter); - const headerMatches = findHeaderRows(parsed.rows, configuration); - if (headerMatches.length === 0) { - throw new ExternalCsvParseError( - "header-not-found", - "CSV does not contain exactly the configured required columns in one header row." - ); - } - if (headerMatches.length > 1) { - throw new ExternalCsvParseError( - "ambiguous-header", - "CSV contains more than one possible header row." - ); - } - - const headerRowIndex = headerMatches[0]; - const configuredColumnNames = [ - ...Object.values(configuration.columns).filter( - (column): column is string => column !== null - ), - ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), - ]; - const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value); - if ( - configuredColumnNames.some( - (columnName) => headerValues.filter((value) => value === columnName).length !== 1 - ) - ) { - throw new ExternalCsvParseError( - "ambiguous-header", - "CSV header contains a configured column more than once." - ); - } - const headerLookup = createHeaderLookup(parsed.rows[headerRowIndex]); - const ifcGuidColumnIndex = headerLookup.get(configuration.columns.ifcGuid)!; - const objectBearingColumnIndexes = [ - configuration.columns.roomNumber, - configuration.columns.roomName, - configuration.columns.familyAndType, - configuration.columns.selectionMarker, - configuration.columns.power, - ...(configuration.columns.quantity === null - ? [] - : [configuration.columns.quantity]), - ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), - ].map((column) => headerLookup.get(column)!); - const seenIfcGuids = new Set(); - - const rows: ExternalCsvRow[] = parsed.rows.map((cells, index) => { - if (index < headerRowIndex) { - return { index, cells, classification: "metadata" }; - } - if (index === headerRowIndex) { - return { index, cells, classification: "header" }; - } - - const rawIfcGuid = cells[ifcGuidColumnIndex]?.value ?? ""; - const ifcGuid = rawIfcGuid.trim(); - if (ifcGuid) { - if (ifcGuid !== rawIfcGuid || !isIfcGuid(ifcGuid)) { - throw new ExternalCsvParseError( - "invalid-ifc-guid", - `CSV row ${index + 1} contains an invalid IfcGUID.` - ); - } - if (seenIfcGuids.has(ifcGuid)) { - throw new ExternalCsvParseError( - "duplicate-ifc-guid", - `CSV contains the IfcGUID more than once: ${ifcGuid}` - ); - } - seenIfcGuids.add(ifcGuid); - return { index, cells, classification: "object" }; - } - - const looksLikeObject = objectBearingColumnIndexes.some( - (columnIndex) => (cells[columnIndex]?.value.trim() ?? "") !== "" - ); - return { - index, - cells, - classification: looksLikeObject ? "suspect-object" : "passthrough", - }; - }); - - return { - dialect: { - encoding: "utf-8", - hasBom, - delimiter: configuration.delimiter, - lineEnding: resolveLineEnding(parsed.lineEndings), - quoteCharacter: '"', - quoteAllFields: - rows.flatMap((row) => row.cells).length > 0 && - rows.flatMap((row) => row.cells).every((cell) => cell.wasQuoted), - hasTrailingLineEnding: parsed.hasTrailingLineEnding, - }, - headerRowIndex, - rows, - }; -} - -export function serializeExternalCsv(document: ExternalCsvDocument): Uint8Array { - assertSerializableDocument(document); - const serializedRows = document.rows.map((row) => - row.cells - .map((cell) => serializeCell(cell, document.dialect.delimiter)) - .join(document.dialect.delimiter) - ); - let text = serializedRows.join(document.dialect.lineEnding); - if (document.dialect.hasTrailingLineEnding && document.rows.length > 0) { - text += document.dialect.lineEnding; - } - const encoded = new TextEncoder().encode(text); - if (!document.dialect.hasBom) { - return encoded; - } - const result = new Uint8Array(encoded.length + 3); - result.set([0xef, 0xbb, 0xbf]); - result.set(encoded, 3); - return result; -} - -function parseRows(text: string, delimiter: string) { - const rows: ExternalCsvCell[][] = []; - const lineEndings: ExternalCsvLineEnding[] = []; - let cells: ExternalCsvCell[] = []; - let value = ""; - let wasQuoted = false; - let inQuotes = false; - let afterClosingQuote = false; - let fieldStarted = false; - let hasTrailingLineEnding = false; - - const finishCell = () => { - cells.push({ value, wasQuoted }); - value = ""; - wasQuoted = false; - inQuotes = false; - afterClosingQuote = false; - fieldStarted = false; - }; - const finishRow = (lineEnding: ExternalCsvLineEnding) => { - finishCell(); - rows.push(cells); - cells = []; - lineEndings.push(lineEnding); - hasTrailingLineEnding = true; - }; - - for (let index = 0; index < text.length; index += 1) { - const character = text[index]; - if (inQuotes) { - if (character === '"') { - if (text[index + 1] === '"') { - value += '"'; - index += 1; - } else { - inQuotes = false; - afterClosingQuote = true; - } - } else { - value += character; - } - hasTrailingLineEnding = false; - continue; - } - - if (afterClosingQuote) { - if (character !== delimiter && character !== "\r" && character !== "\n") { - throw new ExternalCsvParseError( - "invalid-csv", - "CSV contains characters after a closing quote." - ); - } - } - - if (character === delimiter) { - finishCell(); - hasTrailingLineEnding = false; - continue; - } - if (character === "\r" || character === "\n") { - const lineEnding: ExternalCsvLineEnding = - character === "\r" && text[index + 1] === "\n" - ? "\r\n" - : character; - if (lineEnding === "\r\n") { - index += 1; - } - finishRow(lineEnding); - continue; - } - if (character === '"') { - if (fieldStarted || value.length > 0 || afterClosingQuote) { - throw new ExternalCsvParseError( - "invalid-csv", - "CSV contains a quote inside an unquoted field." - ); - } - wasQuoted = true; - inQuotes = true; - fieldStarted = true; - hasTrailingLineEnding = false; - continue; - } - if (afterClosingQuote) { - throw new ExternalCsvParseError( - "invalid-csv", - "CSV contains invalid data after a quoted field." - ); - } - value += character; - fieldStarted = true; - hasTrailingLineEnding = false; - } - - if (inQuotes) { - throw new ExternalCsvParseError( - "invalid-csv", - "CSV contains an unterminated quoted field." - ); - } - if (!hasTrailingLineEnding || cells.length > 0 || fieldStarted || value.length > 0) { - finishCell(); - rows.push(cells); - } - - return { rows, lineEndings, hasTrailingLineEnding }; -} - -function findHeaderRows( - rows: ExternalCsvCell[][], - configuration: ExternalCsvConfiguration -) { - const requiredColumns = new Set([ - ...Object.values(configuration.columns).filter( - (column): column is string => column !== null - ), - ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), - ]); - const matches: number[] = []; - rows.forEach((row, index) => { - const values = new Set(row.map((cell) => cell.value)); - if ([...requiredColumns].every((column) => values.has(column))) { - matches.push(index); - } - }); - return matches; -} - -function createHeaderLookup(cells: ExternalCsvCell[]) { - return new Map(cells.map((cell, index) => [cell.value, index])); -} - -function isIfcGuid(value: string) { - return /^[0-9A-Za-z_$]{22}$/.test(value); -} - -function resolveLineEnding(lineEndings: ExternalCsvLineEnding[]): ExternalCsvLineEnding { - if (lineEndings.length === 0) { - return "\r\n"; - } - const unique = new Set(lineEndings); - if (unique.size !== 1) { - throw new ExternalCsvParseError( - "invalid-csv", - "CSV contains mixed line endings that cannot be preserved reliably." - ); - } - return lineEndings[0]; -} - -function serializeCell(cell: ExternalCsvCell, delimiter: string) { - const mustQuote = - cell.wasQuoted || - cell.value.includes(delimiter) || - cell.value.includes('"') || - cell.value.includes("\r") || - cell.value.includes("\n"); - if (!mustQuote) { - return cell.value; - } - return `"${cell.value.replaceAll('"', '""')}"`; -} - -function assertSerializableDocument(document: ExternalCsvDocument) { - if (document.dialect.encoding !== "utf-8") { - throw new Error("Only UTF-8 external CSV documents can be serialized."); - } - if ( - document.headerRowIndex < 0 || - document.headerRowIndex >= document.rows.length || - document.rows[document.headerRowIndex]?.classification !== "header" - ) { - throw new Error("External CSV document has an invalid header row."); - } - document.rows.forEach((row, index) => { - if (row.index !== index) { - throw new Error("External CSV row indexes must be contiguous and stable."); - } - for (const cell of row.cells) { - if (typeof cell.value !== "string" || typeof cell.wasQuoted !== "boolean") { - throw new Error("External CSV document contains an invalid cell."); - } - } - }); -} - -function hasUtf8Bom(input: Uint8Array) { - return input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf; -} diff --git a/src/external-model/domain/external-model-contracts.ts b/src/external-model/domain/external-model-contracts.ts index b06e2fb..95ab137 100644 --- a/src/external-model/domain/external-model-contracts.ts +++ b/src/external-model/domain/external-model-contracts.ts @@ -1,10 +1,11 @@ import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; +import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js"; import type { - ExternalCsvConfiguration, - ExternalCsvDocument, -} from "../csv/external-csv-contracts.js"; + ExternalXlsxDocument, + ExternalXlsxScalar, +} from "../xlsx/external-xlsx-contracts.js"; -export const externalModelSourceTypes = ["revit_csv"] as const; +export const externalModelSourceTypes = ["revit_xlsx"] as const; export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number]; export const externalImportKinds = ["initial", "follow_up"] as const; @@ -29,11 +30,11 @@ export interface ExternalImportBatchSnapshot { importedAtIso: string; fileName: string; sha256: string; - appliedProjectRevision: number; - configurationVersion: number; - configurationSnapshot: ExternalCsvConfiguration; - originalContentBase64: string; - document: ExternalCsvDocument; + objectCount: number; + appliedProjectRevision: number | null; + configurationSnapshot: ExternalModelConfiguration | null; + originalContentBase64: string | null; + document: ExternalXlsxDocument | null; } export interface ExternalRoomMappingSnapshot { @@ -50,24 +51,28 @@ export interface ExternalRoomMappingSnapshot { export interface ExternalModelObjectSourceValues { rowNumber: number; - roomNumber: string; - roomName: string; + roomNumber: ExternalXlsxScalar; + roomName: ExternalXlsxScalar; familyAndType: string; - selectionMarker: string; - circuitIdentifier: string; - power: string; - quantity: string | null; - additionalSourceValues: Record; + selectionMarker: ExternalXlsxScalar; + socketCount: ExternalXlsxScalar; + sourceRemark: ExternalXlsxScalar; + circuitCombined: ExternalXlsxScalar; + boardName: ExternalXlsxScalar; + circuitIdentifier: ExternalXlsxScalar; + designation: ExternalXlsxScalar; + electricalPowerW: ExternalXlsxScalar; + voltageV: ExternalXlsxScalar; + currentA: ExternalXlsxScalar; } export interface ExternalModelObjectPlanningValues { displayName: string | null; - internalDeviceType: string | null; category: CircuitGroupCategory | null; connectionKind: string | null; effectiveQuantity: number; powerPerUnitW: number | null; - simultaneityFactor: number; + simultaneityFactor: number | null; cosPhi: number | null; costGroup: string | null; remark: string | null; @@ -80,7 +85,9 @@ export interface ExternalModelObjectSnapshot { id: string; projectId: string; sourceId: string; - ifcGuid: string; + worksharingId: string; + ifcGuid: string | null; + deviceTypeId: string; lastSeenImportBatchId: string; lastAcceptedImportBatchId: string; acceptedSourceValues: ExternalModelObjectSourceValues; diff --git a/src/external-model/domain/external-model-matching.ts b/src/external-model/domain/external-model-matching.ts deleted file mode 100644 index 3085211..0000000 --- a/src/external-model/domain/external-model-matching.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js"; -import type { - ExternalModelObjectPlanningValues, - ExternalModelObjectSourceValues, -} from "./external-model-contracts.js"; - -export type InitialExternalObjectIssue = - | "unknown-family-and-type" - | "invalid-power" - | "invalid-quantity" - | "missing-room"; - -export interface InitialExternalObjectProjection { - ifcGuid: string; - sourceValues: ExternalModelObjectSourceValues; - planningValues: ExternalModelObjectPlanningValues; - issues: InitialExternalObjectIssue[]; -} - -export interface ExternalObjectImportCandidate { - rowNumber: number; - ifcGuid: string; - roomNumber: string; - roomName: string; - familyAndType: string; - selectionMarker: string; - circuitIdentifier: string; - power: string; - quantity: string | null; - additionalSourceValues: Record; -} - -export function normalizeExternalRoomPart(value: string): string { - return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE"); -} - -export function createExternalRoomKey( - roomNumber: string, - roomName: string -): string | null { - const normalizedNumber = normalizeExternalRoomPart(roomNumber); - if (normalizedNumber) return `number:${normalizedNumber}`; - const normalizedName = normalizeExternalRoomPart(roomName); - return normalizedName ? `name:${normalizedName}` : null; -} - -export function indexExternalObjectsByIfcGuid( - objects: readonly T[] -): Map { - const index = new Map(); - for (const object of objects) { - if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) { - throw new Error("IfcGUID must be non-empty and must not contain outer whitespace."); - } - if (index.has(object.ifcGuid)) { - throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`); - } - index.set(object.ifcGuid, object); - } - return index; -} - -export function projectInitialExternalObject( - object: ExternalObjectImportCandidate, - configuration: ExternalCsvConfiguration -): InitialExternalObjectProjection { - const rule = configuration.familyTypeRules.find( - (candidate) => candidate.exactFamilyAndType === object.familyAndType - ); - const issues: InitialExternalObjectIssue[] = []; - if (!rule) issues.push("unknown-family-and-type"); - if (!createExternalRoomKey(object.roomNumber, object.roomName)) { - issues.push("missing-room"); - } - - const parsedPower = parseConfiguredNumber(object.power, configuration); - if (object.power.trim() && parsedPower === null) issues.push("invalid-power"); - - let effectiveQuantity = 1; - if (rule?.quantityRule.kind === "fixed") { - effectiveQuantity = rule.quantityRule.quantity; - } else if (rule?.quantityRule.kind === "mapped-column") { - const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration); - if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) { - issues.push("invalid-quantity"); - } else { - effectiveQuantity = parsedQuantity; - } - } - - return { - ifcGuid: object.ifcGuid, - sourceValues: { - rowNumber: object.rowNumber, - roomNumber: object.roomNumber, - roomName: object.roomName, - familyAndType: object.familyAndType, - selectionMarker: object.selectionMarker, - circuitIdentifier: object.circuitIdentifier, - power: object.power, - quantity: object.quantity, - additionalSourceValues: { ...object.additionalSourceValues }, - }, - planningValues: { - displayName: rule - ? resolveDisplayName(rule.displayNameSuggestion, object) - : null, - internalDeviceType: rule?.internalDeviceType ?? null, - category: rule?.category ?? null, - connectionKind: rule?.connectionKind ?? null, - effectiveQuantity, - powerPerUnitW: - parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit, - simultaneityFactor: 1, - cosPhi: null, - costGroup: null, - remark: null, - }, - issues, - }; -} - -function parseConfiguredNumber( - value: string, - configuration: ExternalCsvConfiguration -): number | null { - const trimmed = value.trim(); - if (!trimmed) return null; - const normalized = configuration.decimalSeparator === "," - ? trimmed.replace(",", ".") - : trimmed; - if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null; - const parsed = Number(normalized); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; -} - -function resolveDisplayName( - suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"], - object: ExternalObjectImportCandidate -): string | null { - if (suggestion === null) return null; - if (suggestion.kind === "fixed") return suggestion.value; - if (suggestion.kind === "selection-marker") { - return object.selectionMarker.trim() || null; - } - return object.familyAndType.trim() || null; -} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 572182b..96fd6c2 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -14,7 +14,6 @@ import type { ExternalCsvConfiguration, ExternalCsvDialect, } from "../external-model/csv/external-csv-contracts"; -import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan"; export interface ExternalCsvConfigurationSnapshotDto { id: string; @@ -52,9 +51,80 @@ export interface ExternalCsvPreviewDto { suspectRowNumbers: number[]; } -export type ExternalInitialImportPlanDto = ReturnType< - typeof createExternalInitialImportPlan ->; +export interface ExternalInitialImportPlanDto { + configurationVersion: number; + fileName: string; + sha256: string; + byteLength: number; + rowCount: number; + objectCount: number; + passthroughCount: number; + suspectObjectCount: number; + suspectRowNumbers: number[]; + sourceRooms: Array<{ + sourceRoomKey: string; + roomNumber: string; + roomName: string; + objectCount: number; + suggestedRoomId: string | null; + matchStatus: "exact-number" | "ambiguous-number" | "new"; + candidateRoomIds: string[]; + }>; + familyGroups: Array<{ + familyAndType: string; + objectCount: number; + classified: boolean; + category: string | null; + connectionKind: string | null; + internalDeviceType: string | null; + }>; + issueCounts: { + unknownFamilyAndType: number; + invalidPower: number; + invalidQuantity: number; + missingRoom: number; + }; + objects: Array<{ + ifcGuid: string; + sourceRoomKey: string | null; + sourceValues: ExternalCsvPreviewObjectDto; + suggestedPlanningValues: { + displayName: string | null; + internalDeviceType: string | null; + category: CircuitGroupCategory | null; + connectionKind: string | null; + effectiveQuantity: number; + powerPerUnitW: number | null; + simultaneityFactor: number; + cosPhi: number | null; + costGroup: string | null; + remark: string | null; + }; + issues: string[]; + }>; + existing: { + floors: Array<{ id: string; name: string; sortOrder: number }>; + rooms: Array<{ + id: string; + floorId: string | null; + roomNumber: string; + roomName: string; + }>; + distributionBoards: Array<{ + id: string; + name: string; + floorId: string | null; + }>; + projectDevices: Array<{ + id: string; + name: string; + displayName: string; + category: string | null; + connectionKind: string | null; + powerPerUnit: number; + }>; + }; +} export interface ApplyExternalInitialImportInput { expectedRevision: number; diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 846a0da..a165a30 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -13,7 +13,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js"; -import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; +import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; import { UserRepository } from "../../db/repositories/user.repository.js"; @@ -36,8 +36,8 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db); export const projectRepository = new ProjectRepository(db); export const roomRepository = new RoomRepository(db); -export const externalCsvConfigurationRepository = - new ExternalCsvConfigurationRepository(db); +export const externalModelConfigurationRepository = + new ExternalModelConfigurationRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db); export const userRepository = new UserRepository(db); diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 8768173..00f2d5f 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -22,8 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; -import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js"; -import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js"; @@ -71,10 +70,8 @@ export const projectSettingsProjectCommandStore = export const projectStateRestoreCommandStore = new ProjectStateRestoreCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); -export const externalCsvConfigurationProjectCommandStore = - new ExternalCsvConfigurationProjectCommandRepository(db); -export const externalInitialImportProjectCommandStore = - new ExternalInitialImportProjectCommandRepository(db); +export const externalModelConfigurationProjectCommandStore = + new ExternalModelConfigurationProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = @@ -106,8 +103,7 @@ export const projectCommandService = new ProjectCommandService( circuitGroupSubtreeProjectCommandStore, circuitProtectionProjectCommandStore, projectHistoryStore, - externalCsvConfigurationProjectCommandStore, - externalInitialImportProjectCommandStore, + externalModelConfigurationProjectCommandStore, externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, externalObjectNewCircuitProjectCommandStore, diff --git a/src/server/controllers/external-csv.controller.ts b/src/server/controllers/external-csv.controller.ts deleted file mode 100644 index 4862d9b..0000000 --- a/src/server/controllers/external-csv.controller.ts +++ /dev/null @@ -1,298 +0,0 @@ -import crypto from "node:crypto"; -import type { Request, Response } from "express"; -import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; -import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js"; -import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js"; -import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js"; -import { buildExternalInitialImportTarget } from "../../external-model/application/external-initial-import-target.js"; -import { createExternalCircuitListObjects } from "../../external-model/application/external-circuit-list-objects.js"; -import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js"; -import { - createEmptyExternalModelState, - createExternalInitialImportProjectCommand, -} from "../../domain/models/external-initial-import-project-command.model.js"; -import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js"; -import { - previewExternalCsvSchema, - planExternalInitialImportSchema, - applyExternalInitialImportSchema, - updateExternalCsvConfigurationSchema, -} from "../../shared/validation/external-csv.schemas.js"; -import { - distributionBoardRepository, - externalCsvConfigurationRepository, - externalModelStateRepository, - floorRepository, - projectDeviceRepository, - roomRepository, - circuitListRepository, -} from "../composition/application-repositories.js"; -import { projectCommandService } from "../composition/project-command-stores.js"; -import { respondWithProjectCommandError } from "./project-command.controller.js"; - -export function getExternalCsvConfiguration(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const result = externalCsvConfigurationRepository.getByProject(projectId); - if (!result.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - return res.json({ configuration: result.configuration }); -} - -export async function getExternalCircuitListObjects(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const { circuitListId } = req.params; - if (typeof circuitListId !== "string" || !circuitListId.trim()) { - return res.status(400).json({ error: "Invalid circuitListId" }); - } - const circuitList = await circuitListRepository.findById(projectId, circuitListId); - if (!circuitList) { - return res.status(404).json({ error: "Circuit list not found" }); - } - const state = externalModelStateRepository.getByProject(projectId); - if (!state.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - return res.json(createExternalCircuitListObjects({ - state: state.state, - distributionBoardId: circuitList.distributionBoardId, - })); -} - -export function updateExternalCsvConfiguration(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - try { - assertExternalCsvConfiguration(parsed.data.configuration); - const current = externalCsvConfigurationRepository.getByProject(projectId); - if (!current.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - const target = { - id: current.configuration?.id ?? crypto.randomUUID(), - projectId, - configurationVersion: - (current.configuration?.configurationVersion ?? 0) + 1, - configuration: parsed.data.configuration, - }; - const result = projectCommandService.executeUser({ - projectId, - expectedRevision: parsed.data.expectedRevision, - description: "Revit-CSV-Konfiguration bearbeiten", - command: createExternalCsvConfigurationUpdateProjectCommand( - current.configuration, - target - ), - }); - return res.json({ ...result, configuration: target }); - } catch (error) { - return respondWithProjectCommandError(error, res); - } -} - -export function previewExternalCsv(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const parsed = previewExternalCsvSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - const stored = externalCsvConfigurationRepository.getByProject(projectId); - if (!stored.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - if (!stored.configuration) { - return res.status(409).json({ - error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.", - code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED", - }); - } - try { - const bytes = decodeBase64(parsed.data.contentBase64); - return res.json( - createExternalCsvPreview({ - fileName: parsed.data.fileName, - bytes, - configuration: stored.configuration.configuration, - }) - ); - } catch (error) { - if (error instanceof ExternalCsvParseError) { - return res.status(400).json({ error: error.message, code: error.code }); - } - return res.status(400).json({ - error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.", - }); - } -} - -export async function planExternalInitialImport(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const parsed = planExternalInitialImportSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - const stored = externalCsvConfigurationRepository.getByProject(projectId); - if (!stored.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - if (!stored.configuration) { - return res.status(409).json({ - error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.", - code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED", - }); - } - const externalState = externalModelStateRepository.getByProject(projectId); - if (externalState.state.source !== null) { - return res.status(409).json({ - error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", - code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED", - }); - } - try { - const bytes = decodeBase64(parsed.data.contentBase64); - const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([ - floorRepository.listByProject(projectId), - roomRepository.listByProject(projectId), - distributionBoardRepository.listByProject(projectId), - projectDeviceRepository.listByProject(projectId), - ]); - return res.json(createExternalInitialImportPlan({ - fileName: parsed.data.fileName, - bytes, - configurationVersion: stored.configuration.configurationVersion, - configuration: stored.configuration.configuration, - floors, - rooms, - distributionBoards, - projectDevices, - })); - } catch (error) { - if (error instanceof ExternalCsvParseError) { - return res.status(400).json({ error: error.message, code: error.code }); - } - return res.status(400).json({ - error: error instanceof Error ? error.message : "Erstimport-Planung fehlgeschlagen.", - }); - } -} - -export async function applyExternalInitialImport(req: Request, res: Response) { - const projectId = getProjectId(req, res); - if (!projectId) return; - const parsed = applyExternalInitialImportSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - const stored = externalCsvConfigurationRepository.getByProject(projectId); - if (!stored.projectExists) { - return res.status(404).json({ error: "Project not found" }); - } - if (!stored.configuration) { - return res.status(409).json({ - error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.", - code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED", - }); - } - if ( - stored.configuration.configurationVersion !== - parsed.data.expectedConfigurationVersion - ) { - return res.status(409).json({ - error: "Die Revit-CSV-Konfiguration wurde seit der Planung geändert.", - code: "EXTERNAL_CSV_CONFIGURATION_CHANGED", - }); - } - try { - const bytes = decodeBase64(parsed.data.contentBase64); - const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([ - floorRepository.listByProject(projectId), - roomRepository.listByProject(projectId), - distributionBoardRepository.listByProject(projectId), - projectDeviceRepository.listByProject(projectId), - ]); - const plan = createExternalInitialImportPlan({ - fileName: parsed.data.fileName, - bytes, - configurationVersion: stored.configuration.configurationVersion, - configuration: stored.configuration.configuration, - floors, - rooms, - distributionBoards, - projectDevices, - }); - if (plan.sha256 !== parsed.data.expectedSha256) { - return res.status(409).json({ - error: "Die CSV-Datei stimmt nicht mehr mit der geprüften Planung überein.", - code: "EXTERNAL_CSV_FILE_CHANGED", - }); - } - const target = buildExternalInitialImportTarget({ - projectId, - expectedRevision: parsed.data.expectedRevision, - sourceName: parsed.data.sourceName, - importedAtIso: new Date().toISOString(), - configurationVersion: stored.configuration.configurationVersion, - configuration: stored.configuration.configuration, - originalContentBase64: parsed.data.contentBase64, - document: parseExternalCsv(bytes, stored.configuration.configuration), - plan, - roomDecisions: parsed.data.roomDecisions, - familyProjectDeviceDecisions: - parsed.data.familyProjectDeviceDecisions, - createId: () => crypto.randomUUID(), - }); - const result = projectCommandService.executeUser({ - projectId, - expectedRevision: parsed.data.expectedRevision, - description: `Revit-Erstimport ${parsed.data.fileName}`, - command: createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - target - ), - }); - return res.status(201).json({ - ...result, - import: { - sourceId: target.source!.id, - importBatchId: target.importBatches[0].id, - objectCount: target.objects.length, - }, - }); - } catch (error) { - if (error instanceof ExternalCsvParseError) { - return res.status(400).json({ error: error.message, code: error.code }); - } - return respondWithProjectCommandError(error, res); - } -} - -function decodeBase64(value: string) { - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) { - throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert."); - } - const bytes = Buffer.from(value, "base64"); - if (bytes.toString("base64") !== value) { - throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert."); - } - if (bytes.length > 18 * 1024 * 1024) { - throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB."); - } - return bytes; -} - -function getProjectId(req: Request, res: Response) { - const { projectId } = req.params; - if (typeof projectId !== "string" || !projectId.trim()) { - res.status(400).json({ error: "Invalid projectId" }); - return null; - } - return projectId; -} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 927223e..06f9ec2 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -53,14 +53,6 @@ import { importProjectTransfer, importProjectTransferAsNewProject, } from "../controllers/project-transfer.controller.js"; -import { - getExternalCsvConfiguration, - applyExternalInitialImport, - previewExternalCsv, - planExternalInitialImport, - updateExternalCsvConfiguration, - getExternalCircuitListObjects, -} from "../controllers/external-csv.controller.js"; export const projectRouter = Router(); @@ -70,21 +62,6 @@ projectRouter.post("/import", importProjectTransferAsNewProject); projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId/export", exportProjectTransfer); projectRouter.post("/:projectId/import", importProjectTransfer); -projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration); -projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration); -projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv); -projectRouter.post( - "/:projectId/external-csv/initial-import/plan", - planExternalInitialImport -); -projectRouter.get( - "/:projectId/circuit-lists/:circuitListId/external-objects", - getExternalCircuitListObjects -); -projectRouter.post( - "/:projectId/external-csv/initial-import/apply", - applyExternalInitialImport -); projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.post("/:projectId/commands", executeProjectCommand); diff --git a/src/shared/validation/external-csv.schemas.ts b/src/shared/validation/external-csv.schemas.ts deleted file mode 100644 index cf99e66..0000000 --- a/src/shared/validation/external-csv.schemas.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; - -// Base64 length of the 18 MiB transport limit documented for CSV uploads -// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no -// padding). Keep in sync with the controller's raw byte-length check. -export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824; - -export const updateExternalCsvConfigurationSchema = z - .object({ - expectedRevision: z.number().int().nonnegative(), - configuration: z.unknown(), - }) - .strict(); - -export const previewExternalCsvSchema = z - .object({ - fileName: z.string().trim().min(1).max(255), - contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), - }) - .strict(); - -export const planExternalInitialImportSchema = previewExternalCsvSchema; - -export const applyExternalInitialImportSchema = z.object({ - expectedRevision: z.number().int().nonnegative(), - expectedConfigurationVersion: z.number().int().positive(), - expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), - fileName: z.string().trim().min(1).max(255), - contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH), - sourceName: z.string().trim().min(1).max(200), - roomDecisions: z.array(z.object({ - sourceRoomKey: z.string().trim().min(1), - roomId: z.string().trim().min(1).nullable(), - defaultDistributionBoardId: z.string().trim().min(1).nullable(), - }).strict()), - familyProjectDeviceDecisions: z.array(z.object({ - familyAndType: z.string(), - projectDeviceId: z.string().trim().min(1).nullable(), - }).strict()), -}).strict(); diff --git a/tests/external-circuit-list-objects.test.ts b/tests/external-circuit-list-objects.test.ts deleted file mode 100644 index 4552d58..0000000 --- a/tests/external-circuit-list-objects.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { createExternalCircuitListObjects } from "../src/external-model/application/external-circuit-list-objects.js"; -import type { - ExternalModelObjectSnapshot, - ExternalModelStateSnapshot, -} from "../src/external-model/domain/external-model-contracts.js"; - -function object(input: { - id: string; - distributionBoardId: string; - circuitDeviceRowId: string | null; - roomNumber: string; -}): ExternalModelObjectSnapshot { - return { - id: input.id, - projectId: "project-1", - sourceId: "source-1", - ifcGuid: `ifc-${input.id}`, - lastSeenImportBatchId: "batch-1", - lastAcceptedImportBatchId: "batch-1", - acceptedSourceValues: { - rowNumber: 2, - roomNumber: input.roomNumber, - roomName: "Büro", - familyAndType: "Steckdose: Standard", - selectionMarker: `S-${input.id}`, - circuitIdentifier: "-2F1.1", - power: "120", - quantity: "1", - additionalSourceValues: {}, - }, - planningValues: { - displayName: "Steckdose", - internalDeviceType: "socket", - category: "single_phase", - connectionKind: "socket", - effectiveQuantity: 1, - powerPerUnitW: 120, - simultaneityFactor: 1, - cosPhi: null, - costGroup: null, - remark: null, - }, - overriddenFields: [], - externalRoomMappingId: input.distributionBoardId === "board-1" ? "mapping-1" : null, - distributionBoardId: input.distributionBoardId, - linkedProjectDeviceId: null, - circuitDeviceRowId: input.circuitDeviceRowId, - presenceStatus: "present", - }; -} - -describe("external circuit-list object projection", () => { - it("returns only the board objects with unassigned objects first", () => { - const state: ExternalModelStateSnapshot = { - source: { - id: "source-1", - projectId: "project-1", - name: "Revit-Gesamtmodell", - sourceType: "revit_csv", - }, - importBatches: [], - roomMappings: [{ - id: "mapping-1", - projectId: "project-1", - sourceId: "source-1", - normalizedSourceRoomKey: "number:101", - sourceFloorName: "EG", - sourceRoomNumber: "101", - sourceRoomName: "Besprechung", - roomId: "room-1", - defaultDistributionBoardId: "board-1", - }], - objects: [ - object({ id: "assigned", distributionBoardId: "board-1", circuitDeviceRowId: "row-1", roomNumber: "102" }), - object({ id: "unassigned", distributionBoardId: "board-1", circuitDeviceRowId: null, roomNumber: "101" }), - object({ id: "other-board", distributionBoardId: "board-2", circuitDeviceRowId: null, roomNumber: "001" }), - ], - }; - - const result = createExternalCircuitListObjects({ - state, - distributionBoardId: "board-1", - }); - - assert.equal(result.sourceName, "Revit-Gesamtmodell"); - assert.equal(result.objectCount, 2); - assert.equal(result.unassignedObjectCount, 1); - assert.deepEqual(result.objects.map(({ id }) => id), ["unassigned", "assigned"]); - assert.equal(result.objects[0].sourceRoomName, "Besprechung"); - assert.equal(result.objects[0].roomId, "room-1"); - assert.equal(result.objects[0].sourceCircuitIdentifier, "-2F1.1"); - }); - - it("returns an empty projection before the optional Revit import", () => { - assert.deepEqual( - createExternalCircuitListObjects({ - state: { source: null, importBatches: [], roomMappings: [], objects: [] }, - distributionBoardId: "board-1", - }), - { sourceName: null, objectCount: 0, unassignedObjectCount: 0, objects: [] } - ); - }); -}); diff --git a/tests/external-csv-api-contract.test.ts b/tests/external-csv-api-contract.test.ts deleted file mode 100644 index dfba2ae..0000000 --- a/tests/external-csv-api-contract.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - applyExternalInitialImportSchema, - MAX_CSV_CONTENT_BASE64_LENGTH, - planExternalInitialImportSchema, - previewExternalCsvSchema, - updateExternalCsvConfigurationSchema, -} from "../src/shared/validation/external-csv.schemas.js"; -import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; -import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; - -describe("external CSV API contracts", () => { - it("accepts strict update and preview request envelopes", () => { - assert.equal( - updateExternalCsvConfigurationSchema.safeParse({ - expectedRevision: 4, - configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns), - }).success, - true - ); - assert.equal( - previewExternalCsvSchema.safeParse({ - fileName: "revit.csv", - contentBase64: "YWJj", - }).success, - true - ); - assert.equal( - planExternalInitialImportSchema.safeParse({ - fileName: "revit.csv", - contentBase64: "YWJj", - }).success, - true - ); - assert.equal( - applyExternalInitialImportSchema.safeParse({ - expectedRevision: 5, - expectedConfigurationVersion: 2, - expectedSha256: "a".repeat(64), - fileName: "revit.csv", - contentBase64: "YWJj", - sourceName: "Revit-Gesamtmodell", - roomDecisions: [], - familyProjectDeviceDecisions: [], - }).success, - true - ); - }); - - it("rejects stale-shaped, oversized and unknown request fields", () => { - assert.equal( - updateExternalCsvConfigurationSchema.safeParse({ - expectedRevision: -1, - configuration: {}, - }).success, - false - ); - assert.equal( - previewExternalCsvSchema.safeParse({ - fileName: "revit.csv", - contentBase64: "YWJj", - apply: true, - }).success, - false - ); - assert.equal( - planExternalInitialImportSchema.safeParse({ - fileName: "revit.csv", - contentBase64: "YWJj", - decisions: [], - }).success, - false - ); - assert.equal( - previewExternalCsvSchema.safeParse({ - fileName: "revit.csv", - contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1), - }).success, - false - ); - }); -}); diff --git a/tests/external-csv-configuration-project-command.repository.test.ts b/tests/external-csv-configuration-project-command.repository.test.ts deleted file mode 100644 index 0186581..0000000 --- a/tests/external-csv-configuration-project-command.repository.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { eq } from "drizzle-orm"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { - createDatabaseContext, - type DatabaseContext, -} from "../src/db/database-context.js"; -import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; -import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js"; -import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; -import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; -import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; -import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; -import { projects } from "../src/db/schema/projects.js"; -import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js"; -import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; -import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; -import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; - -function createTestDatabase(): DatabaseContext { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { - migrationsFolder: path.resolve("src", "db", "migrations"), - }); - context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - return context; -} - -function snapshot(version = 1) { - const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns); - configuration.familyTypeRules.push({ - exactFamilyAndType: "Steckdose: Standard", - internalDeviceType: "Steckdose", - connectionKind: null, - category: "single_phase", - quantityRule: { kind: "fixed", quantity: 1 }, - displayNameSuggestion: { kind: "selection-marker" }, - }); - return { - id: "external-config-1", - projectId: "project-1", - configurationVersion: version, - configuration, - }; -} - -describe("external CSV configuration project command", () => { - it("persists insert and update with exact inverse commands", () => { - const context = createTestDatabase(); - try { - const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); - const initial = snapshot(); - const inserted = repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - description: "Revit-CSV-Konfiguration anlegen", - command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), - }); - assert.equal(inserted.revision.revisionNumber, 1); - assert.deepEqual(readConfiguration(context), initial); - assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null }); - - const target = structuredClone(initial); - target.configurationVersion = 2; - target.configuration.decimalSeparator = "."; - const updated = repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(initial, target), - }); - assert.equal(updated.revision.revisionNumber, 2); - assert.deepEqual(readConfiguration(context), target); - assert.deepEqual(updated.inverse.payload, { expected: target, target: initial }); - } finally { - context.close(); - } - }); - - it("rejects stale state and cross-project targets without a partial revision", () => { - const context = createTestDatabase(); - try { - const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); - const initial = snapshot(); - repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), - }); - - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)), - }), - /changed before update/ - ); - const foreign = snapshot(2); - foreign.projectId = "project-2"; - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign), - }), - /identity must remain stable|different project/ - ); - assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1); - - const skippedVersion = snapshot(3); - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion), - }), - /version must advance by one/ - ); - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial), - }), - /did not change/ - ); - } finally { - context.close(); - } - }); - - it("includes configuration in snapshots and remaps it for project duplicates", () => { - const context = createTestDatabase(); - try { - const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db); - const initial = snapshot(); - repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), - }); - - const state = readProjectStateSnapshot(context.db, "project-1")?.state; - assert.deepEqual(state?.externalCsvConfiguration, initial); - const transferRepository = new ProjectTransferRepository(context.db); - const transfer = transferRepository.exportProject("project-1"); - assert.ok(transfer); - const duplicate = transferRepository.importDuplicate(transfer); - const duplicatedConfiguration = context.db - .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, duplicate.projectId)) - .get(); - assert.ok(duplicatedConfiguration); - assert.notEqual(duplicatedConfiguration.id, initial.id); - assert.equal(duplicatedConfiguration.projectId, duplicate.projectId); - assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration); - } finally { - context.close(); - } - }); - - it("distinguishes projects without configuration from unknown projects", () => { - const context = createTestDatabase(); - try { - const reader = new ExternalCsvConfigurationRepository(context.db); - assert.deepEqual(reader.getByProject("project-1"), { - projectExists: true, - configuration: null, - }); - assert.deepEqual(reader.getByProject("missing"), { - projectExists: false, - configuration: null, - }); - } finally { - context.close(); - } - }); - - it("restores and undoes the complete configuration through snapshot history", () => { - const context = createTestDatabase(); - try { - const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db); - const initial = snapshot(); - configurationRepository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(null, initial), - }); - const configuredState = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(configuredState); - configurationRepository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "user", - command: createExternalCsvConfigurationUpdateProjectCommand(initial, null), - }); - const emptyState = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(emptyState); - - const restoreRepository = new ProjectStateRestoreCommandRepository(context.db); - const restored = restoreRepository.execute({ - projectId: "project-1", - expectedRevision: 2, - source: "user", - command: createProjectStateRestoreCommand( - emptyState.payloadSha256, - configuredState.state - ), - }); - assert.deepEqual(readConfiguration(context), initial); - - restoreRepository.execute({ - projectId: "project-1", - expectedRevision: 3, - source: "undo", - historyTargetChangeSetId: restored.revision.changeSetId, - command: restored.inverse, - }); - assert.equal(readConfiguration(context), null); - } finally { - context.close(); - } - }); -}); - -function readConfiguration(context: DatabaseContext) { - return context.db - .select() - .from(externalCsvConfigurations) - .where(eq(externalCsvConfigurations.projectId, "project-1")) - .get() ?? null; -} diff --git a/tests/external-csv-preview.test.ts b/tests/external-csv-preview.test.ts deleted file mode 100644 index b786ebd..0000000 --- a/tests/external-csv-preview.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; -import { - externalCsvTestConfiguration, - firstIfcGuid, - quotedRevitCsv, - utf8Bytes, -} from "./fixtures/revit-csv-fixtures.js"; - -describe("external CSV preview", () => { - it("projects transport statistics and mapped object source values without writes", () => { - const configuration = structuredClone(externalCsvTestConfiguration); - configuration.additionalSourceMappings.push({ - sourceColumn: "Raumname", - targetField: "sourceRoomLabel", - }); - // The additional mapping intentionally needs a distinct source column in - // persisted configuration, so use a separately named synthetic header. - configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert"; - const csv = quotedRevitCsv - .replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"') - .replaceAll(';"2";"0Ab', ';"2";"A";"0Ab') - .replaceAll(';"1";"1Ab', ';"1";"B";"1Ab') - .replaceAll(';"1";""', ';"1";"C";""') - .replaceAll(';"";""\r\n', ';"";"";""\r\n'); - - const preview = createExternalCsvPreview({ - fileName: "export.csv", - bytes: utf8Bytes(csv, true), - configuration, - }); - - assert.equal(preview.fileName, "export.csv"); - assert.equal(preview.headerRowNumber, 2); - assert.equal(preview.rowCount, 7); - assert.equal(preview.objectCount, 2); - assert.equal(preview.passthroughCount, 2); - assert.equal(preview.nonEmptyPassthroughCount, 1); - assert.equal(preview.suspectObjectCount, 1); - assert.deepEqual(preview.suspectRowNumbers, [6]); - assert.match(preview.sha256, /^[a-f0-9]{64}$/); - assert.deepEqual(preview.objects[0], { - rowNumber: 4, - ifcGuid: firstIfcGuid, - roomNumber: "01/101", - roomName: "Technik", - familyAndType: "Steckdose: Doppelsteckdose", - selectionMarker: "Arbeitsplatz", - circuitIdentifier: "UV_AV_01-2F1", - power: "120,5", - quantity: "2", - additionalSourceValues: { sourceRoomLabel: "A" }, - }); - }); -}); diff --git a/tests/external-csv-transport.test.ts b/tests/external-csv-transport.test.ts deleted file mode 100644 index 11a44ac..0000000 --- a/tests/external-csv-transport.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - ExternalCsvParseError, - parseExternalCsv, - serializeExternalCsv, -} from "../src/external-model/csv/external-csv-transport.js"; -import { - externalCsvTestConfiguration, - firstIfcGuid, - quotedRevitCsv, - utf8Bytes, -} from "./fixtures/revit-csv-fixtures.js"; - -describe("external CSV transport", () => { - it("detects the observed Revit dialect and classifies every row", () => { - const document = parseExternalCsv( - utf8Bytes(quotedRevitCsv, true), - externalCsvTestConfiguration - ); - - assert.deepEqual(document.dialect, { - encoding: "utf-8", - hasBom: true, - delimiter: ";", - lineEnding: "\r\n", - quoteCharacter: '"', - quoteAllFields: true, - hasTrailingLineEnding: true, - }); - assert.equal(document.headerRowIndex, 1); - assert.deepEqual( - document.rows.map((row) => row.classification), - [ - "metadata", - "header", - "passthrough", - "object", - "passthrough", - "suspect-object", - "object", - ] - ); - assert.equal(document.rows[6].cells[2].value, 'Büro "Nord"'); - }); - - it("round-trips BOM, CRLF, quotation, order and cell values byte for byte", () => { - const source = utf8Bytes(quotedRevitCsv, true); - const document = parseExternalCsv(source, externalCsvTestConfiguration); - - assert.deepEqual(serializeExternalCsv(document), source); - }); - - it("parses exports without an optional quantity column", () => { - const configuration = { - ...externalCsvTestConfiguration, - columns: { - ...externalCsvTestConfiguration.columns, - quantity: null, - }, - }; - const csv = [ - '"Projekt-Export";"";"";"";"";"";""', - '"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"IfcGUID"', - `"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Standard";"Arbeitsplatz";"100";"${firstIfcGuid}"`, - ].join("\r\n") + "\r\n"; - - const document = parseExternalCsv(utf8Bytes(csv, true), configuration); - - assert.equal( - document.rows.filter((row) => row.classification === "object").length, - 1 - ); - }); - - it("preserves embedded delimiters and line endings inside quoted cells", () => { - const csv = quotedRevitCsv.replace( - '"Steckdose: Doppelsteckdose"', - '"Steckdose; Doppel\r\nsteckdose"' - ); - const source = utf8Bytes(csv, true); - const document = parseExternalCsv(source, externalCsvTestConfiguration); - - assert.equal( - document.rows[3].cells[3].value, - "Steckdose; Doppel\r\nsteckdose" - ); - assert.deepEqual(serializeExternalCsv(document), source); - }); - - it("blocks missing and ambiguous configured headers", () => { - assertParseError( - utf8Bytes(quotedRevitCsv.replace("IfcGUID", "Andere ID")), - "header-not-found" - ); - const duplicatedHeader = quotedRevitCsv.replace( - '"";"";"";"";"";"";"";""', - quotedRevitCsv.split("\r\n")[1] - ); - assertParseError(utf8Bytes(duplicatedHeader), "ambiguous-header"); - - const duplicateConfiguredColumn = quotedRevitCsv.replace( - '"Stromkreis";"Raumnummer"', - '"Stromkreis";"Raumnummer";"Raumnummer"' - ); - assertParseError( - utf8Bytes(duplicateConfiguredColumn), - "ambiguous-header" - ); - }); - - it("blocks invalid and duplicate non-empty IfcGUIDs", () => { - assertParseError( - utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, "not-an-ifc-guid")), - "invalid-ifc-guid" - ); - assertParseError( - utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, ` ${firstIfcGuid}`)), - "invalid-ifc-guid" - ); - const duplicate = quotedRevitCsv.replace( - "1AbCdEfGhIjKlMnOpQrStu", - firstIfcGuid - ); - assertParseError(utf8Bytes(duplicate), "duplicate-ifc-guid"); - }); - - it("blocks invalid UTF-8, unterminated quotes and mixed row endings", () => { - assertParseError(new Uint8Array([0xff, 0xfe, 0xfd]), "invalid-encoding"); - assertParseError( - utf8Bytes(quotedRevitCsv.replace(/"\r\n$/, "\r\n")), - "invalid-csv" - ); - assertParseError( - utf8Bytes(quotedRevitCsv.replace("\r\n", "\n")), - "invalid-csv" - ); - }); -}); - -function assertParseError(input: Uint8Array, code: ExternalCsvParseError["code"]) { - assert.throws( - () => parseExternalCsv(input, externalCsvTestConfiguration), - (error: unknown) => error instanceof ExternalCsvParseError && error.code === code - ); -} diff --git a/tests/external-initial-import-plan.test.ts b/tests/external-initial-import-plan.test.ts deleted file mode 100644 index 7fc1a43..0000000 --- a/tests/external-initial-import-plan.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js"; -import { buildExternalInitialImportTarget } from "../src/external-model/application/external-initial-import-target.js"; -import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; -import { - externalCsvTestConfiguration, - firstIfcGuid, - quotedRevitCsv, - utf8Bytes, -} from "./fixtures/revit-csv-fixtures.js"; - -describe("external initial import plan", () => { - it("groups source rooms and exposes exact room and classification suggestions", () => { - const configuration = structuredClone(externalCsvTestConfiguration); - configuration.familyTypeRules.push({ - exactFamilyAndType: "Steckdose: Doppelsteckdose", - internalDeviceType: "Steckdose", - connectionKind: "socket", - category: "single_phase", - quantityRule: { kind: "mapped-column" }, - displayNameSuggestion: { kind: "selection-marker" }, - }); - - const plan = createExternalInitialImportPlan({ - fileName: "revit.csv", - bytes: utf8Bytes(quotedRevitCsv, true), - configurationVersion: 4, - configuration, - floors: [{ id: "floor-1", name: "EG", sortOrder: 10 }], - rooms: [{ - id: "room-1", - floorId: "floor-1", - roomNumber: "01/101", - roomName: "Technik Bestand", - }], - distributionBoards: [{ id: "board-1", name: "UV 1", floorId: "floor-1" }], - projectDevices: [{ - id: "device-1", - name: "socket", - displayName: "Steckdose", - category: "single_phase", - connectionKind: "socket", - powerPerUnit: 0.12, - }], - }); - - assert.equal(plan.configurationVersion, 4); - assert.equal(plan.objectCount, 2); - assert.equal(plan.sourceRooms.length, 2); - assert.deepEqual( - plan.sourceRooms.map((room) => ({ - number: room.roomNumber, - status: room.matchStatus, - suggested: room.suggestedRoomId, - })), - [ - { number: "01/101", status: "exact-number", suggested: "room-1" }, - { number: "01/103", status: "new", suggested: null }, - ] - ); - assert.equal(plan.familyGroups.length, 2); - assert.equal(plan.issueCounts.unknownFamilyAndType, 1); - assert.equal(plan.issueCounts.invalidPower, 0); - assert.equal(plan.issueCounts.invalidQuantity, 0); - assert.equal(plan.objects[0].ifcGuid, firstIfcGuid); - assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2); - assert.equal(plan.existing.distributionBoards[0].id, "board-1"); - assert.equal(plan.existing.projectDevices[0].id, "device-1"); - }); - - it("builds a stable row-free target only after every family is classified", () => { - const configuration = structuredClone(externalCsvTestConfiguration); - for (const exactFamilyAndType of [ - "Steckdose: Doppelsteckdose", - "Steckdose: Standard", - ]) { - configuration.familyTypeRules.push({ - exactFamilyAndType, - internalDeviceType: "Steckdose", - connectionKind: "socket", - category: "single_phase", - quantityRule: exactFamilyAndType.includes("Doppel") - ? { kind: "mapped-column" } - : { kind: "fixed", quantity: 1 }, - displayNameSuggestion: { kind: "selection-marker" }, - }); - } - const bytes = utf8Bytes(quotedRevitCsv, true); - const plan = createExternalInitialImportPlan({ - fileName: "revit.csv", - bytes, - configurationVersion: 2, - configuration, - floors: [], - rooms: [], - distributionBoards: [], - projectDevices: [], - }); - let id = 0; - const target = buildExternalInitialImportTarget({ - projectId: "project-1", - expectedRevision: 7, - sourceName: "Revit-Gesamtmodell", - importedAtIso: "2026-08-02T17:00:00.000Z", - configurationVersion: 2, - configuration, - originalContentBase64: Buffer.from(bytes).toString("base64"), - document: parseExternalCsv(bytes, configuration), - plan, - roomDecisions: plan.sourceRooms.map((room) => ({ - sourceRoomKey: room.sourceRoomKey, - roomId: null, - defaultDistributionBoardId: null, - })), - familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({ - familyAndType: group.familyAndType, - projectDeviceId: null, - })), - createId: () => `generated-${++id}`, - }); - - assert.equal(target.importBatches[0].appliedProjectRevision, 8); - assert.equal(target.importBatches[0].configurationVersion, 2); - assert.equal(target.objects.length, 2); - assert.ok(target.objects.every((object) => object.circuitDeviceRowId === null)); - assert.ok(target.objects.every((object) => object.overriddenFields.length === 0)); - assert.throws( - () => buildExternalInitialImportTarget({ - projectId: "project-1", - expectedRevision: 7, - sourceName: "Revit-Gesamtmodell", - importedAtIso: "2026-08-02T17:00:00.000Z", - configurationVersion: 2, - configuration, - originalContentBase64: Buffer.from(bytes).toString("base64"), - document: parseExternalCsv(bytes, configuration), - plan, - roomDecisions: [], - familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({ - familyAndType: group.familyAndType, - projectDeviceId: null, - })), - createId: () => "unused", - }), - /Quellraum-Entscheidungen/ - ); - }); -}); diff --git a/tests/external-initial-import-project-command.repository.test.ts b/tests/external-initial-import-project-command.repository.test.ts deleted file mode 100644 index b9acb83..0000000 --- a/tests/external-initial-import-project-command.repository.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import crypto from "node:crypto"; -import path from "node:path"; -import assert from "node:assert/strict"; -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 { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; -import { externalCsvConfigurations } from "../src/db/schema/external-csv-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 { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; -import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; -import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js"; -import { - createExternalRoomKey, - projectInitialExternalObject, -} from "../src/external-model/domain/external-model-matching.js"; -import { - externalCsvTestConfiguration, - quotedRevitCsv, - utf8Bytes, -} from "./fixtures/revit-csv-fixtures.js"; - -function createTestContext() { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - const configuration = configuredCsv(); - context.db.insert(externalCsvConfigurations).values({ - id: "config-1", - projectId: "project-1", - configurationVersion: 1, - configuration, - }).run(); - return { context, configuration }; -} - -function configuredCsv() { - const configuration = structuredClone(externalCsvTestConfiguration); - configuration.familyTypeRules.push( - { - exactFamilyAndType: "Steckdose: Doppelsteckdose", - internalDeviceType: "Steckdose", - connectionKind: "socket", - category: "single_phase", - quantityRule: { kind: "mapped-column" }, - displayNameSuggestion: { kind: "selection-marker" }, - }, - { - exactFamilyAndType: "Steckdose: Standard", - internalDeviceType: "Steckdose", - connectionKind: "socket", - category: "single_phase", - quantityRule: { kind: "fixed", quantity: 1 }, - displayNameSuggestion: { kind: "selection-marker" }, - } - ); - return configuration; -} - -function initialState( - configuration: ReturnType -): ExternalModelStateSnapshot { - const bytes = utf8Bytes(quotedRevitCsv, true); - const preview = createExternalCsvPreview({ - fileName: "revit.csv", - bytes, - configuration, - }); - const roomKeys = [...new Set(preview.objects.map((object) => - createExternalRoomKey(object.roomNumber, object.roomName) - ).filter((key): key is string => key !== null))]; - const roomMappings = roomKeys.map((key, index) => { - const object = preview.objects.find( - (candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key - )!; - return { - id: `mapping-${index + 1}`, - projectId: "project-1", - sourceId: "source-1", - normalizedSourceRoomKey: key, - sourceFloorName: null, - sourceRoomNumber: object.roomNumber, - sourceRoomName: object.roomName, - roomId: null, - defaultDistributionBoardId: null, - }; - }); - const mappingByKey = new Map( - roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id]) - ); - return { - source: { - id: "source-1", - projectId: "project-1", - name: "Revit-Gesamtmodell", - sourceType: "revit_csv", - }, - importBatches: [{ - id: "batch-1", - projectId: "project-1", - sourceId: "source-1", - importKind: "initial", - importedAtIso: "2026-08-02T16:00:00.000Z", - fileName: "revit.csv", - sha256: crypto.createHash("sha256").update(bytes).digest("hex"), - appliedProjectRevision: 1, - configurationVersion: 1, - configurationSnapshot: configuration, - originalContentBase64: Buffer.from(bytes).toString("base64"), - document: parseExternalCsv(bytes, configuration), - }], - roomMappings, - objects: preview.objects.map((candidate, index) => { - const projected = projectInitialExternalObject(candidate, configuration); - const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName); - return { - id: `object-${index + 1}`, - projectId: "project-1", - sourceId: "source-1", - ifcGuid: projected.ifcGuid, - lastSeenImportBatchId: "batch-1", - lastAcceptedImportBatchId: "batch-1", - acceptedSourceValues: projected.sourceValues, - planningValues: projected.planningValues, - overriddenFields: [], - externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!, - distributionBoardId: null, - linkedProjectDeviceId: null, - circuitDeviceRowId: null, - presenceStatus: "present", - }; - }), - }; -} - -describe("external initial import project command", () => { - it("commits the complete import and supports persisted undo and redo", () => { - const { context, configuration } = createTestContext(); - try { - const target = initialState(configuration); - const repository = new ExternalInitialImportProjectCommandRepository(context.db); - const inserted = repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - description: "Revit-Erstimport übernehmen", - command: createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - target - ), - }); - assert.equal(inserted.revision.revisionNumber, 1); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.objects.length, - 2 - ); - assert.ok( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.objects.every((object) => object.circuitDeviceRowId === null) - ); - - const undone = repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "undo", - historyTargetChangeSetId: inserted.revision.changeSetId, - command: inserted.inverse, - }); - assert.equal(undone.revision.revisionNumber, 2); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.source, - null - ); - - repository.execute({ - projectId: "project-1", - expectedRevision: 2, - source: "redo", - historyTargetChangeSetId: inserted.revision.changeSetId, - command: undone.inverse, - }); - const redone = new ExternalModelStateRepository(context.db) - .getByProject("project-1").state; - assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]); - assert.equal(redone.importBatches[0].configurationVersion, 1); - } finally { - context.close(); - } - }); - - it("rejects a target state with zero classified objects before writing anything", () => { - // assertPopulatedInitialState requires at least one object, so a CSV - // that classified none must be rejected at command construction, not - // reach the persistence layer's bulk insert. - const { context, configuration } = createTestContext(); - try { - const target: ExternalModelStateSnapshot = { - ...initialState(configuration), - roomMappings: [], - objects: [], - }; - assert.throws( - () => - createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - target - ), - /at least one object/ - ); - } finally { - context.close(); - } - }); - - it("rejects changed state, checksum drift and row assignment", () => { - const { context, configuration } = createTestContext(); - try { - const target = initialState(configuration); - const repository = new ExternalInitialImportProjectCommandRepository(context.db); - const assigned = structuredClone(target); - assigned.objects[0].circuitDeviceRowId = "row-1"; - assert.throws( - () => createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - assigned - ), - /must not assign circuit device rows/ - ); - - const invalidHash = structuredClone(target); - invalidHash.importBatches[0].sha256 = "b".repeat(64); - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - invalidHash - ), - }), - /checksum is invalid/ - ); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.source, - null - ); - } finally { - context.close(); - } - }); - - it("rolls back all external rows when late history persistence fails", () => { - const { context, configuration } = createTestContext(); - try { - context.sqlite.exec(` - CREATE TRIGGER fail_external_import_history - BEFORE INSERT ON project_change_sets - BEGIN - SELECT RAISE(ABORT, 'late history failure'); - END; - `); - const repository = new ExternalInitialImportProjectCommandRepository(context.db); - assert.throws( - () => repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createExternalInitialImportProjectCommand( - createEmptyExternalModelState(), - initialState(configuration) - ), - }), - /late history failure/ - ); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.source, - null - ); - assert.equal( - context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, - 0 - ); - } finally { - context.close(); - } - }); -}); diff --git a/tests/external-model-configuration.test.ts b/tests/external-model-configuration.test.ts new file mode 100644 index 0000000..7c1066a --- /dev/null +++ b/tests/external-model-configuration.test.ts @@ -0,0 +1,132 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js"; +import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js"; +import { ExternalModelConfigurationRepository } from "../src/db/repositories/external-model-configuration.repository.js"; +import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; +import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js"; +import { + assertExternalModelConfiguration, + createDefaultExternalModelConfiguration, +} from "../src/external-model/configuration/external-model-configuration.js"; + +const deviceTypeId = "10000000-0000-4000-8000-000000000001"; +const ruleId = "20000000-0000-4000-8000-000000000001"; + +function configuration() { + const value = createDefaultExternalModelConfiguration(); + value.deviceTypes.push({ + id: deviceTypeId, + name: "Steckdose", + category: "single_phase", + defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" }, + }); + value.familyTypeRules.push({ + id: ruleId, + exactFamilyAndType: "Steckdose: Standard", + deviceTypeId, + quantityRule: "socketCount", + }); + return value; +} + +function snapshot(projectId = "project-1") { + return { id: "config-1", projectId, configuration: configuration() }; +} + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + return context; +} + +describe("external model configuration", () => { + it("validates the single Worksharing-ID mapping and exact family rules", () => { + const value = configuration(); + assert.doesNotThrow(() => assertExternalModelConfiguration(value)); + assert.equal("idColumn" in value.layout, false); + + const duplicate = structuredClone(value); + duplicate.columns.ifcGuid = duplicate.columns.worksharingId; + assert.throws(() => assertExternalModelConfiguration(duplicate), /nicht eindeutig/); + + const unknownType = structuredClone(value); + unknownType.familyTypeRules[0]!.deviceTypeId = "30000000-0000-4000-8000-000000000001"; + assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/); + }); + + it("persists changes without an independent configuration revision", () => { + const context = createTestDatabase(); + try { + const repository = new ExternalModelConfigurationProjectCommandRepository(context.db); + const initial = snapshot(); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalModelConfigurationUpdateProjectCommand(null, initial), + }); + assert.equal(inserted.revision.revisionNumber, 1); + assert.equal("configurationVersion" in initial, false); + + const target = structuredClone(initial); + target.configuration.deviceTypes[0]!.name = "Steckdose allgemein"; + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createExternalModelConfigurationUpdateProjectCommand(initial, target), + }); + assert.equal(updated.revision.revisionNumber, 2); + assert.deepEqual(updated.inverse.payload, { expected: target, target: initial }); + assert.throws( + () => repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command: createExternalModelConfigurationUpdateProjectCommand(initial, target), + }), + /zwischenzeitlich ge.*ndert/ + ); + } finally { + context.close(); + } + }); + + it("distinguishes missing projects and remaps configuration identities on duplicate", () => { + const context = createTestDatabase(); + try { + const reader = new ExternalModelConfigurationRepository(context.db); + assert.deepEqual(reader.getByProject("project-1"), { projectExists: true, configuration: null }); + assert.deepEqual(reader.getByProject("missing"), { projectExists: false, configuration: null }); + + new ExternalModelConfigurationProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createExternalModelConfigurationUpdateProjectCommand(null, snapshot()), + }); + const transferRepository = new ProjectTransferRepository(context.db); + const transfer = transferRepository.exportProject("project-1"); + assert.ok(transfer); + const duplicate = transferRepository.importDuplicate(transfer); + const copied = context.db.select().from(externalModelConfigurations) + .where(eq(externalModelConfigurations.projectId, duplicate.projectId)).get(); + assert.ok(copied); + assert.notEqual(copied.id, "config-1"); + assert.notEqual(copied.configuration.deviceTypes[0]!.id, deviceTypeId); + assert.equal( + copied.configuration.familyTypeRules[0]!.deviceTypeId, + copied.configuration.deviceTypes[0]!.id + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/external-model-foundation.test.ts b/tests/external-model-foundation.test.ts deleted file mode 100644 index 08319ec..0000000 --- a/tests/external-model-foundation.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import path from "node:path"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { createDatabaseContext } from "../src/db/database-context.js"; -import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; -import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; -import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; -import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.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 { externalImportBatches } from "../src/db/schema/external-import-batches.js"; -import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; -import { externalModelSources } from "../src/db/schema/external-model-sources.js"; -import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; -import { floors } from "../src/db/schema/floors.js"; -import { projectDevices } from "../src/db/schema/project-devices.js"; -import { projects } from "../src/db/schema/projects.js"; -import { rooms } from "../src/db/schema/rooms.js"; -import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; -import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js"; -import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; -import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; -import { - createExternalRoomKey, - indexExternalObjectsByIfcGuid, - projectInitialExternalObject, -} from "../src/external-model/domain/external-model-matching.js"; -import { - externalCsvTestConfiguration, - firstIfcGuid, - quotedRevitCsv, - utf8Bytes, -} from "./fixtures/revit-csv-fixtures.js"; - -function configuredCsv() { - const configuration = structuredClone(externalCsvTestConfiguration); - configuration.familyTypeRules.push({ - exactFamilyAndType: "Steckdose: Doppelsteckdose", - internalDeviceType: "Steckdose", - connectionKind: "socket", - category: "single_phase", - quantityRule: { kind: "mapped-column" }, - displayNameSuggestion: { kind: "selection-marker" }, - }); - return configuration; -} - -describe("external model matching", () => { - it("normalizes source rooms and rejects duplicate IFC identities", () => { - assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101"); - assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD"); - assert.equal(createExternalRoomKey(" ", " "), null); - assert.throws( - () => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]), - /Duplicate IfcGUID/ - ); - }); - - it("derives initial planning values from an exact family rule", () => { - const configuration = configuredCsv(); - const preview = createExternalCsvPreview({ - fileName: "revit.csv", - bytes: utf8Bytes(quotedRevitCsv, true), - configuration, - }); - const projected = projectInitialExternalObject(preview.objects[0], configuration); - - assert.deepEqual(projected.issues, []); - assert.equal(projected.ifcGuid, firstIfcGuid); - assert.equal(projected.planningValues.displayName, "Arbeitsplatz"); - assert.equal(projected.planningValues.category, "single_phase"); - assert.equal(projected.planningValues.effectiveQuantity, 2); - assert.equal(projected.planningValues.powerPerUnitW, 120.5); - }); - - it("keeps unclassified or invalid values visible as issues", () => { - const projected = projectInitialExternalObject( - { - rowNumber: 4, - ifcGuid: firstIfcGuid, - roomNumber: "", - roomName: "", - familyAndType: "Unbekannt", - selectionMarker: "", - circuitIdentifier: "", - power: "12,3,4", - quantity: "0", - additionalSourceValues: {}, - }, - externalCsvTestConfiguration - ); - - assert.deepEqual(projected.issues, [ - "unknown-family-and-type", - "missing-room", - "invalid-power", - ]); - assert.equal(projected.planningValues.powerPerUnitW, null); - }); -}); - -describe("external model state repository", () => { - it("reads a deterministic complete external state without changing project data", () => { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - try { - context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - context.db.insert(externalModelSources).values({ - id: "source-1", - projectId: "project-1", - name: "Revit-Gesamtmodell", - sourceType: "revit_csv", - }).run(); - const configuration = configuredCsv(); - const bytes = utf8Bytes(quotedRevitCsv, true); - const document = parseExternalCsv(bytes, configuration); - context.db.insert(externalImportBatches).values({ - id: "batch-1", - projectId: "project-1", - sourceId: "source-1", - importKind: "initial", - importedAtIso: "2026-08-02T15:30:00.000Z", - fileName: "revit.csv", - sha256: "hash", - appliedProjectRevision: 1, - configurationVersion: 1, - configurationSnapshot: configuration, - originalBytes: Buffer.from(bytes), - document, - }).run(); - context.db.insert(externalRoomMappings).values({ - id: "mapping-1", - projectId: "project-1", - sourceId: "source-1", - normalizedSourceRoomKey: "number:01/101", - sourceFloorName: null, - sourceRoomNumber: "01/101", - sourceRoomName: "Technik", - roomId: null, - defaultDistributionBoardId: null, - }).run(); - const preview = createExternalCsvPreview({ - fileName: "revit.csv", - bytes, - configuration, - }); - const projected = projectInitialExternalObject(preview.objects[0], configuration); - context.db.insert(externalModelObjects).values({ - id: "object-1", - projectId: "project-1", - sourceId: "source-1", - ifcGuid: projected.ifcGuid, - lastSeenImportBatchId: "batch-1", - lastAcceptedImportBatchId: "batch-1", - acceptedSourceValues: projected.sourceValues, - planningValues: projected.planningValues, - overriddenFields: [], - externalRoomMappingId: "mapping-1", - distributionBoardId: null, - linkedProjectDeviceId: null, - circuitDeviceRowId: null, - presenceStatus: "present", - }).run(); - - const result = new ExternalModelStateRepository(context.db).getByProject("project-1"); - assert.equal(result.projectExists, true); - assert.equal(result.state.source?.id, "source-1"); - assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64")); - assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101"); - assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid); - assert.equal(result.state.objects[0].circuitDeviceRowId, null); - assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2); - - assert.throws( - () => context.db.insert(externalModelObjects).values({ - ...context.db.select().from(externalModelObjects).get()!, - id: "object-2", - }).run(), - /UNIQUE constraint failed/ - ); - } finally { - context.close(); - } - }); - - it("distinguishes an empty project from an unknown project", () => { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - try { - context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - const repository = new ExternalModelStateRepository(context.db); - assert.deepEqual(repository.getByProject("project-1"), { - projectExists: true, - state: { source: null, importBatches: [], roomMappings: [], objects: [] }, - }); - assert.equal(repository.getByProject("missing").projectExists, false); - } finally { - context.close(); - } - }); -}); - -describe("external model snapshot and transfer", () => { - it("captures, restores and undoes the complete external state", () => { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - try { - insertLinkedExternalFixture(context.db); - const populated = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(populated); - assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion); - assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1"); - - context.db.delete(externalModelSources).run(); - const empty = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(empty); - assert.equal(empty.state.externalModel.source, null); - - const repository = new ProjectStateRestoreCommandRepository(context.db); - const restored = repository.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createProjectStateRestoreCommand( - empty.payloadSha256, - populated.state - ), - }); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.objects[0].linkedProjectDeviceId, - "device-1" - ); - - repository.execute({ - projectId: "project-1", - expectedRevision: 1, - source: "undo", - historyTargetChangeSetId: restored.revision.changeSetId, - command: restored.inverse, - }); - assert.equal( - new ExternalModelStateRepository(context.db).getByProject("project-1") - .state.source, - null - ); - } finally { - context.close(); - } - }); - - it("remaps every internal external-model link for a project duplicate", () => { - const context = createDatabaseContext(":memory:"); - migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); - try { - insertLinkedExternalFixture(context.db); - const transferRepository = new ProjectTransferRepository(context.db); - const transfer = transferRepository.exportProject("project-1"); - assert.ok(transfer); - const duplicate = transferRepository.importDuplicate(transfer); - const copied = new ExternalModelStateRepository(context.db) - .getByProject(duplicate.projectId).state; - - assert.notEqual(copied.source?.id, "source-1"); - assert.notEqual(copied.importBatches[0].id, "batch-1"); - assert.notEqual(copied.roomMappings[0].id, "mapping-1"); - assert.notEqual(copied.objects[0].id, "object-1"); - assert.equal(copied.objects[0].ifcGuid, firstIfcGuid); - assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id); - assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id); - assert.notEqual(copied.objects[0].distributionBoardId, "board-1"); - assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1"); - assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1"); - assert.equal( - copied.importBatches[0].originalContentBase64, - Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64") - ); - } finally { - context.close(); - } - }); -}); - -function insertLinkedExternalFixture(database: ReturnType["db"]) { - database.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); - database.insert(floors).values({ - id: "floor-1", - projectId: "project-1", - name: "EG", - sortOrder: 10, - }).run(); - database.insert(rooms).values({ - id: "room-1", - projectId: "project-1", - floorId: "floor-1", - roomNumber: "01/101", - roomName: "Technik", - }).run(); - database.insert(projectDevices).values({ - id: "device-1", - projectId: "project-1", - name: "socket", - displayName: "Steckdose", - phaseType: "single_phase", - connectionKind: "socket", - costGroup: null, - category: "single_phase", - quantity: 1, - powerPerUnit: 0.12, - simultaneityFactor: 1, - cosPhi: null, - remark: null, - voltageV: 230, - }).run(); - database.insert(distributionBoards).values({ - id: "board-1", - projectId: "project-1", - name: "UV 1", - floorId: "floor-1", - supplyType: "AV", - simultaneityFactor: 1, - }).run(); - database.insert(circuitLists).values({ - id: "list-1", - projectId: "project-1", - distributionBoardId: "board-1", - name: "UV 1 Stromkreisliste", - }).run(); - database.insert(circuitSections).values({ - id: "section-1", - circuitListId: "list-1", - key: "single_phase", - displayName: "1-phasig 1", - prefix: "-2F1.", - sortOrder: 10, - category: "single_phase", - groupNumber: 1, - }).run(); - database.insert(circuits).values({ - id: "circuit-1", - circuitListId: "list-1", - sectionId: "section-1", - equipmentIdentifier: "-2F1.1", - displayName: "Steckdosen", - sortOrder: 10, - voltage: 230, - isReserve: 0, - }).run(); - database.insert(circuitDeviceRows).values({ - id: "row-1", - circuitId: "circuit-1", - linkedProjectDeviceId: "device-1", - sortOrder: 10, - name: "socket", - displayName: "Steckdose", - phaseType: "single_phase", - connectionKind: "socket", - costGroup: null, - category: "single_phase", - level: null, - roomId: "room-1", - roomNumberSnapshot: "01/101", - roomNameSnapshot: "Technik", - quantity: 2, - powerPerUnit: 0.12, - simultaneityFactor: 1, - cosPhi: null, - remark: null, - overriddenFields: null, - }).run(); - - database.insert(externalModelSources).values({ - id: "source-1", - projectId: "project-1", - name: "Revit-Gesamtmodell", - sourceType: "revit_csv", - }).run(); - const configuration = configuredCsv(); - const bytes = utf8Bytes(quotedRevitCsv, true); - database.insert(externalImportBatches).values({ - id: "batch-1", - projectId: "project-1", - sourceId: "source-1", - importKind: "initial", - importedAtIso: "2026-08-02T15:30:00.000Z", - fileName: "revit.csv", - sha256: "a".repeat(64), - appliedProjectRevision: 0, - configurationVersion: 1, - configurationSnapshot: configuration, - originalBytes: Buffer.from(bytes), - document: parseExternalCsv(bytes, configuration), - }).run(); - database.insert(externalRoomMappings).values({ - id: "mapping-1", - projectId: "project-1", - sourceId: "source-1", - normalizedSourceRoomKey: "number:01/101", - sourceFloorName: "EG", - sourceRoomNumber: "01/101", - sourceRoomName: "Technik", - roomId: "room-1", - defaultDistributionBoardId: "board-1", - }).run(); - const preview = createExternalCsvPreview({ - fileName: "revit.csv", - bytes, - configuration, - }); - const projected = projectInitialExternalObject(preview.objects[0], configuration); - database.insert(externalModelObjects).values({ - id: "object-1", - projectId: "project-1", - sourceId: "source-1", - ifcGuid: projected.ifcGuid, - lastSeenImportBatchId: "batch-1", - lastAcceptedImportBatchId: "batch-1", - acceptedSourceValues: projected.sourceValues, - planningValues: projected.planningValues, - overriddenFields: [], - externalRoomMappingId: "mapping-1", - distributionBoardId: "board-1", - linkedProjectDeviceId: "device-1", - circuitDeviceRowId: "row-1", - presenceStatus: "present", - }).run(); -} diff --git a/tests/external-model-xlsx-foundation.test.ts b/tests/external-model-xlsx-foundation.test.ts new file mode 100644 index 0000000..a4836ac --- /dev/null +++ b/tests/external-model-xlsx-foundation.test.ts @@ -0,0 +1,87 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { parseExternalModelStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js"; +import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; + +const lowerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff"; +const upperId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-FFFFFFFF"; + +function fullBatch(id: string, importedAtIso: string) { + return { + id, + projectId: "p", + sourceId: "s", + importKind: "follow_up" as const, + importedAtIso, + fileName: `${id}.xlsx`, + sha256: "a".repeat(64), + objectCount: 1, + appliedProjectRevision: 3, + configurationSnapshot: createDefaultExternalModelConfiguration(), + originalContentBase64: "AA==", + document: { + sheetName: "Bauteilliste", + headerRowNumber: 1, + firstDataRowNumber: 3, + firstDataColumnNumber: 1, + columns: [], + rows: [], + issues: [], + }, + }; +} + +describe("Linear XLSX external model foundation", () => { + it("keeps only the newest batch complete", () => { + const newest = fullBatch("new", "2026-02-01T00:00:00.000Z"); + const older = { + ...fullBatch("old", "2026-01-01T00:00:00.000Z"), + appliedProjectRevision: null, + configurationSnapshot: null, + originalContentBase64: null, + document: null, + }; + assert.doesNotThrow(() => parseExternalModelStateSnapshot({ + source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" }, + importBatches: [older, newest], + roomMappings: [], + objects: [], + })); + assert.throws(() => parseExternalModelStateSnapshot({ + source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" }, + importBatches: [newest, fullBatch("newer", "2026-03-01T00:00:00.000Z")], + roomMappings: [], + objects: [], + }), /Nur der letzte Importbatch/); + }); + + it("compares Worksharing-IDs exactly and allows duplicate IfcGUID values", () => { + const context = createDatabaseContext(":memory:"); + try { + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.sqlite.exec(` + INSERT INTO projects (id, name) VALUES ('p', 'Projekt'); + INSERT INTO external_model_sources (id, project_id, name) VALUES ('s', 'p', 'Revit'); + INSERT INTO external_import_batches + (id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, object_count) + VALUES ('b', 'p', 's', 'initial', '2026-01-01T00:00:00.000Z', 'model.xlsx', '${"a".repeat(64)}', 2); + `); + const insert = context.sqlite.prepare(` + INSERT INTO external_model_objects + (id, project_id, source_id, worksharing_id, ifc_guid, device_type_id, + last_seen_import_batch_id, last_accepted_import_batch_id, + accepted_source_values, planning_values, overridden_fields) + VALUES (?, 'p', 's', ?, 'same-IfcGUID', 'type', 'b', 'b', '{}', '{}', '[]') + `); + insert.run("o1", lowerId); + insert.run("o2", upperId); + assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2); + assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/); + } finally { + context.close(); + } + }); +}); diff --git a/tests/external-object-row-assignment-project-command.repository.test.ts b/tests/external-object-row-assignment-project-command.repository.test.ts index 35579eb..4512e90 100644 --- a/tests/external-object-row-assignment-project-command.repository.test.ts +++ b/tests/external-object-row-assignment-project-command.repository.test.ts @@ -31,7 +31,6 @@ import { externalObjectAssignToNewCircuitCommandType, } from "../src/domain/models/external-object-new-circuit-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; -import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; function createFixture(initialObjectRowId: string | null = "row-1") { const context = createDatabaseContext(":memory:"); @@ -63,20 +62,16 @@ function createFixture(initialObjectRowId: string | null = "row-1") { simultaneityFactor: 1, }).run(); } - database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run(); + database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run(); database.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T16:00:00.000Z", - fileName: "revit.csv", + fileName: "revit.xlsx", sha256: "a".repeat(64), - appliedProjectRevision: 0, - configurationVersion: 1, - configurationSnapshot: externalCsvTestConfiguration, - originalBytes: Buffer.from("test"), - document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + objectCount: 1, }).run(); database.insert(externalRoomMappings).values({ id: "mapping-1", @@ -98,7 +93,9 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS id: "object-1", projectId: "project-1", sourceId: "source-1", + worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: "ifc-1", + deviceTypeId: "device-type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: { @@ -107,14 +104,18 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS roomName: "Büro", familyAndType: "Steckdose: Standard", selectionMarker: "Steckdose", + socketCount: "2", + sourceRemark: null, + circuitCombined: "-2F1.1", + boardName: "UV 1", circuitIdentifier: "-2F1.1", - power: "120", - quantity: "2", - additionalSourceValues: {}, + designation: null, + electricalPowerW: "120", + voltageV: 230, + currentA: null, }, planningValues: { displayName: "Steckdose", - internalDeviceType: "socket", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, diff --git a/tests/linear-xlsx-external-model-migration.test.ts b/tests/linear-xlsx-external-model-migration.test.ts new file mode 100644 index 0000000..6bfc8bf --- /dev/null +++ b/tests/linear-xlsx-external-model-migration.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { it } from "node:test"; +import { spawnSync } from "node:child_process"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; + +it("replaces legacy CSV state and resets linked row quantities to their manual share", () => { + const source = path.resolve("src", "db", "migrations"); + const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-")); + const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8")); + journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13); + mkdirSync(path.join(staging, "meta")); + writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal)); + for (const entry of journal.entries) { + copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`)); + } + + const context = createDatabaseContext(":memory:"); + try { + migrate(context.db, { migrationsFolder: staging }); + context.sqlite.exec(` + INSERT INTO projects (id, name) VALUES ('p', 'Projekt'); + INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV'); + INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste'); + INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.'); + INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1'); + INSERT INTO circuit_device_rows + (id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor) + VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1); + INSERT INTO external_csv_configurations + (id, project_id, configuration_version, configuration) + VALUES ('cfg', 'p', 3, '{}'); + INSERT INTO external_model_sources (id, project_id, name, source_type) + VALUES ('src', 'p', 'Revit', 'revit_csv'); + INSERT INTO external_import_batches + (id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, + applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version) + VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3); + INSERT INTO external_model_objects + (id, project_id, source_id, ifc_guid, last_seen_import_batch_id, + last_accepted_import_batch_id, accepted_source_values, planning_values, + overridden_fields, circuit_device_row_id) + VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r'); + `); + + migrate(context.db, { migrationsFolder: source }); + + assert.equal( + (context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity, + 2 + ); + assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0); + assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0); + assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/); + + const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>; + assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1); + assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0); + assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []); + assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok"); + } finally { + context.close(); + rmSync(staging, { recursive: true, force: true }); + } +}); + +it("creates and verifies a backup before the Linear-XLSX migration", () => { + const source = path.resolve("src", "db", "migrations"); + const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-")); + const staging = path.join(root, "migrations"); + const dataDirectory = path.join(root, "data"); + mkdirSync(path.join(staging, "meta"), { recursive: true }); + mkdirSync(dataDirectory, { recursive: true }); + const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8")); + journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13); + writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal)); + for (const entry of journal.entries) { + copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`)); + } + + const databasePath = path.join(dataDirectory, "leistungsbilanz.db"); + const context = createDatabaseContext(databasePath); + try { + migrate(context.db, { migrationsFolder: staging }); + } finally { + context.close(); + } + + try { + const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], { + cwd: path.resolve("."), + env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Verified pre-migration backup created/); + const backups = readdirSync(path.join(dataDirectory, "backups")); + assert.equal(backups.length, 1); + + const migrated = createDatabaseContext(databasePath); + try { + assert.ok(migrated.sqlite.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'" + ).get()); + } finally { + migrated.close(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 5630cb8..ac6d887 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; -import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; +import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; @@ -44,7 +44,7 @@ import { projects } from "../src/db/schema/projects.js"; import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { rooms } from "../src/db/schema/rooms.js"; -import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; +import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; @@ -90,9 +90,8 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; -import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js"; -import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; -import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; +import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js"; +import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -166,7 +165,7 @@ function createService(context: DatabaseContext) { new CircuitGroupSubtreeProjectCommandRepository(context.db), new CircuitProtectionProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db), - new ExternalCsvConfigurationProjectCommandRepository(context.db) + new ExternalModelConfigurationProjectCommandRepository(context.db) ); } @@ -1127,31 +1126,62 @@ describe("project command service", () => { } }); - it("dispatches external CSV configuration updates through persistent history", () => { + it("dispatches external XLSX configuration updates through persistent history", () => { const context = createTestDatabase(); try { const service = createService(context); const target = { id: "external-config-1", projectId: "project-1", - configurationVersion: 1, - configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns), + configuration: createDefaultExternalModelConfiguration(), }; const updated = service.executeUser({ projectId: "project-1", expectedRevision: 0, - command: createExternalCsvConfigurationUpdateProjectCommand(null, target), + command: createExternalModelConfigurationUpdateProjectCommand(null, target), }); assert.equal(updated.history.currentRevision, 1); - assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); + assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1); const undone = service.undo({ projectId: "project-1", expectedRevision: 1 }); assert.equal(undone.history.currentRevision, 2); - assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0); + assert.equal(context.db.select().from(externalModelConfigurations).all().length, 0); const redone = service.redo({ projectId: "project-1", expectedRevision: 2 }); assert.equal(redone.history.currentRevision, 3); - assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); + assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1); + } finally { + context.close(); + } + }); + + it("rejects legacy CSV commands stored in project history with a clear error", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createExternalModelConfigurationUpdateProjectCommand(null, { + id: "external-config-1", + projectId: "project-1", + configuration: createDefaultExternalModelConfiguration(), + }), + }); + context.db.update(projectChangeSets).set({ + inversePayloadJson: JSON.stringify({ + schemaVersion: 1, + type: "external-import.apply-initial", + payload: {}, + }), + }).run(); + + assert.throws( + () => service.undo({ projectId: "project-1", expectedRevision: 1 }), + /alten Revit-CSV-Anbindung/ + ); + assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1); + assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1); } finally { context.close(); } diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts index 593a7f6..f5e7a60 100644 --- a/tests/project-device-row-sync-project-command.repository.test.ts +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -19,7 +19,6 @@ import { externalModelSources } from "../src/db/schema/external-model-sources.js import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; -import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, @@ -178,7 +177,7 @@ function linkExternalObject( id: "source-1", projectId: "project-1", name: "Revit", - sourceType: "revit_csv", + sourceType: "revit_xlsx", }) .run(); context.db @@ -189,13 +188,9 @@ function linkExternalObject( sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T16:00:00.000Z", - fileName: "revit.csv", + fileName: "revit.xlsx", sha256: "a".repeat(64), - appliedProjectRevision: 0, - configurationVersion: 1, - configurationSnapshot: externalCsvTestConfiguration, - originalBytes: Buffer.from("test"), - document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + objectCount: 1, }) .run(); context.db @@ -204,7 +199,9 @@ function linkExternalObject( id: "object-1", projectId: "project-1", sourceId: "source-1", + worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: "ifc-1", + deviceTypeId: "device-type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: { @@ -213,14 +210,18 @@ function linkExternalObject( roomName: "Büro", familyAndType: "Leuchte: Standard", selectionMarker: "Leuchte", + socketCount: String(effectiveQuantity), + sourceRemark: null, + circuitCombined: "-1F1", + boardName: "UV 1", circuitIdentifier: "-1F1", - power: "30", - quantity: String(effectiveQuantity), - additionalSourceValues: {}, + designation: null, + electricalPowerW: "30", + voltageV: 230, + currentA: null, }, planningValues: { displayName: "Leuchte", - internalDeviceType: "luminaire", category: "single_phase", connectionKind: "fixed", effectiveQuantity, diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index bc4cd12..b85485c 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -465,6 +465,8 @@ describe("project snapshot repository", () => { rooms: Array>; }; payload.schemaVersion = 5; + (payload as Record).externalCsvConfiguration = null; + delete (payload as Record).externalModelConfiguration; delete payload.buildings; for (const floor of payload.floors) { delete floor.buildingId; diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index aa2371b..1ae460f 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -8,11 +8,16 @@ import { } from "../src/domain/models/project-state-snapshot.model.js"; import { remapProjectState } from "../src/domain/models/project-transfer.model.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; +import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; +import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; /** Removes the fields introduced by snapshot schema version 9. */ function withoutVersionNineFields< T extends { distributionBoards: object[] }, >(snapshot: T): T { + const record = snapshot as Record; + record.externalCsvConfiguration = null; + delete record.externalModelConfiguration; for (const board of snapshot.distributionBoards) { delete (board as Record).feedingDistributionBoardId; delete (board as Record).feedingCircuitId; @@ -77,7 +82,7 @@ function minimalSnapshot() { projectEditor: null as string | null, }, distributionBoards: [], - externalCsvConfiguration: null, + externalModelConfiguration: null, externalModel: { source: null, importBatches: [], @@ -315,6 +320,7 @@ describe("project state snapshot model", () => { const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot())); baseline.schemaVersion = 1 as typeof baseline.schemaVersion; delete (baseline.project as Record).isPublicBuilding; + delete (baseline as Record).externalModelConfiguration; delete (baseline as Record).externalCsvConfiguration; delete (baseline as Record).externalModel; @@ -322,7 +328,7 @@ describe("project state snapshot model", () => { assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.project.isPublicBuilding, false); - assert.equal(upgraded.externalCsvConfiguration, null); + assert.equal(upgraded.externalModelConfiguration, null); }); it("upgrades snapshot version 2 with an empty external CSV configuration", () => { @@ -334,7 +340,7 @@ describe("project state snapshot model", () => { const upgraded = parseProjectStateSnapshot(previous); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); - assert.equal(upgraded.externalCsvConfiguration, null); + assert.equal(upgraded.externalModelConfiguration, null); assert.deepEqual(upgraded.externalModel, { source: null, importBatches: [], @@ -351,7 +357,7 @@ describe("project state snapshot model", () => { const upgraded = parseProjectStateSnapshot(previous); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); - assert.equal(upgraded.externalCsvConfiguration, null); + assert.equal(upgraded.externalModelConfiguration, null); assert.deepEqual(upgraded.externalModel, { source: null, importBatches: [], @@ -360,6 +366,28 @@ describe("project state snapshot model", () => { }); }); + it("rejects legacy CSV configuration and imported state with a German migration error", () => { + const configured = withoutVersionNineFields(structuredClone(minimalSnapshot())); + configured.schemaVersion = 9 as typeof configured.schemaVersion; + configured.externalCsvConfiguration = { + id: "legacy", + projectId: "project-1", + configurationVersion: 1, + configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns), + }; + assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/); + + const imported = withoutVersionNineFields(structuredClone(minimalSnapshot())); + imported.schemaVersion = 9 as typeof imported.schemaVersion; + imported.externalModel.source = { + id: "legacy-source", + projectId: "project-1", + name: "Legacy", + sourceType: "revit_csv", + }; + assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/); + }); + it("upgrades snapshot version 5 with empty board info and cover fields", () => { const previous = withoutVersionSixFields(protectedSnapshot()); previous.schemaVersion = 5 as typeof previous.schemaVersion; @@ -567,7 +595,7 @@ describe("project state snapshot model", () => { }); assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456); - const previous = structuredClone(current); + const previous = withoutVersionNineFields(structuredClone(current)); previous.schemaVersion = 7 as typeof previous.schemaVersion; delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2; const upgraded = parseProjectStateSnapshot(previous); From c633e78cd406688a23f89c0dc15569ad87f6f394 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 09:02:52 +0200 Subject: [PATCH 5/5] Add Revit XLSX settings page --- AGENTS.md | 6 +- README.md | 6 +- docs/spec/revit-excel-phase-14-plan.md | 2 +- src/app/projects/[projectId]/page.tsx | 44 +- src/app/projects/[projectId]/revit/page.tsx | 34 + src/frontend/components/revit-csv-modal.tsx | 666 ------------------ .../components/revit-xlsx-settings-modal.tsx | 45 ++ src/frontend/types.ts | 7 + src/frontend/utils/api.ts | 99 +-- src/frontend/utils/project-version-history.ts | 1 + src/frontend/utils/revit-xlsx-settings.ts | 17 + ...external-model-configuration.controller.ts | 60 ++ src/server/routes/project.routes.ts | 8 + tests/revit-csv-frontend-api.test.ts | 251 ------- tests/revit-xlsx-settings-api.test.ts | 38 + 15 files changed, 259 insertions(+), 1025 deletions(-) create mode 100644 src/app/projects/[projectId]/revit/page.tsx delete mode 100644 src/frontend/components/revit-csv-modal.tsx create mode 100644 src/frontend/components/revit-xlsx-settings-modal.tsx create mode 100644 src/frontend/utils/revit-xlsx-settings.ts create mode 100644 src/server/controllers/external-model-configuration.controller.ts delete mode 100644 tests/revit-csv-frontend-api.test.ts create mode 100644 tests/revit-xlsx-settings-api.test.ts diff --git a/AGENTS.md b/AGENTS.md index c95a271..e8d4bff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -329,8 +329,8 @@ the old development-only external CSV data after resetting linked row totals to their manual share. Snapshot schema 10, restore and project transfer use the new state; non-empty legacy CSV snapshots and legacy CSV history entries are rejected with a German error. The backend CSV parser and routes are removed. -The old project-page frontend remains only until its replacement in R4; do not -reconnect it to runtime CSV endpoints. See +R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and +server-side sample detection, and removes the old project-page CSV modal. See `docs/spec/revit-excel-integration-requirements.md` and `docs/spec/revit-excel-phase-14-plan.md`. @@ -586,7 +586,7 @@ Critical target rules: selected combined/separate mode block export. - Missing GZF defaults visibly to 1 without separate confirmation. - The corrected R2 return workbook was successfully imported into Linear on - 2026-09-30; R3 is complete and R4 is the next package. + 2026-09-30; R3 and R4 are complete and R5 is the next package. - All confirmed writes use typed project commands with `expectedRevision`, complete inverse data and the shared project-command transaction boundary. - Do not add automatic circuit planning, Revit circuit adoption, multi-source diff --git a/README.md b/README.md index b8b9c3e..b70a145 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind vorhanden. Die Backend- und Persistenzgrundlage der bisherigen Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit -Worksharing-ID als externem Schlüssel ersetzt. R0 bis R3 sind abgeschlossen; -die eigene Revit-Seite, Erstimport, Editorzuordnung, Rückgabe und Folgeimport -folgen in R4 bis R10. +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen; +die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport, +Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10. ## Unterstützter Arbeitsablauf diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 4004eb3..0b1b71a 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -12,7 +12,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | | R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. | | R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. | -| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | +| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. | | R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | | R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | | R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index ec99d54..5eb864e 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -5,7 +5,7 @@ import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { ArrowLeft, - FileUp, + FileSpreadsheet, Library, Link2, ListTree, @@ -51,7 +51,6 @@ import { copyProjectDeviceToGlobal, createDistributionBoard, createProjectDevice, - createRoom, deleteProjectDevice, deleteDistributionBoard, disconnectProjectDeviceRows, @@ -83,7 +82,6 @@ import type { BuildingDto, CircuitListDto, CircuitTreeResponseDto, - CreateRoomInput, CreateProjectDeviceInput, DistributionBoardDto, FloorDto, @@ -115,7 +113,6 @@ import { } from "../../../frontend/components/project-settings-modal"; import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; -import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; const projectDeviceSyncFieldLabels: Record = { @@ -200,7 +197,6 @@ export default function ProjectDetailPage() { }) ); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); - const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); const [structureModal, setStructureModal] = useState<"board" | null>(null); const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = useState(false); @@ -679,22 +675,6 @@ export default function ProjectDetailPage() { } } - async function handleCreateRevitRoom(input: CreateRoomInput) { - if (!projectId || !project) throw new Error("Projekt ist nicht geladen."); - const result = await createRoom(projectId, input, project.currentRevision); - setRooms((current) => [...current, result.room]); - applyProjectRevision(result.history.currentRevision); - return result; - } - - async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) { - if (!projectId || !project) throw new Error("Projekt ist nicht geladen."); - const result = await createProjectDevice(projectId, input, project.currentRevision); - setProjectDevices((current) => [...current, result.projectDevice]); - applyProjectRevision(result.history.currentRevision); - return result; - } - async function handleCopyBoard() { if ( !projectId || @@ -997,15 +977,13 @@ export default function ProjectDetailPage() {