diff --git a/AGENTS.md b/AGENTS.md index eae68d2..b0b28ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,14 +284,16 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 7 additionally stores the project buildings -and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6 -are upgraded with no buildings and `buildingId = null`. Version 6 stores the +Current snapshot schema version 8 additionally stores the optional net room +area `rooms[].areaM2` (see `docs/building-structure.md`); versions 1 to 7 are +upgraded with `areaM2 = null`. Version 7 stores the project buildings and +`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and +`buildingId = null`. Version 6 stores the distribution-board info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`; all older supported versions are upgraded with `null` for these fields. -Stored named and automatic snapshots of every supported version (1 to 7) +Stored named and automatic snapshots of every supported version (1 to 8) remain restorable; they are upgraded while parsing. Version 5 stores the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed @@ -307,7 +309,7 @@ project graph. Project-wide Revit CSV configuration uses `external-csv-configuration.update`. Its complete expected/target snapshot is persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 5-8, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the Revit CSV modal. It edits the complete @@ -481,10 +483,15 @@ These fields are intended to be prefillable from Revit parameters later, but only through the Phase 14 rules (preview, explicit confirmation, no silent overwrite). Floor and room setup uses persistent insert, update and delete commands with -complete stable snapshots. Their inverses remove or restore only unchanged records and -reject floors with assigned rooms/distribution boards or rooms referenced by -device rows. All write routes require `expectedRevision` and return the updated -history state. +complete stable snapshots. Room command schema version 2 stores the optional, +finite, non-negative net room area `areaM2`; persisted version-1 commands are +upgraded with `areaM2 = null` and exact record comparison prevents them from +silently overwriting a newer area. Room areas are maintained manually, shown +with two decimals and summarized per floor and building with a separate missing +count. Revit import and W/m² use remain follow-up work. Command inverses remove +or restore only unchanged records and reject floors with +assigned rooms/distribution boards or rooms referenced by device rows. All +write routes require `expectedRevision` and return the updated history state. Required operations: diff --git a/docs/building-structure.md b/docs/building-structure.md index db7f062..21059b4 100644 --- a/docs/building-structure.md +++ b/docs/building-structure.md @@ -13,6 +13,7 @@ zugeordneten Etagen lassen sich nicht löschen. |---|---|---| | `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts | | `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude | +| `rooms` | `area_m2` (nullable `REAL`) | optional gepflegte Netto-Raumfläche in m² | Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration @@ -20,6 +21,11 @@ und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate Datenbanksicherung anzulegen. +Migration `0011_glossy_norman_osborn` ergänzt ausschließlich das nullable Feld +`rooms.area_m2`. Bestehende Räume behalten damit eine unbekannte Fläche. Vor +dem Anwenden auf eine bestehende Datenbank ist ebenfalls eine separate +Datenbanksicherung erforderlich. + ## Befehle, Undo/Redo Alle Änderungen laufen über die bestehende Orts-Struktur @@ -41,17 +47,22 @@ persistentem Undo/Redo: Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen. -- Raumbefehle bleiben unverändert (Version 1). +- Raumbefehle `project-room.insert/.update/.delete` haben seit Schema-Version + **2** das Feld `areaM2`. Version-1-Befehle werden beim Einlesen mit + `areaM2 = null` hochgestuft. Vor einem alten Update oder Delete wird weiterhin + der vollständige erwartete Datensatz verglichen; eine inzwischen vorhandene + Fläche kann deshalb nicht unbemerkt gelöscht werden. ## Snapshots und Transfer -Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`. -Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer -Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte -bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum -Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht -und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue -Gebäude-IDs und stellt die Etagenverweise um. +Projekt-Snapshot-Schema **8** enthält zusätzlich `rooms[].areaM2`. Version 7 +wird mit `areaM2 = null` hochgestuft; die älteren unterstützten Versionen 1–6 +werden außerdem mit leerer Gebäudeliste und `buildingId = null` hochgestuft. +Gespeicherte Sicherungspunkte bleiben wiederherstellbar. Die Relationen werden +geprüft (Gebäude gehört zum Projekt, `buildingId` verweist auf ein Gebäude des +Snapshots). Restore und Projekttransfer erhalten die Raumfläche. Das portable +Transferformat bleibt Version 2, weil es den versionierten Projektsnapshot +bereits einbettet; beim Duplizieren werden nur IDs und Relationen umgesetzt. ## API @@ -63,6 +74,10 @@ Gebäude-IDs und stellt die Etagenverweise um. - `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert. +- `POST/PUT /api/projects/:projectId/rooms[/:roomId]` akzeptieren zusätzlich + optional `areaM2` (`number | null`, endlich und ≥ 0). Beim Anlegen bedeutet + ein fehlendes Feld `null`; beim Bearbeiten bleibt die vorhandene Fläche + erhalten, wenn das Feld fehlt. ## Oberfläche @@ -74,10 +89,24 @@ nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Fi auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der Stromkreisliste. +Der Raumdialog pflegt die optionale **Fläche [m²]** mit deutscher +Dezimaltrennzeichen-Eingabe. Gemeint ist die Netto-Raumfläche, wie sie der +Revit-Raumparameter „Fläche“ liefert, nicht eine Flächenart nach DIN 277. Die +Fläche wird ohne fachliche Rundung gespeichert und mit zwei Nachkommastellen +angezeigt. Die Gebäudestruktur zeigt die Fläche je Raum sowie Summen je Etage, +je Gebäude, „Ohne Etage“ und „Ohne Gebäude“. Fehlende Flächen werden nicht als +Nullfläche ausgegeben, sondern separat gezählt, damit Teilsummen erkennbar +bleiben. + ## Offen - Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg. +- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer + Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau + und ausdrücklicher Bestätigung einhalten. +- Eine W/m²-Auswertung oder Übernahme der Raumfläche in Flächenlasten ist nicht + Teil dieser Funktion; das ist Folgethema von Issue #20. - Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue Gebäude werden hinten angehängt. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index e528f6e..c9cd2b7 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -67,6 +67,12 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo, Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen importierbar. +Das aktuelle Projekt-Snapshot-Schema 8 enthält außerdem die optionale +Netto-Raumfläche `rooms[].areaM2`. Snapshot-Versionen 1–7 werden dafür mit +`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene +Flächen; eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den +versionierten Projektsnapshot einbettet. + The public dispatcher supports the Circuit and CircuitDeviceRow commands `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, @@ -297,19 +303,26 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. - rejects floors with assigned rooms or distribution boards - `POST /projects/:projectId/rooms` - body: - `{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }` - - executes `project-room.insert` with a stable room id; `floorId` is optional - and must belong to the project when present + `{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }` + - executes `project-room.insert` command version 2 with a stable room id; + `floorId` and `areaM2` are optional. An omitted or explicit `null` area + creates a room without area; a value must be finite and non-negative. - response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }` - `PUT /projects/:projectId/rooms/:roomId` - body: - `{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }` - - updates number, name and optional floor as one persistent command + `{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }` + - updates number, name, optional floor and area as one persistent version-2 + command. If `areaM2` is omitted, the current area is retained; `null` + explicitly clears it. - `DELETE /projects/:projectId/rooms/:roomId` - body: `{ "expectedRevision": 17 }` - rejects rooms referenced by device rows - Persistent Undo/Redo restores exact previous and target records without silently clearing foreign keys. +- Stored version-1 room commands remain executable and are upgraded with + `areaM2 = null`. Exact expected-record checks reject an old update or delete + if a newer command has added an area, so legacy history cannot silently + erase it. - Stale revisions return `409 PROJECT_REVISION_CONFLICT`. ### Tree Endpoint diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 2887e8a..baf6883 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -464,8 +464,10 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. -Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung -der Etagen; ältere Versionen werden ohne Gebäude hochgestuft. +Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche +`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft. +Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen; +ältere Versionen werden ohne Gebäude hochgestuft. Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des Projekts; ältere Versionen werden mit `null` hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder @@ -483,7 +485,12 @@ Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; dieser Baseline werden nicht eingelesen. Persistente Insert-, Update- und Delete-Commands versionieren Anlage, Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und -exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur +exakten Vorher-/Nachher-Snapshots. Raumbefehle verwenden Version 2 und führen +die nullable, nicht negative Netto-Raumfläche `areaM2`; Version-1-Befehle +werden mit `null` hochgestuft und bleiben durch den vollständigen +Snapshotvergleich konfliktfest. Der Raumdialog pflegt die Fläche manuell mit +deutscher Dezimaleingabe, die Gebäudestruktur zeigt zwei Nachkommastellen sowie +Summen und die Anzahl fehlender Flächen je Etage und Gebäude. Ein Geschoss wird nur entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten diff --git a/src/app/projects/[projectId]/struktur/page.tsx b/src/app/projects/[projectId]/struktur/page.tsx index 5b207c8..3cefa62 100644 --- a/src/app/projects/[projectId]/struktur/page.tsx +++ b/src/app/projects/[projectId]/struktur/page.tsx @@ -48,13 +48,17 @@ import { filterRooms, groupFloorsByBuilding, hasFloorsWithoutBuilding, + hasRoomsWithoutFloor, normalizeBuildingSelection, normalizeFloorSelection, showsRoomsWithoutFloorEntry, + summarizeRoomAreas, + type RoomAreaSummary, type StructureSelection, } from "../../../../frontend/utils/building-structure"; import { FormModal } from "../../../../frontend/components/form-modal"; import { RoomModal } from "../../../../frontend/components/room-modal"; +import { formatRoomArea } from "../../../../frontend/utils/room-area"; const BUILDING_HAS_FLOORS_MESSAGE = "Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind."; @@ -68,6 +72,23 @@ function formatCount(count: number, singular: string, plural: string) { return `${count} ${count === 1 ? singular : plural}`; } +function formatAreaSummary(summary: RoomAreaSummary) { + return `${formatRoomArea(summary.areaM2)} m² · ${formatCount( + summary.missingAreaCount, + "Raum ohne Fläche", + "Räume ohne Fläche" + )}`; +} + +function formatStructureMeta( + count: number, + singular: string, + plural: string, + summary: RoomAreaSummary +) { + return `${formatCount(count, singular, plural)} · ${formatAreaSummary(summary)}`; +} + function compareRooms(left: RoomDto, right: RoomDto) { return ( left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) || @@ -307,7 +328,25 @@ export default function BuildingStructurePage() { () => countRoomsByFloor(rooms, floors), [floors, rooms] ); - const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings); + const areaSummaries = useMemo( + () => summarizeRoomAreas(rooms, floors, buildings), + [buildings, floors, rooms] + ); + const emptyAreaSummary: RoomAreaSummary = { + areaM2: 0, + roomCount: 0, + missingAreaCount: 0, + }; + const selectedBuildingAreaSummary = + effectiveBuildingSelection === ALL_SELECTION + ? areaSummaries.total + : areaSummaries.byBuilding.get( + effectiveBuildingSelection === UNASSIGNED_SELECTION + ? null + : effectiveBuildingSelection + ) ?? emptyAreaSummary; + const showUnassignedBuilding = + hasFloorsWithoutBuilding(floors, buildings) || hasRoomsWithoutFloor(rooms, floors); const showUnassignedFloor = showsRoomsWithoutFloorEntry( rooms, floors, @@ -435,7 +474,8 @@ export default function BuildingStructurePage() { editingRoom && editingRoom.roomNumber === input.roomNumber.trim() && editingRoom.roomName === input.roomName.trim() && - editingRoom.floorId === (input.floorId || null) + editingRoom.floorId === (input.floorId || null) && + editingRoom.areaM2 === input.areaM2 ) { setDialog(null); return; @@ -536,7 +576,12 @@ export default function BuildingStructurePage() { selectBuilding(ALL_SELECTION)} /> {buildings.map((building) => ( @@ -553,10 +598,11 @@ export default function BuildingStructurePage() { isSelected={effectiveBuildingSelection === building.id} key={building.id} label={building.name} - meta={formatCount( + meta={formatStructureMeta( floorCountByBuilding.get(building.id) ?? 0, "Etage", - "Etagen" + "Etagen", + areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary )} onSelect={() => selectBuilding(building.id)} /> @@ -565,10 +611,11 @@ export default function BuildingStructurePage() { {UNASSIGNED_BUILDING_LABEL}} - meta={formatCount( + meta={formatStructureMeta( floorCountByBuilding.get(null) ?? 0, "Etage", - "Etagen" + "Etagen", + areaSummaries.byBuilding.get(null) ?? emptyAreaSummary )} onSelect={() => selectBuilding(UNASSIGNED_SELECTION)} /> @@ -591,7 +638,12 @@ export default function BuildingStructurePage() { setFloorSelection(ALL_SELECTION)} /> {visibleFloorGroups.map((group) => ( @@ -613,10 +665,11 @@ export default function BuildingStructurePage() { isSelected={effectiveFloorSelection === floor.id} key={floor.id} label={floor.name} - meta={formatCount( + meta={formatStructureMeta( roomCountByFloor.get(floor.id) ?? 0, "Raum", - "Räume" + "Räume", + areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary )} onSelect={() => setFloorSelection(floor.id)} /> @@ -627,7 +680,12 @@ export default function BuildingStructurePage() { {UNASSIGNED_FLOOR_LABEL}} - meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")} + meta={formatStructureMeta( + roomCountByFloor.get(null) ?? 0, + "Raum", + "Räume", + areaSummaries.byFloor.get(null) ?? emptyAreaSummary + )} onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)} /> ) : null} @@ -671,6 +729,11 @@ export default function BuildingStructurePage() { {room.roomName} } + meta={ + room.areaM2 === null + ? "Fläche fehlt" + : `${formatRoomArea(room.areaM2)} m²` + } /> ))} {!visibleRooms.length && !isLoading ? ( diff --git a/src/db/migrations/0011_glossy_norman_osborn.sql b/src/db/migrations/0011_glossy_norman_osborn.sql new file mode 100644 index 0000000..c91b51b --- /dev/null +++ b/src/db/migrations/0011_glossy_norman_osborn.sql @@ -0,0 +1 @@ +ALTER TABLE `rooms` ADD `area_m2` real; \ No newline at end of file diff --git a/src/db/migrations/meta/0011_snapshot.json b/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..2974cf1 --- /dev/null +++ b/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,2931 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "63f27b3a-7378-462d-8119-b67d67903dbb", + "prevId": "5376ae9f-67c7-4603-b3d2-8527136436f9", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_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 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "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 f2c5dba..90c3bd0 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1790438092269, "tag": "0010_estimate_balances", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1790531351492, + "tag": "0011_glossy_norman_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts index 9ec3f48..51edb3b 100644 --- a/src/db/repositories/project-location-structure-project-command.repository.ts +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -28,6 +28,7 @@ import { projectRoomInsertCommandType, projectRoomUpdateCommandType, upgradeProjectFloorCommand, + upgradeProjectRoomCommand, type ProjectBuildingSnapshot, type ProjectFloorSnapshot, type ProjectLocationStructureProjectCommand, @@ -106,23 +107,29 @@ export class ProjectLocationStructureProjectCommandRepository const current = upgradeProjectFloorCommand(command); return this.updateFloor(database, projectId, current.payload.expected, current.payload.target); } - case projectRoomInsertCommandType: + case projectRoomInsertCommandType: { assertProjectRoomInsertProjectCommand(command); + const current = upgradeProjectRoomCommand(command); return this.insertRoom( database, projectId, - command.payload.room + current.payload.room ); - case projectRoomDeleteCommandType: + } + case projectRoomDeleteCommandType: { assertProjectRoomDeleteProjectCommand(command); + const current = upgradeProjectRoomCommand(command); return this.deleteRoom( database, projectId, - command.payload.room + current.payload.room ); - case projectRoomUpdateCommandType: + } + case projectRoomUpdateCommandType: { assertProjectRoomUpdateProjectCommand(command); - return this.updateRoom(database, projectId, command.payload.expected, command.payload.target); + const current = upgradeProjectRoomCommand(command); + return this.updateRoom(database, projectId, current.payload.expected, current.payload.target); + } } } @@ -393,6 +400,7 @@ export class ProjectLocationStructureProjectCommandRepository floorId: target.floorId, roomNumber: target.roomNumber, roomName: target.roomName, + areaM2: target.areaM2, }) .where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId))) .run(); @@ -474,6 +482,15 @@ function toCurrentCommandVersion( case projectFloorUpdateCommandType: assertProjectFloorUpdateProjectCommand(command); return upgradeProjectFloorCommand(command); + case projectRoomInsertCommandType: + assertProjectRoomInsertProjectCommand(command); + return upgradeProjectRoomCommand(command); + case projectRoomDeleteCommandType: + assertProjectRoomDeleteProjectCommand(command); + return upgradeProjectRoomCommand(command); + case projectRoomUpdateCommandType: + assertProjectRoomUpdateProjectCommand(command); + return upgradeProjectRoomCommand(command); default: return command; } diff --git a/src/db/schema/rooms.ts b/src/db/schema/rooms.ts index 271106d..47988bd 100644 --- a/src/db/schema/rooms.ts +++ b/src/db/schema/rooms.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { floors } from "./floors.js"; import { projects } from "./projects.js"; @@ -10,4 +10,6 @@ export const rooms = sqliteTable("rooms", { floorId: text("floor_id").references(() => floors.id, { onDelete: "set null" }), roomNumber: text("room_number").notNull(), roomName: text("room_name").notNull(), + /** Optional net room area from the Revit room parameter "Fläche". */ + areaM2: real("area_m2"), }); diff --git a/src/domain/models/project-location-structure-project-command.model.ts b/src/domain/models/project-location-structure-project-command.model.ts index 21dfa89..8580527 100644 --- a/src/domain/models/project-location-structure-project-command.model.ts +++ b/src/domain/models/project-location-structure-project-command.model.ts @@ -10,7 +10,7 @@ export const projectFloorUpdateCommandType = "project-floor.update" as const; export const projectRoomInsertCommandType = "project-room.insert" as const; export const projectRoomDeleteCommandType = "project-room.delete" as const; export const projectRoomUpdateCommandType = "project-room.update" as const; -/** Room commands, building commands and legacy floor commands. */ +/** Building commands and legacy floor/room commands. */ export const projectLocationStructureCommandSchemaVersion = 1 as const; /** * Floor commands carry `buildingId` since version 2. Version 1 floor @@ -19,6 +19,9 @@ export const projectLocationStructureCommandSchemaVersion = 1 as const; */ export const projectFloorCommandSchemaVersion = 2 as const; const legacyProjectFloorCommandSchemaVersion = 1 as const; +/** Room commands carry `areaM2` since version 2. */ +export const projectRoomCommandSchemaVersion = 2 as const; +const legacyProjectRoomCommandSchemaVersion = 1 as const; export interface ProjectBuildingSnapshot { id: string; @@ -43,8 +46,11 @@ export interface ProjectRoomSnapshot { floorId: string | null; roomNumber: string; roomName: string; + areaM2: number | null; } +export type LegacyProjectRoomSnapshot = Omit; + interface ProjectBuildingStructureCommandPayload { building: ProjectBuildingSnapshot; } @@ -61,6 +67,10 @@ interface ProjectRoomStructureCommandPayload { room: ProjectRoomSnapshot; } +interface LegacyProjectRoomStructureCommandPayload { + room: LegacyProjectRoomSnapshot; +} + interface ProjectBuildingUpdateCommandPayload { expected: ProjectBuildingSnapshot; target: ProjectBuildingSnapshot; @@ -81,6 +91,11 @@ interface ProjectRoomUpdateCommandPayload { target: ProjectRoomSnapshot; } +interface LegacyProjectRoomUpdateCommandPayload { + expected: LegacyProjectRoomSnapshot; + target: LegacyProjectRoomSnapshot; +} + export interface ProjectBuildingInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectLocationStructureCommandSchemaVersion; @@ -137,19 +152,37 @@ export interface LegacyProjectFloorUpdateProjectCommand export interface ProjectRoomInsertProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectRoomCommandSchemaVersion; type: typeof projectRoomInsertCommandType; } export interface ProjectRoomDeleteProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectRoomCommandSchemaVersion; type: typeof projectRoomDeleteCommandType; } export interface ProjectRoomUpdateProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectRoomCommandSchemaVersion; + type: typeof projectRoomUpdateCommandType; +} + +export interface LegacyProjectRoomInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectRoomCommandSchemaVersion; + type: typeof projectRoomInsertCommandType; +} + +export interface LegacyProjectRoomDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectRoomCommandSchemaVersion; + type: typeof projectRoomDeleteCommandType; +} + +export interface LegacyProjectRoomUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectRoomCommandSchemaVersion; type: typeof projectRoomUpdateCommandType; } @@ -165,7 +198,10 @@ export type ProjectLocationStructureProjectCommand = | LegacyProjectFloorUpdateProjectCommand | ProjectRoomInsertProjectCommand | ProjectRoomDeleteProjectCommand - | ProjectRoomUpdateProjectCommand; + | ProjectRoomUpdateProjectCommand + | LegacyProjectRoomInsertProjectCommand + | LegacyProjectRoomDeleteProjectCommand + | LegacyProjectRoomUpdateProjectCommand; export function createProjectBuildingSnapshot( projectId: string, @@ -205,6 +241,7 @@ export function createProjectRoomSnapshot( floorId?: string; roomNumber: string; roomName: string; + areaM2?: number | null; } ): ProjectRoomSnapshot { const room: ProjectRoomSnapshot = { @@ -213,6 +250,7 @@ export function createProjectRoomSnapshot( floorId: input.floorId?.trim() || null, roomNumber: input.roomNumber.trim(), roomName: input.roomName.trim(), + areaM2: input.areaM2 ?? null, }; assertProjectRoomSnapshot(room); return room; @@ -279,7 +317,7 @@ export function createProjectRoomInsertProjectCommand( ): ProjectRoomInsertProjectCommand { assertProjectRoomSnapshot(room); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectRoomCommandSchemaVersion, type: projectRoomInsertCommandType, payload: { room }, }; @@ -290,7 +328,7 @@ export function createProjectRoomDeleteProjectCommand( ): ProjectRoomDeleteProjectCommand { assertProjectRoomSnapshot(room); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectRoomCommandSchemaVersion, type: projectRoomDeleteCommandType, payload: { room }, }; @@ -314,7 +352,7 @@ export function createProjectRoomUpdateProjectCommand( ): ProjectRoomUpdateProjectCommand { assertMatchingRoomUpdate(expected, target); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectRoomCommandSchemaVersion, type: projectRoomUpdateCommandType, payload: { expected, target }, }; @@ -376,7 +414,9 @@ export function assertProjectFloorDeleteProjectCommand( export function assertProjectRoomInsertProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectRoomInsertProjectCommand { +): asserts command is + | ProjectRoomInsertProjectCommand + | LegacyProjectRoomInsertProjectCommand { assertProjectRoomStructureProjectCommand( command, projectRoomInsertCommandType @@ -385,7 +425,9 @@ export function assertProjectRoomInsertProjectCommand( export function assertProjectRoomDeleteProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectRoomDeleteProjectCommand { +): asserts command is + | ProjectRoomDeleteProjectCommand + | LegacyProjectRoomDeleteProjectCommand { assertProjectRoomStructureProjectCommand( command, projectRoomDeleteCommandType @@ -419,15 +461,26 @@ export function assertProjectFloorUpdateProjectCommand( export function assertProjectRoomUpdateProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectRoomUpdateProjectCommand { +): asserts command is + | ProjectRoomUpdateProjectCommand + | LegacyProjectRoomUpdateProjectCommand { if ( - command.schemaVersion !== projectLocationStructureCommandSchemaVersion || command.type !== projectRoomUpdateCommandType || !isPlainObject(command.payload) || Object.keys(command.payload).length !== 2 ) { throw new Error("Unsupported project-room update command."); } + if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) { + assertMatchingRoomUpdate( + upgradeLegacyRoomSnapshot(command.payload.expected), + upgradeLegacyRoomSnapshot(command.payload.target) + ); + return; + } + if (command.schemaVersion !== projectRoomCommandSchemaVersion) { + throw new Error("Unsupported project-room update command."); + } assertMatchingRoomUpdate(command.payload.expected, command.payload.target); } @@ -480,6 +533,51 @@ export function upgradeProjectFloorCommand( } } +/** Upgrades persisted version-1 room commands with an empty area. */ +export function upgradeProjectRoomCommand( + command: + | ProjectRoomInsertProjectCommand + | LegacyProjectRoomInsertProjectCommand +): ProjectRoomInsertProjectCommand; +export function upgradeProjectRoomCommand( + command: + | ProjectRoomDeleteProjectCommand + | LegacyProjectRoomDeleteProjectCommand +): ProjectRoomDeleteProjectCommand; +export function upgradeProjectRoomCommand( + command: + | ProjectRoomUpdateProjectCommand + | LegacyProjectRoomUpdateProjectCommand +): ProjectRoomUpdateProjectCommand; +export function upgradeProjectRoomCommand( + command: + | ProjectRoomInsertProjectCommand + | ProjectRoomDeleteProjectCommand + | ProjectRoomUpdateProjectCommand + | LegacyProjectRoomInsertProjectCommand + | LegacyProjectRoomDeleteProjectCommand + | LegacyProjectRoomUpdateProjectCommand +) { + if (command.schemaVersion === projectRoomCommandSchemaVersion) { + return command; + } + switch (command.type) { + case projectRoomInsertCommandType: + return createProjectRoomInsertProjectCommand( + upgradeLegacyRoomSnapshot(command.payload.room) + ); + case projectRoomDeleteCommandType: + return createProjectRoomDeleteProjectCommand( + upgradeLegacyRoomSnapshot(command.payload.room) + ); + case projectRoomUpdateCommandType: + return createProjectRoomUpdateProjectCommand( + upgradeLegacyRoomSnapshot(command.payload.expected), + upgradeLegacyRoomSnapshot(command.payload.target) + ); + } +} + function assertMatchingBuildingUpdate(expected: unknown, target: unknown) { assertProjectBuildingSnapshot(expected); assertProjectBuildingSnapshot(target); @@ -568,6 +666,29 @@ function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot { export function assertProjectRoomSnapshot( room: unknown ): asserts room is ProjectRoomSnapshot { + if (!isPlainObject(room) || Object.keys(room).length !== 6) { + throw new Error("Project-room snapshot is invalid."); + } + assertNormalizedNonEmptyString(room.id, "room.id"); + assertNormalizedNonEmptyString(room.projectId, "room.projectId"); + if (room.floorId !== null) { + assertNormalizedNonEmptyString(room.floorId, "room.floorId"); + } + assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber"); + assertNormalizedNonEmptyString(room.roomName, "room.roomName"); + if ( + room.areaM2 !== null && + (typeof room.areaM2 !== "number" || + !Number.isFinite(room.areaM2) || + room.areaM2 < 0) + ) { + throw new Error("room.areaM2 must be null or a non-negative finite number."); + } +} + +function assertLegacyProjectRoomSnapshot( + room: unknown +): asserts room is LegacyProjectRoomSnapshot { if (!isPlainObject(room) || Object.keys(room).length !== 5) { throw new Error("Project-room snapshot is invalid."); } @@ -580,6 +701,11 @@ export function assertProjectRoomSnapshot( assertNormalizedNonEmptyString(room.roomName, "room.roomName"); } +function upgradeLegacyRoomSnapshot(room: unknown): ProjectRoomSnapshot { + assertLegacyProjectRoomSnapshot(room); + return { ...room, areaM2: null }; +} + function assertProjectBuildingStructureProjectCommand( command: SerializedProjectCommand, expectedType: @@ -627,13 +753,19 @@ function assertProjectRoomStructureProjectCommand( | typeof projectRoomDeleteCommandType ) { if ( - command.schemaVersion !== projectLocationStructureCommandSchemaVersion || command.type !== expectedType || !isPlainObject(command.payload) || Object.keys(command.payload).length !== 1 ) { throw new Error("Unsupported project-room structure command."); } + if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) { + assertLegacyProjectRoomSnapshot(command.payload.room); + return; + } + if (command.schemaVersion !== projectRoomCommandSchemaVersion) { + throw new Error("Unsupported project-room structure command."); + } assertProjectRoomSnapshot(command.payload.room); } diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 07db42d..b03bfbd 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -35,7 +35,8 @@ import { type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 7 as const; +export const projectStateSnapshotSchemaVersion = 8 as const; +const buildingProjectStateSnapshotSchemaVersion = 7 as const; const coverProjectStateSnapshotSchemaVersion = 6 as const; const boardInfoProjectStateSnapshotSchemaVersion = 5 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const; @@ -260,7 +261,7 @@ const floorSchema = legacyFloorSchema .extend({ buildingId: idSchema.nullable() }) .strict(); -const roomSchema = z +const legacyRoomSchema = z .object({ id: idSchema, projectId: idSchema, @@ -270,6 +271,10 @@ const roomSchema = z }) .strict(); +const roomSchema = legacyRoomSchema + .extend({ areaM2: finiteNumberSchema.nonnegative().nullable() }) + .strict(); + const distributionBoardComponentSchema = z .object({ id: idSchema, @@ -504,14 +509,27 @@ export function parseExternalModelStateSnapshot( return externalModelStateSchema.parse(value); } -// Versions 1-6 have floors without a building and no building list. +// Versions 1-6 have floors without a building and no building list. Versions +// 1-7 have rooms without an area. const previousProjectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), circuits: z.array(circuitSchema), projectDevices: z.array(projectDeviceSchema), floors: z.array(legacyFloorSchema), - rooms: z.array(roomSchema), + rooms: z.array(legacyRoomSchema), + distributionBoardComponents: z.array(distributionBoardComponentSchema), + circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), + distributionBoardComponentProtectionDevices: z.array( + componentProtectionDeviceSchema + ), +}; + +const buildingProjectStateSnapshotContents = { + ...previousProjectStateSnapshotContents, + buildings: z.array(buildingSchema), + floors: z.array(floorSchema), + rooms: z.array(legacyRoomSchema), distributionBoardComponents: z.array(distributionBoardComponentSchema), circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), distributionBoardComponentProtectionDevices: z.array( @@ -520,15 +538,8 @@ const previousProjectStateSnapshotContents = { }; const projectStateSnapshotContents = { - ...previousProjectStateSnapshotContents, - buildings: z.array(buildingSchema), - floors: z.array(floorSchema), + ...buildingProjectStateSnapshotContents, rooms: z.array(roomSchema), - distributionBoardComponents: z.array(distributionBoardComponentSchema), - circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), - distributionBoardComponentProtectionDevices: z.array( - componentProtectionDeviceSchema - ), }; export const projectStateSnapshotSchema = z @@ -542,6 +553,17 @@ export const projectStateSnapshotSchema = z }) .strict(); +const buildingProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: externalModelStateSchema, + ...buildingProjectStateSnapshotContents, + }) + .strict(); + const coverProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion), @@ -634,6 +656,20 @@ export function parseProjectStateSnapshot( function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const cover = upgradeToCoverProjectStateSnapshot(value); + if ( + cover !== null && + typeof cover === "object" && + !Array.isArray(cover) && + (cover as { schemaVersion?: unknown }).schemaVersion === + buildingProjectStateSnapshotSchemaVersion + ) { + const previous = buildingProjectStateSnapshotSchema.parse(cover); + return { + ...previous, + schemaVersion: projectStateSnapshotSchemaVersion, + rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })), + }; + } if ( cover === null || typeof cover !== "object" || @@ -644,14 +680,15 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { return cover; } // Every supported version before 7 lacks buildings; its floors had no - // building. + // building. Build version 7 first so the room-area upgrade above remains a + // single exact transition. const previous = coverProjectStateSnapshotSchema.parse(cover); - return { + return upgradePreviousProjectStateSnapshot({ ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: buildingProjectStateSnapshotSchemaVersion, buildings: [], floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })), - }; + }); } function upgradeToCoverProjectStateSnapshot(value: unknown): unknown { diff --git a/src/frontend/components/room-modal.tsx b/src/frontend/components/room-modal.tsx index eabeedd..b8a0ff0 100644 --- a/src/frontend/components/room-modal.tsx +++ b/src/frontend/components/room-modal.tsx @@ -4,6 +4,10 @@ import React, { type FormEvent, useState } from "react"; import type { BuildingDto, CreateRoomInput, RoomDto } from "../types"; import { FloorSelectOptions, type FloorOption } from "./floor-select-options"; import { FormModal } from "./form-modal"; +import { + formatOptionalRoomAreaInput, + parseRoomAreaInput, +} from "../utils/room-area"; interface RoomModalProps { /** When given, the floor select groups floors by building. */ @@ -36,14 +40,21 @@ export function RoomModal({ const [floorId, setFloorId] = useState( initialRoom?.floorId ?? initialValues?.floorId ?? "" ); + const [areaText, setAreaText] = useState(() => + formatOptionalRoomAreaInput( + initialRoom ? initialRoom.areaM2 : initialValues?.areaM2 + ) + ); + const parsedArea = parseRoomAreaInput(areaText); async function handleSubmit(event: FormEvent) { event.preventDefault(); - if (!roomNumber.trim() || !roomName.trim()) return; + if (!roomNumber.trim() || !roomName.trim() || !parsedArea.ok) return; await onSave({ floorId: floorId || undefined, roomNumber: roomNumber.trim(), roomName: roomName.trim(), + areaM2: parsedArea.value, }); } @@ -52,7 +63,7 @@ export function RoomModal({ isSaving={isSaving} onClose={onClose} onSubmit={handleSubmit} - submitDisabled={!roomNumber.trim() || !roomName.trim()} + submitDisabled={!roomNumber.trim() || !roomName.trim() || !parsedArea.ok} submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"} title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"} > @@ -90,6 +101,27 @@ export function RoomModal({ +
+ + setAreaText(event.target.value)} + placeholder="z. B. 23,45" + type="text" + value={areaText} + /> +
+ {parsedArea.ok + ? "Optionale Netto-Raumfläche; Eingabe mit Dezimalkomma." + : parsedArea.error} +
+
{initialRoom && onDelete ? (
diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 11bde4a..e8a2289 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -257,6 +257,7 @@ export interface RoomDto { floorId: string | null; roomNumber: string; roomName: string; + areaM2: number | null; } export interface GlobalDeviceDto { @@ -315,6 +316,7 @@ export interface CreateRoomInput { floorId?: string; roomNumber: string; roomName: string; + areaM2: number | null; } export interface CreateGlobalDeviceInput { diff --git a/src/frontend/utils/building-structure.ts b/src/frontend/utils/building-structure.ts index 0c9d6ab..11c1a1e 100644 --- a/src/frontend/utils/building-structure.ts +++ b/src/frontend/utils/building-structure.ts @@ -18,6 +18,19 @@ type FloorLike = Pick & { buildingId?: string | null; }; type RoomLike = Pick; +type RoomAreaLike = Pick; + +export interface RoomAreaSummary { + areaM2: number; + roomCount: number; + missingAreaCount: number; +} + +export interface StructureAreaSummaries { + total: RoomAreaSummary; + byBuilding: Map; + byFloor: Map; +} export interface FloorGroup { /** null for floors without (known) building. */ @@ -262,6 +275,59 @@ export function countRoomsByFloor( return counts; } +/** + * Sums only known room areas and counts missing values separately. Rooms with + * no known floor/building are grouped under null for the corresponding UI + * entries "Ohne Etage" and "Ohne Gebäude". + */ +export function summarizeRoomAreas( + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildings: ReadonlyArray +): StructureAreaSummaries { + const buildingIds = toIdSet(buildings); + const floorsById = new Map(floors.map((floor) => [floor.id, floor])); + const byBuilding = new Map(); + const byFloor = new Map(); + const total = emptyAreaSummary(); + + for (const room of rooms) { + const floor = room.floorId === null ? undefined : floorsById.get(room.floorId); + const floorId = floor?.id ?? null; + const buildingId = floor + ? resolveFloorBuildingId(floor, buildingIds) + : null; + addRoomArea(total, room.areaM2); + addRoomArea(summaryFor(byFloor, floorId), room.areaM2); + addRoomArea(summaryFor(byBuilding, buildingId), room.areaM2); + } + return { total, byBuilding, byFloor }; +} + +function emptyAreaSummary(): RoomAreaSummary { + return { areaM2: 0, roomCount: 0, missingAreaCount: 0 }; +} + +function summaryFor( + summaries: Map, + key: string | null +) { + const existing = summaries.get(key); + if (existing) return existing; + const created = emptyAreaSummary(); + summaries.set(key, created); + return created; +} + +function addRoomArea(summary: RoomAreaSummary, areaM2: number | null) { + summary.roomCount += 1; + if (areaM2 === null) { + summary.missingAreaCount += 1; + } else { + summary.areaM2 += areaM2; + } +} + /** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */ export function describeRoomLocation( room: RoomLike, diff --git a/src/frontend/utils/room-area.ts b/src/frontend/utils/room-area.ts new file mode 100644 index 0000000..fa54fd8 --- /dev/null +++ b/src/frontend/utils/room-area.ts @@ -0,0 +1,30 @@ +export type RoomAreaParseResult = + | { ok: true; value: number | null } + | { ok: false; error: string }; + +/** Parses an optional German decimal without rounding the stored value. */ +export function parseRoomAreaInput(text: string): RoomAreaParseResult { + const normalized = text.trim(); + if (!normalized) return { ok: true, value: null }; + if (!/^(?:\d+(?:,\d*)?|,\d+)$/.test(normalized)) { + return { + ok: false, + error: "Bitte eine nicht negative Zahl mit Komma eingeben.", + }; + } + const value = Number(normalized.replace(",", ".")); + return Number.isFinite(value) && value >= 0 + ? { ok: true, value } + : { ok: false, error: "Die Fläche muss mindestens 0 m² betragen." }; +} + +export function formatRoomArea(areaM2: number): string { + return areaM2.toLocaleString("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +export function formatOptionalRoomAreaInput(areaM2: number | null | undefined): string { + return areaM2 === null || areaM2 === undefined ? "" : formatRoomArea(areaM2); +} diff --git a/src/server/controllers/room.controller.ts b/src/server/controllers/room.controller.ts index 3e78628..b65c42a 100644 --- a/src/server/controllers/room.controller.ts +++ b/src/server/controllers/room.controller.ts @@ -67,6 +67,7 @@ export async function updateRoom(req: Request, res: Response) { floorId: parsed.data.floorId, roomNumber: parsed.data.roomNumber, roomName: parsed.data.roomName, + areaM2: parsed.data.areaM2 === undefined ? current.areaM2 : parsed.data.areaM2, }; try { const result = projectCommandService.executeUser({ diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index 5ab13bf..b8655ba 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -135,6 +135,7 @@ export const createRoomSchema = z floorId: z.string().trim().min(1).optional(), roomNumber: z.string().trim().min(1).max(50), roomName: z.string().trim().min(1).max(200), + areaM2: z.number().finite().nonnegative().nullable().optional(), }) .strict(); @@ -144,6 +145,7 @@ export const updateRoomSchema = z floorId: z.string().trim().min(1).nullable(), roomNumber: z.string().trim().min(1).max(50), roomName: z.string().trim().min(1).max(200), + areaM2: z.number().finite().nonnegative().nullable().optional(), }) .strict(); diff --git a/tests/building-structure-view.test.ts b/tests/building-structure-view.test.ts index 59271af..a024818 100644 --- a/tests/building-structure-view.test.ts +++ b/tests/building-structure-view.test.ts @@ -21,6 +21,7 @@ import { normalizeBuildingSelection, normalizeFloorSelection, showsRoomsWithoutFloorEntry, + summarizeRoomAreas, } from "../src/frontend/utils/building-structure.js"; function building(id: string, name: string, sortOrder: number): BuildingDto { @@ -36,13 +37,19 @@ function floor( return { id, projectId: "project-1", name, sortOrder, buildingId }; } -function room(id: string, floorId: string | null, roomNumber = id): RoomDto { +function room( + id: string, + floorId: string | null, + roomNumber = id, + areaM2: number | null = null +): RoomDto { return { id, projectId: "project-1", floorId, roomNumber, roomName: `Raum ${roomNumber}`, + areaM2, }; } @@ -263,6 +270,43 @@ describe("building structure view", () => { ); }); + it("sums known areas and counts missing areas per floor and building", () => { + const areaRooms = [ + room("a", "f-a-eg", "a", 20.25), + room("b", "f-a-eg"), + room("c", "f-free", "c", 5.5), + room("d", null, "d", 3), + room("e", "f-deleted"), + ]; + const summaries = summarizeRoomAreas(areaRooms, floors, buildings); + + assert.deepEqual(summaries.total, { + areaM2: 28.75, + roomCount: 5, + missingAreaCount: 2, + }); + assert.deepEqual(summaries.byFloor.get("f-a-eg"), { + areaM2: 20.25, + roomCount: 2, + missingAreaCount: 1, + }); + assert.deepEqual(summaries.byFloor.get(null), { + areaM2: 3, + roomCount: 2, + missingAreaCount: 1, + }); + assert.deepEqual(summaries.byBuilding.get("b-a"), { + areaM2: 20.25, + roomCount: 2, + missingAreaCount: 1, + }); + assert.deepEqual(summaries.byBuilding.get(null), { + areaM2: 8.5, + roomCount: 3, + missingAreaCount: 1, + }); + }); + it("describes a room location with floor and building", () => { assert.equal( describeRoomLocation(room("r", "f-a-og"), floors, buildings), diff --git a/tests/print-view-migration.test.ts b/tests/print-view-migration.test.ts index f94baf8..19e4dcc 100644 --- a/tests/print-view-migration.test.ts +++ b/tests/print-view-migration.test.ts @@ -28,10 +28,13 @@ it("upgrades a populated pre-print database through the current schema without c VALUES ('b', 'p', 'Existing board', 0.7); INSERT INTO floors (id, project_id, name, sort_order) VALUES ('f', 'p', 'Existing floor', 3); + INSERT INTO rooms (id, project_id, floor_id, room_number, room_name) + VALUES ('r', 'p', 'f', '101', 'Existing room'); `); const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record; const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record; const oldFloor = context.sqlite.prepare("SELECT * FROM floors").get() as Record; + const oldRoom = context.sqlite.prepare("SELECT * FROM rooms").get() as Record; const backup = path.join(staging, "before-print.db"); files.push(backup); await context.sqlite.backup(backup); @@ -40,12 +43,15 @@ it("upgrades a populated pre-print database through the current schema without c const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record; const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record; const floor = context.sqlite.prepare("SELECT * FROM floors").get() as Record; + const room = context.sqlite.prepare("SELECT * FROM rooms").get() as Record; for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value); for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value); for (const [key, value] of Object.entries(oldFloor)) assert.deepEqual(floor[key], value); + for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value); for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null); for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null); assert.equal(floor.building_id, null); + assert.equal(room.area_m2, null); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0); diff --git a/tests/project-location-structure-project-command.repository.test.ts b/tests/project-location-structure-project-command.repository.test.ts index 6378965..03f70f9 100644 --- a/tests/project-location-structure-project-command.repository.test.ts +++ b/tests/project-location-structure-project-command.repository.test.ts @@ -22,6 +22,11 @@ import { createProjectRoomInsertProjectCommand, createProjectRoomSnapshot, createProjectRoomUpdateProjectCommand, + projectRoomCommandSchemaVersion, + projectRoomDeleteCommandType, + projectRoomUpdateCommandType, + type LegacyProjectRoomDeleteProjectCommand, + type LegacyProjectRoomUpdateProjectCommand, } from "../src/domain/models/project-location-structure-project-command.model.js"; import { createFloor, @@ -66,10 +71,12 @@ describe("project-location structure project command", () => { floorId: floor.id, roomNumber: " 001 ", roomName: " Technik ", + areaM2: 23.456, }); assert.equal(floor.name, "EG"); assert.equal(room.roomNumber, "001"); assert.equal(room.roomName, "Technik"); + assert.equal(room.areaM2, 23.456); assert.throws( () => createProjectFloorInsertProjectCommand({ @@ -113,6 +120,7 @@ describe("project-location structure project command", () => { floorId: "floor-1", roomNumber: "001", roomName: "Technik", + areaM2: 23.456, }, 8 ); @@ -120,7 +128,7 @@ describe("project-location structure project command", () => { await updateRoom( "project-1", "room-1", - { roomNumber: "101", roomName: "Büro" }, + { roomNumber: "101", roomName: "Büro", areaM2: null }, 10 ); await deleteFloor("project-1", "floor-1", 11); @@ -141,6 +149,7 @@ describe("project-location structure project command", () => { floorId: "floor-1", roomNumber: "001", roomName: "Technik", + areaM2: 23.456, expectedRevision: 8, }, }, @@ -155,6 +164,7 @@ describe("project-location structure project command", () => { body: { roomNumber: "101", roomName: "Büro", + areaM2: null, floorId: null, expectedRevision: 10, }, @@ -199,6 +209,7 @@ describe("project-location structure project command", () => { floorId: null, roomNumber: "002", roomName: "Lager", + areaM2: 48.125, }; const roomCommand = createProjectRoomUpdateProjectCommand(room, updatedRoom); const roomResult = repository.execute({ @@ -427,6 +438,64 @@ describe("project-location structure project command", () => { } }); + it("upgrades version-1 room commands without overwriting a newer area", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectLocationStructureProjectCommandRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + const room = createProjectRoomSnapshot("project-1", { + roomNumber: "001", + roomName: "Technik", + }); + context.db.insert(rooms).values(room).run(); + const { areaM2: _expectedArea, ...legacyExpected } = room; + const legacyTarget = { ...legacyExpected, roomName: "Technik alt" }; + const legacyUpdate: LegacyProjectRoomUpdateProjectCommand = { + schemaVersion: 1, + type: projectRoomUpdateCommandType, + payload: { expected: legacyExpected, target: legacyTarget }, + }; + + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: legacyUpdate, + }); + assert.equal(updated.inverse.schemaVersion, projectRoomCommandSchemaVersion); + assert.equal(context.db.select().from(rooms).get()?.areaM2, null); + + const undo = history.getNextCommand("project-1", "undo"); + assert.ok(undo); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undo.changeSetId, + command: updated.inverse, + }); + context.db.update(rooms).set({ areaM2: 12.5 }).where(eq(rooms.id, room.id)).run(); + const legacyDelete: LegacyProjectRoomDeleteProjectCommand = { + schemaVersion: 1, + type: projectRoomDeleteCommandType, + payload: { room: legacyExpected }, + }; + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "user", + command: legacyDelete, + }), + /changed before deletion/ + ); + assert.equal(context.db.select().from(rooms).get()?.areaM2, 12.5); + } finally { + context.close(); + } + }); + it("rejects foreign, stale and changed locations without partial writes", () => { const context = createTestDatabase(); try { diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index 9131205..7a02fec 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -142,6 +142,7 @@ function createTestDatabase(): DatabaseContext { floorId: "floor-1", roomNumber: "001", roomName: "Technik", + areaM2: 31.75, }) .run(); context.db @@ -461,12 +462,16 @@ describe("project snapshot repository", () => { distributionBoards: Array>; buildings?: unknown; floors: Array>; + rooms: Array>; }; payload.schemaVersion = 5; delete payload.buildings; for (const floor of payload.floors) { delete floor.buildingId; } + for (const room of payload.rooms) { + delete room.areaM2; + } for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete payload.project[field]; } @@ -597,6 +602,7 @@ describe("portable project transfer", () => { copied.state.circuits[0].deviceRows[0].roomId, copied.state.rooms[0].id ); + assert.equal(copied.state.rooms[0].areaM2, 31.75); assert.equal( copied.state.distributionBoards[0].floorId, copied.state.floors[0].id diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 2f9a841..b093f9e 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -96,6 +96,7 @@ function createTestDatabase(): DatabaseContext { floorId: "floor-1", roomNumber: "001", roomName: "Technik", + areaM2: 31.75, }) .run(); context.db diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index c75555f..c067c19 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -91,7 +91,14 @@ function minimalSnapshot() { sortOrder: number; buildingId: string | null; }>, - rooms: [], + rooms: [] as Array<{ + id: string; + projectId: string; + floorId: string | null; + roomNumber: string; + roomName: string; + areaM2: number | null; + }>, }; } @@ -443,6 +450,26 @@ describe("project state snapshot model", () => { assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField)); }); + it("keeps room areas and upgrades snapshot version 7 with an empty area", () => { + const current = minimalSnapshot(); + current.rooms.push({ + id: "room-1", + projectId: "project-1", + floorId: null, + roomNumber: "001", + roomName: "Technik", + areaM2: 23.456, + }); + assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456); + + const previous = structuredClone(current); + previous.schemaVersion = 7 as typeof previous.schemaVersion; + delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2; + const upgraded = parseProjectStateSnapshot(previous); + assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); + assert.equal(upgraded.rooms[0]?.areaM2, null); + }); + it("upgrades snapshot version 6 without buildings", () => { const snapshot = minimalSnapshot(); snapshot.floors.push({ diff --git a/tests/revit-csv-frontend-api.test.ts b/tests/revit-csv-frontend-api.test.ts index 3603aa7..add2eb7 100644 --- a/tests/revit-csv-frontend-api.test.ts +++ b/tests/revit-csv-frontend-api.test.ts @@ -151,7 +151,7 @@ describe("Revit CSV modal presentation", () => { objects: [], existing: { floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }], - rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }], + rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }], distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }], projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }], }, diff --git a/tests/room-area.test.ts b/tests/room-area.test.ts new file mode 100644 index 0000000..e00f336 --- /dev/null +++ b/tests/room-area.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { RoomModal } from "../src/frontend/components/room-modal.js"; +import { + formatRoomArea, + parseRoomAreaInput, +} from "../src/frontend/utils/room-area.js"; +import { + createRoomSchema, + updateRoomSchema, +} from "../src/shared/validation/project-structure.schemas.js"; + +describe("room area input", () => { + it("parses optional German decimals without rounding", () => { + assert.deepEqual(parseRoomAreaInput(""), { ok: true, value: null }); + assert.deepEqual(parseRoomAreaInput(" 23,456 "), { ok: true, value: 23.456 }); + assert.deepEqual(parseRoomAreaInput(",5"), { ok: true, value: 0.5 }); + assert.deepEqual(parseRoomAreaInput("0"), { ok: true, value: 0 }); + for (const value of ["-1", "23.45", "abc", "1,2,3"]) { + assert.equal(parseRoomAreaInput(value).ok, false, value); + } + assert.equal(formatRoomArea(23.456), "23,46"); + }); + + it("validates finite non-negative API values and remains backward compatible", () => { + const base = { + expectedRevision: 1, + roomNumber: "101", + roomName: "Büro", + }; + assert.equal(createRoomSchema.safeParse({ ...base, areaM2: 23.456 }).success, true); + assert.equal(createRoomSchema.safeParse({ ...base, areaM2: null }).success, true); + assert.equal(createRoomSchema.safeParse(base).success, true); + assert.equal(updateRoomSchema.safeParse({ ...base, floorId: null }).success, true); + assert.equal(createRoomSchema.safeParse({ ...base, areaM2: -1 }).success, false); + assert.equal(createRoomSchema.safeParse({ ...base, areaM2: Number.NaN }).success, false); + }); + + it("renders the optional area with two decimals in the shared room dialog", () => { + const markup = renderToStaticMarkup( + createElement(RoomModal, { + floors: [], + initialValues: { + roomNumber: "101", + roomName: "Büro", + areaM2: 23.4, + }, + isSaving: false, + onClose: () => undefined, + onSave: async () => undefined, + }) + ); + assert.match(markup, /Fläche \[m²\]/); + assert.match(markup, /value="23,40"/); + assert.match(markup, /Netto-Raumfläche/); + }); +});