Compare commits
17 Commits
e1fc81a083
...
cfd4778305
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd4778305 | |||
| fac6c9350f | |||
| 9ea081179d | |||
| dcb303284c | |||
| 602ae6da0b | |||
| 1b2ca84258 | |||
| 096ba8aa1c | |||
| 084103bf54 | |||
| b1a11397b3 | |||
| 194bc9c0b1 | |||
| 0180f768ac | |||
| a04c2c04d4 | |||
| edf245a21c | |||
| feec814dc5 | |||
| da8115fe1d | |||
| 5fa2a701dc | |||
| d77cfbeb49 |
@@ -283,17 +283,23 @@ that have remained unchanged. ProjectDevice create, update, delete and
|
||||
global-to-project copy API/UI paths use these persistent commands and track the
|
||||
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||
paths do the same; their undo action uses the project-wide history endpoint.
|
||||
Project voltage settings use the persistent `project.update-settings` command.
|
||||
Both voltage defaults change in one revision and Undo/Redo restores them
|
||||
together; the project PUT route requires `expectedRevision`.
|
||||
Project settings use the persistent `project.update-settings` command. Project
|
||||
metadata, both voltage defaults and the enabled distribution-board supply types
|
||||
change in one revision and Undo/Redo restores them together; the project PUT
|
||||
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||
cannot be disabled.
|
||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||
stable snapshot of the board, circuit list and four default sections. Its
|
||||
inverse removes only the same unchanged and still-empty structure; the POST
|
||||
route requires `expectedRevision` and returns the updated history state.
|
||||
Distribution-board floor assignment and a project-enabled supply type use
|
||||
`distribution-board.update`; both values are snapshot/export fields and one
|
||||
persistent undo step.
|
||||
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
||||
complete stable snapshots. Their inverses remove only unchanged records and
|
||||
reject floors with assigned rooms or rooms referenced by device rows or
|
||||
retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||
the updated history state.
|
||||
|
||||
Required operations:
|
||||
|
||||
@@ -27,8 +27,10 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||
## Zukunftsarchitektur
|
||||
|
||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||
|
||||
## Fachliche Referenzspezifikation
|
||||
|
||||
|
||||
@@ -145,11 +145,18 @@ synchronizes linked circuit rows implicitly.
|
||||
|
||||
- `PUT /projects/:projectId`
|
||||
- request:
|
||||
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
|
||||
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
||||
- executes `project.update-settings` as one atomic revision
|
||||
- response:
|
||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- persistent Undo/Redo restores both voltage values together
|
||||
- persistent Undo/Redo restores metadata, voltage values and the enabled
|
||||
distribution-board supply types together; project-device and circuit
|
||||
voltages are recalculated from the restored project settings in the same
|
||||
transaction
|
||||
- project-device and circuit voltage are derived values and are not accepted
|
||||
as editable frontend fields
|
||||
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
|
||||
a supply type currently used by a distribution board cannot be disabled
|
||||
- unchanged values are rejected without creating a revision; a stale
|
||||
revision returns `409 PROJECT_REVISION_CONFLICT`
|
||||
|
||||
@@ -181,7 +188,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
### Distribution Board Setup
|
||||
|
||||
- `POST /projects/:projectId/distribution-boards`
|
||||
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
|
||||
- body:
|
||||
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
|
||||
- `floorId` may be `null`; a non-null floor must belong to the project
|
||||
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
|
||||
be enabled in the project settings
|
||||
- executes `distribution-board.insert` with stable ids for the distribution
|
||||
board, its circuit list and all four default circuit sections
|
||||
- response:
|
||||
@@ -189,6 +200,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
- persistent Undo removes only the unchanged and still-empty generated
|
||||
structure; Redo restores the same ids
|
||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||
- body:
|
||||
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||
- executes `distribution-board.update`; floor and supply type are restored
|
||||
together by persistent Undo/Redo
|
||||
|
||||
### Project Floors and Rooms
|
||||
|
||||
@@ -203,8 +219,9 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
and must belong to the project when present
|
||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
||||
assigned rooms and a room referenced by device rows or retained upgrade data
|
||||
are rejected instead of silently clearing foreign keys.
|
||||
assigned rooms or distribution boards and a room referenced by device rows
|
||||
or retained upgrade data are rejected instead of silently clearing foreign
|
||||
keys.
|
||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||
|
||||
### Tree Endpoint
|
||||
|
||||
@@ -37,6 +37,9 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
- `cosPhi`
|
||||
- room snapshots
|
||||
- category/cost group and related row attributes
|
||||
- canonical `phaseType` values `single_phase` or `three_phase`; the
|
||||
frontend always presents these as `1-phasig` or `3-phasig` and edits
|
||||
them through a selection control
|
||||
- `ProjectDevice`
|
||||
- Reusable device template entity at project level.
|
||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||
|
||||
@@ -149,8 +149,14 @@ Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||
nicht Bestandteil des logischen Snapshots sind.
|
||||
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
|
||||
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
||||
benennen, jeden aufgeführten benannten oder automatischen Stand nach
|
||||
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
|
||||
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
|
||||
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
|
||||
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
|
||||
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
|
||||
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
|
||||
abzuhängen. Revision null wird als Projektstart dargestellt.
|
||||
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
|
||||
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
|
||||
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
|
||||
@@ -167,6 +173,26 @@ Felder und vollständiger Zeilenbestand unverändert sind.
|
||||
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
|
||||
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
|
||||
Toolbar-Undo/Redo über die projektweite Historie aus.
|
||||
Die Projektgerätepalette belegt keine permanente Layoutspalte mehr. Sie wird
|
||||
über die Editor-Toolbar als überlagernder Drawer geöffnet, während das
|
||||
Stromkreis-Grid standardmäßig die gesamte verfügbare Breite nutzt. Auswahl,
|
||||
Schnelleinfügen und die vorhandenen Drag-and-drop-Payloads bleiben im Drawer
|
||||
unverändert verfügbar; während eines aktiven Projektgeräte-Drags kann er nicht
|
||||
geschlossen werden. Der Drawer liegt am rechten Fensterrand und verdeckt damit
|
||||
nicht die führenden BMK- und Anzeigenamenspalten. Das Grid zeigt Geräte- und
|
||||
Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
|
||||
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
|
||||
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
|
||||
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
|
||||
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
|
||||
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
|
||||
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
|
||||
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
|
||||
und Filter verändern diese fachlichen Summen nicht.
|
||||
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
|
||||
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
|
||||
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
|
||||
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
|
||||
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
||||
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
||||
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
||||
@@ -209,23 +235,66 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
|
||||
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||
`project.update-settings` versioniert die beiden Projekt-Standardspannungen als
|
||||
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
|
||||
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
|
||||
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
|
||||
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
|
||||
besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||
`project.update-settings` versioniert Projektname, interne und externe
|
||||
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
|
||||
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
|
||||
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
|
||||
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
|
||||
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
|
||||
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
|
||||
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
||||
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
||||
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
||||
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
||||
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||
projektgebundenen Endpunkt beschränkt.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
||||
Controller-Schreibweg ist entfernt.
|
||||
Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
|
||||
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||
Freigabe der Netzart in den Projekteinstellungen.
|
||||
`distribution-board.update` versioniert Etage, Netzart und den
|
||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
|
||||
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
|
||||
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
|
||||
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||
Projektauswahl hochgestuft.
|
||||
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||
entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt,
|
||||
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||
Raum wird nur entfernt,
|
||||
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
||||
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
||||
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
||||
|
||||
@@ -265,7 +265,9 @@ Completed foundation:
|
||||
snapshots in the same transaction
|
||||
- `project.update-settings` persists both project voltage defaults, its exact
|
||||
inverse, revision and history transition atomically; the project page and
|
||||
existing PUT route require the current expected revision
|
||||
existing PUT route require the current expected revision; the same
|
||||
transaction derives every project-device and circuit voltage again, so
|
||||
Undo/Redo cannot leave mixed voltage states
|
||||
- `distribution-board.insert` persists the complete generated board, circuit
|
||||
list and default-section structure with stable ids; its delete inverse
|
||||
refuses changed or populated structures and the project-page POST tracks the
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# Kontext für die Revit-Anforderungsplanung
|
||||
|
||||
## Zweck dieser Datei
|
||||
|
||||
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||
|
||||
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
||||
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
||||
|
||||
## Produkt und fachliches Modell
|
||||
|
||||
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
|
||||
|
||||
Das unterstützte Laufzeitmodell ist:
|
||||
|
||||
```text
|
||||
Project
|
||||
├── Floor
|
||||
│ └── Room
|
||||
├── ProjectDevice
|
||||
└── DistributionBoard
|
||||
└── CircuitList
|
||||
└── CircuitSection
|
||||
└── Circuit
|
||||
└── CircuitDeviceRow
|
||||
```
|
||||
|
||||
Wichtige fachliche Grenzen:
|
||||
|
||||
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
|
||||
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
|
||||
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
|
||||
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
|
||||
und Kategorie gehören zur Gerätezeile.
|
||||
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
|
||||
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
|
||||
lokale Werte und ausdrücklich erfasste Abweichungen.
|
||||
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
|
||||
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
|
||||
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
|
||||
erlaubt.
|
||||
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
|
||||
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
|
||||
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
|
||||
werden.
|
||||
|
||||
Für einen späteren Revit-Austausch besonders relevante Daten sind:
|
||||
|
||||
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
|
||||
Projektspannungen.
|
||||
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
|
||||
Raumname.
|
||||
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
|
||||
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
|
||||
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
|
||||
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
|
||||
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
|
||||
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
|
||||
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
|
||||
|
||||
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
|
||||
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
|
||||
aktuellen Produkt nicht vorgesehen.
|
||||
|
||||
## Aktuelle technische Architektur
|
||||
|
||||
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
|
||||
`src/frontend`.
|
||||
- API: Express und Zod unter `src/server`.
|
||||
- Fachregeln und typisierte Commands: `src/domain`.
|
||||
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
|
||||
- Laufzeitkomposition konkreter Repositories:
|
||||
`src/server/composition`.
|
||||
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
|
||||
SQLite-Datei unter `data/leistungsbilanz.db`.
|
||||
- Docker Compose wird für die lokale Entwicklung unterstützt.
|
||||
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
|
||||
|
||||
Der zentrale Editor liegt unter:
|
||||
|
||||
```text
|
||||
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
|
||||
src/frontend/components/circuit-tree-editor.tsx
|
||||
```
|
||||
|
||||
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
|
||||
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
|
||||
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
|
||||
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
|
||||
|
||||
## Revisionen, Undo/Redo und Sicherungspunkte
|
||||
|
||||
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
|
||||
Command- und Transaktionsgrenze:
|
||||
|
||||
```text
|
||||
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
|
||||
```
|
||||
|
||||
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
|
||||
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
|
||||
geben die erwartete Revision mit, damit veraltete Clients keine neueren
|
||||
Änderungen überschreiben.
|
||||
|
||||
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
|
||||
Anwendungsneustart. Zusätzlich gibt es:
|
||||
|
||||
- eine unveränderliche Revisions-Timeline;
|
||||
- benannte und automatische logische Projektsnapshots;
|
||||
- wiederherstellbare Snapshots mit Prüfsumme;
|
||||
- einen portablen, versionierten JSON-Projekttransfer.
|
||||
|
||||
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
|
||||
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
|
||||
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
|
||||
revisioniert und rückgängig machbar sein.
|
||||
|
||||
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
|
||||
|
||||
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
|
||||
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
|
||||
Projekts innerhalb von Leistungsbilanz.
|
||||
|
||||
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
|
||||
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
|
||||
für CSV-Dateien oder Revit-Parameter verwendet werden.
|
||||
|
||||
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
|
||||
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
|
||||
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
|
||||
zuweisen kann.
|
||||
|
||||
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
|
||||
|
||||
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
|
||||
Implementierungsdesign:
|
||||
|
||||
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
|
||||
Gerätezeilen.
|
||||
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
|
||||
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
|
||||
widersprüchliche Objekte angezeigt.
|
||||
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
|
||||
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
|
||||
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
|
||||
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
|
||||
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
|
||||
unterscheidbar bleiben.
|
||||
- Lokale Planungswerte dürfen niemals still überschrieben werden.
|
||||
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
|
||||
ausdrücklich modelliert werden.
|
||||
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
|
||||
automatisch einem anderen Objekt zugeordnet.
|
||||
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
|
||||
die Werte deterministisch zurückschreiben kann.
|
||||
- Ein Export bezieht sich auf eine bekannte Projektrevision.
|
||||
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
|
||||
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
|
||||
Serverbetrieb oder unabhängigen Hintergrundjobs.
|
||||
|
||||
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
|
||||
|
||||
- `ExternalModelSource`
|
||||
- `ExternalImportBatch`
|
||||
- `ExternalModelObject`
|
||||
- `ExternalObjectLink`
|
||||
- `ExternalParameterMapping`
|
||||
- `ExternalExportBatch`
|
||||
|
||||
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
|
||||
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
|
||||
Tabellen behandeln.
|
||||
|
||||
## Auftrag an das andere LLM
|
||||
|
||||
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
|
||||
realen Ablauf in Revit und kläre danach schrittweise mindestens:
|
||||
|
||||
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
|
||||
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
|
||||
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
|
||||
Revit-Versionen)?
|
||||
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
|
||||
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
|
||||
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
|
||||
aktualisierte Revit-Dateien unterschieden?
|
||||
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
|
||||
Leistungsbilanz geplant?
|
||||
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
|
||||
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
|
||||
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
|
||||
Projektgerät, Raum oder einer Verteilung zugeordnet?
|
||||
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
|
||||
Einheiten und unter welchen Revit-Parameternamen?
|
||||
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
|
||||
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
|
||||
Rückgabedatei erzeugt werden?
|
||||
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
|
||||
wiederverwendet und zwischen Projekten geteilt werden?
|
||||
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
|
||||
doppelte oder nicht mehr sichtbare Revit-Objekte?
|
||||
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
|
||||
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
|
||||
eine Auswahl?
|
||||
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
|
||||
Anwender vor der Übernahme?
|
||||
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
|
||||
verhindern oder nur eine Warnung erzeugen?
|
||||
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
|
||||
16. Müssen Import und Export synchron im Browser laufen oder werden später
|
||||
Hintergrundjobs benötigt?
|
||||
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
|
||||
Versionshistorie und Support gespeichert werden?
|
||||
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
|
||||
Abnahmekriterien?
|
||||
|
||||
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
|
||||
Notizen ausdrücklich:
|
||||
|
||||
- vom Anwender bestätigte Anforderungen;
|
||||
- sinnvolle Vorschläge;
|
||||
- offene Entscheidungen;
|
||||
- technische Folgerungen;
|
||||
- bewusst nicht betrachtete spätere Ausbaustufen.
|
||||
|
||||
## Erwartetes Ergebnis der Anforderungsrunde
|
||||
|
||||
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
|
||||
soll mindestens enthalten:
|
||||
|
||||
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
|
||||
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
|
||||
Rückexport;
|
||||
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
|
||||
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
|
||||
- Identitäts-, Matching- und Quellmodell;
|
||||
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
|
||||
- Konflikt-, Validierungs- und Freigaberegeln;
|
||||
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
|
||||
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
|
||||
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
|
||||
Architekturregeln zu umgehen;
|
||||
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
|
||||
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
|
||||
müssen.
|
||||
|
||||
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
|
||||
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
|
||||
nur Architekturvorschläge des LLM sind.
|
||||
|
||||
## Weiterführende Projektdokumente
|
||||
|
||||
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
|
||||
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
|
||||
Command-Pfad
|
||||
- `docs/project-history-and-external-model-architecture.md` – bisherige
|
||||
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
|
||||
- `docs/spec/01-domain-context.md` – fachlicher Kontext
|
||||
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||
kontrollierte Synchronisierung
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 13
|
||||
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||
Funktionen
|
||||
@@ -0,0 +1,94 @@
|
||||
# Current Product Backlog
|
||||
|
||||
This document records the next agreed product improvements. It describes
|
||||
requirements and intended sequencing, not proof of implementation.
|
||||
|
||||
## Circuit List Editor
|
||||
|
||||
- Collect the remaining editor interaction issues before changing additional
|
||||
behavior.
|
||||
|
||||
## First Release Baseline
|
||||
|
||||
Complete this immediately before the first supported release, once the schema
|
||||
and initial feature set are frozen:
|
||||
|
||||
- Decide explicitly whether pre-release databases, portable JSON exports,
|
||||
logical snapshots and persisted command histories remain supported.
|
||||
- Create one clean baseline migration that builds the complete release schema
|
||||
on an empty database.
|
||||
- Replace the active development migration chain and Drizzle metadata only if
|
||||
no released installation depends on it.
|
||||
- Remove upgrade-only database, snapshot and command compatibility code only
|
||||
for formats that are explicitly declared unsupported.
|
||||
- Preserve the pre-release migration history in Git history or a release tag.
|
||||
- Verify installation, migrations, application startup and core workflows
|
||||
against a completely empty database.
|
||||
- Mark the resulting compatibility boundary with the first release tag.
|
||||
- Treat every migration published after that release as immutable.
|
||||
|
||||
## Project History
|
||||
|
||||
- [x] Every listed logical snapshot, including automatic snapshots, must be
|
||||
explicitly restorable from the project history UI.
|
||||
- [x] Show which project change produced a snapshot. Prefer immutable revision
|
||||
metadata over storing a second free-form description of the same change.
|
||||
- [x] Keep restoration auditable as a new project revision and preserve persistent
|
||||
undo/redo.
|
||||
|
||||
## Distribution Boards
|
||||
|
||||
- [x] Assign an optional project floor to a distribution board.
|
||||
- [x] Store a supply classification for each distribution board:
|
||||
- `AV` (Allgemeine Stromversorgung)
|
||||
- `SV` (Sicherheitsstromversorgung)
|
||||
- `EV` (Ersatzstromversorgung)
|
||||
- `USV` (Unterbrechungsfreie Stromversorgung)
|
||||
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
|
||||
- `SiBe` (Sicherheitsbeleuchtung)
|
||||
- [x] Let project settings select which catalog supply types are used in a
|
||||
project. Distribution-board dialogs offer only that selection; a type in
|
||||
use cannot be disabled.
|
||||
- [x] Include both fields in project snapshots and portable project
|
||||
export/import.
|
||||
- [x] Persist changes through project commands so they remain undoable after a
|
||||
restart.
|
||||
|
||||
## Device Voltage Derivation
|
||||
|
||||
- [x] Derive voltage from the selected phase type and the project settings:
|
||||
- single-phase uses the project's single-phase voltage;
|
||||
- three-phase uses the project's three-phase voltage.
|
||||
- [x] Voltage is not editable on global devices, project devices or circuits.
|
||||
Global devices store only their phase; copying one into a project derives
|
||||
the target project's voltage.
|
||||
- [x] Project-device voltage follows its phase. Circuit voltage follows the
|
||||
section phase; an unassigned circuit is three-phase only when all assigned
|
||||
device rows with a valid phase are three-phase.
|
||||
- [x] Changing project voltage settings updates all project devices and circuits
|
||||
atomically in the same persistent Undo/Redo step.
|
||||
- [x] Database migration `0019` and snapshot schema version `5` normalize older
|
||||
stored values. Version-four and older imports remain supported and are
|
||||
normalized while being upgraded.
|
||||
|
||||
## Circuit List Power Summary
|
||||
|
||||
- [x] Show the total power of every circuit section.
|
||||
- [x] Show the unadjusted total power of the complete distribution board.
|
||||
- [x] Store a distribution-board simultaneity factor between zero and one in
|
||||
the distribution-board settings.
|
||||
- [x] Show the distribution-board total after applying that factor.
|
||||
- [x] Preserve the factor in project commands, snapshots and portable project
|
||||
transfers.
|
||||
- [x] Keep column visibility and order as a project-specific UI preference so
|
||||
all distribution boards in the same project open with the same columns.
|
||||
|
||||
## Recommended Sequence
|
||||
|
||||
1. [x] Distribution-board floor and supply fields.
|
||||
2. [x] Device voltage derivation without overrides.
|
||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||
4. [x] Distribution-board power summary and project column layout.
|
||||
|
||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||
must not be inferred from these tasks.
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"build:web": "next build",
|
||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -48,12 +48,45 @@ const circuitColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||
);
|
||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||
const requiredProjectColumns = [
|
||||
"internal_project_number",
|
||||
"external_project_number",
|
||||
"building_owner",
|
||||
"description",
|
||||
"enabled_distribution_board_supply_types",
|
||||
];
|
||||
const projectColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||
);
|
||||
const missingProjectColumns = requiredProjectColumns.filter(
|
||||
(name) => !projectColumns.has(name)
|
||||
);
|
||||
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
.prepare("PRAGMA table_info(distribution_boards)")
|
||||
.all()
|
||||
.map((column) => column.name)
|
||||
);
|
||||
const missingDistributionBoardColumns =
|
||||
requiredDistributionBoardColumns.filter(
|
||||
(name) => !distributionBoardColumns.has(name)
|
||||
);
|
||||
const integrityCheck = db.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyViolations = db.pragma("foreign_key_check");
|
||||
|
||||
console.log("Database:", dbPath);
|
||||
console.log("Required tables:", requiredTables.join(", "));
|
||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||
console.log(
|
||||
"Required distribution-board columns:",
|
||||
requiredDistributionBoardColumns.join(", ")
|
||||
);
|
||||
console.log("Integrity:", integrityCheck);
|
||||
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing tables:", missing.join(", "));
|
||||
@@ -78,4 +111,22 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingProjectColumns.length > 0) {
|
||||
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingDistributionBoardColumns.length > 0) {
|
||||
console.error(
|
||||
"Missing distribution-board columns:",
|
||||
missingDistributionBoardColumns.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
||||
console.error("Database integrity or foreign-key verification failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Circuit-first schema verification passed.");
|
||||
|
||||
+115
-14
@@ -71,6 +71,13 @@ body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-toolbar .project-device-drawer-toggle {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.active-view-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -109,6 +116,39 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.distribution-power-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(12rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.distribution-power-summary > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.distribution-power-summary span {
|
||||
color: #475569;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.distribution-power-summary strong {
|
||||
color: #172033;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.distribution-power-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.column-settings-menu {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
@@ -271,12 +311,10 @@ body {
|
||||
}
|
||||
|
||||
.tree-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
display: block;
|
||||
min-height: 560px;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-device-sidebar {
|
||||
@@ -354,7 +392,7 @@ body {
|
||||
|
||||
.tree-grid {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-width: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -362,37 +400,50 @@ body {
|
||||
.tree-grid th,
|
||||
.tree-grid td {
|
||||
border: 1px solid #e4e9f2;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
padding: 0.35rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tree-grid th {
|
||||
width: 1px;
|
||||
background: #f4f7fb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tree-grid td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-grid .header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-grid .header-filter-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px solid #c4cddc;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
@@ -400,10 +451,46 @@ body {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-device-drawer {
|
||||
position: fixed;
|
||||
z-index: 1040;
|
||||
top: 5.5rem;
|
||||
right: 1rem;
|
||||
left: auto;
|
||||
width: min(360px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 7rem);
|
||||
overflow: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
|
||||
}
|
||||
|
||||
.project-device-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-device-drawer-header span {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.project-device-drawer-header button {
|
||||
border: 1px solid #c4cddc;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tree-grid .header-sort-btn:hover span:first-child,
|
||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||
text-decoration: underline;
|
||||
@@ -641,6 +728,18 @@ body {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tree-grid .section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-title span {
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-grid .cell-invalid {
|
||||
outline: 2px solid #c2410c;
|
||||
outline-offset: -2px;
|
||||
@@ -651,7 +750,8 @@ body {
|
||||
border-color: #c2410c;
|
||||
}
|
||||
|
||||
.tree-grid input {
|
||||
.tree-grid input,
|
||||
.tree-grid select {
|
||||
width: 100%;
|
||||
min-width: 5rem;
|
||||
border: 1px solid #9fb6e0;
|
||||
@@ -783,8 +883,9 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.tree-editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 720px) {
|
||||
.project-device-drawer {
|
||||
top: 4.5rem;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function CircuitTreeEditPage() {
|
||||
const params = useParams<{ projectId: string; circuitListId: string }>();
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||
<div>
|
||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+137
-11
@@ -6,6 +6,7 @@ import {
|
||||
createGlobalDevice,
|
||||
createProject,
|
||||
deleteGlobalDevice,
|
||||
importProjectTransferAsNew,
|
||||
listGlobalDevices,
|
||||
listProjects,
|
||||
updateGlobalDevice,
|
||||
@@ -23,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||
quantity: 1,
|
||||
installedPowerPerUnitKw: 0.1,
|
||||
demandFactor: 1,
|
||||
voltageV: 230,
|
||||
phaseCount: 1,
|
||||
powerFactor: 1,
|
||||
note: "",
|
||||
@@ -48,13 +48,22 @@ export default function ProjectsPage() {
|
||||
quantity: "1",
|
||||
installedPowerPerUnitKw: "0.1",
|
||||
demandFactor: "1",
|
||||
voltageV: "230",
|
||||
phaseCount: "1",
|
||||
powerFactor: "1",
|
||||
note: "",
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projectImport, setProjectImport] = useState<unknown>(null);
|
||||
const [projectImportFilename, setProjectImportFilename] = useState("");
|
||||
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
|
||||
const [projectImportFileError, setProjectImportFileError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [importedProject, setImportedProject] = useState<{
|
||||
projectId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
async function loadData() {
|
||||
setError(null);
|
||||
@@ -87,6 +96,49 @@ export default function ProjectsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProjectImportFile(file: File | undefined) {
|
||||
setProjectImport(null);
|
||||
setProjectImportFilename("");
|
||||
setProjectImportFileError(null);
|
||||
setImportedProject(null);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setProjectImport(JSON.parse(await file.text()) as unknown);
|
||||
setProjectImportFilename(file.name);
|
||||
} catch {
|
||||
setProjectImportFileError(
|
||||
"Die ausgewählte Datei enthält kein gültiges JSON."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportProject() {
|
||||
if (projectImport === null) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setImportedProject(null);
|
||||
try {
|
||||
const imported = await importProjectTransferAsNew(projectImport);
|
||||
setProjects(await listProjects());
|
||||
setImportedProject(imported);
|
||||
setProjectImport(null);
|
||||
setProjectImportFilename("");
|
||||
setProjectImportInputKey((current) => current + 1);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Projektdatei konnte nicht importiert werden."
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!globalDeviceForm.name.trim()) {
|
||||
@@ -99,7 +151,6 @@ export default function ProjectsPage() {
|
||||
quantity: Number(globalDeviceForm.quantity),
|
||||
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
|
||||
demandFactor: Number(globalDeviceForm.demandFactor),
|
||||
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
|
||||
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
|
||||
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
|
||||
note: globalDeviceForm.note.trim() || undefined,
|
||||
@@ -116,7 +167,6 @@ export default function ProjectsPage() {
|
||||
quantity: String(emptyGlobalDevice.quantity),
|
||||
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
|
||||
demandFactor: String(emptyGlobalDevice.demandFactor),
|
||||
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
|
||||
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
|
||||
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
|
||||
note: emptyGlobalDevice.note ?? "",
|
||||
@@ -143,7 +193,7 @@ export default function ProjectsPage() {
|
||||
|
||||
async function handleQuickUpdateGlobalDevice(
|
||||
device: GlobalDeviceDto,
|
||||
key: "name" | "displayName" | "category",
|
||||
key: "name" | "displayName" | "category" | "phaseCount",
|
||||
value: string
|
||||
) {
|
||||
const payload: CreateGlobalDeviceInput = {
|
||||
@@ -153,8 +203,12 @@ export default function ProjectsPage() {
|
||||
quantity: device.quantity,
|
||||
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
|
||||
demandFactor: device.demandFactor,
|
||||
voltageV: device.voltageV ?? undefined,
|
||||
phaseCount: device.phaseCount ?? undefined,
|
||||
phaseCount:
|
||||
key === "phaseCount"
|
||||
? value === "3"
|
||||
? 3
|
||||
: 1
|
||||
: device.phaseCount ?? 1,
|
||||
powerFactor: device.powerFactor ?? undefined,
|
||||
note: device.note ?? undefined,
|
||||
};
|
||||
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
|
||||
|
||||
<div className="row g-4">
|
||||
<section className="col-12 col-lg-4">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">Neues Projekt</div>
|
||||
<div className="card-body">
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Projekt importieren</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary small">
|
||||
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||
mit neuen internen IDs angelegt.
|
||||
</p>
|
||||
<label className="form-label" htmlFor="project-overview-import">
|
||||
Projektdatei auswählen
|
||||
</label>
|
||||
<input
|
||||
accept=".json,application/json"
|
||||
className="form-control"
|
||||
disabled={isSaving}
|
||||
id="project-overview-import"
|
||||
key={projectImportInputKey}
|
||||
onChange={(event) =>
|
||||
void handleProjectImportFile(event.target.files?.[0])
|
||||
}
|
||||
type="file"
|
||||
/>
|
||||
{projectImportFilename ? (
|
||||
<div className="form-text">
|
||||
{projectImportFilename} ist bereit.
|
||||
</div>
|
||||
) : null}
|
||||
{projectImportFileError ? (
|
||||
<div className="text-danger small mt-1">
|
||||
{projectImportFileError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-outline-primary mt-3 w-100"
|
||||
disabled={isSaving || projectImport === null}
|
||||
onClick={() => void handleImportProject()}
|
||||
type="button"
|
||||
>
|
||||
Als neues Projekt importieren
|
||||
</button>
|
||||
{importedProject ? (
|
||||
<div className="alert alert-success mt-3 mb-0">
|
||||
<div className="small mb-2">
|
||||
„{importedProject.name}“ wurde angelegt.
|
||||
</div>
|
||||
<Link
|
||||
className="btn btn-sm btn-success"
|
||||
href={`/projects/${importedProject.projectId}`}
|
||||
>
|
||||
Importiertes Projekt öffnen
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-8">
|
||||
@@ -313,8 +421,8 @@ export default function ProjectsPage() {
|
||||
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="1">1-ph</option>
|
||||
<option value="3">3-ph</option>
|
||||
<option value="1">1-phasig</option>
|
||||
<option value="3">3-phasig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-6 col-md-2">
|
||||
@@ -334,6 +442,7 @@ export default function ProjectsPage() {
|
||||
<th>Anzahl</th>
|
||||
<th>Leistung je Stück [kW]</th>
|
||||
<th>GZF</th>
|
||||
<th>Phasenart</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -376,6 +485,23 @@ export default function ProjectsPage() {
|
||||
<td>{device.quantity}</td>
|
||||
<td>{device.installedPowerPerUnitKw}</td>
|
||||
<td>{device.demandFactor}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
aria-label={`Phasenart für ${device.displayName}`}
|
||||
value={String(device.phaseCount ?? 1)}
|
||||
onChange={(event) =>
|
||||
void handleQuickUpdateGlobalDevice(
|
||||
device,
|
||||
"phaseCount",
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="1">1-phasig</option>
|
||||
<option value="3">3-phasig</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
@@ -389,7 +515,7 @@ export default function ProjectsPage() {
|
||||
))}
|
||||
{!globalDevices.length ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-secondary py-4">
|
||||
<td colSpan={8} className="text-center text-secondary py-4">
|
||||
Noch keine globalen Geräte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `description` text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `supply_type` text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
|
||||
@@ -0,0 +1,48 @@
|
||||
UPDATE `global_devices`
|
||||
SET `voltage_v` = NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `project_devices` AS `pd`
|
||||
SET `voltage_v` = CASE
|
||||
WHEN `pd`.`phase_type` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END
|
||||
FROM `projects` AS `p`
|
||||
WHERE `p`.`id` = `pd`.`project_id`;
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `other_row`
|
||||
WHERE `other_row`.`circuit_id` = `c`.`id`
|
||||
AND `other_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
UPDATE `project_devices`
|
||||
SET `phase_type` = CASE
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||
THEN 'single_phase'
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||
THEN 'three_phase'
|
||||
ELSE `phase_type`
|
||||
END
|
||||
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN (
|
||||
'1ph',
|
||||
'1phase',
|
||||
'1phasig',
|
||||
'einphasig',
|
||||
'singlephase',
|
||||
'3ph',
|
||||
'3phase',
|
||||
'3phasig',
|
||||
'dreiphasig',
|
||||
'threephase'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = CASE
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||
THEN 'single_phase'
|
||||
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||
THEN 'three_phase'
|
||||
ELSE `phase_type`
|
||||
END
|
||||
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||
IN (
|
||||
'1ph',
|
||||
'1phase',
|
||||
'1phasig',
|
||||
'einphasig',
|
||||
'singlephase',
|
||||
'3ph',
|
||||
'3phase',
|
||||
'3phasig',
|
||||
'dreiphasig',
|
||||
'threephase'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `single_phase_row`
|
||||
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = COALESCE(
|
||||
(
|
||||
SELECT `pd`.`phase_type`
|
||||
FROM `project_devices` AS `pd`
|
||||
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||
),
|
||||
(
|
||||
SELECT CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN 'three_phase'
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN 'single_phase'
|
||||
ELSE NULL
|
||||
END
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||
)
|
||||
)
|
||||
WHERE `phase_type` IS NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM `project_devices` AS `pd`
|
||||
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||
AND `cs`.`key` IN ('lighting', 'single_phase', 'three_phase')
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH `derived_circuit_voltages` AS (
|
||||
SELECT
|
||||
`c`.`id` AS `circuit_id`,
|
||||
CASE
|
||||
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `three_phase_row`
|
||||
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `circuit_device_rows` AS `single_phase_row`
|
||||
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||
) THEN `p`.`three_phase_voltage_v`
|
||||
ELSE `p`.`single_phase_voltage_v`
|
||||
END AS `derived_voltage`
|
||||
FROM `circuits` AS `c`
|
||||
INNER JOIN `circuit_lists` AS `cl`
|
||||
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||
INNER JOIN `projects` AS `p`
|
||||
ON `p`.`id` = `cl`.`project_id`
|
||||
INNER JOIN `circuit_sections` AS `cs`
|
||||
ON `cs`.`id` = `c`.`section_id`
|
||||
)
|
||||
UPDATE `circuits`
|
||||
SET `voltage` = (
|
||||
SELECT `derived_voltage`
|
||||
FROM `derived_circuit_voltages`
|
||||
WHERE `circuit_id` = `circuits`.`id`
|
||||
)
|
||||
WHERE `id` IN (
|
||||
SELECT `circuit_id`
|
||||
FROM `derived_circuit_voltages`
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE `circuit_device_rows`
|
||||
SET `phase_type` = 'single_phase'
|
||||
WHERE `phase_type` IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `global_devices`
|
||||
SET `phase_count` = 1
|
||||
WHERE `phase_count` IS NULL OR `phase_count` NOT IN (1, 3);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `distribution_boards` ADD `simultaneity_factor` real DEFAULT 1 NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,62 @@
|
||||
"when": 1785014383032,
|
||||
"tag": "0015_modern_madame_masque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1785254079435,
|
||||
"tag": "0016_dashing_darkstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1785307189539,
|
||||
"tag": "0017_vengeful_romulus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1785308458789,
|
||||
"tag": "0018_fancy_argent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1785310907349,
|
||||
"tag": "0019_normalize_project_voltages",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1785314248007,
|
||||
"tag": "0020_normalize_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1785314558555,
|
||||
"tag": "0021_fill_missing_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1785314626476,
|
||||
"tag": "0022_default_remaining_phase_types",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1785343645615,
|
||||
"tag": "0023_cheerful_night_thrasher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,11 +20,14 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
interface PersistedMoveRow {
|
||||
id: string;
|
||||
circuitId: string;
|
||||
sortOrder: number;
|
||||
phaseType: string | null;
|
||||
}
|
||||
|
||||
export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
@@ -85,6 +88,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
);
|
||||
this.applyMoves(database, command.payload.moves);
|
||||
this.updateReserveStates(database, circuitIds);
|
||||
this.updateVoltages(database, projectId, circuitIds);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
@@ -94,7 +98,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
const { targetCircuit, targetCircuitAction, moves } =
|
||||
command.payload;
|
||||
const rowsById = this.loadExpectedRows(database, moves);
|
||||
this.assertCircuitLocation(database, projectId, targetCircuit);
|
||||
const appliedTargetCircuit = {
|
||||
...targetCircuit,
|
||||
voltage: resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
targetCircuit.sectionId,
|
||||
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
|
||||
),
|
||||
};
|
||||
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
|
||||
|
||||
if (targetCircuitAction === "create") {
|
||||
const sourceCircuitIds = [
|
||||
@@ -106,13 +119,13 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
sourceCircuitIds,
|
||||
targetCircuit.circuitListId
|
||||
);
|
||||
this.assertTargetCircuitAvailable(database, targetCircuit);
|
||||
this.insertTargetCircuit(database, targetCircuit);
|
||||
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
|
||||
this.insertTargetCircuit(database, appliedTargetCircuit);
|
||||
|
||||
const inverse =
|
||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||
"delete",
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
this.reverseMoves(moves, rowsById)
|
||||
);
|
||||
this.applyMoves(database, moves);
|
||||
@@ -120,12 +133,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
...sourceCircuitIds,
|
||||
targetCircuit.id,
|
||||
]);
|
||||
this.updateVoltages(database, projectId, [
|
||||
...sourceCircuitIds,
|
||||
targetCircuit.id,
|
||||
]);
|
||||
return inverse;
|
||||
}
|
||||
|
||||
this.assertTargetCircuitUnchanged(
|
||||
database,
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
moves.map((move) => move.rowId)
|
||||
);
|
||||
const destinationCircuitIds = [
|
||||
@@ -140,7 +157,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
const inverse =
|
||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||
"create",
|
||||
targetCircuit,
|
||||
appliedTargetCircuit,
|
||||
this.reverseMoves(moves, rowsById)
|
||||
);
|
||||
this.applyMoves(database, moves);
|
||||
@@ -148,6 +165,11 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
targetCircuit.id,
|
||||
...destinationCircuitIds,
|
||||
]);
|
||||
this.updateVoltages(
|
||||
database,
|
||||
projectId,
|
||||
destinationCircuitIds
|
||||
);
|
||||
const deleted = database
|
||||
.delete(circuits)
|
||||
.where(
|
||||
@@ -169,6 +191,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
return inverse;
|
||||
}
|
||||
|
||||
private updateVoltages(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitIds: string[]
|
||||
) {
|
||||
for (const circuitId of new Set(circuitIds)) {
|
||||
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
||||
}
|
||||
}
|
||||
|
||||
private loadExpectedRows(
|
||||
database: AppDatabase,
|
||||
moves: CircuitDeviceRowMoveAssignment[]
|
||||
@@ -179,6 +211,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
sortOrder: circuitDeviceRows.sortOrder,
|
||||
phaseType: circuitDeviceRows.phaseType,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type CircuitDeviceRowPatchInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -80,6 +82,13 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
if (
|
||||
input.source === "user" &&
|
||||
patch.phaseType !== undefined &&
|
||||
!isElectricalPhaseType(patch.phaseType)
|
||||
) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||
this.assertRoom(tx, input.projectId, patch);
|
||||
|
||||
@@ -117,6 +126,13 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit device row changed before command execution.");
|
||||
}
|
||||
if (patch.phaseType !== undefined) {
|
||||
updateDerivedCircuitVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
current.circuitId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
forward: appliedForward,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ExecuteCircuitDeviceRowStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
implements CircuitDeviceRowStructureProjectCommandStore
|
||||
@@ -80,6 +82,9 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
"User commands cannot assign legacy consumer ids."
|
||||
);
|
||||
}
|
||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||
|
||||
@@ -101,6 +106,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row insertion.");
|
||||
}
|
||||
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
||||
|
||||
return createCircuitDeviceRowDeleteProjectCommand(
|
||||
row.id,
|
||||
@@ -153,6 +159,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row deletion.");
|
||||
}
|
||||
updateDerivedCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
expectedCircuitId
|
||||
);
|
||||
|
||||
return createCircuitDeviceRowInsertProjectCommand(
|
||||
toCircuitDeviceRowSnapshot(row)
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
toCircuitPatchValues,
|
||||
type CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
|
||||
type CircuitRow = typeof circuits.$inferSelect;
|
||||
|
||||
@@ -30,7 +32,7 @@ export class CircuitProjectCommandRepository
|
||||
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
|
||||
assertCircuitUpdateProjectCommand(input.command);
|
||||
|
||||
return executeProjectCommandTransaction(
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
@@ -71,14 +73,44 @@ export class CircuitProjectCommandRepository
|
||||
])
|
||||
) as CircuitPatchPersistenceInput;
|
||||
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
||||
if (input.source === "user") {
|
||||
if (
|
||||
input.command.payload.changes.every(
|
||||
(change) => change.field === "voltage"
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit voltage is derived and cannot be edited.");
|
||||
}
|
||||
const devicePhaseTypes = tx
|
||||
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, current.id))
|
||||
.all()
|
||||
.map((row) => row.phaseType);
|
||||
const derivedVoltage = resolveCircuitVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
patch.sectionId ?? current.sectionId,
|
||||
devicePhaseTypes
|
||||
);
|
||||
if (derivedVoltage === current.voltage) {
|
||||
delete patch.voltage;
|
||||
} else {
|
||||
patch.voltage = derivedVoltage;
|
||||
}
|
||||
}
|
||||
this.assertUniqueEquipmentIdentifier(
|
||||
tx,
|
||||
current,
|
||||
patch.equipmentIdentifier
|
||||
);
|
||||
|
||||
const appliedForward = createCircuitUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitFieldValue(current, change.field),
|
||||
])
|
||||
@@ -97,7 +129,7 @@ export class CircuitProjectCommandRepository
|
||||
throw new Error("Circuit changed before command execution.");
|
||||
}
|
||||
|
||||
return inverse;
|
||||
return { forward: appliedForward, inverse };
|
||||
}
|
||||
|
||||
private assertSectionInCircuitList(
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CircuitStructureProjectCommandStore,
|
||||
ExecuteCircuitStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
export class CircuitStructureProjectCommandRepository
|
||||
implements CircuitStructureProjectCommandStore
|
||||
@@ -77,6 +79,19 @@ export class CircuitStructureProjectCommandRepository
|
||||
snapshot: CircuitSnapshot
|
||||
) {
|
||||
this.assertCircuitLocation(database, projectId, snapshot);
|
||||
if (source === "user") {
|
||||
const derivedVoltage = resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.sectionId,
|
||||
snapshot.deviceRows.map((row) => row.phaseType)
|
||||
);
|
||||
if (snapshot.voltage !== derivedVoltage) {
|
||||
throw new Error(
|
||||
"Circuit voltage must match the project phase voltage."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingCircuit = database
|
||||
.select({ id: circuits.id })
|
||||
@@ -122,6 +137,9 @@ export class CircuitStructureProjectCommandRepository
|
||||
"User commands cannot assign legacy consumer ids."
|
||||
);
|
||||
}
|
||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
}
|
||||
assertCircuitDeviceRowReferencesInProject(
|
||||
database,
|
||||
projectId,
|
||||
|
||||
@@ -6,9 +6,20 @@ import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
normalizeDistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
type DistributionBoardStructureSnapshot,
|
||||
legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
createDistributionBoardUpdateProjectCommand,
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||
type DistributionBoardUpdateField,
|
||||
type DistributionBoardUpdatePatch,
|
||||
type DistributionBoardUpdateProjectCommand,
|
||||
type DistributionBoardUpdateValues,
|
||||
} from "../../domain/models/distribution-board-project-command.model.js";
|
||||
import type {
|
||||
DistributionBoardStructureProjectCommandStore,
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
@@ -18,6 +29,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
@@ -35,26 +47,52 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
);
|
||||
}
|
||||
|
||||
executeUpdate(
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
) {
|
||||
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.update(tx, input)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
if (command.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(command);
|
||||
return this.insert(
|
||||
const normalized =
|
||||
normalizeDistributionBoardStructureProjectCommand(command);
|
||||
if (normalized.type === distributionBoardInsertCommandType) {
|
||||
assertDistributionBoardInsertProjectCommand(normalized);
|
||||
const inverse = this.insert(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.structure
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
}
|
||||
if (command.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(command);
|
||||
return this.delete(
|
||||
if (normalized.type === distributionBoardDeleteCommandType) {
|
||||
assertDistributionBoardDeleteProjectCommand(normalized);
|
||||
const inverse = this.delete(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.structure
|
||||
normalized.payload.structure
|
||||
);
|
||||
return command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
? toLegacyStructureCommand(inverse)
|
||||
: inverse;
|
||||
}
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
@@ -73,13 +111,33 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
);
|
||||
}
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.select({
|
||||
id: projects.id,
|
||||
enabledSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
assertSupplyTypeEnabled(
|
||||
project.enabledSupplyTypes,
|
||||
structure.distributionBoard.supplyType
|
||||
);
|
||||
if (structure.distributionBoard.floorId !== null) {
|
||||
const floor = database
|
||||
.select({ projectId: floors.projectId })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, structure.distributionBoard.floorId))
|
||||
.get();
|
||||
if (!floor || floor.projectId !== projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board floor does not belong to project."
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingBoard = database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
@@ -179,6 +237,148 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||
}
|
||||
return createDistributionBoardInsertProjectCommand(structure);
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
) {
|
||||
const current = database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
distributionBoards.id,
|
||||
input.command.payload.distributionBoardId
|
||||
),
|
||||
eq(distributionBoards.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Distribution board does not belong to project.");
|
||||
}
|
||||
const project = database
|
||||
.select({
|
||||
enabledSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as DistributionBoardUpdatePatch;
|
||||
if (patch.floorId !== undefined && patch.floorId !== null) {
|
||||
const floor = database
|
||||
.select({ projectId: floors.projectId })
|
||||
.from(floors)
|
||||
.where(eq(floors.id, patch.floorId))
|
||||
.get();
|
||||
if (!floor || floor.projectId !== input.projectId) {
|
||||
throw new Error(
|
||||
"Distribution-board floor does not belong to project."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (patch.supplyType !== undefined) {
|
||||
assertSupplyTypeEnabled(
|
||||
project.enabledSupplyTypes,
|
||||
patch.supplyType
|
||||
);
|
||||
}
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getDistributionBoardFieldValue(current, change.field),
|
||||
])
|
||||
) as DistributionBoardUpdatePatch;
|
||||
const currentInverse = createDistributionBoardUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
const inverse =
|
||||
input.command.schemaVersion ===
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion
|
||||
? ({
|
||||
...currentInverse,
|
||||
schemaVersion:
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||
} as DistributionBoardUpdateProjectCommand)
|
||||
: currentInverse;
|
||||
const updated = database
|
||||
.update(distributionBoards)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.id, current.id),
|
||||
eq(distributionBoards.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error(
|
||||
"Distribution board changed before command execution."
|
||||
);
|
||||
}
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupplyTypeEnabled(
|
||||
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
|
||||
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
|
||||
) {
|
||||
if (
|
||||
supplyType !== null &&
|
||||
!enabledSupplyTypes.includes(supplyType)
|
||||
) {
|
||||
throw new Error(
|
||||
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toLegacyStructureCommand(
|
||||
command:
|
||||
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
|
||||
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
|
||||
): DistributionBoardStructureProjectCommand {
|
||||
const {
|
||||
floorId: _floorId,
|
||||
supplyType: _supplyType,
|
||||
...distributionBoard
|
||||
} = command.payload.structure.distributionBoard;
|
||||
return {
|
||||
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
|
||||
type: command.type,
|
||||
payload: {
|
||||
structure: {
|
||||
...command.payload.structure,
|
||||
distributionBoard,
|
||||
},
|
||||
},
|
||||
} as DistributionBoardStructureProjectCommand;
|
||||
}
|
||||
|
||||
function getDistributionBoardFieldValue<
|
||||
TField extends DistributionBoardUpdateField,
|
||||
>(
|
||||
distributionBoard: typeof distributionBoards.$inferSelect,
|
||||
field: TField
|
||||
): DistributionBoardUpdateValues[TField] {
|
||||
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
|
||||
}
|
||||
|
||||
function structureMatches(
|
||||
@@ -189,8 +389,16 @@ function structureMatches(
|
||||
sections: Array<typeof circuitSections.$inferSelect>;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
simultaneityFactor,
|
||||
...actualDistributionBoard
|
||||
} = actual.board;
|
||||
if (
|
||||
!sameRecord(expected.distributionBoard, actual.board) ||
|
||||
simultaneityFactor !== 1 ||
|
||||
!sameRecord(
|
||||
expected.distributionBoard,
|
||||
actualDistributionBoard
|
||||
) ||
|
||||
!sameRecord(expected.circuitList, actual.list) ||
|
||||
expected.sections.length !== actual.sections.length
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
|
||||
@@ -12,4 +12,19 @@ export class DistributionBoardRepository {
|
||||
.where(eq(distributionBoards.projectId, projectId));
|
||||
}
|
||||
|
||||
async findById(projectId: string, distributionBoardId: string) {
|
||||
return (
|
||||
this.database
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
eq(distributionBoards.id, distributionBoardId)
|
||||
)
|
||||
)
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export class GlobalDeviceRepository {
|
||||
quantity: input.quantity,
|
||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||
demandFactor: input.demandFactor,
|
||||
voltageV: input.voltageV ?? null,
|
||||
phaseCount: input.phaseCount ?? null,
|
||||
voltageV: null,
|
||||
phaseCount: input.phaseCount,
|
||||
powerFactor: input.powerFactor ?? null,
|
||||
note: input.note ?? null,
|
||||
});
|
||||
@@ -45,8 +45,8 @@ export class GlobalDeviceRepository {
|
||||
quantity: input.quantity,
|
||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||
demandFactor: input.demandFactor,
|
||||
voltageV: input.voltageV ?? null,
|
||||
phaseCount: input.phaseCount ?? null,
|
||||
voltageV: null,
|
||||
phaseCount: input.phaseCount,
|
||||
powerFactor: input.powerFactor ?? null,
|
||||
note: input.note ?? null,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
} from "../../domain/ports/project-device-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
||||
|
||||
@@ -24,7 +25,7 @@ export class ProjectDeviceProjectCommandRepository
|
||||
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
|
||||
assertProjectDeviceUpdateProjectCommand(input.command);
|
||||
|
||||
return executeProjectCommandTransaction(
|
||||
return executeProjectCommandTransactionWithAppliedForward(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input)
|
||||
@@ -60,8 +61,40 @@ export class ProjectDeviceProjectCommandRepository
|
||||
change.value,
|
||||
])
|
||||
) as ProjectDeviceUpdatePatch;
|
||||
if (input.source === "user") {
|
||||
if (
|
||||
input.command.payload.changes.every(
|
||||
(change) => change.field === "voltageV"
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project-device voltage is derived and cannot be edited."
|
||||
);
|
||||
}
|
||||
const targetPhaseType = patch.phaseType ?? current.phaseType;
|
||||
if (
|
||||
targetPhaseType !== "single_phase" &&
|
||||
targetPhaseType !== "three_phase"
|
||||
) {
|
||||
throw new Error("Project-device phase type is invalid.");
|
||||
}
|
||||
const derivedVoltage = resolveProjectDeviceVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
targetPhaseType
|
||||
);
|
||||
if (derivedVoltage === current.voltageV) {
|
||||
delete patch.voltageV;
|
||||
} else {
|
||||
patch.voltageV = derivedVoltage;
|
||||
}
|
||||
}
|
||||
const appliedForward = createProjectDeviceUpdateProjectCommand(
|
||||
current.id,
|
||||
patch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getProjectDeviceFieldValue(current, change.field),
|
||||
])
|
||||
@@ -87,7 +120,7 @@ export class ProjectDeviceProjectCommandRepository
|
||||
);
|
||||
}
|
||||
|
||||
return inverse;
|
||||
return { forward: appliedForward, inverse };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
@@ -129,6 +130,25 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||
);
|
||||
}
|
||||
}
|
||||
const affectedCircuitIds = new Set(
|
||||
input.command.payload.rows
|
||||
.filter(
|
||||
(assignment) =>
|
||||
assignment.expected.phaseType !==
|
||||
assignment.target.phaseType
|
||||
)
|
||||
.map(
|
||||
(assignment) =>
|
||||
persistedById.get(assignment.rowId)!.circuitId
|
||||
)
|
||||
);
|
||||
for (const circuitId of affectedCircuitIds) {
|
||||
updateDerivedCircuitVoltage(
|
||||
tx,
|
||||
input.projectId,
|
||||
circuitId
|
||||
);
|
||||
}
|
||||
|
||||
return inverse;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||
|
||||
const circuitDeviceRowSnapshotFields = [
|
||||
"id",
|
||||
@@ -125,6 +126,19 @@ export class ProjectDeviceStructureProjectCommandRepository
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
if (
|
||||
source === "user" &&
|
||||
snapshot.voltageV !==
|
||||
resolveProjectDeviceVoltage(
|
||||
database,
|
||||
projectId,
|
||||
snapshot.phaseType
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project-device voltage must match the project phase voltage."
|
||||
);
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { and, asc, desc, eq, lt } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import type {
|
||||
ProjectHistoryCommand,
|
||||
ProjectHistoryDirection,
|
||||
ListProjectRevisionsInput,
|
||||
ProjectRevisionPage,
|
||||
ProjectRevisionSummary,
|
||||
ProjectHistoryState,
|
||||
ProjectHistoryStore,
|
||||
} from "../../domain/ports/project-history.store.js";
|
||||
@@ -118,6 +119,64 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
};
|
||||
}
|
||||
|
||||
listRevisionsByNumbers(
|
||||
projectId: string,
|
||||
revisionNumbers: number[]
|
||||
): ProjectRevisionSummary[] {
|
||||
for (const revisionNumber of revisionNumbers) {
|
||||
if (
|
||||
!Number.isSafeInteger(revisionNumber) ||
|
||||
revisionNumber < 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Project revision numbers must be non-negative integers."
|
||||
);
|
||||
}
|
||||
}
|
||||
const normalizedRevisionNumbers = [
|
||||
...new Set(revisionNumbers),
|
||||
].filter((revisionNumber) => revisionNumber > 0);
|
||||
if (!normalizedRevisionNumbers.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select({
|
||||
revisionId: projectRevisions.id,
|
||||
changeSetId: projectChangeSets.id,
|
||||
revisionNumber: projectRevisions.revisionNumber,
|
||||
createdAtIso: projectRevisions.createdAtIso,
|
||||
actorId: projectRevisions.actorId,
|
||||
source: projectRevisions.source,
|
||||
description: projectRevisions.description,
|
||||
commandType: projectChangeSets.commandType,
|
||||
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
|
||||
})
|
||||
.from(projectRevisions)
|
||||
.innerJoin(
|
||||
projectChangeSets,
|
||||
eq(
|
||||
projectChangeSets.projectRevisionId,
|
||||
projectRevisions.id
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(projectRevisions.projectId, projectId),
|
||||
inArray(
|
||||
projectRevisions.revisionNumber,
|
||||
normalizedRevisionNumbers
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(projectRevisions.revisionNumber))
|
||||
.all()
|
||||
.map((row) => ({
|
||||
...row,
|
||||
source: row.source as ProjectRevisionSource,
|
||||
}));
|
||||
}
|
||||
|
||||
getNextCommand(
|
||||
projectId: string,
|
||||
direction: ProjectHistoryDirection
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { consumers } from "../schema/consumers.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
@@ -129,6 +130,17 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||
"A project floor with assigned rooms cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const referencedDistributionBoard = database
|
||||
.select({ id: distributionBoards.id })
|
||||
.from(distributionBoards)
|
||||
.where(eq(distributionBoards.floorId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedDistributionBoard) {
|
||||
throw new Error(
|
||||
"A project floor assigned to a distribution board cannot be removed by history."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(floors)
|
||||
.where(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectSettingsUpdateProjectCommand,
|
||||
createProjectSettingsUpdateProjectCommand,
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||
normalizeProjectSettingsValues,
|
||||
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||
type ProjectSettingsValues,
|
||||
} from "../../domain/models/project-settings-project-command.model.js";
|
||||
import type {
|
||||
ExecuteProjectSettingsUpdateCommandInput,
|
||||
@@ -9,7 +13,9 @@ import type {
|
||||
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateAllDerivedProjectVoltages } from "./project-voltage.persistence.js";
|
||||
|
||||
export class ProjectSettingsProjectCommandRepository
|
||||
implements ProjectSettingsProjectCommandStore
|
||||
@@ -37,28 +43,124 @@ export class ProjectSettingsProjectCommandRepository
|
||||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
if (
|
||||
current.singlePhaseVoltageV ===
|
||||
input.command.payload.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV ===
|
||||
input.command.payload.threePhaseVoltageV
|
||||
) {
|
||||
const target = normalizeProjectSettingsValues(input.command, current);
|
||||
if (projectSettingsEqual(current, target)) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
|
||||
const inverse = createProjectSettingsUpdateProjectCommand({
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
});
|
||||
const inverse =
|
||||
input.command.schemaVersion ===
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion
|
||||
? {
|
||||
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: input.command.type,
|
||||
payload: {
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
},
|
||||
}
|
||||
: input.command.schemaVersion ===
|
||||
previousProjectSettingsUpdateCommandSchemaVersion
|
||||
? {
|
||||
schemaVersion:
|
||||
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: input.command.type,
|
||||
payload: {
|
||||
name: current.name,
|
||||
internalProjectNumber: current.internalProjectNumber,
|
||||
externalProjectNumber: current.externalProjectNumber,
|
||||
buildingOwner: current.buildingOwner,
|
||||
description: current.description,
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
},
|
||||
}
|
||||
: createProjectSettingsUpdateProjectCommand({
|
||||
name: current.name,
|
||||
internalProjectNumber: current.internalProjectNumber,
|
||||
externalProjectNumber: current.externalProjectNumber,
|
||||
buildingOwner: current.buildingOwner,
|
||||
description: current.description,
|
||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
});
|
||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
.set(input.command.payload)
|
||||
.set(target)
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Project changed before settings update.");
|
||||
}
|
||||
updateAllDerivedProjectVoltages(tx, input.projectId);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function projectSettingsEqual(
|
||||
current: ProjectSettingsValues,
|
||||
target: ProjectSettingsValues
|
||||
) {
|
||||
return (
|
||||
current.name === target.name &&
|
||||
current.internalProjectNumber === target.internalProjectNumber &&
|
||||
current.externalProjectNumber === target.externalProjectNumber &&
|
||||
current.buildingOwner === target.buildingOwner &&
|
||||
current.description === target.description &&
|
||||
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV === target.threePhaseVoltageV
|
||||
&& sameSupplyTypes(
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
target.enabledDistributionBoardSupplyTypes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sameSupplyTypes(
|
||||
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
|
||||
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
|
||||
) {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function assertDisabledSupplyTypesAreUnused(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
target: ProjectSettingsValues
|
||||
) {
|
||||
const used = database
|
||||
.select({ supplyType: distributionBoards.supplyType })
|
||||
.from(distributionBoards)
|
||||
.where(
|
||||
and(
|
||||
eq(distributionBoards.projectId, projectId),
|
||||
isNotNull(distributionBoards.supplyType)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
const disabledUsedTypes = [
|
||||
...new Set(
|
||||
used
|
||||
.map((entry) => entry.supplyType)
|
||||
.filter(
|
||||
(supplyType) =>
|
||||
supplyType !== null &&
|
||||
!target.enabledDistributionBoardSupplyTypes.some(
|
||||
(enabled) => enabled === supplyType
|
||||
)
|
||||
)
|
||||
),
|
||||
];
|
||||
if (disabledUsedTypes.length > 0) {
|
||||
throw new Error(
|
||||
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import { ProjectRevisionConflictError } from "../../domain/errors/project-revisi
|
||||
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
distributionBoardProjectStateSnapshotSchemaVersion,
|
||||
deserializeProjectStateSnapshot,
|
||||
legacyProjectStateSnapshotSchemaVersion,
|
||||
previousProjectStateSnapshotSchemaVersion,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
voltageProjectStateSnapshotSchemaVersion,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
CreateNamedProjectSnapshotInput,
|
||||
@@ -138,7 +143,17 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||
if (actualChecksum !== stored.payloadSha256) {
|
||||
throw new Error("Project snapshot checksum verification failed.");
|
||||
}
|
||||
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
if (
|
||||
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
distributionBoardProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !==
|
||||
voltageProjectStateSnapshotSchemaVersion &&
|
||||
stored.schemaVersion !== projectStateSnapshotSchemaVersion
|
||||
) {
|
||||
throw new Error("Project snapshot schema version is not supported.");
|
||||
}
|
||||
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createProjectStateRestoreCommand,
|
||||
} from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
serializeProjectStateSnapshot,
|
||||
parseProjectStateSnapshot,
|
||||
type ProjectStateSnapshot,
|
||||
} from "../../domain/models/project-state-snapshot.model.js";
|
||||
import type {
|
||||
@@ -23,11 +23,11 @@ import { floors } from "../schema/floors.js";
|
||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
|
||||
@@ -38,7 +38,10 @@ export class ProjectStateRestoreCommandRepository
|
||||
|
||||
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||
assertProjectStateRestoreCommand(input.command);
|
||||
if (input.command.payload.targetState.project.id !== input.projectId) {
|
||||
const targetState = parseProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
if (targetState.project.id !== input.projectId) {
|
||||
throw new Error("Restore state belongs to a different project.");
|
||||
}
|
||||
|
||||
@@ -59,7 +62,8 @@ export class ProjectStateRestoreCommandRepository
|
||||
}
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256
|
||||
input.command.payload.expectedStateSha256 &&
|
||||
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
@@ -68,20 +72,93 @@ export class ProjectStateRestoreCommandRepository
|
||||
);
|
||||
}
|
||||
|
||||
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||
const targetState = parseProjectStateSnapshot(
|
||||
input.command.payload.targetState
|
||||
);
|
||||
replaceProjectState(tx, targetState);
|
||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!persistedTarget) {
|
||||
throw new Error("Restored project could not be loaded.");
|
||||
}
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
hashProjectStatePayload(targetPayloadJson),
|
||||
persistedTarget.payloadSha256,
|
||||
current.state
|
||||
);
|
||||
|
||||
replaceProjectState(tx, input.command.payload.targetState);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesHistoryRestoreTarget(
|
||||
database: AppDatabase,
|
||||
input: ExecuteProjectStateRestoreCommandInput,
|
||||
currentState: ProjectStateSnapshot
|
||||
) {
|
||||
if (
|
||||
(input.source !== "undo" && input.source !== "redo") ||
|
||||
!input.historyTargetChangeSetId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const changeSet = database
|
||||
.select({
|
||||
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
|
||||
inversePayloadJson: projectChangeSets.inversePayloadJson,
|
||||
})
|
||||
.from(projectChangeSets)
|
||||
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
|
||||
.get();
|
||||
if (!changeSet) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const serialized =
|
||||
input.source === "undo"
|
||||
? changeSet.forwardPayloadJson
|
||||
: changeSet.inversePayloadJson;
|
||||
const command = JSON.parse(serialized) as {
|
||||
type?: unknown;
|
||||
payload?: { targetState?: unknown };
|
||||
};
|
||||
if (command.type !== "project.restore-state") {
|
||||
return false;
|
||||
}
|
||||
const expectedState = parseProjectStateSnapshot(
|
||||
command.payload?.targetState
|
||||
);
|
||||
return (
|
||||
canonicalProjectState(expectedState) ===
|
||||
canonicalProjectState(currentState)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
return JSON.stringify({
|
||||
...state,
|
||||
distributionBoards: byId(state.distributionBoards),
|
||||
circuitLists: byId(state.circuitLists),
|
||||
circuitSections: byId(state.circuitSections),
|
||||
circuits: byId(
|
||||
state.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
deviceRows: byId(circuit.deviceRows),
|
||||
}))
|
||||
),
|
||||
projectDevices: byId(state.projectDevices),
|
||||
floors: byId(state.floors),
|
||||
rooms: byId(state.rooms),
|
||||
});
|
||||
}
|
||||
|
||||
function byId<T extends { id: string }>(entries: readonly T[]) {
|
||||
return [...entries].sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function replaceProjectState(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot
|
||||
@@ -111,8 +188,14 @@ function replaceProjectState(
|
||||
.update(projects)
|
||||
.set({
|
||||
name: state.project.name,
|
||||
internalProjectNumber: state.project.internalProjectNumber,
|
||||
externalProjectNumber: state.project.externalProjectNumber,
|
||||
buildingOwner: state.project.buildingOwner,
|
||||
description: state.project.description,
|
||||
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
state.project.enabledDistributionBoardSupplyTypes,
|
||||
})
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.run();
|
||||
|
||||
@@ -32,8 +32,14 @@ export function readProjectStateSnapshot(
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
internalProjectNumber: projects.internalProjectNumber,
|
||||
externalProjectNumber: projects.externalProjectNumber,
|
||||
buildingOwner: projects.buildingOwner,
|
||||
description: projects.description,
|
||||
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
currentRevision: projects.currentRevision,
|
||||
})
|
||||
.from(projects)
|
||||
@@ -172,8 +178,14 @@ export function readProjectStateSnapshot(
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
internalProjectNumber: project.internalProjectNumber,
|
||||
externalProjectNumber: project.externalProjectNumber,
|
||||
buildingOwner: project.buildingOwner,
|
||||
description: project.description,
|
||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
project.enabledDistributionBoardSupplyTypes,
|
||||
},
|
||||
distributionBoards: boardRows,
|
||||
circuitLists: listRows,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createProjectTransferEnvelope,
|
||||
parseProjectTransferEnvelope,
|
||||
remapProjectState,
|
||||
} from "../../domain/models/project-transfer.model.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||
|
||||
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
exportProject(projectId: string) {
|
||||
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return createProjectTransferEnvelope(
|
||||
new Date().toISOString(),
|
||||
snapshot.payloadSha256,
|
||||
snapshot.state
|
||||
);
|
||||
}
|
||||
|
||||
prepareReplace(projectId: string, value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const current = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const targetState =
|
||||
transfer.projectState.project.id === projectId
|
||||
? transfer.projectState
|
||||
: remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
return {
|
||||
command: createProjectStateRestoreCommand(
|
||||
current.payloadSha256,
|
||||
targetState
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
importDuplicate(value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const projectId = crypto.randomUUID();
|
||||
const state = remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID(),
|
||||
`${transfer.projectState.project.name} (Kopie)`
|
||||
);
|
||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||
return { projectId, name: state.project.name };
|
||||
}
|
||||
|
||||
private parseVerified(value: unknown) {
|
||||
const rawPayloadSha256 =
|
||||
isPlainObject(value) &&
|
||||
isPlainObject(value.projectState)
|
||||
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
||||
: null;
|
||||
const transfer = parseProjectTransferEnvelope(value);
|
||||
const payloadJson = serializeProjectStateSnapshot(
|
||||
transfer.projectState
|
||||
);
|
||||
if (
|
||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||
rawPayloadSha256 !== transfer.payloadSha256
|
||||
) {
|
||||
throw new Error("Project transfer checksum verification failed.");
|
||||
}
|
||||
return transfer;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function insertProjectState(
|
||||
database: AppDatabase,
|
||||
state: ReturnType<typeof remapProjectState>
|
||||
) {
|
||||
const existing = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project transfer target already exists.");
|
||||
}
|
||||
database
|
||||
.insert(projects)
|
||||
.values({ ...state.project, currentRevision: 0 })
|
||||
.run();
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
insertMany(database, distributionBoards, state.distributionBoards);
|
||||
insertMany(database, circuitLists, state.circuitLists);
|
||||
insertMany(database, circuitSections, state.circuitSections);
|
||||
insertMany(
|
||||
database,
|
||||
circuits,
|
||||
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||
...circuit,
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
database: AppDatabase,
|
||||
table: TTable,
|
||||
values: unknown[]
|
||||
) {
|
||||
if (values.length > 0) {
|
||||
database.insert(table).values(values as never).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
type ElectricalPhaseType,
|
||||
type ProjectVoltageSettings,
|
||||
} from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export function readProjectVoltageSettings(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
): ProjectVoltageSettings {
|
||||
const project = database
|
||||
.select({
|
||||
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export function resolveProjectDeviceVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
phaseType: ElectricalPhaseType
|
||||
) {
|
||||
return resolveProjectVoltage(
|
||||
phaseType,
|
||||
readProjectVoltageSettings(database, projectId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCircuitVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
sectionId: string,
|
||||
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
|
||||
) {
|
||||
const section = database
|
||||
.select({ key: circuitSections.key })
|
||||
.from(circuitSections)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuitSections.circuitListId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, sectionId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!section) {
|
||||
throw new Error("Circuit section does not belong to project.");
|
||||
}
|
||||
return resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(section.key, devicePhaseTypes),
|
||||
readProjectVoltageSettings(database, projectId)
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDerivedCircuitVoltage(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string
|
||||
) {
|
||||
const circuit = database
|
||||
.select({
|
||||
id: circuits.id,
|
||||
sectionId: circuits.sectionId,
|
||||
})
|
||||
.from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, circuitId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!circuit) {
|
||||
throw new Error("Circuit does not belong to project.");
|
||||
}
|
||||
const devicePhaseTypes = database
|
||||
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||
.all()
|
||||
.map((row) => row.phaseType);
|
||||
const voltage = resolveCircuitVoltage(
|
||||
database,
|
||||
projectId,
|
||||
circuit.sectionId,
|
||||
devicePhaseTypes
|
||||
);
|
||||
database
|
||||
.update(circuits)
|
||||
.set({ voltage })
|
||||
.where(eq(circuits.id, circuitId))
|
||||
.run();
|
||||
return voltage;
|
||||
}
|
||||
|
||||
export function updateAllDerivedProjectVoltages(
|
||||
database: AppDatabase,
|
||||
projectId: string
|
||||
) {
|
||||
const settings = readProjectVoltageSettings(database, projectId);
|
||||
const devices = database
|
||||
.select({
|
||||
id: projectDevices.id,
|
||||
phaseType: projectDevices.phaseType,
|
||||
})
|
||||
.from(projectDevices)
|
||||
.where(eq(projectDevices.projectId, projectId))
|
||||
.all();
|
||||
for (const device of devices) {
|
||||
if (
|
||||
device.phaseType !== "single_phase" &&
|
||||
device.phaseType !== "three_phase"
|
||||
) {
|
||||
throw new Error("Persisted project-device phase type is invalid.");
|
||||
}
|
||||
database
|
||||
.update(projectDevices)
|
||||
.set({
|
||||
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||
})
|
||||
.where(eq(projectDevices.id, device.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
const projectCircuits = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.all();
|
||||
for (const circuit of projectCircuits) {
|
||||
updateDerivedCircuitVoltage(database, projectId, circuit.id);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { projects } from "../schema/projects.js";
|
||||
import type {
|
||||
CreateProjectInput,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export class ProjectRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
@@ -20,8 +21,15 @@ export class ProjectRepository {
|
||||
const project = {
|
||||
id,
|
||||
name: input.name,
|
||||
internalProjectNumber: input.internalProjectNumber ?? null,
|
||||
externalProjectNumber: input.externalProjectNumber ?? null,
|
||||
buildingOwner: input.buildingOwner ?? null,
|
||||
description: input.description ?? null,
|
||||
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
||||
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
||||
enabledDistributionBoardSupplyTypes: [
|
||||
...defaultDistributionBoardSupplyTypes,
|
||||
],
|
||||
currentRevision: 0,
|
||||
};
|
||||
await db.insert(projects).values(project);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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";
|
||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const distributionBoards = sqliteTable("distribution_boards", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -7,5 +9,12 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
floorId: text("floor_id").references(() => floors.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
|
||||
simultaneityFactor: real("simultaneity_factor")
|
||||
.notNull()
|
||||
.default(1),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const projects = sqliteTable("projects", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
internalProjectNumber: text("internal_project_number"),
|
||||
externalProjectNumber: text("external_project_number"),
|
||||
buildingOwner: text("building_owner"),
|
||||
description: text("description"),
|
||||
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
||||
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
||||
enabledDistributionBoardSupplyTypes: text(
|
||||
"enabled_distribution_board_supply_types",
|
||||
{ mode: "json" }
|
||||
)
|
||||
.$type<DistributionBoardSupplyType[]>()
|
||||
.notNull()
|
||||
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||
currentRevision: integer("current_revision").notNull().default(0),
|
||||
});
|
||||
|
||||
@@ -15,3 +15,37 @@ export function calculateCircuitTotalPower(
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateSectionTotalPower(
|
||||
circuits: Array<{ circuitTotalPower: number }>
|
||||
): number {
|
||||
return circuits.reduce(
|
||||
(sum, circuit) => sum + circuit.circuitTotalPower,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateDistributionBoardTotalPower(
|
||||
sections: Array<{ sectionTotalPower: number }>
|
||||
): number {
|
||||
return sections.reduce(
|
||||
(sum, section) => sum + section.sectionTotalPower,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function applyDistributionBoardSimultaneityFactor(
|
||||
totalPower: number,
|
||||
simultaneityFactor: number
|
||||
): number {
|
||||
if (
|
||||
!Number.isFinite(simultaneityFactor) ||
|
||||
simultaneityFactor < 0 ||
|
||||
simultaneityFactor > 1
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board simultaneity factor must be between zero and one."
|
||||
);
|
||||
}
|
||||
return totalPower * simultaneityFactor;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,18 @@ export interface CircuitTreeSectionBlock {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
sectionTotalPower: number;
|
||||
circuits: CircuitTreeCircuit[];
|
||||
}
|
||||
|
||||
export interface CircuitTreeResponse {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardUpdateCommandType =
|
||||
"distribution-board.update" as const;
|
||||
export const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
|
||||
|
||||
export interface DistributionBoardUpdateValues {
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
simultaneityFactor: number;
|
||||
}
|
||||
|
||||
export type DistributionBoardUpdateField =
|
||||
keyof DistributionBoardUpdateValues;
|
||||
export type DistributionBoardUpdatePatch =
|
||||
Partial<DistributionBoardUpdateValues>;
|
||||
|
||||
export interface DistributionBoardUpdateFieldChange<
|
||||
TField extends DistributionBoardUpdateField =
|
||||
DistributionBoardUpdateField,
|
||||
> {
|
||||
field: TField;
|
||||
value: DistributionBoardUpdateValues[TField];
|
||||
}
|
||||
|
||||
export interface DistributionBoardUpdateCommandPayload {
|
||||
distributionBoardId: string;
|
||||
changes: DistributionBoardUpdateFieldChange[];
|
||||
}
|
||||
|
||||
interface CurrentDistributionBoardUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
|
||||
type: typeof distributionBoardUpdateCommandType;
|
||||
}
|
||||
|
||||
interface LegacyDistributionBoardUpdateProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||
schemaVersion: typeof legacyDistributionBoardUpdateCommandSchemaVersion;
|
||||
type: typeof distributionBoardUpdateCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardUpdateProjectCommand =
|
||||
| CurrentDistributionBoardUpdateProjectCommand
|
||||
| LegacyDistributionBoardUpdateProjectCommand;
|
||||
|
||||
export function createDistributionBoardUpdateProjectCommand(
|
||||
distributionBoardId: string,
|
||||
patch: DistributionBoardUpdatePatch
|
||||
): CurrentDistributionBoardUpdateProjectCommand {
|
||||
const command: CurrentDistributionBoardUpdateProjectCommand = {
|
||||
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
|
||||
type: distributionBoardUpdateCommandType,
|
||||
payload: {
|
||||
distributionBoardId,
|
||||
changes: Object.entries(patch).map(([field, value]) => ({
|
||||
field: field as DistributionBoardUpdateField,
|
||||
value,
|
||||
})) as DistributionBoardUpdateFieldChange[],
|
||||
},
|
||||
};
|
||||
assertDistributionBoardUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardUpdateProjectCommand {
|
||||
if (
|
||||
(command.schemaVersion !==
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
|
||||
command.type !== distributionBoardUpdateCommandType ||
|
||||
!isPlainObject(command.payload)
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board update command.");
|
||||
}
|
||||
const { distributionBoardId, changes } = command.payload;
|
||||
if (
|
||||
typeof distributionBoardId !== "string" ||
|
||||
!distributionBoardId.trim() ||
|
||||
!Array.isArray(changes) ||
|
||||
changes.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board update command requires an id and changes."
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const change of changes) {
|
||||
if (
|
||||
!isPlainObject(change) ||
|
||||
(change.field !== "floorId" &&
|
||||
change.field !== "supplyType" &&
|
||||
(command.schemaVersion ===
|
||||
legacyDistributionBoardUpdateCommandSchemaVersion ||
|
||||
change.field !== "simultaneityFactor")) ||
|
||||
seen.has(change.field)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board update contains an invalid or duplicate field."
|
||||
);
|
||||
}
|
||||
if (change.field === "floorId") {
|
||||
if (
|
||||
change.value !== null &&
|
||||
(typeof change.value !== "string" || !change.value.trim())
|
||||
) {
|
||||
throw new Error("floorId must be a non-empty string or null.");
|
||||
}
|
||||
} else if (change.field === "supplyType") {
|
||||
if (
|
||||
change.value !== null &&
|
||||
!distributionBoardSupplyTypes.includes(
|
||||
change.value as DistributionBoardSupplyType
|
||||
)
|
||||
) {
|
||||
throw new Error("supplyType is invalid.");
|
||||
}
|
||||
} else if (
|
||||
typeof change.value !== "number" ||
|
||||
!Number.isFinite(change.value) ||
|
||||
change.value < 0 ||
|
||||
change.value > 1
|
||||
) {
|
||||
throw new Error(
|
||||
"simultaneityFactor must be between zero and one."
|
||||
);
|
||||
}
|
||||
seen.add(change.field);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardInsertCommandType =
|
||||
"distribution-board.insert" as const;
|
||||
export const distributionBoardDeleteCommandType =
|
||||
"distribution-board.delete" as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||
|
||||
export const defaultCircuitSectionDefinitions = [
|
||||
{
|
||||
@@ -34,11 +39,23 @@ export const defaultCircuitSectionDefinitions = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface LegacyDistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
};
|
||||
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||
sections: DistributionBoardStructureSnapshot["sections"];
|
||||
}
|
||||
|
||||
export interface DistributionBoardStructureSnapshot {
|
||||
distributionBoard: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
};
|
||||
circuitList: {
|
||||
id: string;
|
||||
@@ -56,29 +73,52 @@ export interface DistributionBoardStructureSnapshot {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DistributionBoardStructureCommandPayload {
|
||||
structure: DistributionBoardStructureSnapshot;
|
||||
interface DistributionBoardStructureCommandPayload<
|
||||
TStructure = DistributionBoardStructureSnapshot,
|
||||
> {
|
||||
structure: TStructure;
|
||||
}
|
||||
|
||||
interface CurrentDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
interface LegacyDistributionBoardStructureProjectCommand
|
||||
extends SerializedProjectCommand<
|
||||
DistributionBoardStructureCommandPayload<LegacyDistributionBoardStructureSnapshot>
|
||||
> {
|
||||
schemaVersion: typeof legacyDistributionBoardStructureCommandSchemaVersion;
|
||||
type:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardInsertProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardInsertCommandType;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDeleteProjectCommand
|
||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||
extends CurrentDistributionBoardStructureProjectCommand {
|
||||
type: typeof distributionBoardDeleteCommandType;
|
||||
}
|
||||
|
||||
export type DistributionBoardStructureProjectCommand =
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
| DistributionBoardDeleteProjectCommand
|
||||
| LegacyDistributionBoardStructureProjectCommand;
|
||||
|
||||
export function createDistributionBoardStructureSnapshot(
|
||||
projectId: string,
|
||||
name: string
|
||||
name: string,
|
||||
input: {
|
||||
floorId?: string | null;
|
||||
supplyType?: DistributionBoardSupplyType | null;
|
||||
} = {}
|
||||
): DistributionBoardStructureSnapshot {
|
||||
const normalizedName = name.trim();
|
||||
assertNonEmptyString(projectId, "projectId");
|
||||
@@ -89,6 +129,8 @@ export function createDistributionBoardStructureSnapshot(
|
||||
id: structureId,
|
||||
projectId,
|
||||
name: normalizedName,
|
||||
floorId: input.floorId ?? null,
|
||||
supplyType: input.supplyType ?? null,
|
||||
},
|
||||
circuitList: {
|
||||
id: structureId,
|
||||
@@ -132,7 +174,7 @@ export function createDistributionBoardDeleteProjectCommand(
|
||||
|
||||
export function assertDistributionBoardInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardInsertProjectCommand {
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardInsertCommandType
|
||||
@@ -141,23 +183,88 @@ export function assertDistributionBoardInsertProjectCommand(
|
||||
|
||||
export function assertDistributionBoardDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is DistributionBoardDeleteProjectCommand {
|
||||
): asserts command is DistributionBoardStructureProjectCommand {
|
||||
assertDistributionBoardStructureProjectCommand(
|
||||
command,
|
||||
distributionBoardDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDistributionBoardStructureProjectCommand(
|
||||
command: DistributionBoardStructureProjectCommand
|
||||
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
const structure: DistributionBoardStructureSnapshot = {
|
||||
...command.payload.structure,
|
||||
distributionBoard: {
|
||||
...command.payload.structure.distributionBoard,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
},
|
||||
};
|
||||
return command.type === distributionBoardInsertCommandType
|
||||
? createDistributionBoardInsertProjectCommand(structure)
|
||||
: createDistributionBoardDeleteProjectCommand(structure);
|
||||
}
|
||||
return command as
|
||||
| DistributionBoardInsertProjectCommand
|
||||
| DistributionBoardDeleteProjectCommand;
|
||||
}
|
||||
|
||||
export function assertDistributionBoardStructureSnapshot(
|
||||
structure: unknown
|
||||
): asserts structure is DistributionBoardStructureSnapshot {
|
||||
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
if (
|
||||
command.schemaVersion ===
|
||||
legacyDistributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshotVersion(
|
||||
command.payload.structure,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureSnapshotVersion(
|
||||
structure: unknown,
|
||||
legacy: boolean
|
||||
) {
|
||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||
throw new Error("Distribution-board structure is invalid.");
|
||||
}
|
||||
const { distributionBoard, circuitList, sections } = structure;
|
||||
if (
|
||||
!isPlainObject(distributionBoard) ||
|
||||
Object.keys(distributionBoard).length !== 3 ||
|
||||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
|
||||
!isPlainObject(circuitList) ||
|
||||
Object.keys(circuitList).length !== 4 ||
|
||||
!Array.isArray(sections) ||
|
||||
@@ -165,8 +272,15 @@ export function assertDistributionBoardStructureSnapshot(
|
||||
) {
|
||||
throw new Error("Distribution-board structure is incomplete.");
|
||||
}
|
||||
for (const [field, value] of Object.entries(distributionBoard)) {
|
||||
assertNonEmptyString(value, `distributionBoard.${field}`);
|
||||
for (const field of ["id", "projectId", "name"] as const) {
|
||||
assertNonEmptyString(
|
||||
distributionBoard[field],
|
||||
`distributionBoard.${field}`
|
||||
);
|
||||
}
|
||||
if (!legacy) {
|
||||
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||
assertNullableSupplyType(distributionBoard.supplyType);
|
||||
}
|
||||
for (const [field, value] of Object.entries(circuitList)) {
|
||||
assertNonEmptyString(value, `circuitList.${field}`);
|
||||
@@ -174,22 +288,16 @@ export function assertDistributionBoardStructureSnapshot(
|
||||
if (
|
||||
circuitList.projectId !== distributionBoard.projectId ||
|
||||
circuitList.distributionBoardId !== distributionBoard.id ||
|
||||
circuitList.name !==
|
||||
`${distributionBoard.name} Stromkreisliste`
|
||||
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution board and circuit list are inconsistent."
|
||||
);
|
||||
throw new Error("Distribution board and circuit list are inconsistent.");
|
||||
}
|
||||
|
||||
const sectionIds = new Set<string>();
|
||||
for (let index = 0; index < sections.length; index += 1) {
|
||||
const section = sections[index];
|
||||
const expected = defaultCircuitSectionDefinitions[index];
|
||||
if (
|
||||
!isPlainObject(section) ||
|
||||
Object.keys(section).length !== 6
|
||||
) {
|
||||
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
||||
throw new Error("Default circuit section is invalid.");
|
||||
}
|
||||
assertNonEmptyString(section.id, "section.id");
|
||||
@@ -211,22 +319,23 @@ export function assertDistributionBoardStructureSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function assertDistributionBoardStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof distributionBoardInsertCommandType
|
||||
| typeof distributionBoardDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
distributionBoardStructureCommandSchemaVersion ||
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported distribution-board structure command.");
|
||||
function assertNullableId(value: unknown, field: string) {
|
||||
if (value !== null) {
|
||||
assertNonEmptyString(value, field);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableSupplyType(
|
||||
value: unknown
|
||||
): asserts value is DistributionBoardSupplyType | null {
|
||||
if (
|
||||
value !== null &&
|
||||
!distributionBoardSupplyTypes.includes(
|
||||
value as DistributionBoardSupplyType
|
||||
)
|
||||
) {
|
||||
throw new Error("distributionBoard.supplyType is invalid.");
|
||||
}
|
||||
assertDistributionBoardStructureSnapshot(command.payload.structure);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import {
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
|
||||
export const projectSettingsUpdateCommandType =
|
||||
"project.update-settings" as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||
|
||||
export interface ProjectSettingsValues {
|
||||
export interface LegacyProjectSettingsValues {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
export interface ProjectSettingsUpdateProjectCommand
|
||||
export interface PreviousProjectSettingsValues extends LegacyProjectSettingsValues {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
buildingOwner: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectSettingsValues extends PreviousProjectSettingsValues {
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
export interface LegacyProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
|
||||
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface CurrentProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectSettingsValues> {
|
||||
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface PreviousProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export type ProjectSettingsUpdateProjectCommand =
|
||||
| LegacyProjectSettingsUpdateProjectCommand
|
||||
| PreviousProjectSettingsUpdateProjectCommand
|
||||
| CurrentProjectSettingsUpdateProjectCommand;
|
||||
|
||||
export function createProjectSettingsUpdateProjectCommand(
|
||||
values: ProjectSettingsValues
|
||||
): ProjectSettingsUpdateProjectCommand {
|
||||
const command: ProjectSettingsUpdateProjectCommand = {
|
||||
): CurrentProjectSettingsUpdateProjectCommand {
|
||||
const command: CurrentProjectSettingsUpdateProjectCommand = {
|
||||
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
||||
type: projectSettingsUpdateCommandType,
|
||||
payload: { ...values },
|
||||
@@ -31,16 +66,100 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectSettingsUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
|
||||
command.type !== projectSettingsUpdateCommandType
|
||||
command.type !== projectSettingsUpdateCommandType ||
|
||||
(command.schemaVersion !==
|
||||
legacyProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !==
|
||||
previousProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
|
||||
) {
|
||||
throw new Error("Unsupported project settings update command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
);
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||
assertLegacyValues(command.payload);
|
||||
return;
|
||||
}
|
||||
const expectedKeyCount =
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||
? 7
|
||||
: 8;
|
||||
if (
|
||||
!isPlainObject(command.payload) ||
|
||||
!isTrimmedString(command.payload.name, 1, 200) ||
|
||||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
|
||||
!isNullableTrimmedString(command.payload.externalProjectNumber, 100) ||
|
||||
!isNullableTrimmedString(command.payload.buildingOwner, 200) ||
|
||||
!isNullableTrimmedString(command.payload.description, 2000) ||
|
||||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
|
||||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
Object.keys(command.payload).length !== expectedKeyCount
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
);
|
||||
}
|
||||
if (
|
||||
command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
||||
!isValidSupplyTypes(
|
||||
command.payload.enabledDistributionBoardSupplyTypes
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid supply types."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectSettingsValues(
|
||||
command: ProjectSettingsUpdateProjectCommand,
|
||||
current: ProjectSettingsValues
|
||||
): ProjectSettingsValues {
|
||||
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||
return {
|
||||
...current,
|
||||
...command.payload,
|
||||
};
|
||||
}
|
||||
if (
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||
) {
|
||||
return {
|
||||
...command.payload,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
};
|
||||
}
|
||||
return command.payload;
|
||||
}
|
||||
|
||||
function isValidSupplyTypes(
|
||||
value: unknown
|
||||
): value is DistributionBoardSupplyType[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
typeof entry === "string" &&
|
||||
distributionBoardSupplyTypes.includes(
|
||||
entry as DistributionBoardSupplyType
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function assertLegacyValues(
|
||||
payload: Record<string, unknown>
|
||||
): asserts payload is Record<string, unknown> & LegacyProjectSettingsValues {
|
||||
if (
|
||||
!isPositiveFiniteNumber(payload.singlePhaseVoltageV) ||
|
||||
!isPositiveFiniteNumber(payload.threePhaseVoltageV) ||
|
||||
Object.keys(payload).length !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid voltages."
|
||||
@@ -59,3 +178,26 @@ function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTrimmedString(
|
||||
value: unknown,
|
||||
minimumLength: number,
|
||||
maximumLength: number
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value === value.trim() &&
|
||||
value.length >= minimumLength &&
|
||||
value.length <= maximumLength
|
||||
);
|
||||
}
|
||||
|
||||
function isNullableTrimmedString(
|
||||
value: unknown,
|
||||
maximumLength: number
|
||||
): value is string | null {
|
||||
return (
|
||||
value === null ||
|
||||
(isTrimmedString(value, 1, maximumLength))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defaultDistributionBoardSupplyTypes,
|
||||
distributionBoardSupplyTypes,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
normalizeKnownElectricalPhaseType,
|
||||
} from "../services/project-voltage.service.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 1 as const;
|
||||
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
|
||||
export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
const finiteNumberSchema = z.number().finite();
|
||||
|
||||
const projectSchema = z
|
||||
const legacyProjectSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
@@ -15,7 +29,21 @@ const projectSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardSchema = z
|
||||
const projectSchema = legacyProjectSchema.extend({
|
||||
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||
buildingOwner: z.string().trim().min(1).max(200).nullable(),
|
||||
description: z.string().trim().min(1).max(2000).nullable(),
|
||||
});
|
||||
|
||||
const currentProjectSchema = projectSchema.extend({
|
||||
enabledDistributionBoardSupplyTypes: z
|
||||
.array(z.enum(distributionBoardSupplyTypes))
|
||||
.min(1)
|
||||
.refine((values) => new Set(values).size === values.length),
|
||||
});
|
||||
|
||||
const legacyDistributionBoardSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
@@ -23,6 +51,19 @@ const distributionBoardSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardSchema = legacyDistributionBoardSchema
|
||||
.extend({
|
||||
floorId: idSchema.nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const currentDistributionBoardSchema = distributionBoardSchema
|
||||
.extend({
|
||||
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const circuitListSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
@@ -132,20 +173,66 @@ const roomSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const projectStateSnapshotSchema = z
|
||||
const commonProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
};
|
||||
|
||||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: legacyProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(
|
||||
distributionBoardProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const supplyTypesProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||
project: currentProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...commonProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const voltageProjectStateSnapshotSchema =
|
||||
supplyTypesProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(
|
||||
voltageProjectStateSnapshotSchemaVersion
|
||||
),
|
||||
});
|
||||
|
||||
export const projectStateSnapshotSchema =
|
||||
voltageProjectStateSnapshotSchema.extend({
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||
});
|
||||
|
||||
export type ProjectStateSnapshot = z.infer<
|
||||
typeof projectStateSnapshotSchema
|
||||
>;
|
||||
@@ -153,11 +240,193 @@ export type ProjectStateSnapshot = z.infer<
|
||||
export function parseProjectStateSnapshot(
|
||||
value: unknown
|
||||
): ProjectStateSnapshot {
|
||||
const snapshot = projectStateSnapshotSchema.parse(value);
|
||||
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||
const parsedSnapshot =
|
||||
version === legacyProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
upgradeLegacyProjectStateSnapshot(
|
||||
legacyProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === previousProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
upgradePreviousProjectStateSnapshot(
|
||||
previousProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
upgradeDistributionBoardProjectStateSnapshot(
|
||||
distributionBoardProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
)
|
||||
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
upgradeSupplyTypesProjectStateSnapshot(
|
||||
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
)
|
||||
: version === voltageProjectStateSnapshotSchemaVersion
|
||||
? upgradeVoltageProjectStateSnapshot(
|
||||
voltageProjectStateSnapshotSchema.parse(value)
|
||||
)
|
||||
: projectStateSnapshotSchema.parse(value);
|
||||
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||
assertProjectStateSnapshotRelations(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function normalizeSnapshotPhaseTypes(
|
||||
snapshot: ProjectStateSnapshot
|
||||
): ProjectStateSnapshot {
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((section) => [section.id, section])
|
||||
);
|
||||
const projectDeviceById = new Map(
|
||||
snapshot.projectDevices.map((device) => [device.id, device])
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
circuits: snapshot.circuits.map((circuit) => {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
const deviceRows = circuit.deviceRows.map((row) => {
|
||||
const normalizedPhaseType =
|
||||
normalizeKnownElectricalPhaseType(row.phaseType);
|
||||
const linkedPhaseType = row.linkedProjectDeviceId
|
||||
? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType
|
||||
: undefined;
|
||||
return {
|
||||
...row,
|
||||
phaseType:
|
||||
normalizedPhaseType ??
|
||||
linkedPhaseType ??
|
||||
(section?.key === "three_phase"
|
||||
? "three_phase"
|
||||
: "single_phase"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...circuit,
|
||||
voltage: section
|
||||
? resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(
|
||||
section.key,
|
||||
deviceRows.map((row) => row.phaseType)
|
||||
),
|
||||
snapshot.project
|
||||
)
|
||||
: circuit.voltage,
|
||||
deviceRows,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeLegacyProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
|
||||
): z.infer<typeof previousProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: previousProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...snapshot.project,
|
||||
internalProjectNumber: null,
|
||||
externalProjectNumber: null,
|
||||
buildingOwner: null,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upgradePreviousProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof previousProjectStateSnapshotSchema>
|
||||
): z.infer<typeof distributionBoardProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: distributionBoardProjectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
floorId: null,
|
||||
supplyType: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeDistributionBoardProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
|
||||
): z.infer<typeof supplyTypesProjectStateSnapshotSchema> {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: supplyTypesProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...snapshot.project,
|
||||
enabledDistributionBoardSupplyTypes: [
|
||||
...defaultDistributionBoardSupplyTypes,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeSupplyTypesProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema>
|
||||
): z.infer<typeof voltageProjectStateSnapshotSchema> {
|
||||
const settings = snapshot.project;
|
||||
const sectionById = new Map(
|
||||
snapshot.circuitSections.map((section) => [section.id, section])
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: voltageProjectStateSnapshotSchemaVersion,
|
||||
projectDevices: snapshot.projectDevices.map((device) => ({
|
||||
...device,
|
||||
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||
})),
|
||||
circuits: snapshot.circuits.map((circuit) => {
|
||||
const section = sectionById.get(circuit.sectionId);
|
||||
if (!section) {
|
||||
return circuit;
|
||||
}
|
||||
const phaseType = resolveCircuitPhaseType(
|
||||
section.key,
|
||||
circuit.deviceRows.map((row) => row.phaseType)
|
||||
);
|
||||
return {
|
||||
...circuit,
|
||||
voltage: resolveProjectVoltage(phaseType, settings),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeVoltageProjectStateSnapshot(
|
||||
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||
): ProjectStateSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
simultaneityFactor: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function serializeProjectStateSnapshot(
|
||||
snapshot: ProjectStateSnapshot
|
||||
): string {
|
||||
@@ -199,6 +468,23 @@ function assertProjectStateSnapshotRelations(
|
||||
|
||||
for (const board of snapshot.distributionBoards) {
|
||||
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||
if (board.floorId !== null) {
|
||||
assertReference(
|
||||
floorIds,
|
||||
board.floorId,
|
||||
"distribution board floor"
|
||||
);
|
||||
}
|
||||
if (
|
||||
board.supplyType !== null &&
|
||||
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
|
||||
board.supplyType
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot distribution board uses a disabled supply type."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const list of snapshot.circuitLists) {
|
||||
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
||||
@@ -217,6 +503,14 @@ function assertProjectStateSnapshotRelations(
|
||||
}
|
||||
for (const device of snapshot.projectDevices) {
|
||||
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||
if (
|
||||
device.voltageV !==
|
||||
resolveProjectVoltage(device.phaseType, snapshot.project)
|
||||
) {
|
||||
throw new Error(
|
||||
"Snapshot project device voltage must match project settings."
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const floor of snapshot.floors) {
|
||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||
@@ -247,6 +541,18 @@ function assertProjectStateSnapshotRelations(
|
||||
"Snapshot circuit reserve state must match its device rows."
|
||||
);
|
||||
}
|
||||
const expectedVoltage = resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(
|
||||
section.key,
|
||||
circuit.deviceRows.map((row) => row.phaseType)
|
||||
),
|
||||
snapshot.project
|
||||
);
|
||||
if (circuit.voltage !== expectedVoltage) {
|
||||
throw new Error(
|
||||
"Snapshot circuit voltage must match project settings."
|
||||
);
|
||||
}
|
||||
for (const row of circuit.deviceRows) {
|
||||
if (row.circuitId !== circuit.id) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
|
||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||
export const projectTransferFormatVersion = 1 as const;
|
||||
|
||||
export interface ProjectTransferEnvelope {
|
||||
format: typeof projectTransferFormat;
|
||||
formatVersion: typeof projectTransferFormatVersion;
|
||||
exportedAtIso: string;
|
||||
payloadSha256: string;
|
||||
projectState: ProjectStateSnapshot;
|
||||
}
|
||||
|
||||
export function createProjectTransferEnvelope(
|
||||
exportedAtIso: string,
|
||||
payloadSha256: string,
|
||||
projectState: ProjectStateSnapshot
|
||||
): ProjectTransferEnvelope {
|
||||
return parseProjectTransferEnvelope({
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso,
|
||||
payloadSha256,
|
||||
projectState,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectTransferEnvelope(
|
||||
value: unknown
|
||||
): ProjectTransferEnvelope {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error("Project transfer file must be an object.");
|
||||
}
|
||||
if (
|
||||
value.format !== projectTransferFormat ||
|
||||
value.formatVersion !== projectTransferFormatVersion
|
||||
) {
|
||||
throw new Error("Project transfer format is not supported.");
|
||||
}
|
||||
if (
|
||||
typeof value.exportedAtIso !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.exportedAtIso))
|
||||
) {
|
||||
throw new Error("Project transfer timestamp is invalid.");
|
||||
}
|
||||
if (
|
||||
typeof value.payloadSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
|
||||
) {
|
||||
throw new Error("Project transfer checksum is invalid.");
|
||||
}
|
||||
const projectState = parseProjectStateSnapshot(value.projectState);
|
||||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
throw new Error("Project transfer snapshot version is not supported.");
|
||||
}
|
||||
if (Object.keys(value).length !== 5) {
|
||||
throw new Error("Project transfer file contains unknown fields.");
|
||||
}
|
||||
return {
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso: value.exportedAtIso,
|
||||
payloadSha256: value.payloadSha256,
|
||||
projectState,
|
||||
};
|
||||
}
|
||||
|
||||
export function remapProjectState(
|
||||
source: ProjectStateSnapshot,
|
||||
targetProjectId: string,
|
||||
createId: () => string,
|
||||
name: string = source.project.name
|
||||
): ProjectStateSnapshot {
|
||||
const boardIds = idMap(source.distributionBoards, createId);
|
||||
const listIds = idMap(source.circuitLists, createId);
|
||||
const sectionIds = idMap(source.circuitSections, createId);
|
||||
const circuitIds = idMap(source.circuits, createId);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
const rowIds = new Map(
|
||||
source.circuits.flatMap((circuit) =>
|
||||
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||
)
|
||||
);
|
||||
return parseProjectStateSnapshot({
|
||||
...source,
|
||||
project: { ...source.project, id: targetProjectId, name },
|
||||
distributionBoards: source.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
id: requiredId(boardIds, board.id),
|
||||
projectId: targetProjectId,
|
||||
floorId:
|
||||
board.floorId === null
|
||||
? null
|
||||
: requiredId(floorIds, board.floorId),
|
||||
})),
|
||||
circuitLists: source.circuitLists.map((list) => ({
|
||||
...list,
|
||||
id: requiredId(listIds, list.id),
|
||||
projectId: targetProjectId,
|
||||
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
|
||||
})),
|
||||
circuitSections: source.circuitSections.map((section) => ({
|
||||
...section,
|
||||
id: requiredId(sectionIds, section.id),
|
||||
circuitListId: requiredId(listIds, section.circuitListId),
|
||||
})),
|
||||
circuits: source.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
id: requiredId(circuitIds, circuit.id),
|
||||
circuitListId: requiredId(listIds, circuit.circuitListId),
|
||||
sectionId: requiredId(sectionIds, circuit.sectionId),
|
||||
deviceRows: circuit.deviceRows.map((row) => ({
|
||||
...row,
|
||||
id: requiredId(rowIds, row.id),
|
||||
circuitId: requiredId(circuitIds, circuit.id),
|
||||
linkedProjectDeviceId:
|
||||
row.linkedProjectDeviceId === null
|
||||
? null
|
||||
: requiredId(deviceIds, row.linkedProjectDeviceId),
|
||||
legacyConsumerId: null,
|
||||
roomId:
|
||||
row.roomId === null ? null : requiredId(roomIds, row.roomId),
|
||||
})),
|
||||
})),
|
||||
projectDevices: source.projectDevices.map((device) => ({
|
||||
...device,
|
||||
id: requiredId(deviceIds, device.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
floors: source.floors.map((floor) => ({
|
||||
...floor,
|
||||
id: requiredId(floorIds, floor.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
rooms: source.rooms.map((room) => ({
|
||||
...room,
|
||||
id: requiredId(roomIds, room.id),
|
||||
projectId: targetProjectId,
|
||||
floorId:
|
||||
room.floorId === null ? null : requiredId(floorIds, room.floorId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function idMap(
|
||||
entries: ReadonlyArray<{ id: string }>,
|
||||
createId: () => string
|
||||
) {
|
||||
return new Map(entries.map((entry) => [entry.id, createId()]));
|
||||
}
|
||||
|
||||
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||
const targetId = ids.get(sourceId);
|
||||
if (!targetId) {
|
||||
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
|
||||
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
@@ -23,4 +24,15 @@ export interface DistributionBoardStructureProjectCommandStore {
|
||||
execute(
|
||||
input: ExecuteDistributionBoardStructureCommandInput
|
||||
): ExecutedDistributionBoardStructureCommand;
|
||||
executeUpdate(
|
||||
input: Omit<
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
"command"
|
||||
> & {
|
||||
command: DistributionBoardUpdateProjectCommand;
|
||||
}
|
||||
): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: DistributionBoardUpdateProjectCommand;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface ProjectHistoryStore {
|
||||
listRevisions(
|
||||
input: ListProjectRevisionsInput
|
||||
): ProjectRevisionPage | null;
|
||||
listRevisionsByNumbers(
|
||||
projectId: string,
|
||||
revisionNumbers: number[]
|
||||
): ProjectRevisionSummary[];
|
||||
getNextCommand(
|
||||
projectId: string,
|
||||
direction: ProjectHistoryDirection
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferEnvelope } from "../models/project-transfer.model.js";
|
||||
|
||||
export interface PreparedProjectTransferReplace {
|
||||
command: ProjectStateRestoreCommand;
|
||||
}
|
||||
|
||||
export interface ProjectTransferStore {
|
||||
exportProject(projectId: string): ProjectTransferEnvelope | null;
|
||||
prepareReplace(
|
||||
projectId: string,
|
||||
transfer: unknown
|
||||
): PreparedProjectTransferReplace | null;
|
||||
importDuplicate(transfer: unknown): { projectId: string; name: string };
|
||||
}
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
circuitDeleteCommandType,
|
||||
circuitInsertCommandType,
|
||||
} from "../models/circuit-structure-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardUpdateProjectCommand,
|
||||
distributionBoardUpdateCommandType,
|
||||
} from "../models/distribution-board-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
@@ -285,6 +289,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case distributionBoardUpdateCommandType: {
|
||||
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||
return this.distributionBoardStructureStore.executeUpdate({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export type ElectricalPhaseType = "single_phase" | "three_phase";
|
||||
|
||||
export function isElectricalPhaseType(
|
||||
value: unknown
|
||||
): value is ElectricalPhaseType {
|
||||
return value === "single_phase" || value === "three_phase";
|
||||
}
|
||||
|
||||
export function normalizeKnownElectricalPhaseType(
|
||||
value: string | null
|
||||
): ElectricalPhaseType | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.replaceAll("-", "")
|
||||
.replaceAll("_", "");
|
||||
if (
|
||||
["1ph", "1phase", "1phasig", "einphasig", "singlephase"].includes(
|
||||
normalized
|
||||
)
|
||||
) {
|
||||
return "single_phase";
|
||||
}
|
||||
if (
|
||||
["3ph", "3phase", "3phasig", "dreiphasig", "threephase"].includes(
|
||||
normalized
|
||||
)
|
||||
) {
|
||||
return "three_phase";
|
||||
}
|
||||
throw new Error(`Unknown electrical phase type: ${value}`);
|
||||
}
|
||||
|
||||
export interface ProjectVoltageSettings {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
export function resolveProjectVoltage(
|
||||
phaseType: ElectricalPhaseType,
|
||||
settings: ProjectVoltageSettings
|
||||
) {
|
||||
return phaseType === "three_phase"
|
||||
? settings.threePhaseVoltageV
|
||||
: settings.singlePhaseVoltageV;
|
||||
}
|
||||
|
||||
export function resolveCircuitPhaseType(
|
||||
sectionKey: string,
|
||||
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
|
||||
): ElectricalPhaseType {
|
||||
if (sectionKey === "three_phase") {
|
||||
return "three_phase";
|
||||
}
|
||||
if (sectionKey === "lighting" || sectionKey === "single_phase") {
|
||||
return "single_phase";
|
||||
}
|
||||
const assignedPhaseTypes = devicePhaseTypes.filter(
|
||||
isElectricalPhaseType
|
||||
);
|
||||
return assignedPhaseTypes.length > 0 &&
|
||||
assignedPhaseTypes.every((phaseType) => phaseType === "three_phase")
|
||||
? "three_phase"
|
||||
: "single_phase";
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
inferProjectDeviceSectionKey,
|
||||
isProjectDevicePlacementValid,
|
||||
} from "../../domain/services/project-device-placement.service";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
} from "../../domain/services/project-voltage.service";
|
||||
import {
|
||||
getInsertionSortOrder,
|
||||
resolveGridInsertionIntent,
|
||||
@@ -21,7 +25,13 @@ import {
|
||||
buildDeviceRowEditPatch,
|
||||
defaultVisibleColumnKeys,
|
||||
deviceFieldKeys,
|
||||
formatPhaseTypeLabel,
|
||||
formatValue,
|
||||
getCircuitSectionLabel,
|
||||
getProjectColumnLayoutStorageKey,
|
||||
isGridEditorControlTarget,
|
||||
normalizeColumnOrder,
|
||||
parseStoredColumnLayout,
|
||||
} from "../utils/circuit-grid-model";
|
||||
import {
|
||||
buildCircuitDeviceRowInsertSnapshot,
|
||||
@@ -125,7 +135,8 @@ type CircuitReorderDropIntent =
|
||||
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
||||
| { kind: "section-end"; sectionId: string; valid: boolean };
|
||||
|
||||
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
||||
const LEGACY_COLUMN_LAYOUT_STORAGE_KEY =
|
||||
"circuitTreeEditor.columnLayout.v1";
|
||||
|
||||
function normalizeUiError(err: unknown): string {
|
||||
const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen.";
|
||||
@@ -149,14 +160,8 @@ function normalizeUiError(err: unknown): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatPhaseTypeLabel(value: string): string {
|
||||
if (value === "three_phase") {
|
||||
return "Dreiphasig";
|
||||
}
|
||||
if (value === "single_phase") {
|
||||
return "Einphasig";
|
||||
}
|
||||
return value;
|
||||
function getFullColumnLabel(column: ColumnDef): string {
|
||||
return column.fullLabel ?? column.label;
|
||||
}
|
||||
|
||||
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
||||
@@ -196,6 +201,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||
useState(false);
|
||||
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
||||
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
||||
@@ -220,6 +227,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const [filterValueSearch, setFilterValueSearch] = useState("");
|
||||
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
|
||||
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
|
||||
const [
|
||||
loadedColumnLayoutProjectId,
|
||||
setLoadedColumnLayoutProjectId,
|
||||
] = useState<string | null>(null);
|
||||
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
||||
const [columnSearch, setColumnSearch] = useState("");
|
||||
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
|
||||
@@ -229,7 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
|
||||
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | HTMLSelectElement | null>(null);
|
||||
const focusTokenRef = useRef(1);
|
||||
const projectRevisionRef = useRef<number | null>(null);
|
||||
|
||||
@@ -265,13 +276,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColumnOrder(keys: CellKey[]) {
|
||||
const unique = [...new Set(keys)];
|
||||
const allKeys = allColumns.map((column) => column.key);
|
||||
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
|
||||
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
|
||||
}
|
||||
|
||||
// Initial/identity-change tree load for current route context.
|
||||
useEffect(() => {
|
||||
void loadTree({ showLoading: true });
|
||||
@@ -290,32 +294,48 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadedColumnLayoutProjectId(null);
|
||||
setColumnOrder(allColumns.map((column) => column.key));
|
||||
setVisibleColumnKeys(defaultVisibleColumnKeys);
|
||||
try {
|
||||
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
const parsed = parseStoredColumnLayout(
|
||||
localStorage.getItem(
|
||||
getProjectColumnLayoutStorageKey(projectId)
|
||||
) ??
|
||||
localStorage.getItem(
|
||||
LEGACY_COLUMN_LAYOUT_STORAGE_KEY
|
||||
)
|
||||
);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] };
|
||||
const validKeys = new Set(allColumns.map((column) => column.key));
|
||||
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
||||
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
||||
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
|
||||
return;
|
||||
}
|
||||
setColumnOrder(normalizeColumnOrder(parsedOrder));
|
||||
setVisibleColumnKeys(parsedVisible);
|
||||
setColumnOrder(parsed.order);
|
||||
setVisibleColumnKeys(parsed.visible);
|
||||
} catch {
|
||||
// ignore invalid local storage and use defaults
|
||||
} finally {
|
||||
setLoadedColumnLayoutProjectId(projectId);
|
||||
}
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedColumnLayoutProjectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
order: columnOrder,
|
||||
visible: visibleColumnKeys,
|
||||
};
|
||||
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload));
|
||||
}, [columnOrder, visibleColumnKeys]);
|
||||
localStorage.setItem(
|
||||
getProjectColumnLayoutStorageKey(projectId),
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
}, [
|
||||
columnOrder,
|
||||
loadedColumnLayoutProjectId,
|
||||
projectId,
|
||||
visibleColumnKeys,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const visible = new Set(visibleColumnKeys);
|
||||
@@ -443,7 +463,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
</button>
|
||||
) : null}
|
||||
{activeColumnFilters.map(({ column, values }) => {
|
||||
const shownValues = values.slice(0, 2).join(", ");
|
||||
const shownValues = values
|
||||
.slice(0, 2)
|
||||
.map((value) =>
|
||||
column.key === "phaseType"
|
||||
? formatPhaseTypeLabel(value)
|
||||
: value
|
||||
)
|
||||
.join(", ");
|
||||
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
|
||||
return (
|
||||
<button
|
||||
@@ -465,6 +492,51 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
);
|
||||
}
|
||||
|
||||
function renderDistributionPowerSummary() {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="distribution-power-summary"
|
||||
aria-label="Leistungszusammenfassung des Verteilers"
|
||||
>
|
||||
<div>
|
||||
<span>Gesamtleistung Verteiler</span>
|
||||
<strong>
|
||||
{formatValue(
|
||||
data.distributionBoardTotalPower,
|
||||
"rowTotalPower"
|
||||
)}{" "}
|
||||
kW
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span>
|
||||
<strong>
|
||||
{formatValue(
|
||||
data.distributionBoardSimultaneityFactor,
|
||||
"simultaneityFactor"
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
Gesamtleistung Verteiler unter Berücksichtigung des
|
||||
Gleichzeitigkeitsfaktors
|
||||
</span>
|
||||
<strong>
|
||||
{formatValue(
|
||||
data.distributionBoardTotalPowerWithSimultaneityFactor,
|
||||
"rowTotalPower"
|
||||
)}{" "}
|
||||
kW
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function closeColumnSettingsMenu() {
|
||||
setIsColumnMenuOpen(false);
|
||||
setColumnSearch("");
|
||||
@@ -486,7 +558,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
|
||||
const search = columnSearch.trim().toLocaleLowerCase("de-DE");
|
||||
const matchingColumns = orderedColumns.filter((column) =>
|
||||
column.label.toLocaleLowerCase("de-DE").includes(search)
|
||||
`${column.label} ${getFullColumnLabel(column)}`
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -530,7 +604,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
onClick={() => toggleColumnVisibility(column.key)}
|
||||
>
|
||||
<span className="selection-marker" aria-hidden="true">{visible ? "✓" : ""}</span>
|
||||
<span>{column.label}</span>
|
||||
<span>{getFullColumnLabel(column)}</span>
|
||||
</button>
|
||||
<div className="column-settings-order">
|
||||
<button type="button" onClick={() => moveColumn(column.key, -1)} disabled={column.locked} title="Spalte nach links verschieben">
|
||||
@@ -817,6 +891,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
values: CreateCircuitInputDto,
|
||||
deviceRowValues: CreateCircuitDeviceRowInputDto[] = []
|
||||
) {
|
||||
const section = data?.sections.find(
|
||||
(entry) => entry.id === values.sectionId
|
||||
);
|
||||
if (!section || !data) {
|
||||
throw new Error("Bereich wurde nicht gefunden.");
|
||||
}
|
||||
const voltage = resolveProjectVoltage(
|
||||
resolveCircuitPhaseType(
|
||||
section.key,
|
||||
deviceRowValues.map((row) => row.phaseType)
|
||||
),
|
||||
data
|
||||
);
|
||||
const circuitId = crypto.randomUUID();
|
||||
const deviceRows = deviceRowValues.map((row, index) =>
|
||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||
@@ -824,7 +911,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return buildCircuitInsertSnapshot({
|
||||
id: circuitId,
|
||||
circuitListId,
|
||||
values,
|
||||
values: { ...values, voltage },
|
||||
deviceRows,
|
||||
});
|
||||
}
|
||||
@@ -1194,9 +1281,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return;
|
||||
}
|
||||
inputRef.current.focus();
|
||||
if (editingCell.mode === "selectExisting") {
|
||||
if (
|
||||
inputRef.current instanceof HTMLInputElement &&
|
||||
editingCell.mode === "selectExisting"
|
||||
) {
|
||||
inputRef.current.select();
|
||||
} else {
|
||||
} else if (inputRef.current instanceof HTMLInputElement) {
|
||||
const len = inputRef.current.value.length;
|
||||
inputRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
@@ -1226,7 +1316,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return;
|
||||
}
|
||||
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
|
||||
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
||||
const currentDisplay =
|
||||
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
|
||||
? typedChar ?? ""
|
||||
: String(visibleCell.value ?? "");
|
||||
focusTokenRef.current += 1;
|
||||
setEditingCell({
|
||||
...cell,
|
||||
@@ -1365,7 +1458,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
// Commits the active editor input through command history so edits remain undoable.
|
||||
async function commitEdit(direction: SaveDirection = "stay") {
|
||||
async function commitEdit(
|
||||
direction: SaveDirection = "stay",
|
||||
draftOverride?: string
|
||||
) {
|
||||
if (!editingCell) {
|
||||
return;
|
||||
}
|
||||
@@ -1375,10 +1471,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
const draft = draftOverride ?? editingCell.draft;
|
||||
if (
|
||||
editingCell.cellKey === "equipmentIdentifier" &&
|
||||
row.circuit &&
|
||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft)
|
||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||
) {
|
||||
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
|
||||
return;
|
||||
@@ -1405,7 +1502,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const command: HistoryCommand = {
|
||||
label: "Aus freier Zeile erstellen",
|
||||
redo: async () => {
|
||||
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
|
||||
const selection = await createFromPlaceholder(
|
||||
row.sectionId,
|
||||
editingCell.cellKey,
|
||||
draft
|
||||
);
|
||||
targetSelection = selection;
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
@@ -1416,7 +1517,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
if (cell.kind === "circuitField" && row.circuit) {
|
||||
const next = editingCell.draft;
|
||||
const next = draft;
|
||||
const command: HistoryCommand = {
|
||||
label: "Stromkreisfeld bearbeiten",
|
||||
redo: async () => {
|
||||
@@ -1430,7 +1531,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
if (cell.kind === "deviceField" && row.device) {
|
||||
const next = editingCell.draft;
|
||||
const next = draft;
|
||||
const command: HistoryCommand = {
|
||||
label: "Gerätefeld bearbeiten",
|
||||
redo: async () => {
|
||||
@@ -1444,7 +1545,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
|
||||
const next = editingCell.draft;
|
||||
const next = draft;
|
||||
const command: HistoryCommand = {
|
||||
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||
redo: async () => {
|
||||
@@ -2452,6 +2553,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
{renderColumnSettingsMenu("col-empty")}
|
||||
</div>
|
||||
{renderActiveViewSummary()}
|
||||
{renderDistributionPowerSummary()}
|
||||
{error ? (
|
||||
<div className="notice error editor-error-notice" role="alert">
|
||||
<span>{error}</span>
|
||||
@@ -2537,6 +2639,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||
Spalten
|
||||
</button>
|
||||
<button
|
||||
aria-controls="project-device-drawer"
|
||||
aria-expanded={isProjectDeviceDrawerOpen}
|
||||
className="project-device-drawer-toggle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||
}
|
||||
>
|
||||
{isProjectDeviceDrawerOpen
|
||||
? "Projektgeräte schließen"
|
||||
: `Projektgeräte öffnen (${projectDevices.length})`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSortAndFilters}
|
||||
@@ -2547,6 +2662,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
{renderColumnSettingsMenu("col")}
|
||||
</div>
|
||||
{renderActiveViewSummary()}
|
||||
{renderDistributionPowerSummary()}
|
||||
{hasActiveSortOrFilter ? (
|
||||
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
|
||||
) : null}
|
||||
@@ -2566,8 +2682,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
) : null}
|
||||
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
|
||||
<div className="tree-editor-layout">
|
||||
<aside className="project-device-sidebar">
|
||||
<h3>Projektgeräte</h3>
|
||||
{isProjectDeviceDrawerOpen ? (
|
||||
<aside
|
||||
className="project-device-sidebar project-device-drawer"
|
||||
id="project-device-drawer"
|
||||
>
|
||||
<div className="project-device-drawer-header">
|
||||
<div>
|
||||
<h3>Projektgeräte</h3>
|
||||
<span>{projectDevices.length} verfügbar</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Projektgeräte schließen"
|
||||
disabled={draggingProjectDeviceId !== null}
|
||||
onClick={() => setIsProjectDeviceDrawerOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name oder Anzeigename suchen …"
|
||||
@@ -2604,10 +2737,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<strong>{device.displayName || device.name}</strong>
|
||||
<span>Name: {device.name}</span>
|
||||
<span>Phasenart: {formatPhaseTypeLabel(device.phaseType)}</span>
|
||||
<span>Anzahl: {device.quantity}</span>
|
||||
<span>Leistung/Gerät: {device.powerPerUnit}</span>
|
||||
<span>Gleichzeitigkeit: {device.simultaneityFactor}</span>
|
||||
<span>Gesamtleistung: {device.totalPower}</span>
|
||||
<span>Anzahl: {formatValue(device.quantity, "quantity")}</span>
|
||||
<span>
|
||||
Leistung/Gerät: {formatValue(device.powerPerUnit, "powerPerUnit")} kW
|
||||
</span>
|
||||
<span>
|
||||
Gleichzeitigkeit:{" "}
|
||||
{formatValue(device.simultaneityFactor, "simultaneityFactor")}
|
||||
</span>
|
||||
<span>
|
||||
Gesamtleistung: {formatValue(device.totalPower, "rowTotalPower")} kW
|
||||
</span>
|
||||
<span>Kostengruppe: {device.costGroup || "-"}</span>
|
||||
<span>Kategorie: {device.category || "-"}</span>
|
||||
</button>
|
||||
@@ -2626,14 +2766,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
|
||||
return (
|
||||
<option key={section.id} value={section.id} disabled={!valid}>
|
||||
{section.displayName}{valid ? "" : " (nicht zulässig)"}
|
||||
{getCircuitSectionLabel(section)}
|
||||
{valid ? "" : " (nicht zulässig)"}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
{selectedProjectDevice && suggestedSection ? (
|
||||
<p className="notice muted">Vorgeschlagener Bereich: {suggestedSection.displayName}</p>
|
||||
<p className="notice muted">
|
||||
Vorgeschlagener Bereich:{" "}
|
||||
{getCircuitSectionLabel(suggestedSection)}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -2668,6 +2812,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
<div
|
||||
className="tree-grid-wrap"
|
||||
ref={containerRef}
|
||||
@@ -2690,8 +2835,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<button
|
||||
type="button"
|
||||
className="header-sort-btn"
|
||||
aria-label={`${column.label} sortieren`}
|
||||
title="Zum Sortieren klicken"
|
||||
aria-label={`${getFullColumnLabel(column)} sortieren`}
|
||||
title={`${getFullColumnLabel(column)} – zum Sortieren klicken`}
|
||||
onClick={() => {
|
||||
setSortState((current) => {
|
||||
if (!current || current.key !== column.key) {
|
||||
@@ -2719,7 +2864,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
title={
|
||||
(columnFilters[column.key]?.length ?? 0) > 0
|
||||
? `${columnFilters[column.key]?.length} Filterwert(e) ausgewählt`
|
||||
: `${column.label} filtern`
|
||||
: `${getFullColumnLabel(column)} filtern`
|
||||
}
|
||||
>
|
||||
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
||||
@@ -2728,7 +2873,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
{openFilterColumn === column.key ? (
|
||||
<div className="header-filter-menu">
|
||||
<div className="header-filter-title-row">
|
||||
<strong>{column.label} filtern</strong>
|
||||
<strong>{getFullColumnLabel(column)} filtern</strong>
|
||||
<button type="button" className="header-filter-close" onClick={closeColumnFilterMenu} aria-label="Filter schließen">
|
||||
×
|
||||
</button>
|
||||
@@ -2742,7 +2887,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
value={filterValueSearch}
|
||||
onChange={(event) => setFilterValueSearch(event.target.value)}
|
||||
placeholder="Werte suchen …"
|
||||
aria-label={`Werte für ${column.label} suchen`}
|
||||
aria-label={`Werte für ${getFullColumnLabel(column)} suchen`}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="header-filter-selection-actions">
|
||||
@@ -2759,7 +2904,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
</div>
|
||||
<div className="header-filter-values">
|
||||
{(distinctValuesByColumn[column.key] ?? [])
|
||||
.filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")))
|
||||
.filter((value) =>
|
||||
(column.key === "phaseType"
|
||||
? formatPhaseTypeLabel(value)
|
||||
: value
|
||||
)
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.includes(
|
||||
filterValueSearch
|
||||
.trim()
|
||||
.toLocaleLowerCase("de-DE")
|
||||
)
|
||||
)
|
||||
.map((value) => {
|
||||
const selected = filterDraftValues.includes(value);
|
||||
return (
|
||||
@@ -2777,12 +2933,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
>
|
||||
<span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span>
|
||||
<span>{value}</span>
|
||||
<span>
|
||||
{column.key === "phaseType"
|
||||
? formatPhaseTypeLabel(value)
|
||||
: value}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(distinctValuesByColumn[column.key] ?? []).filter((value) =>
|
||||
value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))
|
||||
{(distinctValuesByColumn[column.key] ?? []).filter(
|
||||
(value) =>
|
||||
(column.key === "phaseType"
|
||||
? formatPhaseTypeLabel(value)
|
||||
: value
|
||||
)
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.includes(
|
||||
filterValueSearch
|
||||
.trim()
|
||||
.toLocaleLowerCase("de-DE")
|
||||
)
|
||||
).length === 0 ? (
|
||||
<div className="header-filter-empty">Keine passenden Werte gefunden.</div>
|
||||
) : null}
|
||||
@@ -2879,7 +3049,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
>
|
||||
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
|
||||
<div className="section-content">
|
||||
<strong>{section.displayName}</strong>
|
||||
<div className="section-title">
|
||||
<strong>
|
||||
{getCircuitSectionLabel(section)}
|
||||
</strong>
|
||||
<span>
|
||||
Gesamtleistung Abschnitt:{" "}
|
||||
{formatValue(
|
||||
section.sectionTotalPower,
|
||||
"rowTotalPower"
|
||||
)}{" "}
|
||||
kW
|
||||
</span>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||
Stromkreis hinzufügen
|
||||
@@ -3304,6 +3486,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setCircuitReorderIntent(null);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (isGridEditorControlTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (cell.editable) {
|
||||
handleRowSelectionClick(row, column.key, {
|
||||
ctrlKey: event.ctrlKey,
|
||||
@@ -3312,15 +3497,61 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
onDoubleClick={(event) => {
|
||||
if (isGridEditorControlTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (cell.editable) {
|
||||
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
{isEditing && column.key === "phaseType" ? (
|
||||
<select
|
||||
ref={(element) => {
|
||||
inputRef.current = element;
|
||||
}}
|
||||
value={editingCell.draft}
|
||||
aria-label="Phasenart auswählen"
|
||||
onChange={(event) =>
|
||||
void commitEdit("stay", event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitEdit("stay");
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
void commitEdit(
|
||||
event.shiftKey ? "prev" : "next"
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
const active =
|
||||
document.activeElement as HTMLElement | null;
|
||||
if (
|
||||
!active ||
|
||||
!containerRef.current?.contains(active)
|
||||
) {
|
||||
setEditingCell(null);
|
||||
focusGridWithoutScroll();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="single_phase">1-phasig</option>
|
||||
<option value="three_phase">3-phasig</option>
|
||||
</select>
|
||||
) : isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={(element) => {
|
||||
inputRef.current = element;
|
||||
}}
|
||||
value={editingCell.draft}
|
||||
aria-invalid={hasDraftIdentifierConflict}
|
||||
title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined}
|
||||
@@ -3352,7 +3583,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
formatValue(cell.value)
|
||||
formatValue(cell.value, column.key)
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -4,26 +4,10 @@ import { useEffect, useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { getCircuitTree } from "../utils/api";
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
|
||||
|
||||
function formatNumber(value: number | undefined, digits = 2) {
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPhaseType(value: string | undefined) {
|
||||
if (value === "three_phase") {
|
||||
return "Dreiphasig";
|
||||
}
|
||||
if (value === "single_phase") {
|
||||
return "Einphasig";
|
||||
}
|
||||
return value ?? "-";
|
||||
}
|
||||
import {
|
||||
formatValue,
|
||||
getCircuitSectionLabel,
|
||||
} from "../utils/circuit-grid-model";
|
||||
|
||||
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
|
||||
if (circuit.displayName?.trim()) {
|
||||
@@ -86,11 +70,10 @@ export function CircuitTreePreview(props: { projectId: string; circuitListId: st
|
||||
<th>Raumnummer</th>
|
||||
<th>Raumname</th>
|
||||
<th className="text-end">Anzahl</th>
|
||||
<th className="text-end">Leistung / Gerät</th>
|
||||
<th className="text-end">Leistung / Gerät [kW]</th>
|
||||
<th className="text-end">Gleichzeitigkeit</th>
|
||||
<th className="text-end">cos φ</th>
|
||||
<th className="text-end">Zeilensumme</th>
|
||||
<th className="text-end">Stromkreissumme</th>
|
||||
<th className="text-end">Gesamtsumme [kW]</th>
|
||||
<th>Schutzart</th>
|
||||
<th className="text-end">Bemessungsstrom</th>
|
||||
<th>Charakteristik</th>
|
||||
@@ -117,8 +100,8 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
||||
return (
|
||||
<>
|
||||
<tr className="section-row">
|
||||
<td colSpan={22}>
|
||||
<strong>{section.displayName}</strong>
|
||||
<td colSpan={21}>
|
||||
<strong>{getCircuitSectionLabel(section)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{section.circuits.map((circuit) => {
|
||||
@@ -127,14 +110,18 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
||||
<tr key={circuit.id} className="reserve-row">
|
||||
<td>{circuit.equipmentIdentifier}</td>
|
||||
<td>{circuit.displayName?.trim() || "Reserve"}</td>
|
||||
<td colSpan={12}>-</td>
|
||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
||||
<td colSpan={11}>-</td>
|
||||
<td className="text-end">
|
||||
{formatValue(circuit.circuitTotalPower, "circuitTotalPower")}
|
||||
</td>
|
||||
<td>{circuit.protectionType ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
||||
<td className="text-end">
|
||||
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||
</td>
|
||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||
<td>{circuit.cableType ?? "-"}</td>
|
||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
||||
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||
<td>{circuit.remark ?? "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -146,25 +133,28 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
||||
<tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}>
|
||||
<td>{circuit.equipmentIdentifier}</td>
|
||||
<td>{row.displayName || row.name}</td>
|
||||
<td>{formatPhaseType(row.phaseType)}</td>
|
||||
<td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
|
||||
<td>{row.connectionKind ?? "-"}</td>
|
||||
<td>{row.costGroup ?? "-"}</td>
|
||||
<td>{row.category ?? "-"}</td>
|
||||
<td>{row.level ?? "-"}</td>
|
||||
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
||||
<td>{row.roomNameSnapshot ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
|
||||
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
|
||||
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
|
||||
<td className="text-end">{formatNumber(row.cosPhi)}</td>
|
||||
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
|
||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
||||
<td className="text-end">{formatValue(row.quantity, "quantity")}</td>
|
||||
<td className="text-end">{formatValue(row.powerPerUnit, "powerPerUnit")}</td>
|
||||
<td className="text-end">
|
||||
{formatValue(row.simultaneityFactor, "simultaneityFactor")}
|
||||
</td>
|
||||
<td className="text-end">{formatValue(row.cosPhi, "cosPhi")}</td>
|
||||
<td className="text-end">{formatValue(row.rowTotalPower, "rowTotalPower")}</td>
|
||||
<td>{circuit.protectionType ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
||||
<td className="text-end">
|
||||
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||
</td>
|
||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||
<td>{circuit.cableType ?? "-"}</td>
|
||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
||||
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||
<td>{row.remark ?? circuit.remark ?? "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -175,33 +165,38 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
||||
<tr key={`${circuit.id}-summary`} className="summary-row">
|
||||
<td>{circuit.equipmentIdentifier}</td>
|
||||
<td>{renderCircuitSummaryLabel(circuit)}</td>
|
||||
<td colSpan={12}>-</td>
|
||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
||||
<td colSpan={11}>-</td>
|
||||
<td className="text-end">
|
||||
{formatValue(circuit.circuitTotalPower, "circuitTotalPower")}
|
||||
</td>
|
||||
<td>{circuit.protectionType ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
||||
<td className="text-end">
|
||||
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||
</td>
|
||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||
<td>{circuit.cableType ?? "-"}</td>
|
||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
||||
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||
<td>{circuit.remark ?? "-"}</td>
|
||||
</tr>
|
||||
{circuit.deviceRows.map((row) => (
|
||||
<tr key={row.id} className="device-row">
|
||||
<td className="text-muted"> </td>
|
||||
<td className="indented-cell">{row.displayName || row.name}</td>
|
||||
<td>{formatPhaseType(row.phaseType)}</td>
|
||||
<td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
|
||||
<td>{row.connectionKind ?? "-"}</td>
|
||||
<td>{row.costGroup ?? "-"}</td>
|
||||
<td>{row.category ?? "-"}</td>
|
||||
<td>{row.level ?? "-"}</td>
|
||||
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
||||
<td>{row.roomNameSnapshot ?? "-"}</td>
|
||||
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
|
||||
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
|
||||
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
|
||||
<td className="text-end">{formatNumber(row.cosPhi)}</td>
|
||||
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
|
||||
<td className="text-end">-</td>
|
||||
<td className="text-end">{formatValue(row.quantity, "quantity")}</td>
|
||||
<td className="text-end">{formatValue(row.powerPerUnit, "powerPerUnit")}</td>
|
||||
<td className="text-end">
|
||||
{formatValue(row.simultaneityFactor, "simultaneityFactor")}
|
||||
</td>
|
||||
<td className="text-end">{formatValue(row.cosPhi, "cosPhi")}</td>
|
||||
<td className="text-end">{formatValue(row.rowTotalPower, "rowTotalPower")}</td>
|
||||
<td>-</td>
|
||||
<td className="text-end">-</td>
|
||||
<td>-</td>
|
||||
@@ -216,7 +211,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
||||
})}
|
||||
<tr className="placeholder-row">
|
||||
<td>-frei-</td>
|
||||
<td colSpan={21}>Freie Zeile</td>
|
||||
<td colSpan={20}>Freie Zeile</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import React, { type FormEvent, type ReactNode } from "react";
|
||||
|
||||
interface FormModalProps {
|
||||
children: ReactNode;
|
||||
description?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
submitDisabled?: boolean;
|
||||
submitLabel: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
children,
|
||||
description,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitDisabled,
|
||||
submitLabel,
|
||||
title,
|
||||
}: FormModalProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form className="modal-content" onSubmit={onSubmit}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title fs-5">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-secondary small mb-0">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Schließen"
|
||||
className="btn-close"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={isSaving || submitDisabled}
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? "Wird gespeichert …" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-backdrop fade show" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import React, { FormEvent, useState } from "react";
|
||||
import type {
|
||||
CreateProjectDeviceInput,
|
||||
GlobalDeviceDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface ProjectDeviceModalProps {
|
||||
globalDevices: GlobalDeviceDto[];
|
||||
initialDevice?: ProjectDeviceDto;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
||||
onSave: (input: CreateProjectDeviceInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProjectDeviceModal({
|
||||
globalDevices,
|
||||
initialDevice,
|
||||
isSaving,
|
||||
onClose,
|
||||
onImportGlobal,
|
||||
onSave,
|
||||
}: ProjectDeviceModalProps) {
|
||||
const [values, setValues] = useState(() => toFormValues(initialDevice));
|
||||
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||
|
||||
function update(key: keyof typeof values, value: string) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
name: values.name.trim(),
|
||||
displayName: values.displayName.trim() || values.name.trim(),
|
||||
phaseType:
|
||||
values.phaseType === "three_phase" ? "three_phase" : "single_phase",
|
||||
connectionKind: optionalString(values.connectionKind),
|
||||
costGroup: optionalString(values.costGroup),
|
||||
category: optionalString(values.category),
|
||||
quantity: Number(values.quantity),
|
||||
powerPerUnit: Number(values.powerPerUnit),
|
||||
simultaneityFactor: Number(values.simultaneityFactor),
|
||||
cosPhi: optionalNumber(values.cosPhi),
|
||||
remark: optionalString(values.remark),
|
||||
});
|
||||
}
|
||||
|
||||
const isValid =
|
||||
values.name.trim().length > 0 &&
|
||||
Number(values.quantity) >= 0 &&
|
||||
Number(values.powerPerUnit) >= 0 &&
|
||||
Number(values.simultaneityFactor) >= 0 &&
|
||||
Number(values.simultaneityFactor) <= 1;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={
|
||||
initialDevice
|
||||
? "Kanonische Gerätedaten bearbeiten; verknüpfte Stromkreiszeilen werden nicht automatisch überschrieben."
|
||||
: "Ein Gerät manuell anlegen oder aus der globalen Bibliothek übernehmen."
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!isValid}
|
||||
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
||||
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
|
||||
>
|
||||
{!initialDevice && globalDevices.length > 0 ? (
|
||||
<div className="border rounded p-3 mb-4">
|
||||
<label className="form-label" htmlFor="global-project-device">
|
||||
Aus globaler Gerätebibliothek
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<select
|
||||
className="form-select"
|
||||
id="global-project-device"
|
||||
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
|
||||
value={selectedGlobalDeviceId}
|
||||
>
|
||||
<option value="">Gerät auswählen</option>
|
||||
{globalDevices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={isSaving || !selectedGlobalDeviceId}
|
||||
onClick={() => void onImportGlobal(selectedGlobalDeviceId)}
|
||||
type="button"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3">
|
||||
<TextField
|
||||
id="device-name"
|
||||
label="Interner Name"
|
||||
onChange={(value) => update("name", value)}
|
||||
required
|
||||
value={values.name}
|
||||
/>
|
||||
<TextField
|
||||
id="device-display-name"
|
||||
label="Anzeigename"
|
||||
onChange={(value) => update("displayName", value)}
|
||||
value={values.displayName}
|
||||
/>
|
||||
<TextField
|
||||
id="device-category"
|
||||
label="Kategorie"
|
||||
onChange={(value) => update("category", value)}
|
||||
value={values.category}
|
||||
/>
|
||||
<TextField
|
||||
id="device-connection"
|
||||
label="Anschlussart"
|
||||
onChange={(value) => update("connectionKind", value)}
|
||||
value={values.connectionKind}
|
||||
/>
|
||||
<TextField
|
||||
id="device-cost-group"
|
||||
label="Kostengruppe"
|
||||
onChange={(value) => update("costGroup", value)}
|
||||
value={values.costGroup}
|
||||
/>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="device-phase-type">
|
||||
Phasenart
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="device-phase-type"
|
||||
onChange={(event) => update("phaseType", event.target.value)}
|
||||
value={values.phaseType}
|
||||
>
|
||||
<option value="single_phase">1-phasig</option>
|
||||
<option value="three_phase">3-phasig</option>
|
||||
</select>
|
||||
</div>
|
||||
<NumberField
|
||||
id="device-quantity"
|
||||
label="Anzahl"
|
||||
min="0"
|
||||
onChange={(value) => update("quantity", value)}
|
||||
value={values.quantity}
|
||||
/>
|
||||
<NumberField
|
||||
id="device-power"
|
||||
label="Leistung je Stück [kW]"
|
||||
min="0"
|
||||
onChange={(value) => update("powerPerUnit", value)}
|
||||
step="0.01"
|
||||
value={values.powerPerUnit}
|
||||
/>
|
||||
<NumberField
|
||||
id="device-simultaneity"
|
||||
label="Gleichzeitigkeitsfaktor"
|
||||
max="1"
|
||||
min="0"
|
||||
onChange={(value) => update("simultaneityFactor", value)}
|
||||
step="0.01"
|
||||
value={values.simultaneityFactor}
|
||||
/>
|
||||
<NumberField
|
||||
id="device-cos-phi"
|
||||
label="cos Phi"
|
||||
max="1"
|
||||
min="0"
|
||||
onChange={(value) => update("cosPhi", value)}
|
||||
step="0.01"
|
||||
value={values.cosPhi}
|
||||
/>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="device-remark">
|
||||
Bemerkung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="device-remark"
|
||||
onChange={(event) => update("remark", event.target.value)}
|
||||
rows={3}
|
||||
value={values.remark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function TextField({ id, label, onChange, required, value }: FieldProps) {
|
||||
return (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id={id}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
required={required}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
label,
|
||||
max,
|
||||
min,
|
||||
onChange,
|
||||
step,
|
||||
value,
|
||||
}: FieldProps & { max?: string; min?: string; step?: string }) {
|
||||
return (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id={id}
|
||||
max={max}
|
||||
min={min}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
step={step}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toFormValues(device?: ProjectDeviceDto) {
|
||||
return {
|
||||
name: device?.name ?? "",
|
||||
displayName: device?.displayName ?? "",
|
||||
phaseType: device?.phaseType ?? "single_phase",
|
||||
connectionKind: device?.connectionKind ?? "",
|
||||
costGroup: device?.costGroup ?? "",
|
||||
category: device?.category ?? "",
|
||||
quantity: String(device?.quantity ?? 1),
|
||||
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
|
||||
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
|
||||
cosPhi: String(device?.cosPhi ?? 1),
|
||||
remark: device?.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function optionalString(value: string) {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value: string) {
|
||||
return value.trim() ? Number(value) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import React, { FormEvent, useState } from "react";
|
||||
import type { ProjectDto } from "../types";
|
||||
import {
|
||||
distributionBoardSupplyTypeLabels,
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
|
||||
export interface ProjectSettingsInput {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
buildingOwner: string | null;
|
||||
description: string | null;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
interface ProjectSettingsModalProps {
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onExport: () => Promise<void>;
|
||||
onImport: (
|
||||
transfer: unknown,
|
||||
mode: "replace" | "duplicate"
|
||||
) => Promise<void>;
|
||||
onSave: (input: ProjectSettingsInput) => Promise<void>;
|
||||
project: ProjectDto;
|
||||
usedDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
export function ProjectSettingsModal({
|
||||
isSaving,
|
||||
onClose,
|
||||
onExport,
|
||||
onImport,
|
||||
onSave,
|
||||
project,
|
||||
usedDistributionBoardSupplyTypes,
|
||||
}: ProjectSettingsModalProps) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [internalProjectNumber, setInternalProjectNumber] = useState(
|
||||
project.internalProjectNumber ?? ""
|
||||
);
|
||||
const [externalProjectNumber, setExternalProjectNumber] = useState(
|
||||
project.externalProjectNumber ?? ""
|
||||
);
|
||||
const [buildingOwner, setBuildingOwner] = useState(
|
||||
project.buildingOwner ?? ""
|
||||
);
|
||||
const [description, setDescription] = useState(
|
||||
project.description ?? ""
|
||||
);
|
||||
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState(
|
||||
String(project.singlePhaseVoltageV)
|
||||
);
|
||||
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
|
||||
String(project.threePhaseVoltageV)
|
||||
);
|
||||
const [
|
||||
enabledDistributionBoardSupplyTypes,
|
||||
setEnabledDistributionBoardSupplyTypes,
|
||||
] = useState<DistributionBoardSupplyType[]>(
|
||||
project.enabledDistributionBoardSupplyTypes
|
||||
);
|
||||
const [transfer, setTransfer] = useState<unknown>(null);
|
||||
const [transferFilename, setTransferFilename] = useState("");
|
||||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||
"duplicate"
|
||||
);
|
||||
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
internalProjectNumber: toNullableString(internalProjectNumber),
|
||||
externalProjectNumber: toNullableString(externalProjectNumber),
|
||||
buildingOwner: toNullableString(buildingOwner),
|
||||
description: toNullableString(description),
|
||||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
enabledDistributionBoardSupplyTypes,
|
||||
});
|
||||
}
|
||||
|
||||
const isValid =
|
||||
name.trim().length > 0 &&
|
||||
Number(singlePhaseVoltageV) > 0 &&
|
||||
Number(threePhaseVoltageV) > 0 &&
|
||||
enabledDistributionBoardSupplyTypes.length > 0;
|
||||
|
||||
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
|
||||
if (
|
||||
enabledDistributionBoardSupplyTypes.includes(supplyType) &&
|
||||
usedDistributionBoardSupplyTypes.includes(supplyType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setEnabledDistributionBoardSupplyTypes((current) =>
|
||||
current.includes(supplyType)
|
||||
? current.filter((entry) => entry !== supplyType)
|
||||
: distributionBoardSupplyTypes.filter(
|
||||
(entry) => entry === supplyType || current.includes(entry)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTransferFile(file: File | undefined) {
|
||||
setTransfer(null);
|
||||
setTransferFilename("");
|
||||
setFileError(null);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setTransfer(JSON.parse(await file.text()) as unknown);
|
||||
setTransferFilename(file.name);
|
||||
} catch {
|
||||
setFileError("Die ausgewählte Datei enthält kein gültiges JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-labelledby="project-settings-title"
|
||||
aria-modal="true"
|
||||
className="modal fade show d-block"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form className="modal-content" onSubmit={handleSubmit}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title fs-5" id="project-settings-title">
|
||||
Projekteinstellungen
|
||||
</h2>
|
||||
<p className="text-secondary small mb-0">
|
||||
Stammdaten und elektrische Standardwerte des Projekts
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Schließen"
|
||||
className="btn-close"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="project-name">
|
||||
Projektname
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="project-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="internal-project-number">
|
||||
Projektnummer intern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="internal-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setInternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={internalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="external-project-number">
|
||||
Projektnummer extern
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="external-project-number"
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
setExternalProjectNumber(event.target.value)
|
||||
}
|
||||
value={externalProjectNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<fieldset>
|
||||
<legend className="form-label mb-1">
|
||||
Verwendete Netzarten
|
||||
</legend>
|
||||
<p className="form-text mt-0">
|
||||
Nur ausgewählte Netzarten stehen bei Verteilungen zur
|
||||
Auswahl. Bereits verwendete Netzarten können nicht
|
||||
deaktiviert werden.
|
||||
</p>
|
||||
<div className="row g-2">
|
||||
{distributionBoardSupplyTypes.map((supplyType) => (
|
||||
<div
|
||||
className="col-12 col-md-6"
|
||||
key={supplyType}
|
||||
>
|
||||
<label className="form-check">
|
||||
<input
|
||||
checked={enabledDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)}
|
||||
className="form-check-input"
|
||||
disabled={usedDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)}
|
||||
onChange={() => toggleSupplyType(supplyType)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
{distributionBoardSupplyTypeLabels[supplyType]}
|
||||
{usedDistributionBoardSupplyTypes.includes(
|
||||
supplyType
|
||||
)
|
||||
? " (in Verwendung)"
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{enabledDistributionBoardSupplyTypes.length === 0 ? (
|
||||
<div className="text-danger small mt-2">
|
||||
Mindestens eine Netzart muss aktiviert sein.
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="building-owner">
|
||||
Bauherr
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="building-owner"
|
||||
maxLength={200}
|
||||
onChange={(event) => setBuildingOwner(event.target.value)}
|
||||
value={buildingOwner}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="project-description">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="project-description"
|
||||
maxLength={2000}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={4}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="single-phase-voltage">
|
||||
Standardspannung 1-phasig [V]
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="single-phase-voltage"
|
||||
min="1"
|
||||
onChange={(event) =>
|
||||
setSinglePhaseVoltageV(event.target.value)
|
||||
}
|
||||
required
|
||||
type="number"
|
||||
value={singlePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="three-phase-voltage">
|
||||
Standardspannung 3-phasig [V]
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="three-phase-voltage"
|
||||
min="1"
|
||||
onChange={(event) =>
|
||||
setThreePhaseVoltageV(event.target.value)
|
||||
}
|
||||
required
|
||||
type="number"
|
||||
value={threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||
<p className="text-secondary small">
|
||||
Der Export enthält den vollständigen unterstützten
|
||||
Projektzustand. Beim Duplizieren bleibt dieses Projekt
|
||||
unverändert.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-outline-primary mb-3"
|
||||
disabled={isSaving}
|
||||
onClick={() => void onExport()}
|
||||
type="button"
|
||||
>
|
||||
Projektdatei herunterladen
|
||||
</button>
|
||||
<label className="form-label d-block" htmlFor="project-import">
|
||||
Projektdatei auswählen
|
||||
</label>
|
||||
<input
|
||||
accept=".json,application/json"
|
||||
className="form-control"
|
||||
id="project-import"
|
||||
onChange={(event) =>
|
||||
void handleTransferFile(event.target.files?.[0])
|
||||
}
|
||||
type="file"
|
||||
/>
|
||||
{transferFilename ? (
|
||||
<div className="form-text">{transferFilename} ist bereit.</div>
|
||||
) : null}
|
||||
{fileError ? (
|
||||
<div className="text-danger small mt-1">{fileError}</div>
|
||||
) : null}
|
||||
<div className="d-flex flex-wrap gap-3 mt-3">
|
||||
<label className="form-check">
|
||||
<input
|
||||
checked={importMode === "duplicate"}
|
||||
className="form-check-input"
|
||||
name="import-mode"
|
||||
onChange={() => setImportMode("duplicate")}
|
||||
type="radio"
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
Als neues Projekt duplizieren
|
||||
</span>
|
||||
</label>
|
||||
<label className="form-check">
|
||||
<input
|
||||
checked={importMode === "replace"}
|
||||
className="form-check-input"
|
||||
name="import-mode"
|
||||
onChange={() => setImportMode("replace")}
|
||||
type="radio"
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
Dieses Projekt ersetzen
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{importMode === "replace" ? (
|
||||
<label className="form-check mt-2">
|
||||
<input
|
||||
checked={replaceConfirmed}
|
||||
className="form-check-input"
|
||||
onChange={(event) =>
|
||||
setReplaceConfirmed(event.target.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="form-check-label text-danger">
|
||||
Ich bestätige, dass der aktuelle Projektstand ersetzt
|
||||
wird.
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-outline-danger mt-3"
|
||||
disabled={
|
||||
isSaving ||
|
||||
transfer === null ||
|
||||
(importMode === "replace" && !replaceConfirmed)
|
||||
}
|
||||
onClick={() =>
|
||||
transfer === null
|
||||
? undefined
|
||||
: void onImport(transfer, importMode)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Projektdatei importieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={isSaving || !isValid}
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-backdrop fade show" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function toNullableString(value: string) {
|
||||
return value.trim() || null;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getProjectRevisionDescription,
|
||||
getProjectRevisionSourceLabel,
|
||||
getProjectSnapshotKindLabel,
|
||||
getProjectSnapshotRevisionDescription,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
|
||||
@@ -343,7 +344,7 @@ export function ProjectVersionHistory({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Projektstand</th>
|
||||
<th>Gesicherter Projektstand</th>
|
||||
<th>Erstellt</th>
|
||||
<th className="text-end">Aktion</th>
|
||||
</tr>
|
||||
@@ -368,7 +369,16 @@ export function ProjectVersionHistory({
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>Revision {snapshot.sourceRevision}</td>
|
||||
<td>
|
||||
<strong>
|
||||
{snapshot.sourceRevision === 0
|
||||
? "Ausgangsstand"
|
||||
: `Revision ${snapshot.sourceRevision}`}
|
||||
</strong>
|
||||
<div className="small text-secondary">
|
||||
{getProjectSnapshotRevisionDescription(snapshot)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
|
||||
+18
-3
@@ -1,8 +1,15 @@
|
||||
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
||||
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
buildingOwner: string | null;
|
||||
description: string | null;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
@@ -45,6 +52,7 @@ export interface ProjectSnapshotMetadataDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceRevision: number;
|
||||
sourceRevisionMetadata: ProjectRevisionSummaryDto | null;
|
||||
schemaVersion: number;
|
||||
kind: "named" | "automatic";
|
||||
name: string;
|
||||
@@ -85,6 +93,9 @@ export interface DistributionBoardDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
simultaneityFactor: number;
|
||||
}
|
||||
|
||||
export interface DistributionBoardCommandResultDto
|
||||
@@ -178,8 +189,7 @@ export interface CreateGlobalDeviceInput {
|
||||
quantity: number;
|
||||
installedPowerPerUnitKw: number;
|
||||
demandFactor: number;
|
||||
voltageV?: number;
|
||||
phaseCount?: 1 | 3;
|
||||
phaseCount: 1 | 3;
|
||||
powerFactor?: number;
|
||||
note?: string;
|
||||
}
|
||||
@@ -196,7 +206,6 @@ export interface CreateProjectDeviceInput {
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
remark?: string;
|
||||
voltageV?: number;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncDifferenceDto {
|
||||
@@ -288,6 +297,7 @@ export interface CircuitTreeSectionDto {
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
sectionTotalPower: number;
|
||||
circuits: CircuitTreeCircuitDto[];
|
||||
}
|
||||
|
||||
@@ -305,6 +315,11 @@ export interface CircuitTreeMigrationReportDto {
|
||||
export interface CircuitTreeResponseDto {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
distributionBoardSimultaneityFactor: number;
|
||||
distributionBoardTotalPower: number;
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||
sections: CircuitTreeSectionDto[];
|
||||
migrationReport?: CircuitTreeMigrationReportDto;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,15 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
let details = await response.text();
|
||||
try {
|
||||
const parsed = JSON.parse(details) as { error?: unknown };
|
||||
if (typeof parsed.error === "string") {
|
||||
details = parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// Keep non-JSON error responses unchanged.
|
||||
}
|
||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||
}
|
||||
|
||||
@@ -210,7 +218,16 @@ export function createProject(name: string) {
|
||||
export function updateProjectSettings(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
|
||||
input: {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
buildingOwner: string | null;
|
||||
description: string | null;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
|
||||
}
|
||||
) {
|
||||
return request<ProjectSettingsCommandResultDto>(
|
||||
`/api/projects/${projectId}`,
|
||||
@@ -221,20 +238,76 @@ export function updateProjectSettings(
|
||||
);
|
||||
}
|
||||
|
||||
export function exportProjectTransfer(projectId: string) {
|
||||
return request<unknown>(`/api/projects/${projectId}/export`);
|
||||
}
|
||||
|
||||
export function importProjectTransferAsNew(transfer: unknown) {
|
||||
return request<{ projectId: string; name: string }>(
|
||||
"/api/projects/import",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ transfer }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function importProjectTransfer(
|
||||
projectId: string,
|
||||
mode: "replace" | "duplicate",
|
||||
expectedRevision: number,
|
||||
transfer: unknown
|
||||
) {
|
||||
return request<
|
||||
| { projectId: string; name: string }
|
||||
| ProjectSettingsCommandResultDto
|
||||
>(`/api/projects/${projectId}/import`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
mode === "replace"
|
||||
? { mode, expectedRevision, transfer }
|
||||
: { mode, transfer }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function listDistributionBoards(projectId: string) {
|
||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||
}
|
||||
|
||||
export function createDistributionBoard(
|
||||
projectId: string,
|
||||
name: string,
|
||||
input: {
|
||||
name: string;
|
||||
floorId: string | null;
|
||||
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||
},
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<DistributionBoardCommandResultDto>(
|
||||
`/api/projects/${projectId}/distribution-boards`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, expectedRevision }),
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDistributionBoard(
|
||||
projectId: string,
|
||||
distributionBoardId: string,
|
||||
input: {
|
||||
floorId: string | null;
|
||||
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||
simultaneityFactor: number;
|
||||
},
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<DistributionBoardCommandResultDto>(
|
||||
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export type GridValue = string | number | boolean | undefined;
|
||||
export interface ColumnDef {
|
||||
key: CellKey;
|
||||
label: string;
|
||||
fullLabel?: string;
|
||||
numeric?: boolean;
|
||||
defaultVisible?: boolean;
|
||||
locked?: boolean;
|
||||
@@ -57,36 +58,64 @@ export interface ColumnDef {
|
||||
// BMK stays locked as the first column because circuit identity and circuit drag
|
||||
// handles depend on an always-visible equipment identifier.
|
||||
export const allColumns: ColumnDef[] = [
|
||||
{ key: "equipmentIdentifier", label: "Betriebsmittelkennzeichen", defaultVisible: true, locked: true },
|
||||
{
|
||||
key: "equipmentIdentifier",
|
||||
label: "SK-Nr.",
|
||||
fullLabel: "Stromkreisnummer / Betriebsmittelkennzeichen",
|
||||
defaultVisible: true,
|
||||
locked: true,
|
||||
},
|
||||
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
||||
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
|
||||
{ key: "powerPerUnit", label: "Leistung / Gerät", numeric: true, defaultVisible: true },
|
||||
{ key: "simultaneityFactor", label: "Gleichzeitigkeit", numeric: true, defaultVisible: true },
|
||||
{ key: "rowTotalPower", label: "Zeilensumme", numeric: true, defaultVisible: true },
|
||||
{ key: "circuitTotalPower", label: "Stromkreissumme", numeric: true, defaultVisible: true },
|
||||
{
|
||||
key: "powerPerUnit",
|
||||
label: "P/Stk. [kW]",
|
||||
fullLabel: "Leistung je Gerät [kW]",
|
||||
numeric: true,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: "simultaneityFactor",
|
||||
label: "GZF",
|
||||
fullLabel: "Gleichzeitigkeitsfaktor",
|
||||
numeric: true,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: "rowTotalPower",
|
||||
label: "Ges.-Leistung [kW]",
|
||||
fullLabel: "Gesamtleistung [kW]",
|
||||
numeric: true,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{ key: "protectionSummary", label: "Schutz", defaultVisible: true },
|
||||
{ key: "cableSummary", label: "Kabel", defaultVisible: true },
|
||||
{ key: "roomSummary", label: "Raum", defaultVisible: true },
|
||||
{ key: "remark", label: "Bemerkung", defaultVisible: true },
|
||||
{ key: "technicalName", label: "Technischer Name" },
|
||||
{ key: "connectionKind", label: "Anschlussart" },
|
||||
{ key: "technicalName", label: "Techn. Name", fullLabel: "Technischer Name" },
|
||||
{ key: "connectionKind", label: "Anschluss", fullLabel: "Anschlussart" },
|
||||
{ key: "phaseType", label: "Phasenart" },
|
||||
{ key: "costGroup", label: "Kostengruppe" },
|
||||
{ key: "costGroup", label: "KG", fullLabel: "Kostengruppe" },
|
||||
{ key: "category", label: "Kategorie" },
|
||||
{ key: "level", label: "Ebene" },
|
||||
{ key: "roomNumberSnapshot", label: "Raumnummer" },
|
||||
{ key: "roomNumberSnapshot", label: "Raum-Nr.", fullLabel: "Raumnummer" },
|
||||
{ key: "roomNameSnapshot", label: "Raumname" },
|
||||
{ key: "cosPhi", label: "cos φ", numeric: true },
|
||||
{ key: "protectionType", label: "Schutzart" },
|
||||
{ key: "protectionRatedCurrent", label: "Bemessungsstrom", numeric: true },
|
||||
{
|
||||
key: "protectionRatedCurrent",
|
||||
label: "Bem.-Strom",
|
||||
fullLabel: "Bemessungsstrom",
|
||||
numeric: true,
|
||||
},
|
||||
{ key: "protectionCharacteristic", label: "Charakteristik" },
|
||||
{ key: "cableType", label: "Kabeltyp" },
|
||||
{ key: "cableCrossSection", label: "Kabelquerschnitt" },
|
||||
{ key: "cableCrossSection", label: "Querschnitt", fullLabel: "Kabelquerschnitt" },
|
||||
{ key: "cableLength", label: "Kabellänge", numeric: true },
|
||||
{ key: "rcdAssignment", label: "RCD-Zuordnung" },
|
||||
{ key: "terminalDesignation", label: "Klemmenbezeichnung" },
|
||||
{ key: "terminalDesignation", label: "Klemme", fullLabel: "Klemmenbezeichnung" },
|
||||
{ key: "voltage", label: "Spannung", numeric: true },
|
||||
{ key: "controlRequirement", label: "Steuerungsanforderung" },
|
||||
{ key: "controlRequirement", label: "Steuerung", fullLabel: "Steuerungsanforderung" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "isReserve", label: "Reserve" },
|
||||
];
|
||||
@@ -95,6 +124,96 @@ export const defaultVisibleColumnKeys = allColumns
|
||||
.filter((column) => column.defaultVisible)
|
||||
.map((column) => column.key);
|
||||
|
||||
const projectColumnLayoutStoragePrefix =
|
||||
"circuitTreeEditor.columnLayout.v2";
|
||||
|
||||
export interface CircuitColumnLayout {
|
||||
order: CellKey[];
|
||||
visible: CellKey[];
|
||||
}
|
||||
|
||||
const circuitSectionLabels: Record<string, string> = {
|
||||
lighting: "Licht",
|
||||
single_phase: "Einphasig",
|
||||
three_phase: "Dreiphasig",
|
||||
unassigned: "Nicht zugeordnet",
|
||||
};
|
||||
|
||||
export function getCircuitSectionLabel(
|
||||
section: { key: string; displayName: string }
|
||||
): string {
|
||||
return circuitSectionLabels[section.key] ?? section.displayName;
|
||||
}
|
||||
|
||||
export function getProjectColumnLayoutStorageKey(
|
||||
projectId: string
|
||||
): string {
|
||||
return `${projectColumnLayoutStoragePrefix}.${projectId}`;
|
||||
}
|
||||
|
||||
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
|
||||
const unique = [...new Set(keys)];
|
||||
const allKeys = allColumns.map((column) => column.key);
|
||||
const merged = [
|
||||
...unique,
|
||||
...allKeys.filter((key) => !unique.includes(key)),
|
||||
];
|
||||
return [
|
||||
"equipmentIdentifier",
|
||||
...merged.filter((key) => key !== "equipmentIdentifier"),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseStoredColumnLayout(
|
||||
serialized: string | null
|
||||
): CircuitColumnLayout | null {
|
||||
if (!serialized) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as {
|
||||
order?: unknown;
|
||||
visible?: unknown;
|
||||
};
|
||||
if (
|
||||
!Array.isArray(parsed.order) ||
|
||||
!Array.isArray(parsed.visible)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const validKeys = new Set(
|
||||
allColumns.map((column) => column.key)
|
||||
);
|
||||
const order = parsed.order.filter(
|
||||
(key): key is CellKey =>
|
||||
typeof key === "string" &&
|
||||
validKeys.has(key as CellKey)
|
||||
);
|
||||
const visible = [
|
||||
...new Set(
|
||||
parsed.visible.filter(
|
||||
(key): key is CellKey =>
|
||||
typeof key === "string" &&
|
||||
validKeys.has(key as CellKey)
|
||||
)
|
||||
),
|
||||
];
|
||||
if (
|
||||
!order.length ||
|
||||
!visible.length ||
|
||||
!visible.includes("equipmentIdentifier")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
order: normalizeColumnOrder(order),
|
||||
visible,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const deviceOnlyColumns = new Set<CellKey>([
|
||||
"quantity",
|
||||
"powerPerUnit",
|
||||
@@ -162,23 +281,87 @@ const circuitFieldKeys = new Set<CellKey>([
|
||||
"cableSummary",
|
||||
"rcdAssignment",
|
||||
"terminalDesignation",
|
||||
"voltage",
|
||||
"controlRequirement",
|
||||
"status",
|
||||
"isReserve",
|
||||
"remark",
|
||||
]);
|
||||
|
||||
export function formatValue(value: GridValue): string {
|
||||
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
||||
|
||||
function formatNumber(value: number, maximumFractionDigits: number): string {
|
||||
let formatter = numberFormatters.get(maximumFractionDigits);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat("de-DE", {
|
||||
maximumFractionDigits,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
});
|
||||
numberFormatters.set(maximumFractionDigits, formatter);
|
||||
}
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
function getMaximumFractionDigits(key: CellKey | undefined): number {
|
||||
if (
|
||||
key === "powerPerUnit" ||
|
||||
key === "rowTotalPower" ||
|
||||
key === "circuitTotalPower"
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
if (
|
||||
key === "simultaneityFactor" ||
|
||||
key === "cosPhi" ||
|
||||
key === "protectionRatedCurrent" ||
|
||||
key === "cableLength"
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
if (key === "voltage") {
|
||||
return 0;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
export function formatValue(value: GridValue, key?: CellKey): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "Ja" : "Nein";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return formatNumber(value, getMaximumFractionDigits(key));
|
||||
}
|
||||
if (key === "phaseType") {
|
||||
return formatPhaseTypeLabel(String(value));
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatPhaseTypeLabel(value: string): string {
|
||||
if (value === "single_phase") {
|
||||
return "1-phasig";
|
||||
}
|
||||
if (value === "three_phase") {
|
||||
return "3-phasig";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isGridEditorControlTarget(target: unknown): boolean {
|
||||
if (
|
||||
target === null ||
|
||||
typeof target !== "object" ||
|
||||
!("closest" in target) ||
|
||||
typeof target.closest !== "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest("input, select, textarea"));
|
||||
}
|
||||
|
||||
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed === "") {
|
||||
@@ -317,12 +500,15 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFilterValue(value: GridValue): string {
|
||||
return formatValue(value);
|
||||
export function normalizeFilterValue(key: CellKey, value: GridValue): string {
|
||||
return formatValue(value, key);
|
||||
}
|
||||
|
||||
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||
const firstRow = circuit.deviceRows[0];
|
||||
if (key === "rowTotalPower") {
|
||||
return circuit.circuitTotalPower;
|
||||
}
|
||||
if (circuitOnlyColumns.has(key)) {
|
||||
return getCircuitValue(circuit, key);
|
||||
}
|
||||
@@ -351,6 +537,7 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
|
||||
|
||||
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
||||
if (key === "voltage") return "readonly";
|
||||
if (rowType === "section") return "readonly";
|
||||
if (rowType === "placeholder") {
|
||||
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||
|
||||
@@ -47,16 +47,19 @@ export interface VisibleGridRow {
|
||||
|
||||
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
|
||||
const values = new Set<string>();
|
||||
if (key === "rowTotalPower") {
|
||||
values.add(normalizeFilterValue(key, circuit.circuitTotalPower));
|
||||
}
|
||||
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
|
||||
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
|
||||
values.add(normalizeFilterValue(key, getCircuitValue(circuit, key)));
|
||||
}
|
||||
if (!circuitOnlyColumns.has(key)) {
|
||||
for (const device of circuit.deviceRows) {
|
||||
values.add(normalizeFilterValue(getDeviceValue(device, key)));
|
||||
values.add(normalizeFilterValue(key, getDeviceValue(device, key)));
|
||||
}
|
||||
}
|
||||
if (values.size === 0 && deviceFieldKeys.has(key)) {
|
||||
values.add(normalizeFilterValue(undefined));
|
||||
values.add(normalizeFilterValue(key, undefined));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -140,10 +143,14 @@ function makeVisibleGridRow(
|
||||
value = getDeviceValue(device, column.key);
|
||||
} else if (kind === "circuitField" && circuit) {
|
||||
value = getCircuitValue(circuit, column.key);
|
||||
} else if (column.key === "circuitTotalPower" && circuit) {
|
||||
value = circuit.circuitTotalPower;
|
||||
} else if (column.key === "rowTotalPower" && device) {
|
||||
value = device.rowTotalPower;
|
||||
} else if (column.key === "rowTotalPower" && circuit) {
|
||||
value = circuit.circuitTotalPower;
|
||||
} else if (column.key === "circuitTotalPower" && circuit) {
|
||||
value = circuit.circuitTotalPower;
|
||||
} else if (column.key === "voltage" && circuit) {
|
||||
value = circuit.voltage;
|
||||
}
|
||||
|
||||
return { cellKey: column.key, editable, kind, value };
|
||||
|
||||
@@ -31,6 +31,7 @@ const commandTypeLabels: Record<string, string> = {
|
||||
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
||||
"project.update-settings": "Projekteinstellungen bearbeitet",
|
||||
"distribution-board.insert": "Verteilung angelegt",
|
||||
"distribution-board.update": "Verteilung bearbeitet",
|
||||
"distribution-board.delete": "Verteilung entfernt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
@@ -48,8 +49,15 @@ export function getProjectRevisionSourceLabel(
|
||||
export function getProjectRevisionDescription(
|
||||
revision: ProjectRevisionSummaryDto
|
||||
) {
|
||||
const description = revision.description?.trim();
|
||||
if (
|
||||
description === `Undo ${revision.commandType}` ||
|
||||
description === `Redo ${revision.commandType}`
|
||||
) {
|
||||
return commandTypeLabels[revision.commandType] || revision.commandType;
|
||||
}
|
||||
return (
|
||||
revision.description?.trim() ||
|
||||
description ||
|
||||
commandTypeLabels[revision.commandType] ||
|
||||
revision.commandType
|
||||
);
|
||||
@@ -61,6 +69,26 @@ export function getProjectSnapshotKindLabel(
|
||||
return kind === "automatic" ? "Automatisch" : "Benannt";
|
||||
}
|
||||
|
||||
export function getProjectSnapshotRevisionDescription(
|
||||
snapshot: ProjectSnapshotMetadataDto
|
||||
) {
|
||||
if (snapshot.sourceRevision === 0) {
|
||||
return "Projektstart";
|
||||
}
|
||||
if (
|
||||
!snapshot.sourceRevisionMetadata ||
|
||||
snapshot.sourceRevisionMetadata.revisionNumber !==
|
||||
snapshot.sourceRevision
|
||||
) {
|
||||
return "Änderungsdetails nicht verfügbar";
|
||||
}
|
||||
return `${getProjectRevisionSourceLabel(
|
||||
snapshot.sourceRevisionMetadata.source
|
||||
)}: ${getProjectRevisionDescription(
|
||||
snapshot.sourceRevisionMetadata
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function mergeProjectRevisionPages(
|
||||
current: ProjectRevisionSummaryDto[],
|
||||
next: ProjectRevisionSummaryDto[]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { ProjectTransferRepository } from "../../db/repositories/project-transfer.repository.js";
|
||||
|
||||
export const projectTransferStore = new ProjectTransferRepository(db);
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
applyDistributionBoardSimultaneityFactor,
|
||||
calculateCircuitTotalPower,
|
||||
calculateDistributionBoardTotalPower,
|
||||
calculateRowTotalPower,
|
||||
calculateSectionTotalPower,
|
||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||
import {
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
circuitListRepository,
|
||||
circuitRepository,
|
||||
circuitSectionRepository,
|
||||
distributionBoardRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
|
||||
@@ -37,6 +41,16 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const distributionBoard =
|
||||
await distributionBoardRepository.findById(
|
||||
projectId,
|
||||
list.distributionBoardId
|
||||
);
|
||||
if (!distributionBoard) {
|
||||
return res.status(404).json({
|
||||
error: "Distribution board not found",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||
@@ -55,12 +69,19 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
const tree: CircuitTreeResponse = {
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
distributionBoardSimultaneityFactor:
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
key: section.key,
|
||||
displayName: section.displayName,
|
||||
prefix: section.prefix,
|
||||
sortOrder: section.sortOrder,
|
||||
sectionTotalPower: 0,
|
||||
circuits: [],
|
||||
})),
|
||||
};
|
||||
@@ -121,12 +142,31 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const section of tree.sections) {
|
||||
section.sectionTotalPower = calculateSectionTotalPower(
|
||||
section.circuits
|
||||
);
|
||||
}
|
||||
tree.distributionBoardTotalPower =
|
||||
calculateDistributionBoardTotalPower(tree.sections);
|
||||
tree.distributionBoardTotalPowerWithSimultaneityFactor =
|
||||
applyDistributionBoardSimultaneityFactor(
|
||||
tree.distributionBoardTotalPower,
|
||||
tree.distributionBoardSimultaneityFactor
|
||||
);
|
||||
|
||||
return res.json(tree);
|
||||
} catch (error) {
|
||||
if (isMissingCircuitTreeSchemaError(error)) {
|
||||
return res.json({
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
distributionBoardSimultaneityFactor:
|
||||
distributionBoard.simultaneityFactor,
|
||||
distributionBoardTotalPower: 0,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||
sections: [],
|
||||
warning:
|
||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { createDistributionBoardUpdateProjectCommand } from "../../domain/models/distribution-board-project-command.model.js";
|
||||
import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||
import {
|
||||
createDistributionBoardSchema,
|
||||
updateDistributionBoardSchema,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { distributionBoardRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
@@ -31,7 +35,11 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
||||
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
projectId,
|
||||
parsed.data.name
|
||||
parsed.data.name,
|
||||
{
|
||||
floorId: parsed.data.floorId,
|
||||
supplyType: parsed.data.supplyType,
|
||||
}
|
||||
);
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
@@ -43,9 +51,53 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
||||
});
|
||||
return res.status(201).json({
|
||||
...result,
|
||||
distributionBoard: structure.distributionBoard,
|
||||
distributionBoard: {
|
||||
...structure.distributionBoard,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDistributionBoard(
|
||||
req: Request,
|
||||
res: Response
|
||||
) {
|
||||
const { projectId, distributionBoardId } = req.params;
|
||||
if (
|
||||
typeof projectId !== "string" ||
|
||||
typeof distributionBoardId !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid distribution board id" });
|
||||
}
|
||||
const parsed = updateDistributionBoardSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Verteilung bearbeiten",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
distributionBoardId,
|
||||
{
|
||||
floorId: parsed.data.floorId,
|
||||
supplyType: parsed.data.supplyType,
|
||||
simultaneityFactor: parsed.data.simultaneityFactor,
|
||||
}
|
||||
),
|
||||
});
|
||||
const distributionBoard = (
|
||||
await distributionBoardRepository.listByProject(projectId)
|
||||
).find((board) => board.id === distributionBoardId);
|
||||
if (!distributionBoard) {
|
||||
return res.status(404).json({ error: "Distribution board not found" });
|
||||
}
|
||||
return res.json({ ...result, distributionBoard });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ export async function copyProjectDeviceToGlobal(req: Request, res: Response) {
|
||||
quantity: source.quantity,
|
||||
installedPowerPerUnitKw: source.powerPerUnit,
|
||||
demandFactor: source.simultaneityFactor,
|
||||
voltageV: source.voltageV ?? undefined,
|
||||
phaseCount: source.phaseType === "three_phase" ? 3 : 1,
|
||||
powerFactor: source.cosPhi ?? undefined,
|
||||
note: source.remark ?? undefined,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { projectCommandService } from "../composition/project-command-stores.js"
|
||||
import {
|
||||
globalDeviceRepository,
|
||||
projectDeviceRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
import {
|
||||
createProjectDeviceCommandSchema,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "../../shared/validation/project-device.schemas.js";
|
||||
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
import { resolveProjectVoltage } from "../../domain/services/project-voltage.service.js";
|
||||
|
||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
@@ -41,9 +43,18 @@ export async function createProjectDevice(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const { expectedRevision, ...input } = parsed.data;
|
||||
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
|
||||
|
||||
try {
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const projectDevice = toProjectDeviceSnapshot(
|
||||
projectId,
|
||||
randomUUID(),
|
||||
input,
|
||||
project
|
||||
);
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision,
|
||||
@@ -73,13 +84,17 @@ export async function updateProjectDevice(req: Request, res: Response) {
|
||||
const { expectedRevision, ...input } = parsed.data;
|
||||
|
||||
try {
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision,
|
||||
description: "Projektgerät bearbeiten",
|
||||
command: createProjectDeviceUpdateProjectCommand(
|
||||
projectDeviceId,
|
||||
toProjectDeviceValues(input)
|
||||
toProjectDeviceValues(input, project)
|
||||
),
|
||||
});
|
||||
// Linked rows are synchronized only through the explicit review flow.
|
||||
@@ -133,6 +148,10 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Global device not found" });
|
||||
}
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
|
||||
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
||||
name: source.name,
|
||||
@@ -144,8 +163,7 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
simultaneityFactor: source.demandFactor,
|
||||
cosPhi: source.powerFactor ?? undefined,
|
||||
remark: source.note ?? undefined,
|
||||
voltageV: source.voltageV ?? undefined,
|
||||
});
|
||||
}, project);
|
||||
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
@@ -167,16 +185,26 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
function toProjectDeviceSnapshot(
|
||||
projectId: string,
|
||||
id: string,
|
||||
input: CreateProjectDeviceInput
|
||||
input: CreateProjectDeviceInput,
|
||||
project: {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
): ProjectDeviceSnapshot {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
...toProjectDeviceValues(input),
|
||||
...toProjectDeviceValues(input, project),
|
||||
};
|
||||
}
|
||||
|
||||
function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
||||
function toProjectDeviceValues(
|
||||
input: CreateProjectDeviceInput,
|
||||
project: {
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
@@ -189,7 +217,7 @@ function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
||||
simultaneityFactor: input.simultaneityFactor,
|
||||
cosPhi: input.cosPhi ?? null,
|
||||
remark: input.remark ?? null,
|
||||
voltageV: input.voltageV ?? null,
|
||||
voltageV: resolveProjectVoltage(input.phaseType, project),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
createNamedProjectSnapshotSchema,
|
||||
restoreProjectSnapshotSchema,
|
||||
} from "../../shared/validation/project-snapshot.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import {
|
||||
projectCommandService,
|
||||
projectHistoryStore,
|
||||
} from "../composition/project-command-stores.js";
|
||||
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
|
||||
|
||||
export function listProjectSnapshots(req: Request, res: Response) {
|
||||
@@ -18,7 +21,7 @@ export function listProjectSnapshots(req: Request, res: Response) {
|
||||
if (!snapshots) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
return res.json(snapshots);
|
||||
return res.json(withSourceRevisionMetadata(projectId, snapshots));
|
||||
}
|
||||
|
||||
export function createNamedProjectSnapshot(
|
||||
@@ -43,7 +46,9 @@ export function createNamedProjectSnapshot(
|
||||
if (!snapshot) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
return res.status(201).json(snapshot);
|
||||
return res
|
||||
.status(201)
|
||||
.json(withSourceRevisionMetadata(projectId, [snapshot])[0]);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectRevisionConflictError) {
|
||||
return res.status(409).json({
|
||||
@@ -97,7 +102,9 @@ export function restoreProjectSnapshot(
|
||||
command: prepared.command,
|
||||
});
|
||||
return res.json({
|
||||
snapshot: prepared.snapshot,
|
||||
snapshot: withSourceRevisionMetadata(projectId, [
|
||||
prepared.snapshot,
|
||||
])[0],
|
||||
revision: result.revision,
|
||||
history: result.history,
|
||||
});
|
||||
@@ -120,6 +127,29 @@ export function restoreProjectSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function withSourceRevisionMetadata<
|
||||
TSnapshot extends { sourceRevision: number },
|
||||
>(
|
||||
projectId: string,
|
||||
snapshots: TSnapshot[]
|
||||
) {
|
||||
const revisions = projectHistoryStore.listRevisionsByNumbers(
|
||||
projectId,
|
||||
snapshots.map((snapshot) => snapshot.sourceRevision)
|
||||
);
|
||||
const revisionByNumber = new Map(
|
||||
revisions.map((revision) => [
|
||||
revision.revisionNumber,
|
||||
revision,
|
||||
])
|
||||
);
|
||||
return snapshots.map((snapshot) => ({
|
||||
...snapshot,
|
||||
sourceRevisionMetadata:
|
||||
revisionByNumber.get(snapshot.sourceRevision) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function getProjectId(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||
import {
|
||||
importProjectTransferAsNewSchema,
|
||||
importProjectTransferSchema,
|
||||
} from "../../shared/validation/project-transfer.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||
|
||||
export function exportProjectTransfer(req: Request, res: Response) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
const transfer = projectTransferStore.exportProject(projectId);
|
||||
if (!transfer) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const filename = safeFilename(transfer.projectState.project.name);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename}.leistungsbilanz.json"`
|
||||
);
|
||||
return res.json(transfer);
|
||||
}
|
||||
|
||||
export function importProjectTransferAsNewProject(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const parsed = importProjectTransferAsNewSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
return res
|
||||
.status(201)
|
||||
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.startsWith("Project transfer") ||
|
||||
error.message.startsWith("Snapshot"))
|
||||
) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function importProjectTransfer(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
const parsed = importProjectTransferSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
if (parsed.data.mode === "duplicate") {
|
||||
return res
|
||||
.status(201)
|
||||
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||
}
|
||||
const prepared = projectTransferStore.prepareReplace(
|
||||
projectId,
|
||||
parsed.data.transfer
|
||||
);
|
||||
if (!prepared) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const result = projectCommandService.executeRestore({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Projektimport wiederherstellen",
|
||||
command: prepared.command,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectRevisionConflictError) {
|
||||
return res.status(409).json({
|
||||
error: error.message,
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
expectedRevision: error.expectedRevision,
|
||||
currentRevision: error.actualRevision,
|
||||
});
|
||||
}
|
||||
if (error instanceof ProjectStateConflictError) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: error.message, code: "PROJECT_STATE_CONFLICT" });
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.startsWith("Project transfer") ||
|
||||
error.message.startsWith("Snapshot"))
|
||||
) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectId(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||
res.status(400).json({ error: "Invalid projectId" });
|
||||
return null;
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
function safeFilename(name: string) {
|
||||
return (
|
||||
name
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "projekt"
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
createDistributionBoard,
|
||||
listDistributionBoardsByProject,
|
||||
updateDistributionBoard,
|
||||
} from "../controllers/distribution-board.controller.js";
|
||||
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
|
||||
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
||||
@@ -27,12 +28,20 @@ import {
|
||||
listProjectSnapshots,
|
||||
restoreProjectSnapshot,
|
||||
} from "../controllers/project-snapshot.controller.js";
|
||||
import {
|
||||
exportProjectTransfer,
|
||||
importProjectTransfer,
|
||||
importProjectTransferAsNewProject,
|
||||
} from "../controllers/project-transfer.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||
@@ -47,6 +56,10 @@ projectRouter.post(
|
||||
projectRouter.put("/:projectId", updateProjectSettings);
|
||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||
projectRouter.put(
|
||||
"/:projectId/distribution-boards/:distributionBoardId",
|
||||
updateDistributionBoard
|
||||
);
|
||||
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
||||
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
||||
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const distributionBoardSupplyTypes = [
|
||||
"AV",
|
||||
"SV",
|
||||
"EV",
|
||||
"USV",
|
||||
"MSR",
|
||||
"SiBe",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardSupplyType =
|
||||
(typeof distributionBoardSupplyTypes)[number];
|
||||
|
||||
export const defaultDistributionBoardSupplyTypes: DistributionBoardSupplyType[] =
|
||||
[...distributionBoardSupplyTypes];
|
||||
|
||||
export const distributionBoardSupplyTypeLabels: Record<
|
||||
DistributionBoardSupplyType,
|
||||
string
|
||||
> = {
|
||||
AV: "AV – Allgemeine Stromversorgung",
|
||||
SV: "SV – Sicherheitsstromversorgung",
|
||||
EV: "EV – Ersatzstromversorgung",
|
||||
USV: "USV – Unterbrechungsfreie Stromversorgung",
|
||||
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
|
||||
SiBe: "SiBe – Sicherheitsbeleuchtung",
|
||||
};
|
||||
@@ -7,11 +7,10 @@ export const createGlobalDeviceSchema = z.object({
|
||||
quantity: z.number().min(0),
|
||||
installedPowerPerUnitKw: z.number().min(0),
|
||||
demandFactor: z.number().min(0).max(1),
|
||||
voltageV: z.number().positive().optional(),
|
||||
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
|
||||
phaseCount: z.union([z.literal(1), z.literal(3)]),
|
||||
powerFactor: z.number().min(0).max(1).optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
}).strict();
|
||||
|
||||
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ export const createProjectDeviceSchema = z.object({
|
||||
simultaneityFactor: z.number().min(0).max(1),
|
||||
cosPhi: z.number().min(0).max(1).optional(),
|
||||
remark: z.string().optional(),
|
||||
// Transitional metadata used when importing from the legacy global-device library.
|
||||
voltageV: z.number().positive().optional(),
|
||||
});
|
||||
}).strict();
|
||||
|
||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
internalProjectNumber: z.string().trim().max(100).optional(),
|
||||
externalProjectNumber: z.string().trim().max(100).optional(),
|
||||
buildingOwner: z.string().trim().max(200).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
singlePhaseVoltageV: z.number().positive().optional(),
|
||||
threePhaseVoltageV: z.number().positive().optional(),
|
||||
});
|
||||
@@ -10,8 +15,19 @@ export const createProjectSchema = z.object({
|
||||
export const updateProjectSettingsSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
internalProjectNumber: z.string().trim().max(100).nullable(),
|
||||
externalProjectNumber: z.string().trim().max(100).nullable(),
|
||||
buildingOwner: z.string().trim().max(200).nullable(),
|
||||
description: z.string().trim().max(2000).nullable(),
|
||||
singlePhaseVoltageV: z.number().positive(),
|
||||
threePhaseVoltageV: z.number().positive(),
|
||||
enabledDistributionBoardSupplyTypes: z
|
||||
.array(z.enum(distributionBoardSupplyTypes))
|
||||
.min(1)
|
||||
.refine((values) => new Set(values).size === values.length, {
|
||||
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -19,6 +35,17 @@ export const createDistributionBoardSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1),
|
||||
floorId: z.string().trim().min(1).nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateDistributionBoardSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
floorId: z.string().trim().min(1).nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||
simultaneityFactor: z.number().finite().min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
const requiredProjectTransferSchema = z
|
||||
.unknown()
|
||||
.refine((value) => value !== undefined, {
|
||||
message: "Projektdatei fehlt.",
|
||||
});
|
||||
|
||||
export const importProjectTransferAsNewSchema = z
|
||||
.object({
|
||||
transfer: requiredProjectTransferSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("replace"),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
transfer: requiredProjectTransferSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("duplicate"),
|
||||
transfer: requiredProjectTransferSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
@@ -4,10 +4,15 @@ import {
|
||||
allColumns,
|
||||
buildCircuitEditPatch,
|
||||
buildDeviceRowEditPatch,
|
||||
formatValue,
|
||||
getCircuitSectionLabel,
|
||||
getProjectColumnLayoutStorageKey,
|
||||
isGridEditorControlTarget,
|
||||
getBlockSortValue,
|
||||
getCellKind,
|
||||
getCircuitValue,
|
||||
getDeviceValue,
|
||||
parseStoredColumnLayout,
|
||||
parseNumeric,
|
||||
} from "../src/frontend/utils/circuit-grid-model.js";
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
|
||||
@@ -49,11 +54,69 @@ const circuit: CircuitTreeCircuitDto = {
|
||||
};
|
||||
|
||||
describe("circuit grid model", () => {
|
||||
it("does not route clicks from active editor controls back to the grid cell", () => {
|
||||
const inputTarget = {
|
||||
closest: (selector: string) =>
|
||||
selector === "input, select, textarea" ? {} : null,
|
||||
};
|
||||
const cellTarget = { closest: () => null };
|
||||
|
||||
assert.equal(isGridEditorControlTarget(inputTarget), true);
|
||||
assert.equal(isGridEditorControlTarget(cellTarget), false);
|
||||
assert.equal(isGridEditorControlTarget(null), false);
|
||||
});
|
||||
|
||||
it("keeps the equipment identifier locked as the first column", () => {
|
||||
assert.equal(allColumns[0].key, "equipmentIdentifier");
|
||||
assert.equal(allColumns[0].label, "SK-Nr.");
|
||||
assert.equal(
|
||||
allColumns[0].fullLabel,
|
||||
"Stromkreisnummer / Betriebsmittelkennzeichen"
|
||||
);
|
||||
assert.equal(allColumns[0].locked, true);
|
||||
});
|
||||
|
||||
it("stores valid column layouts under a project-specific key", () => {
|
||||
assert.equal(
|
||||
getProjectColumnLayoutStorageKey("project-1"),
|
||||
"circuitTreeEditor.columnLayout.v2.project-1"
|
||||
);
|
||||
const layout = parseStoredColumnLayout(
|
||||
JSON.stringify({
|
||||
order: ["displayName", "equipmentIdentifier"],
|
||||
visible: [
|
||||
"displayName",
|
||||
"equipmentIdentifier",
|
||||
"unknown",
|
||||
],
|
||||
})
|
||||
);
|
||||
assert.ok(layout);
|
||||
assert.equal(layout.order[0], "equipmentIdentifier");
|
||||
assert.deepEqual(layout.visible, [
|
||||
"displayName",
|
||||
"equipmentIdentifier",
|
||||
]);
|
||||
assert.equal(parseStoredColumnLayout("{invalid"), null);
|
||||
});
|
||||
|
||||
it("shows stable circuit sections with German labels", () => {
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
}),
|
||||
"Licht"
|
||||
);
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "custom",
|
||||
displayName: "Sonderbereich",
|
||||
}),
|
||||
"Sonderbereich"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps shared fields to the correct level for every row shape", () => {
|
||||
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
|
||||
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
|
||||
@@ -76,6 +139,15 @@ describe("circuit grid model", () => {
|
||||
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
||||
});
|
||||
|
||||
it("formats displayed numeric values without changing their stored precision", () => {
|
||||
assert.equal(formatValue(0.123456, "powerPerUnit"), "0,123");
|
||||
assert.equal(formatValue(0.87654, "simultaneityFactor"), "0,88");
|
||||
assert.equal(formatValue(230.4, "voltage"), "230");
|
||||
assert.equal(formatValue(16, "protectionRatedCurrent"), "16");
|
||||
assert.equal(formatValue("single_phase", "phaseType"), "1-phasig");
|
||||
assert.equal(formatValue("three_phase", "phaseType"), "3-phasig");
|
||||
});
|
||||
|
||||
it("uses circuit display values for block sorting and falls back to the first device", () => {
|
||||
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
|
||||
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
|
||||
|
||||
@@ -164,6 +164,15 @@ describe("circuit grid projection", () => {
|
||||
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
|
||||
"-frei-"
|
||||
);
|
||||
const circuitSummary = rows.find((row) => row.rowType === "circuitSummary");
|
||||
assert.equal(
|
||||
circuitSummary?.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
|
||||
multiDeviceCircuit.circuitTotalPower
|
||||
);
|
||||
assert.equal(
|
||||
circuitSummary?.cells.some((cell) => cell.cellKey === "circuitTotalPower"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("collects filter options from both circuit and device values", () => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
applyDistributionBoardSimultaneityFactor,
|
||||
calculateCircuitTotalPower,
|
||||
calculateDistributionBoardTotalPower,
|
||||
calculateRowTotalPower,
|
||||
calculateSectionTotalPower,
|
||||
} from "../src/domain/calculations/circuit-power-calculation.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
normalizeKnownElectricalPhaseType,
|
||||
} from "../src/domain/services/project-voltage.service.js";
|
||||
|
||||
describe("circuit power calculation", () => {
|
||||
it("calculates row and circuit totals from device rows", () => {
|
||||
@@ -14,5 +22,68 @@ describe("circuit power calculation", () => {
|
||||
]);
|
||||
assert.equal(total, 4.5);
|
||||
});
|
||||
|
||||
it("calculates section and distribution-board totals", () => {
|
||||
const lighting = calculateSectionTotalPower([
|
||||
{ circuitTotalPower: 1.5 },
|
||||
{ circuitTotalPower: 2.25 },
|
||||
]);
|
||||
const singlePhase = calculateSectionTotalPower([
|
||||
{ circuitTotalPower: 3 },
|
||||
]);
|
||||
const total = calculateDistributionBoardTotalPower([
|
||||
{ sectionTotalPower: lighting },
|
||||
{ sectionTotalPower: singlePhase },
|
||||
{ sectionTotalPower: 0 },
|
||||
]);
|
||||
|
||||
assert.equal(lighting, 3.75);
|
||||
assert.equal(total, 6.75);
|
||||
assert.equal(
|
||||
applyDistributionBoardSimultaneityFactor(total, 0.8),
|
||||
5.4
|
||||
);
|
||||
assert.throws(
|
||||
() => applyDistributionBoardSimultaneityFactor(total, 1.1),
|
||||
/between zero and one/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project voltage derivation", () => {
|
||||
const settings = {
|
||||
singlePhaseVoltageV: 240,
|
||||
threePhaseVoltageV: 415,
|
||||
};
|
||||
|
||||
it("derives device voltage exclusively from phase and project settings", () => {
|
||||
assert.equal(resolveProjectVoltage("single_phase", settings), 240);
|
||||
assert.equal(resolveProjectVoltage("three_phase", settings), 415);
|
||||
});
|
||||
|
||||
it("uses section phase and handles unassigned circuits conservatively", () => {
|
||||
assert.equal(resolveCircuitPhaseType("lighting", ["three_phase"]), "single_phase");
|
||||
assert.equal(resolveCircuitPhaseType("single_phase", ["three_phase"]), "single_phase");
|
||||
assert.equal(resolveCircuitPhaseType("three_phase", ["single_phase"]), "three_phase");
|
||||
assert.equal(resolveCircuitPhaseType("unassigned", ["three_phase"]), "three_phase");
|
||||
assert.equal(
|
||||
resolveCircuitPhaseType("unassigned", ["three_phase", "single_phase"]),
|
||||
"single_phase"
|
||||
);
|
||||
assert.equal(resolveCircuitPhaseType("unassigned", []), "single_phase");
|
||||
assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase");
|
||||
});
|
||||
|
||||
it("normalizes known legacy phase labels without guessing unknown values", () => {
|
||||
assert.equal(normalizeKnownElectricalPhaseType("1phasig"), "single_phase");
|
||||
assert.equal(normalizeKnownElectricalPhaseType("1-phasig"), "single_phase");
|
||||
assert.equal(normalizeKnownElectricalPhaseType("3phase"), "three_phase");
|
||||
assert.equal(normalizeKnownElectricalPhaseType("3-phasig"), "three_phase");
|
||||
assert.equal(normalizeKnownElectricalPhaseType(null), null);
|
||||
assert.throws(
|
||||
() => normalizeKnownElectricalPhaseType("zweiphasig"),
|
||||
/Unknown electrical phase type/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("circuit project-command repository", () => {
|
||||
const circuit = getCircuit(fixture.context);
|
||||
assert.equal(circuit.displayName, null);
|
||||
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||
assert.equal(circuit.voltage, 400);
|
||||
assert.equal(circuit.voltage, 230);
|
||||
assert.equal(circuit.controlRequirement, "DALI");
|
||||
assert.equal(circuit.isReserve, 1);
|
||||
assert.equal(executed.revision.revisionNumber, 1);
|
||||
@@ -138,7 +138,6 @@ describe("circuit project-command repository", () => {
|
||||
changes: [
|
||||
{ field: "displayName", value: "Licht Bestand" },
|
||||
{ field: "protectionRatedCurrent", value: 10 },
|
||||
{ field: "voltage", value: 230 },
|
||||
{ field: "controlRequirement", value: "none" },
|
||||
{ field: "isReserve", value: false },
|
||||
],
|
||||
@@ -149,7 +148,15 @@ describe("circuit project-command repository", () => {
|
||||
.from(projectChangeSets)
|
||||
.get();
|
||||
assert.ok(changeSet);
|
||||
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command);
|
||||
assert.deepEqual(
|
||||
deserializeProjectCommand(changeSet.forwardPayloadJson),
|
||||
createCircuitUpdateProjectCommand("circuit-1", {
|
||||
displayName: null,
|
||||
protectionRatedCurrent: 16,
|
||||
controlRequirement: "DALI",
|
||||
isReserve: true,
|
||||
})
|
||||
);
|
||||
assert.deepEqual(
|
||||
deserializeProjectCommand(changeSet.inversePayloadJson),
|
||||
executed.inverse
|
||||
|
||||
@@ -13,14 +13,23 @@ import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
type DistributionBoardStructureProjectCommand,
|
||||
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||
import { createDistributionBoard } from "../src/frontend/utils/api.js";
|
||||
import {
|
||||
createDistributionBoardUpdateProjectCommand,
|
||||
type DistributionBoardUpdateProjectCommand,
|
||||
} from "../src/domain/models/distribution-board-project-command.model.js";
|
||||
import {
|
||||
createDistributionBoard,
|
||||
updateDistributionBoard,
|
||||
} from "../src/frontend/utils/api.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
@@ -50,9 +59,11 @@ describe("distribution-board structure project command", () => {
|
||||
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
" UV-02 "
|
||||
" UV-02 ",
|
||||
{ floorId: null, supplyType: "SV" }
|
||||
);
|
||||
assert.equal(structure.distributionBoard.name, "UV-02");
|
||||
assert.equal(structure.distributionBoard.supplyType, "SV");
|
||||
assert.equal(
|
||||
structure.circuitList.name,
|
||||
"UV-02 Stromkreisliste"
|
||||
@@ -91,14 +102,27 @@ describe("distribution-board structure project command", () => {
|
||||
});
|
||||
};
|
||||
try {
|
||||
await createDistributionBoard("project-1", "UV-02", 7);
|
||||
await createDistributionBoard(
|
||||
"project-1",
|
||||
{
|
||||
name: "UV-02",
|
||||
floorId: null,
|
||||
supplyType: "SV",
|
||||
},
|
||||
7
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
url: "/api/projects/project-1/distribution-boards",
|
||||
body: { name: "UV-02", expectedRevision: 7 },
|
||||
body: {
|
||||
name: "UV-02",
|
||||
floorId: null,
|
||||
supplyType: "SV",
|
||||
expectedRevision: 7,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -178,6 +202,306 @@ describe("distribution-board structure project command", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("allows only supply types enabled in the project", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.update(projects)
|
||||
.set({
|
||||
enabledDistributionBoardSupplyTypes: ["MSR", "SiBe"],
|
||||
})
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.run();
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createDistributionBoardInsertProjectCommand(
|
||||
createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV AV",
|
||||
{ floorId: null, supplyType: "AV" }
|
||||
)
|
||||
),
|
||||
}),
|
||||
/nicht aktiviert/
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV SiBe",
|
||||
{ floorId: null, supplyType: "SiBe" }
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(distributionBoards).get()?.supplyType,
|
||||
"SiBe"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies stored version-one setup commands with unassigned new fields", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const current = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV Alt",
|
||||
{ floorId: null, supplyType: null }
|
||||
);
|
||||
const {
|
||||
floorId: _floorId,
|
||||
supplyType: _supplyType,
|
||||
...legacyBoard
|
||||
} = current.distributionBoard;
|
||||
const legacyCommand = {
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board.insert",
|
||||
payload: {
|
||||
structure: {
|
||||
...current,
|
||||
distributionBoard: legacyBoard,
|
||||
},
|
||||
},
|
||||
} as unknown as DistributionBoardStructureProjectCommand;
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: legacyCommand,
|
||||
});
|
||||
const inserted = context.db
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.get();
|
||||
assert.equal(inserted?.floorId, null);
|
||||
assert.equal(inserted?.supplyType, null);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates floor, supply type and simultaneity factor with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.insert(floors)
|
||||
.values([
|
||||
{
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "floor-foreign",
|
||||
projectId: "project-2",
|
||||
name: "Fremd",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV-02",
|
||||
{ floorId: null, supplyType: "AV" }
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{
|
||||
floorId: "floor-1",
|
||||
supplyType: "SV",
|
||||
simultaneityFactor: 0.75,
|
||||
}
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
floorId: distributionBoards.floorId,
|
||||
supplyType: distributionBoards.supplyType,
|
||||
simultaneityFactor:
|
||||
distributionBoards.simultaneityFactor,
|
||||
})
|
||||
.from(distributionBoards)
|
||||
.get(),
|
||||
{
|
||||
floorId: "floor-1",
|
||||
supplyType: "SV",
|
||||
simultaneityFactor: 0.75,
|
||||
}
|
||||
);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({
|
||||
floorId: distributionBoards.floorId,
|
||||
supplyType: distributionBoards.supplyType,
|
||||
simultaneityFactor:
|
||||
distributionBoards.simultaneityFactor,
|
||||
})
|
||||
.from(distributionBoards)
|
||||
.get(),
|
||||
{
|
||||
floorId: null,
|
||||
supplyType: "AV",
|
||||
simultaneityFactor: 1,
|
||||
}
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 3,
|
||||
source: "user",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{ floorId: "floor-foreign" }
|
||||
),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(projectRevisions)
|
||||
.all().length,
|
||||
3
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stored version-one board updates executable", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV Alt",
|
||||
{ supplyType: "AV" }
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command:
|
||||
createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
const legacyUpdate = {
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board.update",
|
||||
payload: {
|
||||
distributionBoardId: structure.distributionBoard.id,
|
||||
changes: [{ field: "supplyType", value: "SV" }],
|
||||
},
|
||||
} as DistributionBoardUpdateProjectCommand;
|
||||
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: legacyUpdate,
|
||||
});
|
||||
|
||||
const board = context.db
|
||||
.select()
|
||||
.from(distributionBoards)
|
||||
.get();
|
||||
assert.equal(board?.supplyType, "SV");
|
||||
assert.equal(board?.simultaneityFactor, 1);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends distribution-board updates to the revision-safe API", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: JSON.parse(String(init?.body)),
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
await updateDistributionBoard(
|
||||
"project-1",
|
||||
"board-1",
|
||||
{
|
||||
floorId: "floor-1",
|
||||
supplyType: "USV",
|
||||
simultaneityFactor: 0.8,
|
||||
},
|
||||
9
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
url: "/api/projects/project-1/distribution-boards/board-1",
|
||||
method: "PUT",
|
||||
body: {
|
||||
floorId: "floor-1",
|
||||
supplyType: "USV",
|
||||
simultaneityFactor: 0.8,
|
||||
expectedRevision: 9,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects foreign, stale and changed structures without partial writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -951,8 +951,21 @@ describe("project command service", () => {
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createProjectSettingsUpdateProjectCommand({
|
||||
name: "Test project",
|
||||
internalProjectNumber: null,
|
||||
externalProjectNumber: null,
|
||||
buildingOwner: null,
|
||||
description: null,
|
||||
singlePhaseVoltageV: 240,
|
||||
threePhaseVoltageV: 415,
|
||||
enabledDistributionBoardSupplyTypes: [
|
||||
"AV",
|
||||
"SV",
|
||||
"EV",
|
||||
"USV",
|
||||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 1);
|
||||
|
||||
@@ -41,6 +41,20 @@ describe("project device circuit-first schema", () => {
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects manually supplied device voltages", () => {
|
||||
const result = createProjectDeviceSchema.safeParse({
|
||||
name: "Pumpe",
|
||||
displayName: "Pumpe",
|
||||
phaseType: "three_phase",
|
||||
quantity: 1,
|
||||
powerPerUnit: 2,
|
||||
simultaneityFactor: 1,
|
||||
voltageV: 500,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("requires a project revision for create and update commands", () => {
|
||||
const device = {
|
||||
name: "E-Line Pro",
|
||||
|
||||
@@ -177,6 +177,33 @@ describe("project history repository", () => {
|
||||
nextBeforeRevision: null,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
history
|
||||
.listRevisionsByNumbers("project-1", [3, 1, 3, 99])
|
||||
.map((revision) => ({
|
||||
revisionNumber: revision.revisionNumber,
|
||||
description: revision.description,
|
||||
})),
|
||||
[
|
||||
{
|
||||
revisionNumber: 3,
|
||||
description: "Anzahl zurücknehmen",
|
||||
},
|
||||
{
|
||||
revisionNumber: 1,
|
||||
description: "Stromkreis umbenennen",
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
history.listRevisionsByNumbers("project-1", [0]),
|
||||
[]
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
history.listRevisionsByNumbers("project-1", [-1]),
|
||||
/non-negative integers/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||
import { consumers } from "../src/db/schema/consumers.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
@@ -183,6 +184,52 @@ describe("project-location structure project command", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not undo a floor assigned to a distribution board", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectFloorInsertProjectCommand(floor),
|
||||
});
|
||||
context.db
|
||||
.insert(distributionBoards)
|
||||
.values({
|
||||
id: "board-1",
|
||||
projectId: "project-1",
|
||||
name: "UV-01",
|
||||
floorId: floor.id,
|
||||
supplyType: "AV",
|
||||
})
|
||||
.run();
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/assigned to a distribution board/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a stable room and supports persisted undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ProjectsPage from "../src/app/projects/page.js";
|
||||
import { importProjectTransferAsNew } from "../src/frontend/utils/api.js";
|
||||
import { importProjectTransferAsNewSchema } from "../src/shared/validation/project-transfer.schemas.js";
|
||||
|
||||
describe("project overview import", () => {
|
||||
it("renders a dedicated import-as-new-project entry point", () => {
|
||||
const globalWithReact = globalThis as typeof globalThis & {
|
||||
React?: typeof React;
|
||||
};
|
||||
const originalReact = globalWithReact.React;
|
||||
globalWithReact.React = React;
|
||||
let markup: string;
|
||||
try {
|
||||
markup = renderToStaticMarkup(React.createElement(ProjectsPage));
|
||||
} finally {
|
||||
globalWithReact.React = originalReact;
|
||||
}
|
||||
|
||||
assert.match(markup, /Projekt importieren/);
|
||||
assert.match(markup, /Projektdatei auswählen/);
|
||||
assert.match(markup, /Als neues Projekt importieren/);
|
||||
assert.doesNotMatch(markup, /Dieses Projekt ersetzen/);
|
||||
});
|
||||
|
||||
it("uses the collection import endpoint without a target project id", async () => {
|
||||
const requests: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ projectId: "project-copy", name: "Projekt (Kopie)" }),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const transfer = { format: "test" };
|
||||
const result = await importProjectTransferAsNew(transfer);
|
||||
assert.deepEqual(result, {
|
||||
projectId: "project-copy",
|
||||
name: "Projekt (Kopie)",
|
||||
});
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
url: "/api/projects/import",
|
||||
method: "POST",
|
||||
body: { transfer },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only the strict transfer wrapper", () => {
|
||||
assert.equal(
|
||||
importProjectTransferAsNewSchema.safeParse({ transfer: {} }).success,
|
||||
true
|
||||
);
|
||||
assert.equal(importProjectTransferAsNewSchema.safeParse({}).success, false);
|
||||
assert.equal(
|
||||
importProjectTransferAsNewSchema.safeParse({
|
||||
transfer: {},
|
||||
mode: "replace",
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user