From 76493b573ef2734bd6cbf791979bea7c3943819d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 17:18:24 +0200 Subject: [PATCH] Add external model persistence foundation --- AGENTS.md | 7 + docs/current-architecture.md | 19 +- .../spec/revit-csv-phase-14-audit-and-plan.md | 14 +- src/db/migrations/0003_outstanding_maddog.sql | 74 + src/db/migrations/meta/0003_snapshot.json | 2389 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + .../external-model-state.repository.ts | 74 + src/db/schema/external-import-batches.ts | 43 + src/db/schema/external-model-objects.ts | 80 + src/db/schema/external-model-sources.ts | 23 + src/db/schema/external-room-mappings.ts | 37 + .../ports/external-model-state.reader.ts | 10 + .../domain/external-model-contracts.ts | 100 + .../domain/external-model-matching.ts | 147 + tests/external-model-foundation.test.ts | 191 ++ 15 files changed, 3207 insertions(+), 8 deletions(-) create mode 100644 src/db/migrations/0003_outstanding_maddog.sql create mode 100644 src/db/migrations/meta/0003_snapshot.json create mode 100644 src/db/repositories/external-model-state.repository.ts create mode 100644 src/db/schema/external-import-batches.ts create mode 100644 src/db/schema/external-model-objects.ts create mode 100644 src/db/schema/external-model-sources.ts create mode 100644 src/db/schema/external-room-mappings.ts create mode 100644 src/domain/ports/external-model-state.reader.ts create mode 100644 src/external-model/domain/external-model-contracts.ts create mode 100644 src/external-model/domain/external-model-matching.ts create mode 100644 tests/external-model-foundation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7790117..dd0a5d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,6 +269,13 @@ complete transport/column/family-rule configuration through the revision-safe endpoint and requests a stateless preview. Preview responses include all mapped objects, while the UI renders only the first 25. No preview creates external objects, rows, revisions or server-side drafts. +Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, +immutable import batches with original bytes plus the classified cell matrix, +normalized source-room mappings and external objects unique by +`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. +The current repository is read-only; no runtime import command writes these +tables yet, and snapshot/transfer schema remains version 3 until the next work +package adds version 4 atomically. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 704b540..832b354 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -500,8 +500,20 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren -werden Konfigurations-UUID und Projektlink remapped. Persistente externe Objekte -und die bestätigte Importübernahme sind noch nicht implementiert. +werden Konfigurations-UUID und Projektlink remapped. + +Migration `0003` ergänzt die relationale Grundlage für genau eine implizite +`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte +Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte. +Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte +Zellmatrix zusammen. Quellwerte, lokale Planungswerte und Override-Metadaten +der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice +und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den +vollständigen Zustand deterministisch und unterscheidet unbekannte von noch +nicht importierten Projekten. Der letzte bestätigte Batch wird aus der +Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. +Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch +nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. @@ -519,7 +531,8 @@ Response verfügbar, ohne alle Zeilen in den DOM einzufügen. ## Noch nicht unterstützt - Mehrbenutzerbetrieb und Konfliktauflösung -- vollständiger Revit-/CSV-/IFCGUID-Round-trip über die vorhandene Grundlage hinaus +- bestätigter Revit-Erstimport und vollständiger CSV-/IFCGUID-Round-trip über + die vorhandene Transport- und Persistenzgrundlage hinaus - vollständige elektrische Dimensionierung - Produktionsdeployment diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index 0249f98..3d22b12 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -3,10 +3,10 @@ ## Status Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den -gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in -Umsetzung; Transportverträge, Konfigurationsvalidatoren, die zustandsfreie -CSV-Transportgrundlage und die persistente projektweite Konfiguration sind -vorhanden. +gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen. +Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem +additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert +noch nicht. Fachliche Quelle bleibt [Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). @@ -81,7 +81,8 @@ jeweilige englische Pluralform in `snake_case`. - stabile interne UUID; - `projectId`; - technischer Name und Quelltyp `revit_csv`; -- Verweis auf den zuletzt bestätigten Importbatch. +- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie + abgeleitet und nicht als redundanter Zeiger gespeichert. In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die @@ -268,6 +269,9 @@ einen Commit aufgenommen. ### 14.2 – Persistente Objekte und Erstimport 1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen. + **Erledigt:** Migration `0003`, vollständiger externer Read-State sowie + exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt + weiterhin keinen Import-Write außerhalb eines Commands. 2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen. 3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard diff --git a/src/db/migrations/0003_outstanding_maddog.sql b/src/db/migrations/0003_outstanding_maddog.sql new file mode 100644 index 0000000..81f6947 --- /dev/null +++ b/src/db/migrations/0003_outstanding_maddog.sql @@ -0,0 +1,74 @@ +CREATE TABLE `external_import_batches` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `import_kind` text NOT NULL, + `imported_at_iso` text NOT NULL, + `file_name` text NOT NULL, + `sha256` text NOT NULL, + `applied_project_revision` integer NOT NULL, + `configuration_snapshot` text NOT NULL, + `original_bytes` blob NOT NULL, + `document` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint +CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint +CREATE TABLE `external_model_objects` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `ifc_guid` text NOT NULL, + `last_seen_import_batch_id` text NOT NULL, + `last_accepted_import_batch_id` text NOT NULL, + `accepted_source_values` text NOT NULL, + `planning_values` text NOT NULL, + `overridden_fields` text NOT NULL, + `external_room_mapping_id` text, + `distribution_board_id` text, + `linked_project_device_id` text, + `circuit_device_row_id` text, + `presence_status` text DEFAULT 'present' NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `external_model_objects_source_ifc_guid_unique` ON `external_model_objects` (`source_id`,`ifc_guid`);--> statement-breakpoint +CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint +CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint +CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint +CREATE TABLE `external_model_sources` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `source_type` text DEFAULT 'revit_csv' NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `external_model_sources_project_type_unique` ON `external_model_sources` (`project_id`,`source_type`);--> statement-breakpoint +CREATE TABLE `external_room_mappings` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `source_id` text NOT NULL, + `normalized_source_room_key` text NOT NULL, + `source_floor_name` text, + `source_room_number` text NOT NULL, + `source_room_name` text NOT NULL, + `room_id` text, + `default_distribution_board_id` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint +CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0003_snapshot.json b/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..2b5972e --- /dev/null +++ b/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,2389 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6012d737-97f7-420d-a44d-85edbe5e53cf", + "prevId": "45d3523a-a37a-4c68-8fa1-abd6585108ab", + "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 + }, + "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": {}, + "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_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_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 d1e3295..1ac144a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785680771603, "tag": "0002_awesome_madripoor", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1785683710155, + "tag": "0003_outstanding_maddog", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/external-model-state.repository.ts b/src/db/repositories/external-model-state.repository.ts new file mode 100644 index 0000000..36079c9 --- /dev/null +++ b/src/db/repositories/external-model-state.repository.ts @@ -0,0 +1,74 @@ +import { asc, eq } from "drizzle-orm"; +import type { ExternalModelStateReader } from "../../domain/ports/external-model-state.reader.js"; +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import type { AppDatabase } from "../database-context.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; +import { projects } from "../schema/projects.js"; + +const emptyState = (): ExternalModelStateSnapshot => ({ + source: null, + importBatches: [], + roomMappings: [], + objects: [], +}); + +export class ExternalModelStateRepository implements ExternalModelStateReader { + constructor(private readonly database: AppDatabase) {} + + getByProject(projectId: string) { + const project = this.database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) return { projectExists: false, state: emptyState() }; + + const source = this.database + .select() + .from(externalModelSources) + .where(eq(externalModelSources.projectId, projectId)) + .get() ?? null; + if (!source) return { projectExists: true, state: emptyState() }; + + const batches = this.database + .select() + .from(externalImportBatches) + .where(eq(externalImportBatches.sourceId, source.id)) + .orderBy( + asc(externalImportBatches.appliedProjectRevision), + asc(externalImportBatches.id) + ) + .all(); + const roomMappings = this.database + .select() + .from(externalRoomMappings) + .where(eq(externalRoomMappings.sourceId, source.id)) + .orderBy( + asc(externalRoomMappings.normalizedSourceRoomKey), + asc(externalRoomMappings.id) + ) + .all(); + const objects = this.database + .select() + .from(externalModelObjects) + .where(eq(externalModelObjects.sourceId, source.id)) + .orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id)) + .all(); + + return { + projectExists: true, + state: { + source, + importBatches: batches.map(({ originalBytes, ...batch }) => ({ + ...batch, + originalContentBase64: Buffer.from(originalBytes).toString("base64"), + })), + roomMappings, + objects, + }, + }; + } +} diff --git a/src/db/schema/external-import-batches.ts b/src/db/schema/external-import-batches.ts new file mode 100644 index 0000000..bf317e9 --- /dev/null +++ b/src/db/schema/external-import-batches.ts @@ -0,0 +1,43 @@ +import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import type { + ExternalCsvConfiguration, + ExternalCsvDocument, +} from "../../external-model/csv/external-csv-contracts.js"; +import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js"; +import { externalModelSources } from "./external-model-sources.js"; +import { projects } from "./projects.js"; + +export const externalImportBatches = sqliteTable( + "external_import_batches", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + sourceId: text("source_id") + .notNull() + .references(() => externalModelSources.id, { onDelete: "cascade" }), + importKind: text("import_kind", { enum: externalImportKinds }).notNull(), + importedAtIso: text("imported_at_iso").notNull(), + fileName: text("file_name").notNull(), + sha256: text("sha256").notNull(), + appliedProjectRevision: integer("applied_project_revision").notNull(), + configurationSnapshot: text("configuration_snapshot", { mode: "json" }) + .$type() + .notNull(), + originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(), + document: text("document", { mode: "json" }) + .$type() + .notNull(), + }, + (table) => [ + index("external_import_batches_project_revision_idx").on( + table.projectId, + table.appliedProjectRevision + ), + index("external_import_batches_source_imported_idx").on( + table.sourceId, + table.importedAtIso + ), + ] +); diff --git a/src/db/schema/external-model-objects.ts b/src/db/schema/external-model-objects.ts new file mode 100644 index 0000000..fbd50b7 --- /dev/null +++ b/src/db/schema/external-model-objects.ts @@ -0,0 +1,80 @@ +import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import type { + ExternalModelObjectOverrideField, + ExternalModelObjectPlanningValues, + ExternalModelObjectSourceValues, +} from "../../external-model/domain/external-model-contracts.js"; +import { externalObjectPresenceStatuses } from "../../external-model/domain/external-model-contracts.js"; +import { circuitDeviceRows } from "./circuit-device-rows.js"; +import { distributionBoards } from "./distribution-boards.js"; +import { externalImportBatches } from "./external-import-batches.js"; +import { externalModelSources } from "./external-model-sources.js"; +import { externalRoomMappings } from "./external-room-mappings.js"; +import { projectDevices } from "./project-devices.js"; +import { projects } from "./projects.js"; + +export const externalModelObjects = sqliteTable( + "external_model_objects", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + sourceId: text("source_id") + .notNull() + .references(() => externalModelSources.id, { onDelete: "cascade" }), + ifcGuid: text("ifc_guid").notNull(), + lastSeenImportBatchId: text("last_seen_import_batch_id") + .notNull() + .references(() => externalImportBatches.id, { onDelete: "restrict" }), + lastAcceptedImportBatchId: text("last_accepted_import_batch_id") + .notNull() + .references(() => externalImportBatches.id, { onDelete: "restrict" }), + acceptedSourceValues: text("accepted_source_values", { mode: "json" }) + .$type() + .notNull(), + planningValues: text("planning_values", { mode: "json" }) + .$type() + .notNull(), + overriddenFields: text("overridden_fields", { mode: "json" }) + .$type() + .notNull(), + externalRoomMappingId: text("external_room_mapping_id").references( + () => externalRoomMappings.id, + { onDelete: "set null" } + ), + distributionBoardId: text("distribution_board_id").references( + () => distributionBoards.id, + { onDelete: "set null" } + ), + linkedProjectDeviceId: text("linked_project_device_id").references( + () => projectDevices.id, + { onDelete: "set null" } + ), + circuitDeviceRowId: text("circuit_device_row_id").references( + () => circuitDeviceRows.id, + { onDelete: "set null" } + ), + presenceStatus: text("presence_status", { + enum: externalObjectPresenceStatuses, + }) + .notNull() + .default("present"), + }, + (table) => [ + uniqueIndex("external_model_objects_source_ifc_guid_unique").on( + table.sourceId, + table.ifcGuid + ), + index("external_model_objects_project_presence_idx").on( + table.projectId, + table.presenceStatus + ), + index("external_model_objects_distribution_board_idx").on( + table.distributionBoardId + ), + index("external_model_objects_circuit_device_row_idx").on( + table.circuitDeviceRowId + ), + ] +); diff --git a/src/db/schema/external-model-sources.ts b/src/db/schema/external-model-sources.ts new file mode 100644 index 0000000..a4f9ded --- /dev/null +++ b/src/db/schema/external-model-sources.ts @@ -0,0 +1,23 @@ +import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { externalModelSourceTypes } from "../../external-model/domain/external-model-contracts.js"; +import { projects } from "./projects.js"; + +export const externalModelSources = sqliteTable( + "external_model_sources", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + sourceType: text("source_type", { enum: externalModelSourceTypes }) + .notNull() + .default("revit_csv"), + }, + (table) => [ + uniqueIndex("external_model_sources_project_type_unique").on( + table.projectId, + table.sourceType + ), + ] +); diff --git a/src/db/schema/external-room-mappings.ts b/src/db/schema/external-room-mappings.ts new file mode 100644 index 0000000..d7cced4 --- /dev/null +++ b/src/db/schema/external-room-mappings.ts @@ -0,0 +1,37 @@ +import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { distributionBoards } from "./distribution-boards.js"; +import { externalModelSources } from "./external-model-sources.js"; +import { projects } from "./projects.js"; +import { rooms } from "./rooms.js"; + +export const externalRoomMappings = sqliteTable( + "external_room_mappings", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + sourceId: text("source_id") + .notNull() + .references(() => externalModelSources.id, { onDelete: "cascade" }), + normalizedSourceRoomKey: text("normalized_source_room_key").notNull(), + sourceFloorName: text("source_floor_name"), + sourceRoomNumber: text("source_room_number").notNull(), + sourceRoomName: text("source_room_name").notNull(), + roomId: text("room_id").references(() => rooms.id, { onDelete: "set null" }), + defaultDistributionBoardId: text("default_distribution_board_id").references( + () => distributionBoards.id, + { onDelete: "set null" } + ), + }, + (table) => [ + uniqueIndex("external_room_mappings_source_key_unique").on( + table.sourceId, + table.normalizedSourceRoomKey + ), + index("external_room_mappings_project_room_idx").on( + table.projectId, + table.roomId + ), + ] +); diff --git a/src/domain/ports/external-model-state.reader.ts b/src/domain/ports/external-model-state.reader.ts new file mode 100644 index 0000000..68ffa48 --- /dev/null +++ b/src/domain/ports/external-model-state.reader.ts @@ -0,0 +1,10 @@ +import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js"; + +export interface ExternalModelStateReadResult { + projectExists: boolean; + state: ExternalModelStateSnapshot; +} + +export interface ExternalModelStateReader { + getByProject(projectId: string): ExternalModelStateReadResult; +} diff --git a/src/external-model/domain/external-model-contracts.ts b/src/external-model/domain/external-model-contracts.ts new file mode 100644 index 0000000..e18d2e5 --- /dev/null +++ b/src/external-model/domain/external-model-contracts.ts @@ -0,0 +1,100 @@ +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; +import type { + ExternalCsvConfiguration, + ExternalCsvDocument, +} from "../csv/external-csv-contracts.js"; + +export const externalModelSourceTypes = ["revit_csv"] as const; +export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number]; + +export const externalImportKinds = ["initial", "follow_up"] as const; +export type ExternalImportKind = (typeof externalImportKinds)[number]; + +export const externalObjectPresenceStatuses = ["present", "missing"] as const; +export type ExternalObjectPresenceStatus = + (typeof externalObjectPresenceStatuses)[number]; + +export interface ExternalModelSourceSnapshot { + id: string; + projectId: string; + name: string; + sourceType: ExternalModelSourceType; +} + +export interface ExternalImportBatchSnapshot { + id: string; + projectId: string; + sourceId: string; + importKind: ExternalImportKind; + importedAtIso: string; + fileName: string; + sha256: string; + appliedProjectRevision: number; + configurationSnapshot: ExternalCsvConfiguration; + originalContentBase64: string; + document: ExternalCsvDocument; +} + +export interface ExternalRoomMappingSnapshot { + id: string; + projectId: string; + sourceId: string; + normalizedSourceRoomKey: string; + sourceFloorName: string | null; + sourceRoomNumber: string; + sourceRoomName: string; + roomId: string | null; + defaultDistributionBoardId: string | null; +} + +export interface ExternalModelObjectSourceValues { + rowNumber: number; + roomNumber: string; + roomName: string; + familyAndType: string; + selectionMarker: string; + circuitIdentifier: string; + power: string; + quantity: string | null; + additionalSourceValues: Record; +} + +export interface ExternalModelObjectPlanningValues { + displayName: string | null; + internalDeviceType: string | null; + category: CircuitGroupCategory | null; + connectionKind: string | null; + effectiveQuantity: number; + powerPerUnitW: number | null; + simultaneityFactor: number; + cosPhi: number | null; + costGroup: string | null; + remark: string | null; +} + +export type ExternalModelObjectOverrideField = + keyof ExternalModelObjectPlanningValues; + +export interface ExternalModelObjectSnapshot { + id: string; + projectId: string; + sourceId: string; + ifcGuid: string; + lastSeenImportBatchId: string; + lastAcceptedImportBatchId: string; + acceptedSourceValues: ExternalModelObjectSourceValues; + planningValues: ExternalModelObjectPlanningValues; + overriddenFields: ExternalModelObjectOverrideField[]; + externalRoomMappingId: string | null; + distributionBoardId: string | null; + linkedProjectDeviceId: string | null; + circuitDeviceRowId: string | null; + presenceStatus: ExternalObjectPresenceStatus; +} + +export interface ExternalModelStateSnapshot { + source: ExternalModelSourceSnapshot | null; + importBatches: ExternalImportBatchSnapshot[]; + roomMappings: ExternalRoomMappingSnapshot[]; + objects: ExternalModelObjectSnapshot[]; +} diff --git a/src/external-model/domain/external-model-matching.ts b/src/external-model/domain/external-model-matching.ts new file mode 100644 index 0000000..3085211 --- /dev/null +++ b/src/external-model/domain/external-model-matching.ts @@ -0,0 +1,147 @@ +import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js"; +import type { + ExternalModelObjectPlanningValues, + ExternalModelObjectSourceValues, +} from "./external-model-contracts.js"; + +export type InitialExternalObjectIssue = + | "unknown-family-and-type" + | "invalid-power" + | "invalid-quantity" + | "missing-room"; + +export interface InitialExternalObjectProjection { + ifcGuid: string; + sourceValues: ExternalModelObjectSourceValues; + planningValues: ExternalModelObjectPlanningValues; + issues: InitialExternalObjectIssue[]; +} + +export interface ExternalObjectImportCandidate { + rowNumber: number; + ifcGuid: string; + roomNumber: string; + roomName: string; + familyAndType: string; + selectionMarker: string; + circuitIdentifier: string; + power: string; + quantity: string | null; + additionalSourceValues: Record; +} + +export function normalizeExternalRoomPart(value: string): string { + return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE"); +} + +export function createExternalRoomKey( + roomNumber: string, + roomName: string +): string | null { + const normalizedNumber = normalizeExternalRoomPart(roomNumber); + if (normalizedNumber) return `number:${normalizedNumber}`; + const normalizedName = normalizeExternalRoomPart(roomName); + return normalizedName ? `name:${normalizedName}` : null; +} + +export function indexExternalObjectsByIfcGuid( + objects: readonly T[] +): Map { + const index = new Map(); + for (const object of objects) { + if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) { + throw new Error("IfcGUID must be non-empty and must not contain outer whitespace."); + } + if (index.has(object.ifcGuid)) { + throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`); + } + index.set(object.ifcGuid, object); + } + return index; +} + +export function projectInitialExternalObject( + object: ExternalObjectImportCandidate, + configuration: ExternalCsvConfiguration +): InitialExternalObjectProjection { + const rule = configuration.familyTypeRules.find( + (candidate) => candidate.exactFamilyAndType === object.familyAndType + ); + const issues: InitialExternalObjectIssue[] = []; + if (!rule) issues.push("unknown-family-and-type"); + if (!createExternalRoomKey(object.roomNumber, object.roomName)) { + issues.push("missing-room"); + } + + const parsedPower = parseConfiguredNumber(object.power, configuration); + if (object.power.trim() && parsedPower === null) issues.push("invalid-power"); + + let effectiveQuantity = 1; + if (rule?.quantityRule.kind === "fixed") { + effectiveQuantity = rule.quantityRule.quantity; + } else if (rule?.quantityRule.kind === "mapped-column") { + const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration); + if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) { + issues.push("invalid-quantity"); + } else { + effectiveQuantity = parsedQuantity; + } + } + + return { + ifcGuid: object.ifcGuid, + sourceValues: { + rowNumber: object.rowNumber, + roomNumber: object.roomNumber, + roomName: object.roomName, + familyAndType: object.familyAndType, + selectionMarker: object.selectionMarker, + circuitIdentifier: object.circuitIdentifier, + power: object.power, + quantity: object.quantity, + additionalSourceValues: { ...object.additionalSourceValues }, + }, + planningValues: { + displayName: rule + ? resolveDisplayName(rule.displayNameSuggestion, object) + : null, + internalDeviceType: rule?.internalDeviceType ?? null, + category: rule?.category ?? null, + connectionKind: rule?.connectionKind ?? null, + effectiveQuantity, + powerPerUnitW: + parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + issues, + }; +} + +function parseConfiguredNumber( + value: string, + configuration: ExternalCsvConfiguration +): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = configuration.decimalSeparator === "," + ? trimmed.replace(",", ".") + : trimmed; + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function resolveDisplayName( + suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"], + object: ExternalObjectImportCandidate +): string | null { + if (suggestion === null) return null; + if (suggestion.kind === "fixed") return suggestion.value; + if (suggestion.kind === "selection-marker") { + return object.selectionMarker.trim() || null; + } + return object.familyAndType.trim() || null; +} diff --git a/tests/external-model-foundation.test.ts b/tests/external-model-foundation.test.ts new file mode 100644 index 0000000..aa67c0a --- /dev/null +++ b/tests/external-model-foundation.test.ts @@ -0,0 +1,191 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; +import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; +import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; +import { + createExternalRoomKey, + indexExternalObjectsByIfcGuid, + projectInitialExternalObject, +} from "../src/external-model/domain/external-model-matching.js"; +import { + externalCsvTestConfiguration, + firstIfcGuid, + quotedRevitCsv, + utf8Bytes, +} from "./fixtures/revit-csv-fixtures.js"; + +function configuredCsv() { + const configuration = structuredClone(externalCsvTestConfiguration); + configuration.familyTypeRules.push({ + exactFamilyAndType: "Steckdose: Doppelsteckdose", + internalDeviceType: "Steckdose", + connectionKind: "socket", + category: "single_phase", + quantityRule: { kind: "mapped-column" }, + displayNameSuggestion: { kind: "selection-marker" }, + }); + return configuration; +} + +describe("external model matching", () => { + it("normalizes source rooms and rejects duplicate IFC identities", () => { + assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101"); + assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD"); + assert.equal(createExternalRoomKey(" ", " "), null); + assert.throws( + () => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]), + /Duplicate IfcGUID/ + ); + }); + + it("derives initial planning values from an exact family rule", () => { + const configuration = configuredCsv(); + const preview = createExternalCsvPreview({ + fileName: "revit.csv", + bytes: utf8Bytes(quotedRevitCsv, true), + configuration, + }); + const projected = projectInitialExternalObject(preview.objects[0], configuration); + + assert.deepEqual(projected.issues, []); + assert.equal(projected.ifcGuid, firstIfcGuid); + assert.equal(projected.planningValues.displayName, "Arbeitsplatz"); + assert.equal(projected.planningValues.category, "single_phase"); + assert.equal(projected.planningValues.effectiveQuantity, 2); + assert.equal(projected.planningValues.powerPerUnitW, 120.5); + }); + + it("keeps unclassified or invalid values visible as issues", () => { + const projected = projectInitialExternalObject( + { + rowNumber: 4, + ifcGuid: firstIfcGuid, + roomNumber: "", + roomName: "", + familyAndType: "Unbekannt", + selectionMarker: "", + circuitIdentifier: "", + power: "12,3,4", + quantity: "0", + additionalSourceValues: {}, + }, + externalCsvTestConfiguration + ); + + assert.deepEqual(projected.issues, [ + "unknown-family-and-type", + "missing-room", + "invalid-power", + ]); + assert.equal(projected.planningValues.powerPerUnitW, null); + }); +}); + +describe("external model state repository", () => { + it("reads a deterministic complete external state without changing project data", () => { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + try { + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + context.db.insert(externalModelSources).values({ + id: "source-1", + projectId: "project-1", + name: "Revit-Gesamtmodell", + sourceType: "revit_csv", + }).run(); + const configuration = configuredCsv(); + const bytes = utf8Bytes(quotedRevitCsv, true); + const document = parseExternalCsv(bytes, configuration); + context.db.insert(externalImportBatches).values({ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T15:30:00.000Z", + fileName: "revit.csv", + sha256: "hash", + appliedProjectRevision: 1, + configurationSnapshot: configuration, + originalBytes: Buffer.from(bytes), + document, + }).run(); + context.db.insert(externalRoomMappings).values({ + id: "mapping-1", + projectId: "project-1", + sourceId: "source-1", + normalizedSourceRoomKey: "number:01/101", + sourceFloorName: null, + sourceRoomNumber: "01/101", + sourceRoomName: "Technik", + roomId: null, + defaultDistributionBoardId: null, + }).run(); + const preview = createExternalCsvPreview({ + fileName: "revit.csv", + bytes, + configuration, + }); + const projected = projectInitialExternalObject(preview.objects[0], configuration); + context.db.insert(externalModelObjects).values({ + id: "object-1", + projectId: "project-1", + sourceId: "source-1", + ifcGuid: projected.ifcGuid, + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: projected.sourceValues, + planningValues: projected.planningValues, + overriddenFields: [], + externalRoomMappingId: "mapping-1", + distributionBoardId: null, + linkedProjectDeviceId: null, + circuitDeviceRowId: null, + presenceStatus: "present", + }).run(); + + const result = new ExternalModelStateRepository(context.db).getByProject("project-1"); + assert.equal(result.projectExists, true); + assert.equal(result.state.source?.id, "source-1"); + assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64")); + assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101"); + assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid); + assert.equal(result.state.objects[0].circuitDeviceRowId, null); + assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2); + + assert.throws( + () => context.db.insert(externalModelObjects).values({ + ...context.db.select().from(externalModelObjects).get()!, + id: "object-2", + }).run(), + /UNIQUE constraint failed/ + ); + } finally { + context.close(); + } + }); + + it("distinguishes an empty project from an unknown project", () => { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + try { + context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); + const repository = new ExternalModelStateRepository(context.db); + assert.deepEqual(repository.getByProject("project-1"), { + projectExists: true, + state: { source: null, importBatches: [], roomMappings: [], objects: [] }, + }); + assert.equal(repository.getByProject("missing").projectExists, false); + } finally { + context.close(); + } + }); +});