diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md
index 29a4aaf..ce80c46 100644
--- a/docs/spec/revit-excel-integration-requirements.md
+++ b/docs/spec/revit-excel-integration-requirements.md
@@ -306,6 +306,16 @@ aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
## 11. Editor-Zuordnung und Löschen
+Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste
+gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen
+Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als
+Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die
+drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen
+kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und
+Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und
+Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen
+explizit.
+
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
@@ -338,6 +348,14 @@ Pfade.
## 12. Rückgabe-Excel
+Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
+an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
+Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
+Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
+Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
+Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
+liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
+
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
diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md
index 9b43c4f..61d8f2d 100644
--- a/docs/spec/revit-excel-phase-14-plan.md
+++ b/docs/spec/revit-excel-phase-14-plan.md
@@ -15,8 +15,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
-| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
-| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
+| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
+| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
@@ -173,6 +173,13 @@ Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
+Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
+aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
+Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
+Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
+Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
+Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
+
## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID;
diff --git a/src/app/globals.css b/src/app/globals.css
index 8b55c75..67985f4 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1128,6 +1128,17 @@ a.kpi:hover {
border-style: dashed;
}
+.project-device-item > summary {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ cursor: grab;
+}
+
+.project-device-item > summary::marker {
+ color: var(--text-faint);
+}
+
.sidebar-actions {
display: flex;
flex-direction: column;
diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx
index fc8b85e..46c3d5d 100644
--- a/src/app/projects/[projectId]/revit/page.tsx
+++ b/src/app/projects/[projectId]/revit/page.tsx
@@ -45,7 +45,7 @@ export default function RevitPage() {
{error ?
{error}
: null}
XLSX-Konfiguration {snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}
{snapshot ? "Konfiguriert" : "Offen"} Importstatus {importStatus?.imported ? <>
{importStatus.objectCount} Revit-Objekte {importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}
Quelle: {importStatus.sourceName}
> :
Noch kein Linear-XLSX-Import vorhanden.
}
{importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
- {settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
+ {settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
;
}
diff --git a/src/db/migrations/0015_wide_night_thrasher.sql b/src/db/migrations/0015_wide_night_thrasher.sql
new file mode 100644
index 0000000..fcd376b
--- /dev/null
+++ b/src/db/migrations/0015_wide_night_thrasher.sql
@@ -0,0 +1,16 @@
+CREATE TABLE `external_export_batches` (
+ `id` text PRIMARY KEY NOT NULL,
+ `project_id` text NOT NULL,
+ `project_revision` integer NOT NULL,
+ `import_batch_id` text NOT NULL,
+ `exported_at_iso` text NOT NULL,
+ `file_name` text NOT NULL,
+ `sha256` text NOT NULL,
+ `configuration_snapshot` text NOT NULL,
+ `configuration_sha256` text NOT NULL,
+ `scope` text NOT NULL,
+ `object_count` integer NOT NULL,
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);
\ No newline at end of file
diff --git a/src/db/migrations/meta/0015_snapshot.json b/src/db/migrations/meta/0015_snapshot.json
new file mode 100644
index 0000000..9c00cc3
--- /dev/null
+++ b/src/db/migrations/meta/0015_snapshot.json
@@ -0,0 +1,3210 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "eab4abfa-dc46-4330-9ddd-b4676343d610",
+ "prevId": "b443af16-e0aa-459a-9443-62a7f8c6b6dd",
+ "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_export_batches": {
+ "name": "external_export_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
+ },
+ "project_revision": {
+ "name": "project_revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "import_batch_id": {
+ "name": "import_batch_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exported_at_iso": {
+ "name": "exported_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
+ },
+ "configuration_snapshot": {
+ "name": "configuration_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "configuration_sha256": {
+ "name": "configuration_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "object_count": {
+ "name": "object_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "external_export_batches_project_exported_idx": {
+ "name": "external_export_batches_project_exported_idx",
+ "columns": [
+ "project_id",
+ "exported_at_iso"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "external_export_batches_project_id_projects_id_fk": {
+ "name": "external_export_batches_project_id_projects_id_fk",
+ "tableFrom": "external_export_batches",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_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 0f6c303..0ab138e 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -106,6 +106,13 @@
"when": 1790748107566,
"tag": "0014_linear_xlsx_external_model",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "6",
+ "when": 1790773066696,
+ "tag": "0015_wide_night_thrasher",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts
index 6919d20..f69dc78 100644
--- a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts
+++ b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts
@@ -23,8 +23,15 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
-import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
+import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
+import {
+ applyExternalObjectLinks,
+ captureExternalObjectDetachTransitions,
+ invertExternalObjectLinkTransitions,
+ loadExpectedExternalObjectTransitions,
+ type ExternalObjectLinkTransition,
+} from "./external-object-assignment.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
@@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
- return executeProjectCommandTransaction(
+ return executeProjectCommandTransactionWithAppliedForward<
+ CircuitDeviceRowStructureProjectCommand,
+ CircuitDeviceRowStructureProjectCommand,
+ CircuitDeviceRowStructureProjectCommand
+ >(
this.database,
input,
(tx) =>
@@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand
- ): CircuitDeviceRowStructureProjectCommand {
+ ): AppliedProjectCommand {
if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
- command.payload.row
+ command.payload.row,
+ command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
@@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database,
projectId,
command.payload.rowId,
- command.payload.expectedCircuitId
+ command.payload.expectedCircuitId,
+ command.payload.externalObjects
);
}
throw new Error("Unsupported circuit device-row structure command.");
@@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
- row: CircuitDeviceRowSnapshot
+ row: CircuitDeviceRowSnapshot,
+ externalObjects: ExternalObjectLinkTransition[]
) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
@@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
}
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
- return createCircuitDeviceRowDeleteProjectCommand(
- row.id,
- row.circuitId
- );
+ if (externalObjects.length > 0) {
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
+ }
+
+ return {
+ forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
+ inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
private delete(
database: AppDatabase,
projectId: string,
rowId: string,
- expectedCircuitId: string
+ expectedCircuitId: string,
+ suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const row = database
.select()
@@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
+ const externalObjects = suppliedExternalObjects ??
+ captureExternalObjectDetachTransitions(database, projectId, [rowId]);
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const result = database
.delete(circuitDeviceRows)
@@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
expectedCircuitId
);
- return createCircuitDeviceRowInsertProjectCommand(
- toCircuitDeviceRowSnapshot(row)
- );
+ return {
+ forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
+ inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
private assertCircuitInProject(
diff --git a/src/db/repositories/circuit-group-subtree-project-command.repository.ts b/src/db/repositories/circuit-group-subtree-project-command.repository.ts
index 9bf27a0..b4baeb6 100644
--- a/src/db/repositories/circuit-group-subtree-project-command.repository.ts
+++ b/src/db/repositories/circuit-group-subtree-project-command.repository.ts
@@ -25,8 +25,14 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
-import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
+import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
+import {
+ applyExternalObjectLinks,
+ captureExternalObjectDetachTransitions,
+ invertExternalObjectLinkTransitions,
+ loadExpectedExternalObjectTransitions,
+} from "./external-object-assignment.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
@@ -43,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
} else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
}
- return executeProjectCommandTransaction(
+ return executeProjectCommandTransactionWithAppliedForward<
+ CircuitGroupSubtreeProjectCommand,
+ CircuitGroupSubtreeProjectCommand,
+ CircuitGroupSubtreeProjectCommand
+ >(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
@@ -54,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
database: AppDatabase,
projectId: string,
command: CircuitGroupSubtreeProjectCommand
- ) {
+ ): AppliedProjectCommand {
if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
@@ -71,6 +81,14 @@ export class CircuitGroupSubtreeProjectCommandRepository
projectId,
current.circuits.map((entry) => entry.circuit.id)
);
+ const externalObjects = command.payload.externalObjects ??
+ captureExternalObjectDetachTransitions(
+ database,
+ projectId,
+ current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id))
+ );
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))
@@ -78,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted.");
}
- return createCircuitGroupRestoreSubtreeProjectCommand(
- command.payload.snapshot
- );
+ return {
+ forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
+ inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot);
- return createCircuitGroupDeleteSubtreeProjectCommand(
- command.payload.snapshot
- );
+ const externalObjects = command.payload.externalObjects ?? [];
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
+ return {
+ forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects),
+ inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
throw new Error("Unsupported circuit-group subtree command.");
}
diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts
index 4841616..f4fc420 100644
--- a/src/db/repositories/circuit-structure-project-command.repository.ts
+++ b/src/db/repositories/circuit-structure-project-command.repository.ts
@@ -25,10 +25,17 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
-import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
+import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
+import {
+ applyExternalObjectLinks,
+ captureExternalObjectDetachTransitions,
+ invertExternalObjectLinkTransitions,
+ loadExpectedExternalObjectTransitions,
+ type ExternalObjectLinkTransition,
+} from "./external-object-assignment.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@@ -36,11 +43,15 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
- return executeProjectCommandTransaction(
+ return executeProjectCommandTransactionWithAppliedForward<
+ CircuitStructureProjectCommand,
+ CircuitStructureProjectCommand,
+ CircuitStructureProjectCommand
+ >(
this.database,
input,
(tx) =>
- this.applyInTransaction(
+ this.applyWithForward(
tx,
input.projectId,
input.source,
@@ -59,13 +70,23 @@ export class CircuitStructureProjectCommandRepository
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
+ return this.applyWithForward(database, projectId, source, command).inverse;
+ }
+
+ private applyWithForward(
+ database: AppDatabase,
+ projectId: string,
+ source: ExecuteCircuitStructureCommandInput["source"],
+ command: CircuitStructureProjectCommand
+ ): AppliedProjectCommand {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
- command.payload.circuit
+ command.payload.circuit,
+ command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeleteCommandType) {
@@ -74,7 +95,8 @@ export class CircuitStructureProjectCommandRepository
database,
projectId,
command.payload.circuitId,
- command.payload.expectedCircuitListId
+ command.payload.expectedCircuitListId,
+ command.payload.externalObjects
);
}
throw new Error("Unsupported circuit structure command.");
@@ -84,7 +106,8 @@ export class CircuitStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
- snapshot: CircuitSnapshot
+ snapshot: CircuitSnapshot,
+ externalObjects: ExternalObjectLinkTransition[]
) {
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
@@ -172,17 +195,23 @@ export class CircuitStructureProjectCommandRepository
.run();
}
- return createCircuitDeleteProjectCommand(
- snapshot.id,
- snapshot.circuitListId
- );
+ if (externalObjects.length > 0) {
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
+ }
+
+ return {
+ forward: createCircuitInsertProjectCommand(snapshot, externalObjects),
+ inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
- expectedCircuitListId: string
+ expectedCircuitListId: string,
+ suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const circuit = database
.select()
@@ -220,7 +249,12 @@ export class CircuitStructureProjectCommandRepository
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
- const inverse = createCircuitInsertProjectCommand({
+ const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions(
+ database, projectId, rows.map((row) => row.id)
+ );
+ loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
+ applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
+ const circuitSnapshot: CircuitSnapshot = {
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
@@ -239,7 +273,7 @@ export class CircuitStructureProjectCommandRepository
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
- });
+ };
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
@@ -255,7 +289,10 @@ export class CircuitStructureProjectCommandRepository
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
- return inverse;
+ return {
+ forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
+ inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
+ };
}
private assertCircuitLocation(
diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts
index 115705b..f7003c5 100644
--- a/src/db/repositories/distribution-board-structure-project-command.repository.ts
+++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts
@@ -46,6 +46,7 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
+import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository
@@ -290,6 +291,7 @@ export class DistributionBoardStructureProjectCommandRepository
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
+ assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
const deleted = database
.delete(distributionBoards)
diff --git a/src/db/repositories/distribution-board-subtree-project-command.repository.ts b/src/db/repositories/distribution-board-subtree-project-command.repository.ts
index 969c287..b32fee5 100644
--- a/src/db/repositories/distribution-board-subtree-project-command.repository.ts
+++ b/src/db/repositories/distribution-board-subtree-project-command.repository.ts
@@ -33,6 +33,7 @@ import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
+import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore
@@ -86,6 +87,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id,
]);
+ assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
const deleted = database
.delete(distributionBoards)
.where(
diff --git a/src/db/repositories/external-export-batch.repository.ts b/src/db/repositories/external-export-batch.repository.ts
new file mode 100644
index 0000000..c8f7a7d
--- /dev/null
+++ b/src/db/repositories/external-export-batch.repository.ts
@@ -0,0 +1,18 @@
+import { desc, eq } from "drizzle-orm";
+import type { AppDatabase } from "../database-context.js";
+import { externalExportBatches } from "../schema/external-export-batches.js";
+
+export class ExternalExportBatchRepository {
+ constructor(private readonly database: AppDatabase) {}
+
+ append(batch: typeof externalExportBatches.$inferInsert) {
+ this.database.insert(externalExportBatches).values(batch).run();
+ return batch;
+ }
+
+ listByProject(projectId: string) {
+ return this.database.select().from(externalExportBatches)
+ .where(eq(externalExportBatches.projectId, projectId))
+ .orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
+ }
+}
diff --git a/src/db/repositories/external-object-assignment.persistence.ts b/src/db/repositories/external-object-assignment.persistence.ts
index 1111730..505481d 100644
--- a/src/db/repositories/external-object-assignment.persistence.ts
+++ b/src/db/repositories/external-object-assignment.persistence.ts
@@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition {
target: ExternalModelObjectSnapshot;
}
+export function captureExternalObjectDetachTransitions(
+ database: AppDatabase,
+ projectId: string,
+ rowIds: string[]
+): ExternalObjectLinkTransition[] {
+ if (rowIds.length === 0) return [];
+ return database.select().from(externalModelObjects)
+ .where(and(
+ eq(externalModelObjects.projectId, projectId),
+ inArray(externalModelObjects.circuitDeviceRowId, rowIds)
+ )).all()
+ .map(toExternalObjectSnapshot)
+ .map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } }));
+}
+
+export function invertExternalObjectLinkTransitions(
+ transitions: ExternalObjectLinkTransition[]
+): ExternalObjectLinkTransition[] {
+ return transitions.map(({ expected, target }) => ({ expected: target, target: expected }));
+}
+
export function loadExpectedExternalObjectTransitions(
database: AppDatabase,
projectId: string,
@@ -86,7 +107,13 @@ export function applyExternalObjectLinks(
) {
for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects)
- .set({ circuitDeviceRowId: target.circuitDeviceRowId })
+ .set({
+ circuitDeviceRowId: target.circuitDeviceRowId,
+ distributionBoardId: target.distributionBoardId,
+ linkedProjectDeviceId: target.linkedProjectDeviceId,
+ planningValues: target.planningValues,
+ overriddenFields: target.overriddenFields,
+ })
.where(and(
eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null
diff --git a/src/db/repositories/external-object-deletion-guard.persistence.ts b/src/db/repositories/external-object-deletion-guard.persistence.ts
new file mode 100644
index 0000000..45fdac7
--- /dev/null
+++ b/src/db/repositories/external-object-deletion-guard.persistence.ts
@@ -0,0 +1,37 @@
+import { and, eq } from "drizzle-orm";
+import type { AppDatabase } from "../database-context.js";
+import { externalModelObjects } from "../schema/external-model-objects.js";
+import { externalRoomMappings } from "../schema/external-room-mappings.js";
+
+export function assertDistributionBoardHasNoExternalObjects(
+ database: AppDatabase,
+ projectId: string,
+ distributionBoardId: string
+) {
+ const reference = database.select({ id: externalModelObjects.id })
+ .from(externalModelObjects)
+ .where(and(
+ eq(externalModelObjects.projectId, projectId),
+ eq(externalModelObjects.distributionBoardId, distributionBoardId)
+ )).limit(1).get();
+ if (reference) {
+ throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
+ }
+}
+
+export function assertRoomHasNoExternalObjects(
+ database: AppDatabase,
+ projectId: string,
+ roomId: string
+) {
+ const reference = database.select({ id: externalModelObjects.id })
+ .from(externalModelObjects)
+ .innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId))
+ .where(and(
+ eq(externalModelObjects.projectId, projectId),
+ eq(externalRoomMappings.roomId, roomId)
+ )).limit(1).get();
+ if (reference) {
+ throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
+ }
+}
diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts
index 51edb3b..40a1743 100644
--- a/src/db/repositories/project-location-structure-project-command.repository.ts
+++ b/src/db/repositories/project-location-structure-project-command.repository.ts
@@ -46,6 +46,7 @@ import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
+import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore
@@ -439,6 +440,7 @@ export class ProjectLocationStructureProjectCommandRepository
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
);
}
+ assertRoomHasNoExternalObjects(database, projectId, expected.id);
const deleted = database
.delete(rooms)
.where(
diff --git a/src/db/schema/external-export-batches.ts b/src/db/schema/external-export-batches.ts
new file mode 100644
index 0000000..37e94d8
--- /dev/null
+++ b/src/db/schema/external-export-batches.ts
@@ -0,0 +1,24 @@
+import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
+import { projects } from "./projects.js";
+
+export const externalExportScopes = ["all", "deviating", "selected"] as const;
+export type ExternalExportScope = (typeof externalExportScopes)[number];
+
+export const externalExportBatches = sqliteTable(
+ "external_export_batches",
+ {
+ id: text("id").primaryKey(),
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
+ projectRevision: integer("project_revision").notNull(),
+ importBatchId: text("import_batch_id").notNull(),
+ exportedAtIso: text("exported_at_iso").notNull(),
+ fileName: text("file_name").notNull(),
+ sha256: text("sha256").notNull(),
+ configurationSnapshot: text("configuration_snapshot", { mode: "json" }).$type().notNull(),
+ configurationSha256: text("configuration_sha256").notNull(),
+ scope: text("scope", { enum: externalExportScopes }).notNull(),
+ objectCount: integer("object_count").notNull(),
+ },
+ (table) => [index("external_export_batches_project_exported_idx").on(table.projectId, table.exportedAtIso)]
+);
diff --git a/src/domain/models/circuit-device-row-structure-project-command.model.ts b/src/domain/models/circuit-device-row-structure-project-command.model.ts
index 2464a27..ebba5ac 100644
--- a/src/domain/models/circuit-device-row-structure-project-command.model.ts
+++ b/src/domain/models/circuit-device-row-structure-project-command.model.ts
@@ -1,4 +1,11 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
+import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
+import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
+
+export interface ExternalObjectLinkSnapshotTransition {
+ expected: ExternalModelObjectSnapshot;
+ target: ExternalModelObjectSnapshot;
+}
export const circuitDeviceRowInsertCommandType =
"circuit-device-row.insert" as const;
@@ -32,11 +39,13 @@ export interface CircuitDeviceRowSnapshot {
export interface CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot;
+ externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string;
expectedCircuitId: string;
+ externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeviceRowInsertProjectCommand
@@ -56,7 +65,8 @@ export type CircuitDeviceRowStructureProjectCommand =
| CircuitDeviceRowDeleteProjectCommand;
export function createCircuitDeviceRowInsertProjectCommand(
- row: CircuitDeviceRowSnapshot
+ row: CircuitDeviceRowSnapshot,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = {
...row,
@@ -65,7 +75,7 @@ export function createCircuitDeviceRowInsertProjectCommand(
const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
- payload: { row: normalizedRow },
+ payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeviceRowInsertProjectCommand(command);
return command;
@@ -73,12 +83,13 @@ export function createCircuitDeviceRowInsertProjectCommand(
export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string,
- expectedCircuitId: string
+ expectedCircuitId: string,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType,
- payload: { rowId, expectedCircuitId },
+ payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeviceRowDeleteProjectCommand(command);
return command;
@@ -143,6 +154,7 @@ export function assertCircuitDeviceRowInsertProjectCommand(
throw new Error("row.cosPhi must be positive or null.");
}
}
+ assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertCircuitDeviceRowDeleteProjectCommand(
@@ -163,6 +175,41 @@ export function assertCircuitDeviceRowDeleteProjectCommand(
command.payload.expectedCircuitId,
"expectedCircuitId"
);
+ assertExternalObjectLinkTransitions(command.payload.externalObjects);
+}
+
+export function assertExternalObjectLinkTransitions(value: unknown) {
+ if (value === undefined) return;
+ if (!Array.isArray(value)) throw new Error("externalObjects must be an array.");
+ const ids = new Set();
+ for (const transition of value) {
+ if (!isPlainObject(transition) || !isPlainObject(transition.expected) || !isPlainObject(transition.target)) {
+ throw new Error("Invalid external object link transition.");
+ }
+ const expectedId = transition.expected.id;
+ const targetId = transition.target.id;
+ if (typeof expectedId !== "string" || expectedId !== targetId || ids.has(expectedId)) {
+ throw new Error("External object link transition has mismatched or duplicate ids.");
+ }
+ ids.add(expectedId);
+ const expectedRowId = transition.expected.circuitDeviceRowId;
+ const targetRowId = transition.target.circuitDeviceRowId;
+ if ((expectedRowId !== null && typeof expectedRowId !== "string") || (targetRowId !== null && typeof targetRowId !== "string")) {
+ throw new Error("Invalid external object row link.");
+ }
+ const parsed = parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [transition.expected, transition.target] }).objects;
+ const expectedRest = { ...parsed[0], circuitDeviceRowId: null };
+ const targetRest = { ...parsed[1], circuitDeviceRowId: null };
+ if (canonicalJson(expectedRest) !== canonicalJson(targetRest) || expectedRowId === targetRowId) {
+ throw new Error("External object transition may only change the row link.");
+ }
+ }
+}
+
+function canonicalJson(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+ if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
+ return JSON.stringify(value) ?? "null";
}
function assertNonEmptyString(value: unknown, field: string) {
diff --git a/src/domain/models/circuit-group-subtree-project-command.model.ts b/src/domain/models/circuit-group-subtree-project-command.model.ts
index a3e53b3..4470cd2 100644
--- a/src/domain/models/circuit-group-subtree-project-command.model.ts
+++ b/src/domain/models/circuit-group-subtree-project-command.model.ts
@@ -3,6 +3,7 @@ import {
type CircuitGroupSubtreeSnapshot,
} from "./circuit-group-subtree-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
+import { assertExternalObjectLinkTransitions, type ExternalObjectLinkSnapshotTransition } from "./circuit-device-row-structure-project-command.model.js";
export const circuitGroupDeleteSubtreeCommandType =
"circuit-group.delete-subtree" as const;
@@ -12,6 +13,7 @@ export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
interface Payload {
snapshot: CircuitGroupSubtreeSnapshot;
+ externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitGroupDeleteSubtreeProjectCommand
@@ -31,24 +33,26 @@ export type CircuitGroupSubtreeProjectCommand =
| CircuitGroupRestoreSubtreeProjectCommand;
export function createCircuitGroupDeleteSubtreeProjectCommand(
- snapshot: CircuitGroupSubtreeSnapshot
+ snapshot: CircuitGroupSubtreeSnapshot,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupDeleteSubtreeProjectCommand {
const command: CircuitGroupDeleteSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupDeleteSubtreeCommandType,
- payload: { snapshot },
+ payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitGroupDeleteSubtreeProjectCommand(command);
return command;
}
export function createCircuitGroupRestoreSubtreeProjectCommand(
- snapshot: CircuitGroupSubtreeSnapshot
+ snapshot: CircuitGroupSubtreeSnapshot,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupRestoreSubtreeProjectCommand {
const command: CircuitGroupRestoreSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupRestoreSubtreeCommandType,
- payload: { snapshot },
+ payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitGroupRestoreSubtreeProjectCommand(command);
return command;
@@ -76,11 +80,12 @@ function assertCommand(
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
- Object.keys(command.payload).length !== 1
+ Object.keys(command.payload).some((key) => key !== "snapshot" && key !== "externalObjects")
) {
throw new Error("Unsupported circuit-group subtree command.");
}
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
+ assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
function isPlainObject(value: unknown): value is Record {
diff --git a/src/domain/models/circuit-structure-project-command.model.ts b/src/domain/models/circuit-structure-project-command.model.ts
index 5260c4b..292c776 100644
--- a/src/domain/models/circuit-structure-project-command.model.ts
+++ b/src/domain/models/circuit-structure-project-command.model.ts
@@ -3,6 +3,8 @@ import {
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
+ type ExternalObjectLinkSnapshotTransition,
+ assertExternalObjectLinkTransitions,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
@@ -37,11 +39,13 @@ export interface CircuitSnapshot {
export interface CircuitInsertCommandPayload {
circuit: CircuitSnapshot;
+ externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeleteCommandPayload {
circuitId: string;
expectedCircuitListId: string;
+ externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitInsertProjectCommand
@@ -61,12 +65,13 @@ export type CircuitStructureProjectCommand =
| CircuitDeleteProjectCommand;
export function createCircuitInsertProjectCommand(
- circuit: CircuitSnapshot
+ circuit: CircuitSnapshot,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitInsertProjectCommand {
const command: CircuitInsertProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
- payload: { circuit },
+ payload: { circuit, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitInsertProjectCommand(command);
return command;
@@ -74,12 +79,13 @@ export function createCircuitInsertProjectCommand(
export function createCircuitDeleteProjectCommand(
circuitId: string,
- expectedCircuitListId: string
+ expectedCircuitListId: string,
+ externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeleteProjectCommand {
const command: CircuitDeleteProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitDeleteCommandType,
- payload: { circuitId, expectedCircuitListId },
+ payload: { circuitId, expectedCircuitListId, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeleteProjectCommand(command);
return command;
@@ -168,6 +174,7 @@ export function assertCircuitInsertProjectCommand(
}
rowIds.add(row.id);
}
+ assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertCircuitDeleteProjectCommand(
@@ -187,6 +194,7 @@ export function assertCircuitDeleteProjectCommand(
command.payload.expectedCircuitListId,
"expectedCircuitListId"
);
+ assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
function assertNonEmptyString(value: unknown, field: string) {
diff --git a/src/domain/models/circuit-tree.model.ts b/src/domain/models/circuit-tree.model.ts
index 00a0c2d..fd1bd2c 100644
--- a/src/domain/models/circuit-tree.model.ts
+++ b/src/domain/models/circuit-tree.model.ts
@@ -20,6 +20,7 @@ export interface CircuitTreeDeviceRow {
remark?: string;
overriddenFields?: string;
rowTotalPower: number;
+ externalObjectCount?: number;
}
export interface CircuitTreeCircuit {
diff --git a/src/domain/models/external-object-new-circuit-project-command.model.ts b/src/domain/models/external-object-new-circuit-project-command.model.ts
index b34a449..e7a3e23 100644
--- a/src/domain/models/external-object-new-circuit-project-command.model.ts
+++ b/src/domain/models/external-object-new-circuit-project-command.model.ts
@@ -146,8 +146,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
- const expectedRest = { ...expected, circuitDeviceRowId: null };
- const targetRest = { ...target, circuitDeviceRowId: null };
+ const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null };
+ const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-circuit assignment may only change the external object row link.");
}
diff --git a/src/domain/models/external-object-new-row-project-command.model.ts b/src/domain/models/external-object-new-row-project-command.model.ts
index d0416dd..6bae039 100644
--- a/src/domain/models/external-object-new-row-project-command.model.ts
+++ b/src/domain/models/external-object-new-row-project-command.model.ts
@@ -141,8 +141,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
- const expectedRest = { ...expected, circuitDeviceRowId: null };
- const targetRest = { ...target, circuitDeviceRowId: null };
+ const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null };
+ const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-row assignment may only change the external object row link.");
}
diff --git a/src/domain/models/external-object-row-assignment-project-command.model.ts b/src/domain/models/external-object-row-assignment-project-command.model.ts
index a45fe31..cb4ae97 100644
--- a/src/domain/models/external-object-row-assignment-project-command.model.ts
+++ b/src/domain/models/external-object-row-assignment-project-command.model.ts
@@ -104,10 +104,10 @@ export function assertExternalObjectRowAssignmentProjectCommand(
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
throw new Error("External object row assignment must change its row link.");
}
- assertOnlyFieldChanged(
+ assertOnlyFieldsChanged(
expected as unknown as Record,
target as unknown as Record,
- "circuitDeviceRowId",
+ new Set(["circuitDeviceRowId", "distributionBoardId", "linkedProjectDeviceId", "planningValues", "overriddenFields"]),
"external object"
);
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
@@ -161,11 +161,22 @@ function assertOnlyFieldChanged(
target: Record,
allowedField: string,
label: string
+) {
+ assertOnlyFieldsChanged(expected, target, new Set([allowedField]), label);
+}
+
+function assertOnlyFieldsChanged(
+ expected: Record,
+ target: Record,
+ allowedFields: ReadonlySet,
+ label: string
) {
const expectedRest = { ...expected };
const targetRest = { ...target };
- delete expectedRest[allowedField];
- delete targetRest[allowedField];
+ for (const field of allowedFields) {
+ delete expectedRest[field];
+ delete targetRest[field];
+ }
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error(`Row assignment may only change the ${label} assignment state.`);
}
diff --git a/src/external-model/application/external-xlsx-return-projection.ts b/src/external-model/application/external-xlsx-return-projection.ts
new file mode 100644
index 0000000..a80125c
--- /dev/null
+++ b/src/external-model/application/external-xlsx-return-projection.ts
@@ -0,0 +1,135 @@
+import type { ExternalModelObjectSnapshot } from "../domain/external-model-contracts.js";
+import type { ExternalXlsxCircuitMode, ExternalXlsxReturnValues } from "../xlsx/external-xlsx-contracts.js";
+import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
+
+export interface ExternalReturnRowContext {
+ rowId: string;
+ displayName: string;
+ powerPerUnitKw: number;
+ cosPhi: number | null;
+ phaseType: string | null;
+ category: CircuitGroupCategory | null;
+ circuitIdentifier: string;
+ circuitVoltageV: number | null;
+ distributionBoardName: string;
+}
+
+export interface ExternalReturnProjection {
+ objectId: string;
+ worksharingId: string;
+ values: ExternalXlsxReturnValues;
+ differsFromImported: boolean;
+ warnings: string[];
+}
+
+export function getRevitBoardName(board: { name: string }) {
+ return board.name;
+}
+
+export function projectExternalXlsxReturn(input: {
+ object: ExternalModelObjectSnapshot;
+ row: ExternalReturnRowContext | null;
+ circuitMode: ExternalXlsxCircuitMode;
+ singlePhaseVoltageV: number;
+ threePhaseVoltageV: number;
+}): ExternalReturnProjection {
+ const { object, row } = input;
+ const planning = object.planningValues;
+ const warnings: string[] = [];
+ const category = row?.category ?? planning.category;
+ const phaseType = row?.phaseType ?? (category === "three_phase" ? "three_phase" : category ? "single_phase" : null);
+ const voltageV = row?.circuitVoltageV ?? defaultVoltage(category, input.singlePhaseVoltageV, input.threePhaseVoltageV);
+ const powerPerUnitW = row ? row.powerPerUnitKw * 1000 : planning.powerPerUnitW;
+ const electricalPowerW = powerPerUnitW === null
+ ? null
+ : roundNumber(planning.effectiveQuantity * powerPerUnitW, 6);
+ const cosPhi = row?.cosPhi ?? planning.cosPhi;
+ const currentA = calculateCurrent({ electricalPowerW, voltageV, cosPhi, phaseType });
+ if (electricalPowerW === null) warnings.push("Elektrische Leistung ist nicht berechenbar.");
+ if (voltageV === null) warnings.push("Spannung ist nicht bestimmbar.");
+ if (currentA === null) warnings.push("Stromstärke ist nicht berechenbar.");
+
+ const values: ExternalXlsxReturnValues = {
+ ...(input.circuitMode === "combined"
+ ? { circuitCombined: row ? `${getRevitBoardName({ name: row.distributionBoardName })}${row.circuitIdentifier}` : "" }
+ : {
+ boardName: row ? getRevitBoardName({ name: row.distributionBoardName }) : "",
+ circuitIdentifier: row?.circuitIdentifier ?? "",
+ }),
+ designation: row?.displayName ?? planning.displayName ?? "",
+ electricalPowerW,
+ voltageV,
+ currentA,
+ };
+ const source = object.acceptedSourceValues;
+ const sourceValues: ExternalXlsxReturnValues = input.circuitMode === "combined"
+ ? {
+ circuitCombined: source.circuitCombined,
+ designation: source.designation,
+ electricalPowerW: source.electricalPowerW,
+ voltageV: source.voltageV,
+ currentA: source.currentA,
+ }
+ : {
+ boardName: source.boardName,
+ circuitIdentifier: source.circuitIdentifier,
+ designation: source.designation,
+ electricalPowerW: source.electricalPowerW,
+ voltageV: source.voltageV,
+ currentA: source.currentA,
+ };
+
+ return {
+ objectId: object.id,
+ worksharingId: object.worksharingId,
+ values,
+ differsFromImported: Object.keys(values).some((key) =>
+ !sameScalar(values[key as keyof ExternalXlsxReturnValues], sourceValues[key as keyof ExternalXlsxReturnValues])
+ ),
+ warnings,
+ };
+}
+
+function defaultVoltage(category: CircuitGroupCategory | null, singlePhase: number, threePhase: number) {
+ if (category === "three_phase") return threePhase;
+ if (category === "lighting" || category === "single_phase") return singlePhase;
+ return null;
+}
+
+function calculateCurrent(input: { electricalPowerW: number | null; voltageV: number | null; cosPhi: number | null; phaseType: string | null }) {
+ if (
+ input.electricalPowerW === null ||
+ input.voltageV === null || input.voltageV <= 0 ||
+ input.cosPhi === null || input.cosPhi <= 0 ||
+ (input.phaseType !== "single_phase" && input.phaseType !== "three_phase")
+ ) return null;
+ const divisor = input.phaseType === "three_phase"
+ ? Math.sqrt(3) * input.voltageV * input.cosPhi
+ : input.voltageV * input.cosPhi;
+ return roundNumber(input.electricalPowerW / divisor, 2);
+}
+
+function sameScalar(left: string | number | boolean | null | undefined, right: string | number | boolean | null | undefined) {
+ const normalizedLeft = normalizeScalar(left);
+ const normalizedRight = normalizeScalar(right);
+ if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
+ return Math.abs(normalizedLeft - normalizedRight) < 1e-9;
+ }
+ return normalizedLeft === normalizedRight;
+}
+
+function normalizeScalar(value: string | number | boolean | null | undefined): string | number | boolean {
+ if (value === null || value === undefined || value === "") return "";
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ const numeric = Number(trimmed.replace(",", "."));
+ return Number.isFinite(numeric) ? numeric : trimmed;
+ }
+ return value;
+}
+
+function roundNumber(value: number, decimals: number) {
+ const factor = 10 ** decimals;
+ return Math.round((value + Number.EPSILON) * factor) / factor;
+}
diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx
index f75b64e..5418922 100644
--- a/src/frontend/components/circuit-tree-editor.tsx
+++ b/src/frontend/components/circuit-tree-editor.tsx
@@ -120,6 +120,11 @@ import {
deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand,
+ assignExternalProjectObjectsToRow,
+ assignExternalProjectObjectsToNewRow,
+ assignExternalProjectObjectsToNewCircuit,
+ detachExternalProjectObjectsFromRow,
+ getExternalProjectObjects,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
@@ -154,6 +159,7 @@ import type {
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
+ ExternalProjectObjectsDto,
} from "../types";
import type {
CircuitDeviceRowSnapshot,
@@ -330,6 +336,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState(null);
const [projectDevices, setProjectDevices] = useState([]);
+ const [externalObjects, setExternalObjects] = useState(null);
+ const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
+ const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@@ -381,6 +390,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys]
);
+ const externalObjectGroups = useMemo(() => {
+ if (!data || !externalObjects) return [];
+ const groups = new Map();
+ for (const object of externalObjects.objects) {
+ if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
+ const key = JSON.stringify([
+ object.roomId, object.selectionMarker, object.deviceTypeId,
+ object.planningValues.category, object.linkedProjectDeviceId,
+ object.planningValues,
+ ]);
+ const entries = groups.get(key) ?? [];
+ entries.push(object);
+ groups.set(key, entries);
+ }
+ return [...groups.values()].map((objects) => ({
+ key: objects.map((object) => object.id).sort().join("|"),
+ objects,
+ quantity: objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
+ }));
+ }, [data, externalObjects]);
async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false;
@@ -389,13 +418,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
setError(null);
try {
- const { tree, history } = await loadCircuitEditorSnapshot(
- () => getCircuitTree(projectId, circuitListId),
- () => getProjectHistory(projectId)
- );
+ const [{ tree, history }, external] = await Promise.all([
+ loadCircuitEditorSnapshot(
+ () => getCircuitTree(projectId, circuitListId),
+ () => getProjectHistory(projectId)
+ ),
+ getExternalProjectObjects(projectId),
+ ]);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree);
+ setExternalObjects(external);
} catch (err) {
setError(normalizeUiError(err));
} finally {
@@ -1321,12 +1354,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const summary = subtree
? summarizeCircuitGroupSubtree(subtree)
: null;
+ const externalObjectCount = section.circuits.reduce(
+ (total, circuit) => total + circuit.deviceRows.reduce(
+ (rowTotal, row) => rowTotal + (row.externalObjectCount ?? 0), 0
+ ), 0
+ );
+ const externalWarning = externalObjectCount > 0
+ ? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
+ : "";
const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` +
`Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` +
"Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
- if (!confirm(warning)) {
+ if (!confirm(warning + externalWarning)) {
return;
}
await runCommand({
@@ -2878,11 +2919,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Die Gerätezeile ist ungültig.");
return;
}
+ const externalWarning = (rowSnapshot.externalObjectCount ?? 0) > 0
+ ? `\n\n${rowSnapshot.externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
+ : "";
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" +
- "Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll."
+ "Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll." +
+ externalWarning
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
@@ -2894,7 +2939,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
- } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) {
+ } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?${externalWarning}`)) {
return;
}
await runCommand({
@@ -2917,11 +2962,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Der Stromkreis ist ungültig.");
return;
}
+ const externalObjectCount = circuit.deviceRows.reduce(
+ (total, row) => total + (row.externalObjectCount ?? 0), 0
+ );
+ const externalWarning = externalObjectCount > 0
+ ? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
+ : "";
if (
askForConfirmation &&
!confirm(
`Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` +
- "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert."
+ "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." +
+ externalWarning
)
) {
return;
@@ -2958,6 +3010,56 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
+ async function handleAssignExternalObjects(rowId: string, objectIds: string[]) {
+ if (!confirm(`${objectIds.length} Revit-Objekt(e) dieser Gerätezeile zuordnen?\n\nAbweichende Planungswerte werden ausdrücklich zugunsten der Gerätezeile bestätigt.`)) return;
+ await runCommand({
+ label: "Revit-Objekte zuordnen",
+ redo: async () => {
+ const result = await assignExternalProjectObjectsToRow(
+ projectId,
+ getExpectedProjectRevision(),
+ objectIds,
+ rowId,
+ objectIds
+ );
+ applyProjectCommandResult(result);
+ setDraggingExternalObjectIds([]);
+ return null;
+ },
+ });
+ }
+
+ async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
+ if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) return;
+ await runCommand({ label: "Revit-Objekte als Gerätezeile zuordnen", redo: async () => {
+ const result = await assignExternalProjectObjectsToNewRow(projectId, getExpectedProjectRevision(), objectIds, circuitId);
+ applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
+ }});
+ }
+
+ async function handleAssignExternalObjectsToNewCircuit(sectionId: string, objectIds: string[]) {
+ if (!confirm(`${objectIds.length} Revit-Objekt(e) als neuen Stromkreis anlegen?`)) return;
+ await runCommand({ label: "Revit-Objekte als Stromkreis zuordnen", redo: async () => {
+ const result = await assignExternalProjectObjectsToNewCircuit(projectId, getExpectedProjectRevision(), objectIds, sectionId);
+ applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
+ }});
+ }
+
+ async function handleDetachExternalObjects(rowId: string) {
+ const objectIds = externalObjects?.objects
+ .filter((object) => object.circuitDeviceRowId === rowId)
+ .map((object) => object.id) ?? [];
+ if (objectIds.length === 0 || !confirm(`${objectIds.length} Revit-Objekt(e) lösen?\n\nDie aktuellen Werte der Gerätezeile werden als Planungswerte und Overrides übernommen.`)) return;
+ await runCommand({
+ label: "Revit-Objekte lösen",
+ redo: async () => {
+ const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
+ applyProjectCommandResult(result);
+ return null;
+ },
+ });
+ }
+
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
@@ -3381,6 +3483,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
showLabel
/>
+ sum + group.objects.length, 0)})`}
+ onClick={() => setIsRevitDrawerOpen((current) => !current)}
+ showLabel
+ />
Wird gespeichert … : null}
+ {isRevitDrawerOpen ? (
+
+
+
+
Revit-Objekte
+ {externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)} unzugeordnet
+
+
setIsRevitDrawerOpen(false)}>Schließen
+
+
+ {externalObjectGroups.map((group) => {
+ const first = group.objects[0]!;
+ return (
+
+ {
+ const ids = group.objects.map((object) => object.id);
+ setDraggingExternalObjectIds(ids);
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify(ids));
+ }}
+ onDragEnd={() => setDraggingExternalObjectIds([])}
+ >
+ {first.planningValues.displayName || first.selectionMarker || first.familyAndType}
+ {first.sourceRoomNumber} {first.sourceRoomName}
+ {group.objects.length} Objekt(e), Menge {formatValue(group.quantity, "quantity")}
+ {first.familyAndType}
+
+ {group.objects.map((object) => (
+ {
+ setDraggingExternalObjectIds([object.id]);
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify([object.id]));
+ event.stopPropagation();
+ }}
+ onDragEnd={() => setDraggingExternalObjectIds([])}
+ >
+ {object.worksharingId}
+
+ ))}
+
+ );
+ })}
+ {externalObjectGroups.length === 0 ?
Keine unzugeordneten Revit-Objekte für diesen Verteiler.
: null}
+
+
+ ) : null}
{isProjectDeviceDrawerOpen ? (
setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
+ if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ return;
+ }
if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid(
row.sectionId
@@ -4342,6 +4514,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
onDrop={(event) => {
+ if (draggingExternalObjectIds.length > 0 && row.device) {
+ event.preventDefault();
+ void handleAssignExternalObjects(row.device.id, draggingExternalObjectIds);
+ return;
+ }
+ if (draggingExternalObjectIds.length > 0 && row.rowType === "placeholder") {
+ event.preventDefault();
+ void handleAssignExternalObjectsToNewCircuit(row.sectionId, draggingExternalObjectIds);
+ return;
+ }
+ if (draggingExternalObjectIds.length > 0 && row.circuit) {
+ event.preventDefault();
+ void handleAssignExternalObjectsToNewRow(row.circuit.id, draggingExternalObjectIds);
+ return;
+ }
if (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, {
@@ -4728,14 +4915,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
) : null}
{row.device ? (
- void handleDeleteDevice(row.device!.id)}
- tabIndex={-1}
- variant="danger"
- />
+ <>
+ {(row.device.externalObjectCount ?? 0) > 0 ? (
+ void handleDetachExternalObjects(row.device!.id)}
+ >
+ Revit {row.device.externalObjectCount}
+
+ ) : null}
+ void handleDeleteDevice(row.device!.id)}
+ tabIndex={-1}
+ variant="danger"
+ />
+ >
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx
index c82d9c7..2fdee3e 100644
--- a/src/frontend/components/revit-object-table.tsx
+++ b/src/frontend/components/revit-object-table.tsx
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import type { ExternalProjectObjectsDto } from "../types";
-import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
+import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
+import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44;
const viewportHeight = 520;
@@ -23,6 +23,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const [planningValue, setPlanningValue] = useState("");
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
+ const [returnScope, setReturnScope] = useState("all");
+ const [exporting, setExporting] = useState(false);
+ const [exportWarnings, setExportWarnings] = useState([]);
+ const [exportMessage, setExportMessage] = useState(null);
async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -44,6 +48,7 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
+ const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
async function applyBulk() {
if (!data || selected.size === 0) return;
@@ -64,10 +69,38 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
finally { setSaving(false); }
}
+ async function exportReturnWorkbook() {
+ if (!data || (returnScope === "selected" && selected.size === 0)) return;
+ setExporting(true); setError(null); setExportWarnings([]); setExportMessage(null);
+ try {
+ const result = await createExternalReturnWorkbook(projectId, data.currentRevision, returnScope, [...selected]);
+ downloadBase64(result.contentBase64, result.fileName);
+ setExportWarnings(result.warnings);
+ setExportMessage(`${result.objectCount} Revit-Objekte wurden in ${result.fileName} geschrieben.`);
+ } catch (value) {
+ setError(value instanceof Error ? value.message : "Rückgabe-Excel konnte nicht erstellt werden.");
+ } finally {
+ setExporting(false);
+ }
+ }
+
if (!data) return Revit-Objekte werden geladen …
;
return
{error ?
{error}
: null}
-
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType} {object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{linked ? "verknüpft" : "frei"} ; })}
-
setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen setSelected(new Set())}>Auswahl leeren Verteiler setBulkBoard(event.target.value)}>Unverändert Zuordnung lösen {data.distributionBoards.map((board) => {board.name} )} Projektgerät setBulkDevice(event.target.value)}>Unverändert Verknüpfung lösen {data.projectDevices.map((device) => {device.name} )} Leistung [W] setPower(event.target.value)} /> GZF setGzf(event.target.value)} /> Weiterer Planungswert setPlanningField(event.target.value)}>Unverändert Anzeigename Kategorie (lighting/single_phase/three_phase) Anschlussart Wirksame Menge cosPhi Kostengruppe Bemerkung {planningField ? Neuer Wert setPlanningValue(event.target.value)} /> : null} void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}
+
Rückgabeumfang setReturnScope(event.target.value as ExternalReturnScopeDto)}>Alle Objekte des letzten Imports Nur geänderte Objekte Nur ausgewählte Objekte void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}
{exportMessage ?
{exportMessage}
: null}{exportWarnings.length ?
Hinweise zur Rückgabe: {exportWarnings.map((warning) => {warning} )} : null}
+
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType} {object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{linked ? "verknüpft" : "frei"} ; })}
+
setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen setSelected(new Set())}>Auswahl leeren Verteiler setBulkBoard(event.target.value)}>Unverändert Zuordnung lösen {data.distributionBoards.map((board) => {board.name} )} Projektgerät setBulkDevice(event.target.value)}>Unverändert Verknüpfung lösen {data.projectDevices.map((device) => {device.name} )} Leistung [W] setPower(event.target.value)} /> GZF setGzf(event.target.value)} /> Weiterer Planungswert setPlanningField(event.target.value)}>Unverändert Anzeigename Kategorie (lighting/single_phase/three_phase) Anschlussart Wirksame Menge cosPhi Kostengruppe Bemerkung {planningField ? Neuer Wert setPlanningValue(event.target.value)} /> : null} void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}
;
}
+
+function downloadBase64(contentBase64: string, fileName: string) {
+ const binary = atob(contentBase64);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/frontend/components/revit-xlsx-settings-modal.tsx b/src/frontend/components/revit-xlsx-settings-modal.tsx
index 7041ebc..899c3ca 100644
--- a/src/frontend/components/revit-xlsx-settings-modal.tsx
+++ b/src/frontend/components/revit-xlsx-settings-modal.tsx
@@ -13,8 +13,9 @@ const labels: Record = {
electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke",
};
-export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: {
+export function RevitXlsxSettingsModal({ configuration, isNewConfiguration = false, isSaving, onClose, onInspectSample, onSave }: {
configuration: ExternalModelConfiguration;
+ isNewConfiguration?: boolean;
isSaving: boolean;
onClose: () => void;
onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise;
@@ -33,7 +34,7 @@ export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onIns
}
}
- return { event.preventDefault(); void onSave(draft); }}>
+ return { event.preventDefault(); void onSave(draft); }}>
Musterdatei prüfen void inspectSample(event.target.files?.[0])} />{sampleStatus ?
{sampleStatus}
: null}
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) =>
{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"} setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} />
)}
diff --git a/src/frontend/types.ts b/src/frontend/types.ts
index 49466f5..a4239b1 100644
--- a/src/frontend/types.ts
+++ b/src/frontend/types.ts
@@ -126,6 +126,29 @@ export interface ExternalProjectObjectsDto {
objects: ExternalProjectObjectDto[];
}
+export type ExternalReturnScopeDto = "all" | "deviating" | "selected";
+
+export interface ExternalReturnWorkbookDto {
+ fileName: string;
+ contentBase64: string;
+ sha256: string;
+ objectCount: number;
+ warnings: string[];
+}
+
+export interface ExternalExportBatchDto {
+ id: string;
+ projectId: string;
+ projectRevision: number;
+ importBatchId: string;
+ exportedAtIso: string;
+ fileName: string;
+ sha256: string;
+ configurationSha256: string;
+ scope: ExternalReturnScopeDto;
+ objectCount: number;
+}
+
export interface ProjectDto {
id: string;
name: string;
@@ -447,6 +470,7 @@ export interface CircuitTreeDeviceRowDto {
remark?: string;
overriddenFields?: string;
rowTotalPower: number;
+ externalObjectCount?: number;
}
export interface CircuitTreeProtectionDeviceDto {
diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts
index c46f80b..c873ef3 100644
--- a/src/frontend/utils/api.ts
+++ b/src/frontend/utils/api.ts
@@ -44,6 +44,8 @@ import type {
ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput,
ExternalProjectObjectsDto,
+ ExternalReturnScopeDto,
+ ExternalReturnWorkbookDto,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
@@ -245,6 +247,44 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
return request
(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
}
+export function createExternalReturnWorkbook(
+ projectId: string,
+ expectedRevision: number,
+ scope: ExternalReturnScopeDto,
+ selectedObjectIds: string[]
+) {
+ return request(`/api/projects/${projectId}/external-model/return-workbook`, {
+ method: "POST",
+ body: JSON.stringify({ expectedRevision, scope, selectedObjectIds }),
+ });
+}
+
+export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
+ return request(`/api/projects/${projectId}/external-model/objects/assign-row`, {
+ method: "POST",
+ body: JSON.stringify({ expectedRevision, objectIds, rowId, confirmedConflictObjectIds }),
+ });
+}
+
+export function detachExternalProjectObjectsFromRow(projectId: string, expectedRevision: number, objectIds: string[]) {
+ return request(`/api/projects/${projectId}/external-model/objects/detach-row`, {
+ method: "POST",
+ body: JSON.stringify({ expectedRevision, objectIds }),
+ });
+}
+
+export function assignExternalProjectObjectsToNewRow(projectId: string, expectedRevision: number, objectIds: string[], circuitId: string) {
+ return request(`/api/projects/${projectId}/external-model/objects/assign-new-row`, {
+ method: "POST", body: JSON.stringify({ expectedRevision, objectIds, circuitId }),
+ });
+}
+
+export function assignExternalProjectObjectsToNewCircuit(projectId: string, expectedRevision: number, objectIds: string[], sectionId: string) {
+ return request(`/api/projects/${projectId}/external-model/objects/assign-new-circuit`, {
+ method: "POST", body: JSON.stringify({ expectedRevision, objectIds, sectionId }),
+ });
+}
+
export function getProjectHistory(projectId: string) {
return request(
`/api/projects/${projectId}/history`
diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts
index a165a30..002a784 100644
--- a/src/server/composition/application-repositories.ts
+++ b/src/server/composition/application-repositories.ts
@@ -15,6 +15,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
+import { ExternalExportBatchRepository } from "../../db/repositories/external-export-batch.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js";
@@ -39,5 +40,6 @@ export const roomRepository = new RoomRepository(db);
export const externalModelConfigurationRepository =
new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
+export const externalExportBatchRepository = new ExternalExportBatchRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db);
diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts
index 17ac134..d1fb7f6 100644
--- a/src/server/controllers/circuit-tree.controller.ts
+++ b/src/server/controllers/circuit-tree.controller.ts
@@ -14,6 +14,7 @@ import {
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
+ externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
@@ -88,6 +89,13 @@ export async function buildProjectCircuitTrees(
circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
]);
+ const externalState = externalModelStateRepository.getByProject(projectId).state;
+ const externalCountByRowId = new Map();
+ for (const object of externalState.objects) {
+ if (object.circuitDeviceRowId) {
+ externalCountByRowId.set(object.circuitDeviceRowId, (externalCountByRowId.get(object.circuitDeviceRowId) ?? 0) + 1);
+ }
+ }
const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds),
@@ -237,6 +245,7 @@ export async function buildProjectCircuitTrees(
row.powerPerUnit,
row.simultaneityFactor
),
+ externalObjectCount: externalCountByRowId.get(row.id) ?? 0,
}));
const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing.");
diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts
index f56f7be..8e90673 100644
--- a/src/server/controllers/external-objects.controller.ts
+++ b/src/server/controllers/external-objects.controller.ts
@@ -1,9 +1,17 @@
import type { Request, Response } from "express";
+import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
+import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
+import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
+import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
+import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType } from "../../domain/models/external-object-new-row-project-command.model.js";
+import { createExternalObjectNewCircuitProjectCommand, externalObjectAssignToNewCircuitCommandType } from "../../domain/models/external-object-new-circuit-project-command.model.js";
+import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults.js";
+import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function listExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
@@ -49,6 +57,221 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); }
}
+export async function assignExternalObjectsToRow(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ try {
+ const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
+ const objectIds = parseObjectIds(req.body?.objectIds);
+ const rowId = requiredId(req.body?.rowId, "Ungültige Gerätezeile.");
+ const confirmedConflictObjectIds = Array.isArray(req.body?.confirmedConflictObjectIds)
+ ? req.body.confirmedConflictObjectIds.map((value: unknown) => requiredId(value, "Ungültige Konfliktbestätigung."))
+ : [];
+ const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
+ if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
+ const treeResult = await buildProjectCircuitTrees(projectId);
+ const location = findRow(treeResult, rowId);
+ if (!location) throw new Error("Gerätezeile wurde nicht gefunden.");
+ const byId = new Map(state.objects.map((object) => [object.id, object]));
+ const selected = objectIds.map((id) => {
+ const object = byId.get(id);
+ if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
+ if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits einer Gerätezeile zugeordnet.");
+ return object;
+ });
+ const expectedRow = toRowSnapshot(location.row, location.circuitId);
+ const addedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
+ const targetRow = { ...expectedRow, quantity: expectedRow.quantity + addedQuantity };
+ const objects = selected.map((expected) => ({
+ expected,
+ target: { ...expected, distributionBoardId: location.distributionBoardId, circuitDeviceRowId: rowId },
+ }));
+ const result = projectCommandService.executeUser({
+ projectId,
+ expectedRevision,
+ description: `${objects.length} Revit-Objekte zugeordnet`,
+ command: createExternalObjectRowAssignmentProjectCommand({
+ rows: [{ expected: expectedRow, target: targetRow }],
+ objects,
+ confirmedConflictObjectIds,
+ }),
+ });
+ return res.json(result);
+ } catch (error) { return respondWithProjectCommandError(error, res); }
+}
+
+export async function detachExternalObjectsFromRow(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ try {
+ const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
+ const objectIds = parseObjectIds(req.body?.objectIds);
+ const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
+ if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
+ const treeResult = await buildProjectCircuitTrees(projectId);
+ const byId = new Map(state.objects.map((object) => [object.id, object]));
+ const selected = objectIds.map((id) => {
+ const object = byId.get(id);
+ if (!object?.circuitDeviceRowId) throw new Error("Ein Revit-Objekt ist keiner Gerätezeile zugeordnet.");
+ return object;
+ });
+ const rowIds = new Set(selected.map((object) => object.circuitDeviceRowId!));
+ if (rowIds.size !== 1) throw new Error("Revit-Objekte können nur aus derselben Gerätezeile gemeinsam gelöst werden.");
+ const rowId = [...rowIds][0]!;
+ const location = findRow(treeResult, rowId);
+ if (!location) throw new Error("Gerätezeile wurde nicht gefunden.");
+ const expectedRow = toRowSnapshot(location.row, location.circuitId);
+ const removedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
+ const targetRow = { ...expectedRow, quantity: expectedRow.quantity - removedQuantity };
+ const copiedFields = ["displayName", "category", "connectionKind", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const;
+ const objects = selected.map((expected) => ({
+ expected,
+ target: {
+ ...expected,
+ circuitDeviceRowId: null,
+ distributionBoardId: location.distributionBoardId,
+ linkedProjectDeviceId: expectedRow.linkedProjectDeviceId,
+ planningValues: {
+ ...expected.planningValues,
+ displayName: expectedRow.displayName,
+ category: location.category,
+ connectionKind: expectedRow.connectionKind,
+ powerPerUnitW: expectedRow.powerPerUnit * 1000,
+ simultaneityFactor: expectedRow.simultaneityFactor,
+ cosPhi: expectedRow.cosPhi,
+ costGroup: expectedRow.costGroup,
+ remark: expectedRow.remark,
+ },
+ overriddenFields: [...new Set([...expected.overriddenFields, ...copiedFields])],
+ },
+ }));
+ const result = projectCommandService.executeUser({
+ projectId,
+ expectedRevision,
+ description: `${objects.length} Revit-Objekte gelöst`,
+ command: createExternalObjectRowAssignmentProjectCommand({
+ rows: [{ expected: expectedRow, target: targetRow }],
+ objects,
+ confirmedConflictObjectIds: [],
+ }),
+ });
+ return res.json(result);
+ } catch (error) { return respondWithProjectCommandError(error, res); }
+}
+
+export async function assignExternalObjectsToNewRow(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ try {
+ const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
+ const objectIds = parseObjectIds(req.body?.objectIds);
+ const circuitId = requiredId(req.body?.circuitId, "Ungültiger Stromkreis.");
+ const context = await loadAssignmentContext(projectId, objectIds);
+ const circuitLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.flatMap((section) => section.circuits.map((circuit) => ({ tree, section, circuit })))).find((entry) => entry.circuit.id === circuitId);
+ if (!circuitLocation || !circuitLocation.section.category) throw new Error("Stromkreis wurde nicht gefunden.");
+ const row = buildExternalRow(context.objects, context.roomId, circuitId, Math.max(0, ...circuitLocation.circuit.deviceRows.map((entry) => entry.sortOrder)) + 10);
+ const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: circuitLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
+ const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neue Gerätezeile zugeordnet`, command: createExternalObjectNewRowProjectCommand(externalObjectAssignToNewRowCommandType, { row, objects: transitions, confirmedConflictObjectIds: objectIds }) });
+ return res.json(result);
+ } catch (error) { return respondWithProjectCommandError(error, res); }
+}
+
+export async function assignExternalObjectsToNewCircuit(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ try {
+ const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
+ const objectIds = parseObjectIds(req.body?.objectIds);
+ const sectionId = requiredId(req.body?.sectionId, "Ungültige Stromkreisgruppe.");
+ const context = await loadAssignmentContext(projectId, objectIds);
+ const sectionLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.map((section) => ({ tree, section }))).find((entry) => entry.section.id === sectionId);
+ if (!sectionLocation?.section.category) throw new Error("Stromkreisgruppe wurde nicht gefunden.");
+ const circuitId = randomUUID();
+ const row = buildExternalRow(context.objects, context.roomId, circuitId, 10);
+ const protection = createDefaultCircuitProtection(sectionLocation.section.category);
+ const circuit = {
+ id: circuitId,
+ circuitListId: sectionLocation.tree.circuitListId,
+ sectionId,
+ equipmentIdentifier: await circuitNumberingService.getNextIdentifier(sectionId),
+ displayName: null,
+ sortOrder: Math.max(0, ...sectionLocation.section.circuits.map((entry) => entry.sortOrder)) + 10,
+ cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null,
+ terminalDesignation: null,
+ voltage: sectionLocation.section.category === "three_phase" ? sectionLocation.tree.threePhaseVoltageV : sectionLocation.tree.singlePhaseVoltageV,
+ controlRequirement: null, status: null, isReserve: false, remark: null,
+ deviceRows: [row],
+ protectionDevice: {
+ circuitId,
+ type: protection.type,
+ ratedCurrentA: protection.ratedCurrentA,
+ fuseUtilizationCategory: "fuseUtilizationCategory" in protection ? protection.fuseUtilizationCategory : null,
+ tripCharacteristic: "tripCharacteristic" in protection ? protection.tripCharacteristic : null,
+ rcdType: "rcdType" in protection ? protection.rcdType : null,
+ ratedResidualCurrentMa: "ratedResidualCurrentMa" in protection ? protection.ratedResidualCurrentMa : null,
+ },
+ };
+ const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: sectionLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
+ const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neuen Stromkreis zugeordnet`, command: createExternalObjectNewCircuitProjectCommand(externalObjectAssignToNewCircuitCommandType, { circuit, objects: transitions, confirmedConflictObjectIds: objectIds }) });
+ return res.json(result);
+ } catch (error) { return respondWithProjectCommandError(error, res); }
+}
+
+async function loadAssignmentContext(projectId: string, objectIds: string[]) {
+ const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
+ if (!projectExists) throw new Error("Projekt nicht gefunden.");
+ const byId = new Map(state.objects.map((object) => [object.id, object]));
+ const objects = objectIds.map((id) => {
+ const object = byId.get(id);
+ if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
+ if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits zugeordnet.");
+ return object;
+ });
+ const roomIds = new Set(objects.map((object) => object.externalRoomMappingId === null ? null : state.roomMappings.find((mapping) => mapping.id === object.externalRoomMappingId)?.roomId ?? null));
+ if (roomIds.size !== 1) throw new Error("Revit-Objekte aus verschiedenen Räumen benötigen getrennte Gerätezeilen.");
+ return { objects, roomId: [...roomIds][0]!, treeResult: await buildProjectCircuitTrees(projectId) };
+}
+
+function buildExternalRow(objects: ReturnType["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot {
+ const first = objects[0]!;
+ const planning = first.planningValues;
+ const quantity = objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
+ return {
+ id: randomUUID(), circuitId, linkedProjectDeviceId: first.linkedProjectDeviceId,
+ sortOrder, name: first.acceptedSourceValues.familyAndType,
+ displayName: planning.displayName ?? (scalarText(first.acceptedSourceValues.selectionMarker) || first.acceptedSourceValues.familyAndType),
+ phaseType: planning.category === "three_phase" ? "three_phase" : "single_phase",
+ connectionKind: planning.connectionKind, costGroup: planning.costGroup,
+ category: planning.category, level: null,
+ roomId,
+ roomNumberSnapshot: scalarText(first.acceptedSourceValues.roomNumber) || null,
+ roomNameSnapshot: scalarText(first.acceptedSourceValues.roomName) || null,
+ quantity, manualQuantity: 0,
+ powerPerUnit: (planning.powerPerUnitW ?? 0) / 1000,
+ simultaneityFactor: planning.simultaneityFactor ?? 1,
+ cosPhi: planning.cosPhi, remark: planning.remark, overriddenFields: null,
+ };
+}
+
+function parseExpectedRevision(value: unknown) {
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
+ return value as number;
+}
+function parseObjectIds(value: unknown): string[] {
+ if (!Array.isArray(value) || value.length === 0) throw new Error("Keine Revit-Objekte ausgewählt.");
+ const ids = value.map((entry) => requiredId(entry, "Ungültige Revit-Objekt-ID."));
+ if (new Set(ids).size !== ids.length) throw new Error("Revit-Objekte sind doppelt ausgewählt.");
+ return ids;
+}
+function requiredId(value: unknown, message: string) { if (typeof value !== "string" || !value) throw new Error(message); return value; }
+function findRow(result: Awaited>, rowId: string) {
+ if (!result) return null;
+ for (const tree of result.trees) for (const section of tree.sections) for (const circuit of section.circuits) {
+ const row = circuit.deviceRows.find((entry) => entry.id === rowId);
+ if (row) return { row, circuitId: circuit.id, distributionBoardId: tree.distributionBoardId, category: section.category ?? null };
+ }
+ return null;
+}
+function toRowSnapshot(row: NonNullable>["row"], circuitId: string): CircuitDeviceRowSnapshot {
+ return { id: row.id, circuitId, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null };
+}
+
const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[];
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); }
function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }
diff --git a/src/server/controllers/external-return-workbook.controller.ts b/src/server/controllers/external-return-workbook.controller.ts
new file mode 100644
index 0000000..0db6d81
--- /dev/null
+++ b/src/server/controllers/external-return-workbook.controller.ts
@@ -0,0 +1,180 @@
+import { createHash, randomUUID } from "node:crypto";
+import type { Request, Response } from "express";
+import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
+import type { ExternalExportScope } from "../../db/schema/external-export-batches.js";
+import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
+import { createExternalXlsxReturn } from "../../external-model/xlsx/external-xlsx-transport.js";
+import {
+ externalExportBatchRepository,
+ externalModelStateRepository,
+ projectRepository,
+} from "../composition/application-repositories.js";
+import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
+
+const exportScopes = new Set(["all", "deviating", "selected"]);
+
+export async function createExternalReturnWorkbook(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ try {
+ const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
+ const scope = parseScope(req.body?.scope);
+ const selectedObjectIds = scope === "selected" ? parseSelectedIds(req.body?.selectedObjectIds) : [];
+ if (scope === "selected" && selectedObjectIds.length === 0) {
+ return res.status(400).json({ error: "Für die ausgewählte Rückgabe wurden keine Revit-Objekte markiert." });
+ }
+
+ const project = await projectRepository.findById(projectId);
+ if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
+ if (project.currentRevision !== expectedRevision) {
+ return res.status(409).json({
+ error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
+ code: "PROJECT_REVISION_CONFLICT",
+ expectedRevision,
+ currentRevision: project.currentRevision,
+ });
+ }
+
+ const { state } = externalModelStateRepository.getByProject(projectId);
+ const importBatch = state.importBatches.at(-1);
+ if (!importBatch?.originalContentBase64 || !importBatch.configurationSnapshot) {
+ return res.status(409).json({
+ error: "Für die Rückgabe fehlt der letzte vollständige Revit-Import.",
+ code: "EXTERNAL_XLSX_IMPORT_REQUIRED",
+ });
+ }
+ const treeResult = await buildProjectCircuitTrees(projectId);
+ if (!treeResult || treeResult.currentRevision !== expectedRevision) {
+ return res.status(409).json({
+ error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
+ code: "PROJECT_REVISION_CONFLICT",
+ });
+ }
+
+ const rowById = new Map();
+ for (const tree of treeResult.trees) {
+ for (const section of tree.sections) {
+ for (const circuit of section.circuits) {
+ for (const row of circuit.deviceRows) {
+ rowById.set(row.id, {
+ rowId: row.id,
+ displayName: row.displayName,
+ powerPerUnitKw: row.powerPerUnit,
+ cosPhi: row.cosPhi ?? null,
+ phaseType: row.phaseType ?? null,
+ category: (section.category ?? null) as CircuitGroupCategory | null,
+ circuitIdentifier: circuit.equipmentIdentifier,
+ circuitVoltageV: circuit.voltage ?? null,
+ distributionBoardName: tree.distributionBoardName,
+ });
+ }
+ }
+ }
+ }
+
+ const projections = state.objects
+ .filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === importBatch.id)
+ .map((object) => projectExternalXlsxReturn({
+ object,
+ row: object.circuitDeviceRowId ? rowById.get(object.circuitDeviceRowId) ?? null : null,
+ circuitMode: importBatch.configurationSnapshot!.circuitMode,
+ singlePhaseVoltageV: project.singlePhaseVoltageV,
+ threePhaseVoltageV: project.threePhaseVoltageV,
+ }));
+ const projectionById = new Map(projections.map((projection) => [projection.objectId, projection]));
+ for (const objectId of selectedObjectIds) {
+ if (!projectionById.has(objectId)) {
+ return res.status(400).json({ error: "Ein ausgewähltes Revit-Objekt gehört nicht zum letzten Import." });
+ }
+ }
+ const selected = scope === "all"
+ ? projections
+ : scope === "deviating"
+ ? projections.filter((projection) => projection.differsFromImported)
+ : selectedObjectIds.map((id) => projectionById.get(id)!);
+ if (selected.length === 0) {
+ return res.status(400).json({ error: "Der gewählte Rückgabeumfang enthält keine Revit-Objekte." });
+ }
+
+ const result = await createExternalXlsxReturn({
+ fileName: importBatch.fileName,
+ bytes: Buffer.from(importBatch.originalContentBase64, "base64"),
+ configuration: importBatch.configurationSnapshot,
+ selectedWorksharingIds: selected.map((projection) => projection.worksharingId),
+ updates: selected.map((projection) => ({
+ worksharingId: projection.worksharingId,
+ values: projection.values,
+ })),
+ });
+ const fileName = `${safeFileName(project.name)}_Revit-Rueckgabe_${new Date().toISOString().slice(0, 10)}.xlsx`;
+ const sha256 = hash(result.bytes);
+ const configurationSha256 = hash(Buffer.from(JSON.stringify(importBatch.configurationSnapshot), "utf8"));
+ const currentProject = await projectRepository.findById(projectId);
+ if (!currentProject || currentProject.currentRevision !== expectedRevision) {
+ return res.status(409).json({
+ error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
+ code: "PROJECT_REVISION_CONFLICT",
+ expectedRevision,
+ currentRevision: currentProject?.currentRevision ?? null,
+ });
+ }
+ externalExportBatchRepository.append({
+ id: randomUUID(),
+ projectId,
+ projectRevision: expectedRevision,
+ importBatchId: importBatch.id,
+ exportedAtIso: new Date().toISOString(),
+ fileName,
+ sha256,
+ configurationSnapshot: importBatch.configurationSnapshot,
+ configurationSha256,
+ scope,
+ objectCount: result.exportedObjectCount,
+ });
+
+ const projectionWarnings = selected.flatMap((projection) =>
+ projection.warnings.map((warning) => `${projection.worksharingId}: ${warning}`)
+ );
+ return res.json({
+ fileName,
+ contentBase64: Buffer.from(result.bytes).toString("base64"),
+ sha256,
+ objectCount: result.exportedObjectCount,
+ warnings: [...new Set([...result.issues.map((issue) => issue.message), ...projectionWarnings])],
+ });
+ } catch (error) {
+ return res.status(400).json({ error: error instanceof Error ? error.message : "Revit-Rückgabe fehlgeschlagen." });
+ }
+}
+
+export async function listExternalExportBatches(req: Request, res: Response) {
+ const projectId = String(req.params.projectId ?? "");
+ const project = await projectRepository.findById(projectId);
+ if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
+ return res.json(externalExportBatchRepository.listByProject(projectId).map(({ configurationSnapshot: _configuration, ...batch }) => batch));
+}
+
+function parseExpectedRevision(value: unknown) {
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
+ return value as number;
+}
+
+function parseScope(value: unknown): ExternalExportScope {
+ if (typeof value !== "string" || !exportScopes.has(value as ExternalExportScope)) throw new Error("Ungültiger Rückgabeumfang.");
+ return value as ExternalExportScope;
+}
+
+function parseSelectedIds(value: unknown) {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string") || new Set(value).size !== value.length) {
+ throw new Error("Ungültige Objektauswahl.");
+ }
+ return value as string[];
+}
+
+function safeFileName(value: string) {
+ return value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "Projekt";
+}
+
+function hash(bytes: Uint8Array) {
+ return createHash("sha256").update(bytes).digest("hex");
+}
diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts
index 53c2225..436ff52 100644
--- a/src/server/routes/project.routes.ts
+++ b/src/server/routes/project.routes.ts
@@ -63,7 +63,8 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
-import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
+import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
+import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
export const projectRouter = Router();
@@ -93,6 +94,12 @@ projectRouter.post("/:projectId/external-model/initial-import/plan", planExterna
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
+projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
+projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
+projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
+projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow);
+projectRouter.post("/:projectId/external-model/return-workbook", createExternalReturnWorkbook);
+projectRouter.get("/:projectId/external-model/export-batches", listExternalExportBatches);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post(
diff --git a/tests/external-export-batch.repository.test.ts b/tests/external-export-batch.repository.test.ts
new file mode 100644
index 0000000..50278b6
--- /dev/null
+++ b/tests/external-export-batch.repository.test.ts
@@ -0,0 +1,40 @@
+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 { ExternalExportBatchRepository } from "../src/db/repositories/external-export-batch.repository.js";
+import { projects } from "../src/db/schema/projects.js";
+import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
+
+describe("external export batch repository", () => {
+ it("appends immutable audit metadata without changing the project revision", () => {
+ const context = createDatabaseContext(":memory:");
+ try {
+ migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
+ context.db.insert(projects).values({ id: "project-1", name: "Projekt", currentRevision: 7 }).run();
+ const repository = new ExternalExportBatchRepository(context.db);
+ const configuration = createDefaultExternalModelConfiguration();
+ repository.append({
+ id: "export-1", projectId: "project-1", projectRevision: 7,
+ importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T10:00:00.000Z",
+ fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "a".repeat(64),
+ configurationSnapshot: configuration, configurationSha256: "b".repeat(64), scope: "all", objectCount: 4,
+ });
+ configuration.columns.designation = "Nachträglich geändert";
+ repository.append({
+ id: "export-2", projectId: "project-1", projectRevision: 7,
+ importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T11:00:00.000Z",
+ fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "c".repeat(64),
+ configurationSnapshot: configuration, configurationSha256: "d".repeat(64), scope: "selected", objectCount: 1,
+ });
+
+ const batches = repository.listByProject("project-1");
+ assert.deepEqual(batches.map((batch) => batch.id), ["export-2", "export-1"]);
+ assert.notEqual(batches[0]!.configurationSnapshot.columns.designation, batches[1]!.configurationSnapshot.columns.designation);
+ assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()!.revision, 7);
+ } 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 4512e90..547d60f 100644
--- a/tests/external-object-row-assignment-project-command.repository.test.ts
+++ b/tests/external-object-row-assignment-project-command.repository.test.ts
@@ -7,6 +7,9 @@ import { createDatabaseContext, type AppDatabase } from "../src/db/database-cont
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
+import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
+import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
+import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
@@ -31,6 +34,10 @@ 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 { createCircuitDeviceRowDeleteProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
+import { createCircuitDeleteProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
+import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
+import { assertDistributionBoardHasNoExternalObjects, assertRoomHasNoExternalObjects } from "../src/db/repositories/external-object-deletion-guard.persistence.js";
function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:");
@@ -626,4 +633,63 @@ describe("external object new-circuit project command", () => {
context.close();
}
});
+
+ it("detaches linked objects on row deletion and restores the exact link on undo", () => {
+ const context = createFixture();
+ try {
+ const repository = new CircuitDeviceRowStructureProjectCommandRepository(context.db);
+ const deleted = repository.execute({
+ projectId: "project-1",
+ expectedRevision: 0,
+ source: "user",
+ command: createCircuitDeviceRowDeleteProjectCommand("row-1", "circuit-1"),
+ });
+ assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
+ assert.equal(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get(), undefined);
+
+ repository.execute({
+ projectId: "project-1",
+ expectedRevision: 1,
+ source: "undo",
+ historyTargetChangeSetId: deleted.revision.changeSetId,
+ command: deleted.inverse,
+ });
+ assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
+ assert.ok(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get());
+ } finally {
+ context.close();
+ }
+ });
+
+ it("preserves external links through circuit and populated-group deletion undo", () => {
+ for (const kind of ["circuit", "group"] as const) {
+ const context = createFixture();
+ try {
+ const repository = kind === "circuit"
+ ? new CircuitStructureProjectCommandRepository(context.db)
+ : new CircuitGroupSubtreeProjectCommandRepository(context.db);
+ const command = kind === "circuit"
+ ? createCircuitDeleteProjectCommand("circuit-1", "list-1")
+ : createCircuitGroupDeleteSubtreeProjectCommand(
+ (repository as CircuitGroupSubtreeProjectCommandRepository).capture("project-1", "section-1")
+ );
+ const deleted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command } as never);
+ assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
+ repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse } as never);
+ assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
+ } finally {
+ context.close();
+ }
+ }
+ });
+
+ it("blocks room and distribution-board deletion while external objects reference them", () => {
+ const context = createFixture(null);
+ try {
+ assert.throws(() => assertDistributionBoardHasNoExternalObjects(context.db, "project-1", "board-1"), /Revit-Objekte/);
+ assert.throws(() => assertRoomHasNoExternalObjects(context.db, "project-1", "room-1"), /Revit-Objekte/);
+ } finally {
+ context.close();
+ }
+ });
});
diff --git a/tests/external-xlsx-return-projection.test.ts b/tests/external-xlsx-return-projection.test.ts
new file mode 100644
index 0000000..10c1980
--- /dev/null
+++ b/tests/external-xlsx-return-projection.test.ts
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
+import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
+
+function object(): ExternalModelObjectSnapshot {
+ return {
+ id: "object-1", projectId: "project-1", sourceId: "source-1",
+ worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: null,
+ deviceTypeId: "type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
+ acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose", selectionMarker: "SD", socketCount: 2, sourceRemark: null, circuitCombined: "UV 1-2F1.1", boardName: "UV 1", circuitIdentifier: "-2F1.1", designation: "Steckdose", electricalPowerW: "240", voltageV: 230, currentA: "1,16" },
+ planningValues: { displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null, remark: null },
+ overriddenFields: [], externalRoomMappingId: null, distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: "row-1", presenceStatus: "present",
+ };
+}
+
+describe("external XLSX return projection", () => {
+ it("projects typed row values and compares normalized imported scalars", () => {
+ const result = projectExternalXlsxReturn({ object: object(), circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400, row: { rowId: "row-1", displayName: "Steckdose", powerPerUnitKw: 0.12, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } });
+ assert.deepEqual(result.values, { circuitCombined: "UV 1-2F1.1", designation: "Steckdose", electricalPowerW: 240, voltageV: 230, currentA: 1.16 });
+ assert.equal(result.differsFromImported, false);
+ assert.deepEqual(result.warnings, []);
+ });
+
+ it("uses object planning without a row and leaves an uncomputable current empty", () => {
+ const input = object(); input.circuitDeviceRowId = null; input.planningValues.cosPhi = null;
+ const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "separate", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
+ assert.equal(result.values.boardName, "");
+ assert.equal(result.values.circuitIdentifier, "");
+ assert.equal(result.values.electricalPowerW, 240);
+ assert.equal(result.values.currentA, null);
+ assert.equal(result.differsFromImported, true);
+ assert.match(result.warnings.join(" "), /Stromstärke/);
+ });
+
+ it("uses three-phase current and object-specific effective quantity", () => {
+ const input = object(); input.planningValues.category = "three_phase"; input.planningValues.effectiveQuantity = 3;
+ const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
+ assert.equal(result.values.electricalPowerW, 360);
+ assert.equal(result.values.voltageV, 400);
+ assert.equal(result.values.currentA, 0.58);
+ });
+});
diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts
index ed91b57..90161b3 100644
--- a/tests/revit-xlsx-settings-api.test.ts
+++ b/tests/revit-xlsx-settings-api.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
-import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
+import { applyExternalInitialImport, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
@@ -59,11 +59,15 @@ describe("Revit XLSX settings", () => {
assert.match(revitPage, /Importstatus/);
assert.match(settings, /Musterdatei prüfen/);
assert.match(settings, /Kombinierte Stromkreisspalte/);
+ assert.match(settings, /isNewConfiguration/);
+ assert.match(revitPage, /isNewConfiguration={snapshot === null}/);
assert.match(wizard, /Schritt.*von/);
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
assert.match(wizard, /GZF 1/);
assert.match(objectTable, /viewportHeight/);
assert.match(objectTable, /Sammeländerung anwenden/);
+ assert.match(objectTable, /Rückgabe-Excel herunterladen/);
+ assert.match(objectTable, /Nur geänderte Objekte/);
});
it("loads one project object list and sends revision-safe bulk updates", async () => {
@@ -74,4 +78,16 @@ describe("Revit XLSX settings", () => {
finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]);
});
+
+ it("creates a revision-safe return workbook for the requested scope", async () => {
+ const requests: Array<{ url: string; method: string; body: unknown }> = [];
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (input, init) => {
+ requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
+ return new Response(JSON.stringify({ fileName: "return.xlsx", contentBase64: "", sha256: "a".repeat(64), objectCount: 1, warnings: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
+ };
+ try { await createExternalReturnWorkbook("project-1", 7, "selected", ["object-1"]); }
+ finally { globalThis.fetch = originalFetch; }
+ assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
+ });
});