diff --git a/compose.yaml b/compose.yaml index d8d787e..6a5ec7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,7 @@ services: CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true + restart: unless-stopped logging: driver: json-file options: @@ -53,6 +54,7 @@ services: NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true + restart: unless-stopped logging: driver: json-file options: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 4ed696b..9ed9e0a 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -359,8 +359,9 @@ Response sketch: ### Circuit Structure -- `GET /circuit-sections/:sectionId/next-identifier` +- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier` - preview next identifier for section (`prefix + maxSuffix + 1`) + - returns 404 if the section does not belong to the given project Circuit and device-row field updates, standalone insertions/deletions, single or bulk device-row moves, circuit reorders and explicit renumbering are diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 32f6814..92efc98 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -476,16 +476,28 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. und Verteilerkomponenten. Separate 1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt. - Ein triggergeführtes Register erzwingt bereits eine normalisierte, + Ein triggergeführtes Register erzwingt eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und - Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell - Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für - veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter - Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor - zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und - Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung, - -sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel, - geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert. + Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites + eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht + als denselben Wert. Die gemeinsame Prüfung + `src/db/repositories/equipment-identifier-uniqueness.persistence.ts` + schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem + `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird + von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten + aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und + Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente + Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, + Gruppen einschließlich befüllter Unterbäume sowie vollständige + Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur + an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte + Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite + Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes + Unterbaumlöschen und Stromkreisschutz sind integriert. + Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und + `circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten + Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und + Stromkreisen. PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen diff --git a/docs/deployment.md b/docs/deployment.md index 5458a69..dfbb3f1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -58,6 +58,15 @@ mit Laufzeit und Speicherverbrauch – nützlich, um Speicherlecks oder Hänger einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche `LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar. +Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before +response finished` (`warn`) geloggt und zeigt damit vom Client oder einem +vorgeschalteten Proxy abgebrochene Verbindungen. + +Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet +den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in +einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit +`restart: unless-stopped`, damit Docker sie danach automatisch neu startet; +ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen. ## Voraussetzungen für ein späteres Produktionssetup diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index c675340..fa342ea 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -17,13 +17,13 @@ import { deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, + getProject, getProjectDeviceSyncPreview, listCircuitLists, listDistributionBoards, listFloors, listGlobalDevices, listProjectDevices, - listProjects, listRooms, importProjectTransfer, synchronizeProjectDeviceRows, @@ -129,7 +129,7 @@ export default function ProjectDetailPage() { return; } Promise.all([ - listProjects(), + getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), listFloors(projectId), @@ -138,7 +138,7 @@ export default function ProjectDetailPage() { listGlobalDevices(), ]) .then(([ - projects, + currentProject, distributionBoards, loadedCircuitLists, loadedFloors, @@ -146,7 +146,6 @@ export default function ProjectDetailPage() { loadedProjectDevices, loadedGlobalDevices, ]) => { - const currentProject = projects.find((item) => item.id === projectId) ?? null; setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); diff --git a/src/db/migrations/0006_damp_skrulls.sql b/src/db/migrations/0006_damp_skrulls.sql new file mode 100644 index 0000000..3f6badf --- /dev/null +++ b/src/db/migrations/0006_damp_skrulls.sql @@ -0,0 +1,2 @@ +CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint +CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0006_snapshot.json b/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..84f5229 --- /dev/null +++ b/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,2419 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b25e1a40-c400-4848-bc24-a04bda7a8209", + "prevId": "b79934fe-5d05-49da-b7c4-95c954e5fec2", + "tables": { + "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 + } + }, + "indexes": {}, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_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_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "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 + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "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_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 + }, + "ifc_guid": { + "name": "ifc_guid", + "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_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "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_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_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_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "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 + } + }, + "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" + } + }, + "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\"]'" + }, + "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 + } + }, + "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": {} + } + }, + "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 5a65548..eedfc7a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785687503453, "tag": "0005_stale_gorilla_man", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1786043080323, + "tag": "0006_damp_skrulls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/circuit-device-row.persistence.ts b/src/db/repositories/circuit-device-row.persistence.ts index c075f61..4a32623 100644 --- a/src/db/repositories/circuit-device-row.persistence.ts +++ b/src/db/repositories/circuit-device-row.persistence.ts @@ -43,12 +43,6 @@ export interface CircuitDeviceRowPatchInput { overriddenFields?: string | null; } -export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput { - circuitId: string; - linkedProjectDeviceId?: string; - sortOrder: number; -} - export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) { return { linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, @@ -105,15 +99,3 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput) return values; } - -export function toCircuitDeviceRowCreateValues( - id: string, - input: CircuitDeviceRowCreateInput -) { - return { - id, - circuitId: input.circuitId, - sortOrder: input.sortOrder, - ...toCircuitDeviceRowUpdateValues(input), - }; -} diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts index 6f04ffb..bbd6543 100644 --- a/src/db/repositories/circuit-project-command.repository.ts +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, ne } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { assertCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand, @@ -21,6 +21,7 @@ import { import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; type CircuitRow = typeof circuits.$inferSelect; @@ -166,20 +167,12 @@ export class CircuitProjectCommandRepository ) { return; } - const duplicate = database - .select({ id: circuits.id }) - .from(circuits) - .where( - and( - eq(circuits.circuitListId, circuit.circuitListId), - eq(circuits.equipmentIdentifier, equipmentIdentifier), - ne(circuits.id, circuit.id) - ) - ) - .get(); - if (duplicate) { - throw new Error("Duplicate equipmentIdentifier in circuit list."); - } + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + equipmentIdentifier, + circuit.id + ); } } diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts index 454028e..9b8941d 100644 --- a/src/db/repositories/circuit-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -27,6 +27,7 @@ import { } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class CircuitStructureProjectCommandRepository implements CircuitStructureProjectCommandStore @@ -103,24 +104,11 @@ export class CircuitStructureProjectCommandRepository if (existingCircuit) { throw new Error("Circuit id already exists."); } - const duplicateIdentifier = database - .select({ id: circuits.id }) - .from(circuits) - .where( - and( - eq(circuits.circuitListId, snapshot.circuitListId), - eq( - circuits.equipmentIdentifier, - snapshot.equipmentIdentifier - ) - ) - ) - .get(); - if (duplicateIdentifier) { - throw new Error( - "Duplicate equipmentIdentifier in circuit list." - ); - } + assertEquipmentIdentifierAvailable( + database, + snapshot.circuitListId, + snapshot.equipmentIdentifier + ); if (snapshot.deviceRows.length > 0) { const rowIds = snapshot.deviceRows.map((row) => row.id); diff --git a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts index 2dc3bba..3ea4291 100644 --- a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts @@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class DistributionBoardComponentStructureProjectCommandRepository implements DistributionBoardComponentStructureProjectCommandStore @@ -94,6 +95,11 @@ export class DistributionBoardComponentStructureProjectCommandRepository if (existing) { throw new Error("Distribution-board component id already exists."); } + assertEquipmentIdentifierAvailable( + database, + snapshot.component.circuitListId, + snapshot.component.equipmentIdentifier + ); database .insert(distributionBoardComponents) .values(snapshot.component) @@ -187,6 +193,17 @@ export class DistributionBoardComponentStructureProjectCommandRepository "Distribution-board component changed before update." ); } + if ( + target.component.equipmentIdentifier !== + expected.component.equipmentIdentifier + ) { + assertEquipmentIdentifierAvailable( + database, + expected.component.circuitListId, + target.component.equipmentIdentifier, + expected.component.id + ); + } database .update(distributionBoardComponents) .set(target.component) diff --git a/src/db/repositories/equipment-identifier-uniqueness.persistence.ts b/src/db/repositories/equipment-identifier-uniqueness.persistence.ts new file mode 100644 index 0000000..43b63ad --- /dev/null +++ b/src/db/repositories/equipment-identifier-uniqueness.persistence.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { circuitListEquipmentIdentifiers } from "../schema/circuit-list-equipment-identifiers.js"; + +export function normalizeEquipmentIdentifier(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * The DB-level normalized unique index uses SQLite's built-in lower(), + * which only folds ASCII a-z and leaves German characters (Ä/Ö/Ü/ß/…) + * untouched, so it alone would let e.g. "Ä1" and "ä1" coexist. This check + * normalizes with JS's Unicode-aware toLowerCase() against every + * identifier already registered for the circuit list (circuits and + * distribution-board components share one BMK namespace via + * circuit_list_equipment_identifiers), catching what the DB index cannot. + */ +export function assertEquipmentIdentifierAvailable( + database: AppDatabase, + circuitListId: string, + equipmentIdentifier: string, + excludeOwnerId?: string +): void { + const candidate = normalizeEquipmentIdentifier(equipmentIdentifier); + const existing = database + .select({ + ownerId: circuitListEquipmentIdentifiers.ownerId, + equipmentIdentifier: circuitListEquipmentIdentifiers.equipmentIdentifier, + }) + .from(circuitListEquipmentIdentifiers) + .where(eq(circuitListEquipmentIdentifiers.circuitListId, circuitListId)) + .all(); + const duplicate = existing.some( + (row) => + row.ownerId !== excludeOwnerId && + normalizeEquipmentIdentifier(row.equipmentIdentifier) === candidate + ); + if (duplicate) { + throw new Error("Duplicate equipmentIdentifier in circuit list."); + } +} diff --git a/src/db/repositories/external-initial-import-project-command.repository.ts b/src/db/repositories/external-initial-import-project-command.repository.ts index 9d2895a..1f2281e 100644 --- a/src/db/repositories/external-initial-import-project-command.repository.ts +++ b/src/db/repositories/external-initial-import-project-command.repository.ts @@ -212,7 +212,9 @@ function replaceExternalState( if (target.roomMappings.length) { database.insert(externalRoomMappings).values(target.roomMappings).run(); } - database.insert(externalModelObjects).values(target.objects).run(); + if (target.objects.length) { + database.insert(externalModelObjects).values(target.objects).run(); + } } function decodeCanonicalBase64(value: string) { diff --git a/src/db/repositories/external-object-new-circuit-project-command.repository.ts b/src/db/repositories/external-object-new-circuit-project-command.repository.ts index c25ac76..bea64ff 100644 --- a/src/db/repositories/external-object-new-circuit-project-command.repository.ts +++ b/src/db/repositories/external-object-new-circuit-project-command.repository.ts @@ -32,6 +32,7 @@ import { loadExpectedExternalObjectTransitions, snapshotsEqual, } from "./external-object-assignment.persistence.js"; +import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; export class ExternalObjectNewCircuitProjectCommandRepository implements ExternalObjectNewCircuitProjectCommandStore @@ -90,12 +91,11 @@ export class ExternalObjectNewCircuitProjectCommandRepository .where(eq(circuits.id, circuit.id)).get()) { throw new Error("External circuit id already exists."); } - if (database.select({ id: circuits.id }).from(circuits).where(and( - eq(circuits.circuitListId, circuit.circuitListId), - eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier) - )).get()) { - throw new Error("Duplicate equipmentIdentifier in circuit list."); - } + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + circuit.equipmentIdentifier + ); if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, row.id)).get()) { throw new Error("External device-row id already exists."); diff --git a/src/db/schema/circuit-device-rows.ts b/src/db/schema/circuit-device-rows.ts index a5da01e..f718c5d 100644 --- a/src/db/schema/circuit-device-rows.ts +++ b/src/db/schema/circuit-device-rows.ts @@ -1,35 +1,38 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { circuits } from "./circuits.js"; import { projectDevices } from "./project-devices.js"; import { rooms } from "./rooms.js"; -export const circuitDeviceRows = sqliteTable("circuit_device_rows", { - id: text("id").primaryKey(), - circuitId: text("circuit_id") - .notNull() - .references(() => circuits.id, { onDelete: "cascade" }), - linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { - onDelete: "set null", - }), - sortOrder: integer("sort_order").notNull().default(0), - name: text("name").notNull(), - displayName: text("display_name").notNull(), - phaseType: text("phase_type"), - connectionKind: text("connection_kind"), - costGroup: text("cost_group"), - category: text("category"), - level: text("level"), - roomId: text("room_id").references(() => rooms.id, { - onDelete: "set null", - }), - roomNumberSnapshot: text("room_number_snapshot"), - roomNameSnapshot: text("room_name_snapshot"), - quantity: integer("quantity").notNull(), - manualQuantity: integer("manual_quantity").notNull().default(0), - powerPerUnit: real("power_per_unit").notNull(), - simultaneityFactor: real("simultaneity_factor").notNull(), - cosPhi: real("cos_phi"), - remark: text("remark"), - overriddenFields: text("overridden_fields"), -}); - +export const circuitDeviceRows = sqliteTable( + "circuit_device_rows", + { + id: text("id").primaryKey(), + circuitId: text("circuit_id") + .notNull() + .references(() => circuits.id, { onDelete: "cascade" }), + linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { + onDelete: "set null", + }), + sortOrder: integer("sort_order").notNull().default(0), + name: text("name").notNull(), + displayName: text("display_name").notNull(), + phaseType: text("phase_type"), + connectionKind: text("connection_kind"), + costGroup: text("cost_group"), + category: text("category"), + level: text("level"), + roomId: text("room_id").references(() => rooms.id, { + onDelete: "set null", + }), + roomNumberSnapshot: text("room_number_snapshot"), + roomNameSnapshot: text("room_name_snapshot"), + quantity: integer("quantity").notNull(), + manualQuantity: integer("manual_quantity").notNull().default(0), + powerPerUnit: real("power_per_unit").notNull(), + simultaneityFactor: real("simultaneity_factor").notNull(), + cosPhi: real("cos_phi"), + remark: text("remark"), + overriddenFields: text("overridden_fields"), + }, + (table) => [index("circuit_device_rows_circuit_id_idx").on(table.circuitId)] +); diff --git a/src/db/schema/circuits.ts b/src/db/schema/circuits.ts index c0c733c..6e3ed3e 100644 --- a/src/db/schema/circuits.ts +++ b/src/db/schema/circuits.ts @@ -1,4 +1,4 @@ -import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { circuitLists } from "./circuit-lists.js"; import { circuitSections } from "./circuit-sections.js"; @@ -26,6 +26,9 @@ export const circuits = sqliteTable( isReserve: integer("is_reserve").notNull().default(0), remark: text("remark"), }, - (table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)] + (table) => [ + unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier), + index("circuits_section_id_idx").on(table.sectionId), + ] ); diff --git a/src/domain/models/circuit-device-row-project-command.model.ts b/src/domain/models/circuit-device-row-project-command.model.ts index 825701f..8e949a1 100644 --- a/src/domain/models/circuit-device-row-project-command.model.ts +++ b/src/domain/models/circuit-device-row-project-command.model.ts @@ -144,6 +144,9 @@ function assertCircuitDeviceRowUpdateFieldValue( if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw new Error(`${field} must be a non-negative finite number.`); } + if (field === "simultaneityFactor" && value > 1) { + throw new Error("simultaneityFactor must not exceed 1."); + } return; } if (field === "cosPhi") { 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 d6e3cf6..2464a27 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 @@ -134,6 +134,9 @@ export function assertCircuitDeviceRowInsertProjectCommand( row.simultaneityFactor, "row.simultaneityFactor" ); + if (row.simultaneityFactor > 1) { + throw new Error("row.simultaneityFactor must not exceed 1."); + } if (row.cosPhi !== null) { assertFiniteNumber(row.cosPhi, "row.cosPhi"); if (row.cosPhi <= 0) { diff --git a/src/domain/models/circuit-device-row.model.ts b/src/domain/models/circuit-device-row.model.ts deleted file mode 100644 index f18c63c..0000000 --- a/src/domain/models/circuit-device-row.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface CircuitDeviceRow { - id: string; - circuitId: string; - linkedProjectDeviceId?: string; - sortOrder: number; - name: string; - displayName: string; - phaseType?: string; - connectionKind?: string; - costGroup?: string; - category?: string; - level?: string; - roomId?: string; - roomNumberSnapshot?: string; - roomNameSnapshot?: string; - quantity: number; - manualQuantity: number; - powerPerUnit: number; - simultaneityFactor: number; - cosPhi?: number; - remark?: string; - overriddenFields?: string; -} - diff --git a/src/domain/models/circuit-protection-project-command.model.ts b/src/domain/models/circuit-protection-project-command.model.ts index 2e8e6f4..0c8a19e 100644 --- a/src/domain/models/circuit-protection-project-command.model.ts +++ b/src/domain/models/circuit-protection-project-command.model.ts @@ -71,15 +71,33 @@ export function assertCircuitProtectionUpdateProjectCommand( if (target !== null) { assertCircuitProtectionSnapshot(target, circuitId); } - if (JSON.stringify(expected) === JSON.stringify(target)) { + if (circuitProtectionSnapshotsEqual(expected, target)) { throw new Error("Circuit protection update must change state."); } } +function circuitProtectionSnapshotsEqual( + left: CircuitProtectionSnapshot | null, + right: CircuitProtectionSnapshot | null +): boolean { + if (left === null || right === null) { + return left === right; + } + return ( + left.circuitId === right.circuitId && + left.type === right.type && + left.ratedCurrentA === right.ratedCurrentA && + left.fuseUtilizationCategory === right.fuseUtilizationCategory && + left.tripCharacteristic === right.tripCharacteristic && + left.rcdType === right.rcdType && + left.ratedResidualCurrentMa === right.ratedResidualCurrentMa + ); +} + export function assertCircuitProtectionSnapshot( value: unknown, circuitId: string -) { +): asserts value is CircuitProtectionSnapshot { if ( !isPlainObject(value) || Object.keys(value).length !== 7 || diff --git a/src/domain/models/circuit-section.model.ts b/src/domain/models/circuit-section.model.ts deleted file mode 100644 index bba2c4e..0000000 --- a/src/domain/models/circuit-section.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CircuitSection { - id: string; - circuitListId: string; - key: string; - displayName: string; - prefix: string; - sortOrder: number; -} - diff --git a/src/domain/models/circuit.model.ts b/src/domain/models/circuit.model.ts deleted file mode 100644 index 3efe12c..0000000 --- a/src/domain/models/circuit.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface Circuit { - id: string; - circuitListId: string; - sectionId: string; - equipmentIdentifier: string; - displayName?: string; - sortOrder: number; - cableType?: string; - cableCrossSection?: string; - cableLength?: number; - rcdAssignment?: string; - terminalDesignation?: string; - voltage?: number; - controlRequirement?: string; - status?: string; - isReserve: boolean; - remark?: string; -} - diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 060c795..7172cb7 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -130,7 +130,7 @@ const circuitDeviceRowSchema = z.preprocess( quantity: finiteNumberSchema.nonnegative(), manualQuantity: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(), - simultaneityFactor: finiteNumberSchema.nonnegative(), + simultaneityFactor: finiteNumberSchema.min(0).max(1), cosPhi: finiteNumberSchema.positive().nullable(), remark: nullableStringSchema, overriddenFields: nullableStringSchema, diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index b63cd8e..03aa38d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -268,6 +268,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); + // Synchronous re-entry guard: isSaving is React state and only reflects + // reality after the next render, so a second click/drop fired within the + // same tick could otherwise race past it and double-submit a command. + const commandInFlightRef = useRef(false); const [componentEditorIntent, setComponentEditorIntent] = useState(null); const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = @@ -723,6 +727,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); }, [data]); + // Clears the sidebar's target selection once it no longer resolves to a + // real section/circuit (e.g. deleted, moved or renumbered elsewhere) + // instead of silently holding a stale id after a tree reload. + useEffect(() => { + if (!data) { + return; + } + if ( + targetSectionId && + !data.sections.some((section) => section.id === targetSectionId) + ) { + setTargetSectionId(null); + } + if ( + targetCircuitId && + !circuitOptions.some((option) => option.id === targetCircuitId) + ) { + setTargetCircuitId(null); + } + }, [data, circuitOptions, targetSectionId, targetCircuitId]); + const allCircuits = useMemo( () => data?.sections.flatMap((section) => section.circuits) ?? [], [data] @@ -1046,6 +1071,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Runs a normal command. The server records it in project-wide history. async function runCommand(command: HistoryCommand) { + if (commandInFlightRef.current) { + return; + } + commandInFlightRef.current = true; try { setError(null); setIsSaving(true); @@ -1056,6 +1085,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await loadTree({ showLoading: false }); setError(message); } finally { + commandInFlightRef.current = false; setIsSaving(false); } } @@ -1063,6 +1093,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Applies the next eligible project-wide history operation. Selection is only // a best-effort local hint; command eligibility and data changes stay server-owned. async function applyHistory(mode: "undo" | "redo") { + if (commandInFlightRef.current) { + return; + } + commandInFlightRef.current = true; try { setError(null); setHistoryBusy(true); @@ -1088,6 +1122,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await loadTree({ showLoading: false }); setError(message); } finally { + commandInFlightRef.current = false; setIsSaving(false); setHistoryBusy(false); } @@ -1744,7 +1779,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!section) { throw new Error("Bereich wurde nicht gefunden."); } - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const isDeviceField = deviceFieldKeys.has(key); @@ -1933,7 +1968,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await runCommand({ label: "Stromkreis hinzufügen", redo: async () => { - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const circuit = createCircuitSnapshot({ sectionId, @@ -2127,7 +2162,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!section) { throw new Error("Der Zielbereich ist ungültig."); } - const next = await getNextCircuitIdentifier(sectionId); + const next = await getNextCircuitIdentifier(projectId, sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const circuit = createCircuitSnapshot( @@ -2538,7 +2573,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await runCommand({ label: newCircuitLabel, redo: async () => { - const next = await getNextCircuitIdentifier(intent.sectionId); + const next = await getNextCircuitIdentifier(projectId, intent.sectionId); const sortOrder = intent.targetCircuitId && intent.placement ? getAdjacentInsertionSortOrder( @@ -3699,6 +3734,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || !buildCircuitGroupReorderAssignments( data.sections, section.id, @@ -3716,6 +3752,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || !buildCircuitGroupReorderAssignments( data.sections, section.id, @@ -3733,6 +3770,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str type="button" tabIndex={-1} disabled={ + isSaving || hasActiveSortOrFilter || !section.category || !canRenumberCircuitGroups( @@ -3758,6 +3796,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - ) : null} diff --git a/src/frontend/components/form-modal.tsx b/src/frontend/components/form-modal.tsx index 05e18eb..d893690 100644 --- a/src/frontend/components/form-modal.tsx +++ b/src/frontend/components/form-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { type FormEvent, type ReactNode } from "react"; +import React, { type FormEvent, type ReactNode, useEffect, useRef } from "react"; interface FormModalProps { children: ReactNode; @@ -14,6 +14,9 @@ interface FormModalProps { title: string; } +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + export function FormModal({ children, description, @@ -25,11 +28,56 @@ export function FormModal({ submitLabel, title, }: FormModalProps) { + const dialogRef = useRef(null); + + // Focuses the dialog on open and returns focus to the element that + // triggered it on close, so keyboard users never lose their place in the + // grid behind the backdrop. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const firstFocusable = + dialogRef.current?.querySelector(FOCUSABLE_SELECTOR); + firstFocusable?.focus(); + return () => { + previouslyFocused?.focus(); + }; + }, []); + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + if (!isSaving) { + event.stopPropagation(); + onClose(); + } + return; + } + if (event.key !== "Tab" || !dialogRef.current) { + return; + } + const focusable = Array.from( + dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR) + ); + if (focusable.length === 0) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + return ( <>
diff --git a/src/frontend/components/project-settings-modal.tsx b/src/frontend/components/project-settings-modal.tsx index 77295df..4af751c 100644 --- a/src/frontend/components/project-settings-modal.tsx +++ b/src/frontend/components/project-settings-modal.tsx @@ -7,6 +7,7 @@ import { distributionBoardSupplyTypes, type DistributionBoardSupplyType, } from "../../shared/constants/distribution-board"; +import { FormModal } from "./form-modal"; export interface ProjectSettingsInput { name: string; @@ -131,319 +132,278 @@ export function ProjectSettingsModal({ } return ( - <> -
-
-
-
-
-

- Projekteinstellungen -

-

- Stammdaten und elektrische Standardwerte des Projekts -

-
-
-
-
-
- - setName(event.target.value)} - required - value={name} - /> -
-
- - - setInternalProjectNumber(event.target.value) - } - value={internalProjectNumber} - /> -
-
- - - setExternalProjectNumber(event.target.value) - } - value={externalProjectNumber} - /> -
-
-
- - Verwendete Netzarten - -

- Nur ausgewählte Netzarten stehen bei Verteilungen zur - Auswahl. Bereits verwendete Netzarten können nicht - deaktiviert werden. -

-
- {distributionBoardSupplyTypes.map((supplyType) => ( -
- -
- ))} -
- {enabledDistributionBoardSupplyTypes.length === 0 ? ( -
- Mindestens eine Netzart muss aktiviert sein. -
- ) : null} -
-
-
- - setBuildingOwner(event.target.value)} - value={buildingOwner} - /> -
-
- -