forked from jappel/leistungsbilanz-ts
Merge Issue #3 logical distribution board feeds
This commit is contained in:
commit
552362bad1
61 changed files with 5399 additions and 352 deletions
51
AGENTS.md
51
AGENTS.md
|
|
@ -52,7 +52,8 @@ See `docs/current-architecture.md` for the complete module and request flow.
|
||||||
- Device rows inside a circuit do not have their own equipment identifiers.
|
- Device rows inside a circuit do not have their own equipment identifiers.
|
||||||
- Protection and cable data belong to the circuit, not to individual devices.
|
- Protection and cable data belong to the circuit, not to individual devices.
|
||||||
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
||||||
- Circuit total power is the sum of all device rows in that circuit.
|
- Circuit total power is the sum of all device rows in that circuit plus the
|
||||||
|
read-only loads of logically linked downstream boards on that feeder circuit.
|
||||||
- Existing equipment identifiers must never be changed automatically.
|
- Existing equipment identifiers must never be changed automatically.
|
||||||
- Renumbering is always an explicit user action.
|
- Renumbering is always an explicit user action.
|
||||||
|
|
||||||
|
|
@ -284,16 +285,19 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 8 additionally stores the optional net room
|
Current snapshot schema version 9 additionally stores
|
||||||
area `rooms[].areaM2` (see `docs/building-structure.md`); versions 1 to 7 are
|
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
||||||
upgraded with `areaM2 = null`. Version 7 stores the project buildings and
|
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
||||||
|
stores the optional net room area `rooms[].areaM2` (see
|
||||||
|
`docs/building-structure.md`); versions 1 to 7 are upgraded with
|
||||||
|
`areaM2 = null`. Version 7 stores the project buildings and
|
||||||
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
|
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
|
||||||
`buildingId = null`. Version 6 stores the
|
`buildingId = null`. Version 6 stores the
|
||||||
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
||||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||||
with `null` for these fields.
|
with `null` for these fields.
|
||||||
Stored named and automatic snapshots of every supported version (1 to 8)
|
Stored named and automatic snapshots of every supported version (1 to 9)
|
||||||
remain restorable; they are upgraded while parsing. Version 5 stores
|
remain restorable; they are upgraded while parsing. Version 5 stores
|
||||||
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
|
|
@ -309,7 +313,7 @@ project graph.
|
||||||
Project-wide Revit CSV configuration uses
|
Project-wide Revit CSV configuration uses
|
||||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||||
persisted through the shared project-command transaction, so create, update,
|
persisted through the shared project-command transaction, so create, update,
|
||||||
delete, Undo and Redo are revision-safe. Snapshot schema 5-8, restore and portable
|
delete, Undo and Redo are revision-safe. Snapshot schema 5-9, restore and portable
|
||||||
project duplication preserve the configuration; duplication remaps its internal
|
project duplication preserve the configuration; duplication remaps its internal
|
||||||
id and project link.
|
id and project link.
|
||||||
The project page exposes the Revit CSV modal. It edits the complete
|
The project page exposes the Revit CSV modal. It edits the complete
|
||||||
|
|
@ -323,7 +327,7 @@ immutable import batches with original bytes plus the classified cell matrix,
|
||||||
normalized source-room mappings and external objects unique by
|
normalized source-room mappings and external objects unique by
|
||||||
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
||||||
The general external-model repository is read-only; runtime writes belong only
|
The general external-model repository is read-only; runtime writes belong only
|
||||||
to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-8
|
to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-9
|
||||||
capture the complete external state and remap every internal UUID and link while
|
capture the complete external state and remap every internal UUID and link while
|
||||||
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
||||||
Import batches additionally store the monotonic project CSV configuration
|
Import batches additionally store the monotonic project CSV configuration
|
||||||
|
|
@ -463,22 +467,34 @@ snapshot contains the group, optional component protections, complete circuits,
|
||||||
circuit protections and all device-row link/override metadata. Delete
|
circuit protections and all device-row link/override metadata. Delete
|
||||||
re-captures and compares the subtree before cascading; restore preserves every
|
re-captures and compares the subtree before cascading; restore preserves every
|
||||||
UUID in foreign-key-safe order.
|
UUID in foreign-key-safe order.
|
||||||
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||||
`components`, `footerComponents`, optional group category/number and optional
|
`components`, `footerComponents`, optional group category/number and optional
|
||||||
one-to-one protection DTOs for circuits and group components. These are
|
one-to-one protection DTOs for circuits and group components. These are
|
||||||
the only supported circuit-protection source; no flat compatibility fields remain.
|
the only supported circuit-protection source; no flat compatibility fields remain.
|
||||||
|
It resolves logical board feeds once for the complete project and adds each
|
||||||
|
downstream board's demand after its own board simultaneity factor to the chosen
|
||||||
|
feeding circuit as a read-only virtual row. Recursive chains and several boards
|
||||||
|
on one circuit are supported. A board-only link affects hierarchy but adds no
|
||||||
|
load until a feeding circuit is selected.
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
persistent undo step.
|
||||||
The same command (still schema version 1, additively extended) carries the
|
The same command uses schema version 2 for the logical
|
||||||
|
`feedingDistributionBoardId` and optional matching `feedingCircuitId` together
|
||||||
|
with the existing fields. Version-1 history commands remain executable and do
|
||||||
|
not change logical feed references. The command rejects self-links, cycles and
|
||||||
|
circuits from another board. The
|
||||||
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
|
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
|
||||||
(migration `0007`). They are nullable, trimmed planner values; the PUT route
|
(migration `0007`). They are nullable, trimmed planner values; the PUT route
|
||||||
only includes submitted info fields in the command and its inverse. An empty
|
only includes submitted info fields in the command and its inverse. An empty
|
||||||
board with info values is no longer an unchanged setup, so history deletion of
|
board with info values is no longer an unchanged setup, so history deletion of
|
||||||
it is rejected like a changed simultaneity factor. Populated-subtree commands
|
it is rejected like a changed simultaneity factor. Populated-subtree commands
|
||||||
stored before these fields existed are normalized to `null` values
|
stored before these fields existed are normalized to `null` values
|
||||||
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free
|
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` remains free text for
|
||||||
text: the logical link to the feeding board/circuit is an open design decision.
|
external feeds and notes; project-board feeds use the two UUID references.
|
||||||
|
Deleting a referenced board or feeding circuit is blocked in every direct,
|
||||||
|
subtree and history-only removal path. Board copies retain their incoming feed,
|
||||||
|
and project duplication remaps both references.
|
||||||
These fields are intended to be prefillable from Revit parameters later, but
|
These fields are intended to be prefillable from Revit parameters later, but
|
||||||
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
||||||
overwrite).
|
overwrite).
|
||||||
|
|
@ -521,9 +537,10 @@ Required operations:
|
||||||
`<thead>` so it repeats on printed pages. PDF export uses the browser print
|
`<thead>` so it repeats on printed pages. PDF export uses the browser print
|
||||||
dialog; page size, margins and "Seite x von y" come from injected named
|
dialog; page size, margins and "Seite x von y" come from injected named
|
||||||
`@page` rules (`lb-portrait`, `lb-landscape`).
|
`@page` rules (`lb-portrait`, `lb-landscape`).
|
||||||
- The board hierarchy and "Σ inkl. unterlagert" in the overview are
|
- The board hierarchy follows logical board ids. The overview shows power
|
||||||
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum,
|
before board GZF and demand including linked downstream boards; it does not
|
||||||
no upstream simultaneity factor). Replace this once the feed link is modeled.
|
add a second cumulative column. Board plans render downstream-board loads as
|
||||||
|
read-only rows. `fedFrom` groups only external root feeds.
|
||||||
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
|
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
|
||||||
`print-document.css`). Empty cover values are shown as
|
`print-document.css`). Empty cover values are shown as
|
||||||
"[ergänzen]"; the Planstände editor column shows the project's current
|
"[ergänzen]"; the Planstände editor column shows the project's current
|
||||||
|
|
@ -701,9 +718,9 @@ for the early-phase power balance by DIN 276 cost group (see
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- supported production deployment
|
||||||
- logical feed link between distribution boards (`fedFrom` stays free text
|
- second/replacement board feeds, including changeover semantics for SV/SiBe
|
||||||
until the project owner decides whether it references a board, a feeding
|
- Revit prefill proposals for logical board feeds through the Phase 14 preview
|
||||||
circuit or both) and the resulting hierarchical power summation
|
and explicit-confirmation workflow
|
||||||
|
|
||||||
Do not implement these while working on an unrelated phase.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,13 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
||||||
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
||||||
importierbar.
|
importierbar.
|
||||||
|
|
||||||
Das aktuelle Projekt-Snapshot-Schema 8 enthält außerdem die optionale
|
Das aktuelle Projekt-Snapshot-Schema 9 enthält die logischen Einspeisungsverweise
|
||||||
Netto-Raumfläche `rooms[].areaM2`. Snapshot-Versionen 1–7 werden dafür mit
|
der Verteilungen; Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
|
||||||
|
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
|
||||||
|
Snapshot-Versionen 1–7 werden dafür mit
|
||||||
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
|
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
|
||||||
Flächen; eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
|
Flächen und Einspeisungsverweise; beim Duplizieren werden Board- und Circuit-IDs
|
||||||
|
neu zugeordnet. Eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
|
||||||
versionierten Projektsnapshot einbettet.
|
versionierten Projektsnapshot einbettet.
|
||||||
|
|
||||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||||
|
|
@ -254,23 +257,30 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }`
|
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
|
||||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
||||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
||||||
and an empty string is stored as `null`
|
and an empty string is stored as `null`
|
||||||
- executes `distribution-board.update`; all submitted values, including the
|
- `feedingDistributionBoardId` and `feedingCircuitId` are optional nullable
|
||||||
|
fields. A feeding circuit requires a feeding board and must belong to it;
|
||||||
|
self-links and cycles are rejected. A board-only link creates hierarchy
|
||||||
|
without assigning its load to an upstream circuit.
|
||||||
|
- executes `distribution-board.update` schema version 2; all submitted values, including the
|
||||||
submitted info fields, are restored together by persistent Undo/Redo
|
submitted info fields, are restored together by persistent Undo/Redo
|
||||||
- `fedFrom` is free text until the logical feed link is modeled
|
- `fedFrom` remains free text for external feeds such as HAK, metering,
|
||||||
|
transformer or generator and for supplementary notes
|
||||||
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
||||||
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
||||||
- duplicates the complete distribution-board subtree with new owning UUIDs
|
- duplicates the complete distribution-board subtree with new owning UUIDs
|
||||||
while preserving project-device and room links
|
while preserving project-device, room and incoming feeding links
|
||||||
- response includes the new `distributionBoard`, its `circuitList`, the
|
- response includes the new `distributionBoard`, its `circuitList`, the
|
||||||
revision and current history state
|
revision and current history state
|
||||||
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body: `{ "expectedRevision": 15 }`
|
- body: `{ "expectedRevision": 15 }`
|
||||||
- removes the complete, exactly captured distribution-board subtree in one
|
- removes the complete, exactly captured distribution-board subtree in one
|
||||||
transaction
|
transaction
|
||||||
|
- rejects deletion while another board references this board or one of its
|
||||||
|
circuits as a feeder; circuit and populated-group deletion use the same guard
|
||||||
- response includes `distributionBoardId`, the revision and current history
|
- response includes `distributionBoardId`, the revision and current history
|
||||||
state
|
state
|
||||||
- Copy and deletion use `distribution-board.insert-subtree` and
|
- Copy and deletion use `distribution-board.insert-subtree` and
|
||||||
|
|
@ -327,12 +337,17 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/circuit-trees`
|
||||||
|
- returns all circuit trees for one project from one project-wide read and is
|
||||||
|
used by the print view and feeding-board selection
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
- Purpose: returns fixed header components, grouped sections with optional
|
- Purpose: returns fixed header components, grouped sections with optional
|
||||||
protection components, complete circuit/device-row blocks and auxiliary
|
protection components, complete circuit/device-row blocks and auxiliary
|
||||||
footer components. Circuit and component protection devices use their
|
footer components. Circuit and component protection devices use their
|
||||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||||
remain included.
|
remain included. Circuit totals contain the demand of linked downstream
|
||||||
|
boards. `downstreamBoardLoads` exposes those contributions as read-only rows;
|
||||||
|
`feedingReference` resolves the current board/circuit labels by id.
|
||||||
|
|
||||||
Response sketch:
|
Response sketch:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,13 @@
|
||||||
- Protection and cable values are planner-owned manual selections. The later
|
- Protection and cable values are planner-owned manual selections. The later
|
||||||
sizing/warning engine does not yet validate them against load, length or
|
sizing/warning engine does not yet validate them against load, length or
|
||||||
voltage-drop rules.
|
voltage-drop rules.
|
||||||
- Distribution-board `fedFrom` is free text. The print overview derives its
|
- One logical incoming feed per distribution board is supported. A second or
|
||||||
supply structure and "Σ inkl. unterlagert" only from exact text matches with
|
replacement feed with changeover semantics (for example SV/SiBe) is still a
|
||||||
board numbers/names and adds values without an upstream simultaneity factor
|
follow-up. `fedFrom` remains the free-text field for external sources and
|
||||||
(see `docs/print-view.md`).
|
supplementary notes. Revit does not yet propose logical feed links.
|
||||||
|
- A logical link without a selected feeding circuit appears in the hierarchy,
|
||||||
|
but deliberately contributes no load to an upstream circuit until the planner
|
||||||
|
selects that circuit (see `docs/print-view.md`).
|
||||||
- The print view uses a kbp corporate design derived from logo and
|
- The print view uses a kbp corporate design derived from logo and
|
||||||
presentation material. Snapshots do not record an editor, so the cover's
|
presentation material. Snapshots do not record an editor, so the cover's
|
||||||
Planstände list shows the project's current editor for every entry. On screen, long
|
Planstände list shows the project's current editor for every entry. On screen, long
|
||||||
|
|
|
||||||
|
|
@ -468,8 +468,22 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
||||||
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
||||||
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
||||||
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
||||||
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
|
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
|
||||||
Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
externe Einspeisungen und Hinweise.
|
||||||
|
`distribution-board.update` verwendet Schema-Version 2 für die optionalen
|
||||||
|
logischen Verweise `feedingDistributionBoardId` und `feedingCircuitId`.
|
||||||
|
Version-1-Befehle aus der Historie bleiben ausführbar und verändern diese
|
||||||
|
Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören;
|
||||||
|
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
|
||||||
|
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
|
||||||
|
vor der Datenbankänderung fachlich gesperrt.
|
||||||
|
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
|
||||||
|
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
||||||
|
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
||||||
|
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
||||||
|
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
||||||
|
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
||||||
|
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
||||||
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
|
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
|
||||||
Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen;
|
Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen;
|
||||||
ältere Versionen werden ohne Gebäude hochgestuft.
|
ältere Versionen werden ohne Gebäude hochgestuft.
|
||||||
|
|
@ -580,7 +594,7 @@ Oberfläche: `/projects/:projectId/grobbilanz`. Details:
|
||||||
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||||
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||||
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
||||||
Transfer-Integration verwenden aktuell Snapshot-Schema 8. Persistente
|
Transfer-Integration verwenden aktuell Snapshot-Schema 9. Persistente
|
||||||
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||||
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||||
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
|
||||||
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
|
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
|
||||||
über Projektbefehle mit Undo/Redo gepflegt.
|
über Projektbefehle mit Undo/Redo gepflegt.
|
||||||
|
|
||||||
Beim Laden werden die Projektrevisionen aller Stromkreislisten und ein
|
Beim Laden werden alle Stromkreislisten in einer projektweiten Abfrage gelesen;
|
||||||
abschließend erneut gelesener Projektstand verglichen. Ändert sich das Projekt
|
deren Projektrevision und ein abschließend erneut gelesener Projektstand werden
|
||||||
|
verglichen. Ändert sich das Projekt
|
||||||
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
|
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
|
||||||
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
|
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
|
||||||
automatisch bei späteren Änderungen in einem anderen Tab.
|
automatisch bei späteren Änderungen in einem anderen Tab.
|
||||||
|
|
@ -31,12 +32,14 @@ automatisch bei späteren Änderungen in einem anderen Tab.
|
||||||
erscheinen als orangefarbenes „[ergänzen]“.
|
erscheinen als orangefarbenes „[ergänzen]“.
|
||||||
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
|
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
|
||||||
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
|
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
|
||||||
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
|
Stück, nur lokale Geräte), Leistung vor Verteiler-GZF einschließlich
|
||||||
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“.
|
gespeister Unterverteilungen, Verteiler-GZF, P<sub>gl</sub> einschließlich
|
||||||
|
Unterverteilungen und I<sub>B</sub>. Die Hierarchie folgt den logischen IDs.
|
||||||
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
||||||
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
||||||
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
||||||
(mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen),
|
(mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und
|
||||||
|
schreibgeschützten Unterverteilungszeilen),
|
||||||
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
|
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
|
||||||
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
|
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
|
||||||
Drucken auf Folgeseiten.
|
Drucken auf Folgeseiten.
|
||||||
|
|
@ -46,19 +49,25 @@ Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
|
||||||
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
|
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
|
||||||
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
|
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
|
||||||
|
|
||||||
## Vorläufige Punkte
|
## Berechnung und offene Punkte
|
||||||
|
|
||||||
- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet
|
- **Einspeisung / Hierarchie:** `feedingDistributionBoardId` bestimmt die
|
||||||
einen Verteiler nur dann unter, wenn der Text eindeutig der Nummer oder
|
Hierarchie. Ist zusätzlich `feedingCircuitId` gesetzt, fließt die Leistung
|
||||||
Bezeichnung eines anderen Verteilers entspricht (ohne Beachtung von
|
der Unterverteilung nach deren eigenem Verteiler-GZF als virtuelle Last in
|
||||||
Groß-/Kleinschreibung und äußeren Leerzeichen). Mehrdeutige Treffer bleiben
|
genau diesen Abgang ein. Ketten werden rekursiv berechnet; mehrere
|
||||||
ohne Zuordnung; „Σ inkl. unterlagert“ ist
|
Unterverteilungen an einem Abgang sind erlaubt. Ohne Abgang bleibt es bei der
|
||||||
eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung
|
Hierarchie ohne Leistungsaufschlag. `fedFrom` bezeichnet nur externe Quellen
|
||||||
(Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind
|
oder ergänzende Hinweise.
|
||||||
offen.
|
- **Keine Doppelzählung:** Die Abgangs- und Verteilersummen enthalten die
|
||||||
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert
|
Unterverteilungsleistung bereits. Deshalb gibt es keine zusätzliche Spalte
|
||||||
S / (√3 · U<sub>3~</sub>) mit S aus P / cos φ je Gerätezeile
|
„Σ inkl. unterlagert“. Die Summenzeile verwendet für hierarchische Leistungen
|
||||||
(fehlendes cos φ = 1) und Verteiler-GZF.
|
nur Wurzelverteilungen; P<sub>inst</sub> summiert weiterhin die lokalen Geräte.
|
||||||
|
- **I<sub>B</sub>:** S entsteht aus Σ(P / cos φ) für Geräte und den wirksamen
|
||||||
|
cos φ der Unterverteilungen, jeweils mit dem fachlich zugehörigen GZF. Bei
|
||||||
|
einem 1-phasigen speisenden Abgang gilt I = S / U<sub>1~</sub>, sonst
|
||||||
|
I = S / (√3 · U<sub>3~</sub>). Für externe oder nur bis zur Verteilung
|
||||||
|
bekannte Einspeisungen bleibt die bisherige Darstellung als symmetrischer
|
||||||
|
Drehstromwert bestehen.
|
||||||
- **Corporate Design:** Farben (`--pd-*` in
|
- **Corporate Design:** Farben (`--pd-*` in
|
||||||
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
|
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
|
||||||
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
|
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
|
||||||
|
|
@ -118,10 +127,11 @@ ihren Wert.
|
||||||
|
|
||||||
## Bestehende Datenbanken
|
## Bestehende Datenbanken
|
||||||
|
|
||||||
Vor dem Anwenden der additiven Migrationen `0007` und `0008` eine verifizierte
|
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
|
||||||
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
||||||
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
||||||
Snapshot-Schema 6 enthält beide Feldgruppen; die unterstützten Versionen 1–5
|
Snapshot-Schema 9 enthält zusätzlich die logischen Einspeisungsverweise; die
|
||||||
werden beim Lesen hochgestuft. Restore und Projektkopie erhalten die neuen
|
unterstützten Versionen 1–8 werden mit leeren Verweisen hochgestuft. Restore und
|
||||||
Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
|
||||||
|
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
||||||
zum logischen Projektsnapshot oder Projekttransfer.
|
zum logischen Projektsnapshot oder Projekttransfer.
|
||||||
|
|
|
||||||
|
|
@ -149,9 +149,10 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison
|
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison
|
||||||
with the detailed balance and the explicit copy of one row as a
|
with the detailed balance and the explicit copy of one row as a
|
||||||
ProjectDevice.
|
ProjectDevice.
|
||||||
|
- [x] Logical distribution-board feed link with an optional feeding circuit,
|
||||||
|
recursive load rollup and deletion guards (#3).
|
||||||
|
|
||||||
Open follow-ups (Gitea issues): logical feed link between distribution boards
|
Open follow-ups (Gitea issues): bill of quantities export (#5), area-based loads in W/m² for
|
||||||
(#3), bill of quantities export (#5), area-based loads in W/m² for
|
|
||||||
ProjectDevices and device rows (#20).
|
ProjectDevices and device rows (#20).
|
||||||
|
|
||||||
## Recommended Sequence
|
## Recommended Sequence
|
||||||
|
|
|
||||||
|
|
@ -1541,6 +1541,16 @@ a.kpi:hover {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .downstream-board-load-row td {
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .downstream-board-load-row td:nth-child(2) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .empty-circuit-row td {
|
.tree-grid .empty-circuit-row td {
|
||||||
background: var(--panel-bg-subtle);
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import {
|
||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
exportProjectTransfer,
|
exportProjectTransfer,
|
||||||
getProject,
|
getProject,
|
||||||
|
getProjectCircuitTrees,
|
||||||
getProjectDeviceSyncPreview,
|
getProjectDeviceSyncPreview,
|
||||||
listBuildings,
|
listBuildings,
|
||||||
listCircuitLists,
|
listCircuitLists,
|
||||||
|
|
@ -73,6 +74,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
BuildingDto,
|
BuildingDto,
|
||||||
CircuitListDto,
|
CircuitListDto,
|
||||||
|
CircuitTreeResponseDto,
|
||||||
CreateRoomInput,
|
CreateRoomInput,
|
||||||
CreateProjectDeviceInput,
|
CreateProjectDeviceInput,
|
||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
|
|
@ -151,6 +153,7 @@ export default function ProjectDetailPage() {
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
||||||
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
||||||
|
const [circuitTrees, setCircuitTrees] = useState<CircuitTreeResponseDto[]>([]);
|
||||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||||
|
|
@ -170,6 +173,8 @@ export default function ProjectDetailPage() {
|
||||||
setEditingBoardSimultaneityFactor,
|
setEditingBoardSimultaneityFactor,
|
||||||
] = useState("1");
|
] = useState("1");
|
||||||
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
||||||
|
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
|
||||||
|
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
|
||||||
const [editingBoardInfo, setEditingBoardInfo] =
|
const [editingBoardInfo, setEditingBoardInfo] =
|
||||||
useState<DistributionBoardInfoDraft>(
|
useState<DistributionBoardInfoDraft>(
|
||||||
toDistributionBoardInfoDraft({
|
toDistributionBoardInfoDraft({
|
||||||
|
|
@ -209,6 +214,7 @@ export default function ProjectDetailPage() {
|
||||||
getProject(projectId),
|
getProject(projectId),
|
||||||
listDistributionBoards(projectId),
|
listDistributionBoards(projectId),
|
||||||
listCircuitLists(projectId),
|
listCircuitLists(projectId),
|
||||||
|
getProjectCircuitTrees(projectId),
|
||||||
listBuildings(projectId),
|
listBuildings(projectId),
|
||||||
listFloors(projectId),
|
listFloors(projectId),
|
||||||
listRooms(projectId),
|
listRooms(projectId),
|
||||||
|
|
@ -219,6 +225,7 @@ export default function ProjectDetailPage() {
|
||||||
currentProject,
|
currentProject,
|
||||||
distributionBoards,
|
distributionBoards,
|
||||||
loadedCircuitLists,
|
loadedCircuitLists,
|
||||||
|
loadedCircuitTrees,
|
||||||
loadedBuildings,
|
loadedBuildings,
|
||||||
loadedFloors,
|
loadedFloors,
|
||||||
loadedRooms,
|
loadedRooms,
|
||||||
|
|
@ -228,6 +235,7 @@ export default function ProjectDetailPage() {
|
||||||
setProject(currentProject);
|
setProject(currentProject);
|
||||||
setBoards(distributionBoards);
|
setBoards(distributionBoards);
|
||||||
setCircuitLists(loadedCircuitLists);
|
setCircuitLists(loadedCircuitLists);
|
||||||
|
setCircuitTrees(loadedCircuitTrees.trees);
|
||||||
setBuildings(loadedBuildings);
|
setBuildings(loadedBuildings);
|
||||||
setFloors(loadedFloors);
|
setFloors(loadedFloors);
|
||||||
setRooms(loadedRooms);
|
setRooms(loadedRooms);
|
||||||
|
|
@ -310,6 +318,48 @@ export default function ProjectDetailPage() {
|
||||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||||
[circuitLists]
|
[circuitLists]
|
||||||
);
|
);
|
||||||
|
const circuitTreeByBoardId = useMemo(
|
||||||
|
() => new Map(circuitTrees.map((tree) => [tree.distributionBoardId, tree])),
|
||||||
|
[circuitTrees]
|
||||||
|
);
|
||||||
|
const feedingBoardOptions = useMemo(() => {
|
||||||
|
if (!editingBoard) return [];
|
||||||
|
const boardById = new Map(boards.map((board) => [board.id, board]));
|
||||||
|
return boards.filter((candidate) => {
|
||||||
|
if (candidate.id === editingBoard.id) return false;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let current: DistributionBoardDto | undefined = candidate;
|
||||||
|
while (current && current.feedingDistributionBoardId && !visited.has(current.id)) {
|
||||||
|
if (current.feedingDistributionBoardId === editingBoard.id) return false;
|
||||||
|
visited.add(current.id);
|
||||||
|
current = boardById.get(current.feedingDistributionBoardId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [boards, editingBoard]);
|
||||||
|
const selectedFeedingTree = editingFeedingBoardId
|
||||||
|
? circuitTreeByBoardId.get(editingFeedingBoardId)
|
||||||
|
: undefined;
|
||||||
|
const selectedFeedingCircuits = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedFeedingTree?.sections.flatMap((section) =>
|
||||||
|
section.circuits.map((circuit) => ({ circuit, section }))
|
||||||
|
) ?? [],
|
||||||
|
[selectedFeedingTree]
|
||||||
|
);
|
||||||
|
const selectedFeedingCircuit = selectedFeedingCircuits.find(
|
||||||
|
({ circuit }) => circuit.id === editingFeedingCircuitId
|
||||||
|
);
|
||||||
|
const selectedFeedingBoard = boards.find(
|
||||||
|
(board) => board.id === editingFeedingBoardId
|
||||||
|
);
|
||||||
|
const selectedFeedingCircuitUseCount = editingFeedingCircuitId
|
||||||
|
? boards.filter(
|
||||||
|
(board) =>
|
||||||
|
board.id !== editingBoard?.id &&
|
||||||
|
board.feedingCircuitId === editingFeedingCircuitId
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
const enabledBoardSupplyTypes =
|
const enabledBoardSupplyTypes =
|
||||||
project?.enabledDistributionBoardSupplyTypes ?? [];
|
project?.enabledDistributionBoardSupplyTypes ?? [];
|
||||||
const usedBoardSupplyTypes = useMemo(
|
const usedBoardSupplyTypes = useMemo(
|
||||||
|
|
@ -329,6 +379,21 @@ export default function ProjectDetailPage() {
|
||||||
[boards]
|
[boards]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function formatBoardFeedingReference(board: DistributionBoardDto) {
|
||||||
|
if (!board.feedingDistributionBoardId) return board.fedFrom ?? "–";
|
||||||
|
const feedingBoard = boards.find(
|
||||||
|
(candidate) => candidate.id === board.feedingDistributionBoardId
|
||||||
|
);
|
||||||
|
if (!feedingBoard) return "Unbekannte Verteilung";
|
||||||
|
const boardLabel = feedingBoard.boardNumber ?? feedingBoard.name;
|
||||||
|
if (!board.feedingCircuitId) return boardLabel;
|
||||||
|
const circuit = circuitTreeByBoardId
|
||||||
|
.get(feedingBoard.id)
|
||||||
|
?.sections.flatMap((section) => section.circuits)
|
||||||
|
.find((candidate) => candidate.id === board.feedingCircuitId);
|
||||||
|
return `${boardLabel}${circuit?.equipmentIdentifier ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyProjectRevision(currentRevision: number) {
|
function applyProjectRevision(currentRevision: number) {
|
||||||
setProject((current) =>
|
setProject((current) =>
|
||||||
current ? { ...current, currentRevision } : current
|
current ? { ...current, currentRevision } : current
|
||||||
|
|
@ -358,6 +423,7 @@ export default function ProjectDetailPage() {
|
||||||
]);
|
]);
|
||||||
setCircuitLists(await listCircuitLists(projectId));
|
setCircuitLists(await listCircuitLists(projectId));
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setBoardName("");
|
setBoardName("");
|
||||||
setBoardFloorId("");
|
setBoardFloorId("");
|
||||||
setBoardSupplyType(
|
setBoardSupplyType(
|
||||||
|
|
@ -488,6 +554,8 @@ export default function ProjectDetailPage() {
|
||||||
);
|
);
|
||||||
setEditingBoardCopyName(`${board.name} Kopie`);
|
setEditingBoardCopyName(`${board.name} Kopie`);
|
||||||
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
|
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
|
||||||
|
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
|
||||||
|
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBoardCreator() {
|
function openBoardCreator() {
|
||||||
|
|
@ -523,6 +591,8 @@ export default function ProjectDetailPage() {
|
||||||
floorId: editingBoardFloorId || null,
|
floorId: editingBoardFloorId || null,
|
||||||
supplyType: editingBoardSupplyType,
|
supplyType: editingBoardSupplyType,
|
||||||
simultaneityFactor,
|
simultaneityFactor,
|
||||||
|
feedingDistributionBoardId: editingFeedingBoardId || null,
|
||||||
|
feedingCircuitId: editingFeedingCircuitId || null,
|
||||||
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
||||||
},
|
},
|
||||||
project.currentRevision
|
project.currentRevision
|
||||||
|
|
@ -535,6 +605,7 @@ export default function ProjectDetailPage() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -590,6 +661,7 @@ export default function ProjectDetailPage() {
|
||||||
result.circuitList,
|
result.circuitList,
|
||||||
]);
|
]);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -633,6 +705,7 @@ export default function ProjectDetailPage() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -989,6 +1062,7 @@ export default function ProjectDetailPage() {
|
||||||
<th>Örtlichkeit</th>
|
<th>Örtlichkeit</th>
|
||||||
<th>Etage</th>
|
<th>Etage</th>
|
||||||
<th>Netzart</th>
|
<th>Netzart</th>
|
||||||
|
<th>Gespeist von</th>
|
||||||
<th>GZF</th>
|
<th>GZF</th>
|
||||||
<th className="text-end">Aktionen</th>
|
<th className="text-end">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1015,6 +1089,7 @@ export default function ProjectDetailPage() {
|
||||||
]
|
]
|
||||||
: "Nicht festgelegt"}
|
: "Nicht festgelegt"}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatBoardFeedingReference(board)}</td>
|
||||||
<td>
|
<td>
|
||||||
{board.simultaneityFactor.toLocaleString(
|
{board.simultaneityFactor.toLocaleString(
|
||||||
"de-DE",
|
"de-DE",
|
||||||
|
|
@ -1392,7 +1467,7 @@ export default function ProjectDetailPage() {
|
||||||
|
|
||||||
{editingBoard ? (
|
{editingBoard ? (
|
||||||
<FormModal
|
<FormModal
|
||||||
description={`${editingBoard.name}: Etagenzuordnung, Netzart und Gleichzeitigkeitsfaktor ändern.`}
|
description={`${editingBoard.name}: Etagenzuordnung, Netzart, Einspeisung und Gleichzeitigkeitsfaktor ändern.`}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onClose={() => setEditingBoard(null)}
|
onClose={() => setEditingBoard(null)}
|
||||||
onSubmit={handleUpdateBoard}
|
onSubmit={handleUpdateBoard}
|
||||||
|
|
@ -1479,6 +1554,91 @@ export default function ProjectDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section className="card mt-4" aria-labelledby="feeding-reference-heading">
|
||||||
|
<div className="card-header" id="feeding-reference-heading">
|
||||||
|
Logische Einspeisung
|
||||||
|
</div>
|
||||||
|
<div className="card-body row g-3">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="feeding-distribution-board">
|
||||||
|
Speisende Verteilung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="feeding-distribution-board"
|
||||||
|
onChange={(event) => {
|
||||||
|
setEditingFeedingBoardId(event.target.value);
|
||||||
|
setEditingFeedingCircuitId("");
|
||||||
|
}}
|
||||||
|
value={editingFeedingBoardId}
|
||||||
|
>
|
||||||
|
<option value="">Keine Projektverteilung</option>
|
||||||
|
{feedingBoardOptions.map((board) => (
|
||||||
|
<option key={board.id} value={board.id}>
|
||||||
|
{board.boardNumber ?? board.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="feeding-circuit">
|
||||||
|
Speisender Abgang
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
disabled={!editingFeedingBoardId}
|
||||||
|
id="feeding-circuit"
|
||||||
|
onChange={(event) => setEditingFeedingCircuitId(event.target.value)}
|
||||||
|
value={editingFeedingCircuitId}
|
||||||
|
>
|
||||||
|
<option value="">Nur Verteilung bekannt</option>
|
||||||
|
{selectedFeedingCircuits.map(({ circuit }) => (
|
||||||
|
<option key={circuit.id} value={circuit.id}>
|
||||||
|
{circuit.equipmentIdentifier}
|
||||||
|
{circuit.displayName ? ` · ${circuit.displayName}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{editingFeedingBoardId && !editingFeedingCircuitId ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
|
||||||
|
Leistung dieser Verteilung aber keinem Stromkreis zugerechnet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingBoard?.supplyType &&
|
||||||
|
editingBoardSupplyType !== selectedFeedingBoard.supplyType ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Die Netzarten weichen ab: {selectedFeedingBoard.supplyType}
|
||||||
|
{" → "}{editingBoardSupplyType}. Bitte Umschaltung und
|
||||||
|
Versorgungskonzept prüfen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingCircuit &&
|
||||||
|
selectedFeedingCircuit.section.category !== "three_phase" ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese
|
||||||
|
Einspeisung für die Unterverteilung geeignet ist.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingCircuitUseCount > 0 ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-info mb-0 py-2">
|
||||||
|
Dieser Abgang speist bereits {selectedFeedingCircuitUseCount}
|
||||||
|
{selectedFeedingCircuitUseCount === 1
|
||||||
|
? " weitere Verteilung."
|
||||||
|
: " weitere Verteilungen."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DistributionBoardInfoCard
|
<DistributionBoardInfoCard
|
||||||
draft={editingBoardInfo}
|
draft={editingBoardInfo}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ import "../../../../frontend/print/print-document.css";
|
||||||
import {
|
import {
|
||||||
deleteDocumentLogo,
|
deleteDocumentLogo,
|
||||||
documentLogoUrl,
|
documentLogoUrl,
|
||||||
getCircuitTree,
|
getProjectCircuitTrees,
|
||||||
getDocumentBranding,
|
getDocumentBranding,
|
||||||
getProject,
|
getProject,
|
||||||
listCircuitLists,
|
|
||||||
listDistributionBoards,
|
listDistributionBoards,
|
||||||
listFloors,
|
listFloors,
|
||||||
listProjectSnapshots,
|
listProjectSnapshots,
|
||||||
|
|
@ -31,7 +30,7 @@ import {
|
||||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
import { PrintDocument } from "../../../../frontend/print/print-document";
|
||||||
import {
|
import {
|
||||||
assertPrintRevisionConsistency,
|
assertPrintRevisionConsistency,
|
||||||
buildPreliminaryBoardHierarchy,
|
buildBoardHierarchy,
|
||||||
summarizePrintBoard,
|
summarizePrintBoard,
|
||||||
type PrintBoardSummary,
|
type PrintBoardSummary,
|
||||||
} from "../../../../frontend/print/print-document-model";
|
} from "../../../../frontend/print/print-document-model";
|
||||||
|
|
@ -89,17 +88,20 @@ export default function ProjectPrintPage() {
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
const [project, boards, circuitLists, floors, snapshots, branding] =
|
const [project, boards, projectTrees, floors, snapshots, branding] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getProject(projectId),
|
getProject(projectId),
|
||||||
listDistributionBoards(projectId),
|
listDistributionBoards(projectId),
|
||||||
listCircuitLists(projectId),
|
getProjectCircuitTrees(projectId),
|
||||||
listFloors(projectId),
|
listFloors(projectId),
|
||||||
listProjectSnapshots(projectId),
|
listProjectSnapshots(projectId),
|
||||||
getDocumentBranding(),
|
getDocumentBranding(),
|
||||||
]);
|
]);
|
||||||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||||||
const summaries = await Promise.all(
|
const treeByBoardId = new Map(
|
||||||
|
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
|
||||||
|
);
|
||||||
|
const summaries =
|
||||||
[...boards]
|
[...boards]
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
|
|
@ -107,19 +109,16 @@ export default function ProjectPrintPage() {
|
||||||
boardSortCollator.compare(left.name, right.name)
|
boardSortCollator.compare(left.name, right.name)
|
||||||
)
|
)
|
||||||
.flatMap((board) => {
|
.flatMap((board) => {
|
||||||
const circuitList = circuitLists.find(
|
const tree = treeByBoardId.get(board.id);
|
||||||
(list) => list.distributionBoardId === board.id
|
return tree ? [{ board, tree }] : [];
|
||||||
);
|
|
||||||
return circuitList ? [{ board, circuitList }] : [];
|
|
||||||
})
|
})
|
||||||
.map(async ({ board, circuitList }) =>
|
.map(({ board, tree }) =>
|
||||||
summarizePrintBoard({
|
summarizePrintBoard({
|
||||||
board,
|
board,
|
||||||
circuitListId: circuitList.id,
|
circuitListId: tree.circuitListId,
|
||||||
tree: await getCircuitTree(projectId, circuitList.id),
|
tree,
|
||||||
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const finalProject = await getProject(projectId);
|
const finalProject = await getProject(projectId);
|
||||||
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
|
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
|
||||||
|
|
@ -148,7 +147,7 @@ export default function ProjectPrintPage() {
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
|
() => (data ? buildBoardHierarchy(data.summaries) : []),
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
const stand = useMemo(
|
const stand = useMemo(
|
||||||
|
|
|
||||||
4
src/db/migrations/0012_good_gateway.sql
Normal file
4
src/db/migrations/0012_good_gateway.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE `distribution_boards` ADD `feeding_distribution_board_id` text REFERENCES distribution_boards(id);--> statement-breakpoint
|
||||||
|
ALTER TABLE `distribution_boards` ADD `feeding_circuit_id` text REFERENCES circuits(id);--> statement-breakpoint
|
||||||
|
CREATE INDEX `distribution_boards_feeding_board_idx` ON `distribution_boards` (`feeding_distribution_board_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `distribution_boards_feeding_circuit_idx` ON `distribution_boards` (`feeding_circuit_id`);
|
||||||
2986
src/db/migrations/meta/0012_snapshot.json
Normal file
2986
src/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
||||||
"when": 1790531351492,
|
"when": 1790531351492,
|
||||||
"tag": "0011_glossy_norman_osborn",
|
"tag": "0011_glossy_norman_osborn",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790540142918,
|
||||||
|
"tag": "0012_good_gateway",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
interface PersistedMoveRow {
|
interface PersistedMoveRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||||
projectId,
|
projectId,
|
||||||
destinationCircuitIds
|
destinationCircuitIds
|
||||||
);
|
);
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
targetCircuit.id,
|
||||||
|
]);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(circuits)
|
.delete(circuits)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { distributionBoardComponentProtectionDevices } from "../schema/distribut
|
||||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class CircuitGroupSubtreeProjectCommandRepository
|
export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
implements CircuitGroupSubtreeProjectCommandStore
|
implements CircuitGroupSubtreeProjectCommandStore
|
||||||
|
|
@ -65,6 +66,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
"Circuit-group subtree changed before deletion."
|
"Circuit-group subtree changed before deletion."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
current.circuits.map((entry) => entry.circuit.id)
|
||||||
|
);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(circuitSections)
|
.delete(circuitSections)
|
||||||
.where(eq(circuitSections.id, current.group.id))
|
.where(eq(circuitSections.id, current.group.id))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
|
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
|
||||||
.orderBy(asc(circuitSections.sortOrder));
|
.orderBy(asc(circuitSections.sortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(inArray(circuitSections.circuitListId, circuitListIds))
|
||||||
|
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id));
|
||||||
|
}
|
||||||
|
|
||||||
async createDefaults(circuitListId: string) {
|
async createDefaults(circuitListId: string) {
|
||||||
const db = this.database;
|
const db = this.database;
|
||||||
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
toCircuitDeviceRowSnapshot,
|
toCircuitDeviceRowSnapshot,
|
||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||||
|
|
||||||
|
|
@ -236,6 +237,8 @@ export class CircuitStructureProjectCommandRepository
|
||||||
protectionDevice: protectionDevice ?? null,
|
protectionDevice: protectionDevice ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
|
||||||
|
|
||||||
const result = database
|
const result = database
|
||||||
.delete(circuits)
|
.delete(circuits)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
|
||||||
|
|
@ -14,6 +14,15 @@ export class CircuitRepository {
|
||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(inArray(circuits.circuitListId, circuitListIds))
|
||||||
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
async listBySection(sectionId: string) {
|
async listBySection(sectionId: string) {
|
||||||
const db = this.database;
|
const db = this.database;
|
||||||
return db
|
return db
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export class DistributionBoardComponentRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(inArray(distributionBoardComponents.circuitListId, circuitListIds))
|
||||||
|
.orderBy(
|
||||||
|
asc(distributionBoardComponents.sortOrder),
|
||||||
|
asc(distributionBoardComponents.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listProtectionByComponentIds(componentIds: string[]) {
|
async listProtectionByComponentIds(componentIds: string[]) {
|
||||||
if (componentIds.length === 0) {
|
if (componentIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
||||||
|
export function assertBoardsAreNotReferencedAsFeeders(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardIds: string[]
|
||||||
|
) {
|
||||||
|
if (distributionBoardIds.length === 0) return;
|
||||||
|
const dependent = database
|
||||||
|
.select({
|
||||||
|
name: distributionBoards.name,
|
||||||
|
boardNumber: distributionBoards.boardNumber,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
[...new Set(distributionBoardIds)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (dependent) throwFeedingReferenceError(dependent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitsAreNotReferencedAsFeeders(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitIds: string[]
|
||||||
|
) {
|
||||||
|
if (circuitIds.length === 0) return;
|
||||||
|
const dependent = database
|
||||||
|
.select({
|
||||||
|
name: distributionBoards.name,
|
||||||
|
boardNumber: distributionBoards.boardNumber,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
distributionBoards.feedingCircuitId,
|
||||||
|
[...new Set(circuitIds)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (dependent) throwFeedingReferenceError(dependent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwFeedingReferenceError(dependent: {
|
||||||
|
name: string;
|
||||||
|
boardNumber: string | null;
|
||||||
|
}): never {
|
||||||
|
const label = dependent.boardNumber ?? dependent.name;
|
||||||
|
throw new Error(`Speist ${label} – erst Einspeisung ändern.`);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class DistributionBoardStructureProjectCommandRepository
|
export class DistributionBoardStructureProjectCommandRepository
|
||||||
implements DistributionBoardStructureProjectCommandStore
|
implements DistributionBoardStructureProjectCommandStore
|
||||||
|
|
@ -248,6 +249,8 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
|
||||||
|
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -325,6 +328,16 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
patch.supplyType
|
patch.supplyType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.assertFeedingReference(database, input.projectId, current.id, {
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
patch.feedingDistributionBoardId === undefined
|
||||||
|
? current.feedingDistributionBoardId
|
||||||
|
: patch.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId:
|
||||||
|
patch.feedingCircuitId === undefined
|
||||||
|
? current.feedingCircuitId
|
||||||
|
: patch.feedingCircuitId,
|
||||||
|
});
|
||||||
const inversePatch = Object.fromEntries(
|
const inversePatch = Object.fromEntries(
|
||||||
input.command.payload.changes.map((change) => [
|
input.command.payload.changes.map((change) => [
|
||||||
change.field,
|
change.field,
|
||||||
|
|
@ -333,7 +346,8 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
) as DistributionBoardUpdatePatch;
|
) as DistributionBoardUpdatePatch;
|
||||||
const inverse = createDistributionBoardUpdateProjectCommand(
|
const inverse = createDistributionBoardUpdateProjectCommand(
|
||||||
current.id,
|
current.id,
|
||||||
inversePatch
|
inversePatch,
|
||||||
|
input.command.schemaVersion
|
||||||
);
|
);
|
||||||
const updated = database
|
const updated = database
|
||||||
.update(distributionBoards)
|
.update(distributionBoards)
|
||||||
|
|
@ -352,6 +366,77 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
}
|
}
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertFeedingReference(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardId: string,
|
||||||
|
reference: {
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { feedingDistributionBoardId, feedingCircuitId } = reference;
|
||||||
|
if (feedingDistributionBoardId === null) {
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedingDistributionBoardId === distributionBoardId) {
|
||||||
|
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
|
||||||
|
}
|
||||||
|
const projectBoards = database
|
||||||
|
.select({
|
||||||
|
id: distributionBoards.id,
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
|
||||||
|
if (!boardById.has(feedingDistributionBoardId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die speisende Verteilung gehört nicht zum Projekt."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const visited = new Set<string>([distributionBoardId]);
|
||||||
|
let currentId: string | null = feedingDistributionBoardId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen.");
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
||||||
|
}
|
||||||
|
if (feedingCircuitId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const feedingCircuit = database
|
||||||
|
.select({
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
distributionBoardId: circuitLists.distributionBoardId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuits.id, feedingCircuitId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!feedingCircuit ||
|
||||||
|
feedingCircuit.projectId !== projectId ||
|
||||||
|
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSupplyTypeEnabled(
|
function assertSupplyTypeEnabled(
|
||||||
|
|
@ -392,11 +477,20 @@ function structureMatches(
|
||||||
location,
|
location,
|
||||||
fedFrom,
|
fedFrom,
|
||||||
remark,
|
remark,
|
||||||
|
feedingDistributionBoardId,
|
||||||
|
feedingCircuitId,
|
||||||
...actualDistributionBoard
|
...actualDistributionBoard
|
||||||
} = actual.board;
|
} = actual.board;
|
||||||
if (
|
if (
|
||||||
simultaneityFactor !== 1 ||
|
simultaneityFactor !== 1 ||
|
||||||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
|
[
|
||||||
|
boardNumber,
|
||||||
|
location,
|
||||||
|
fedFrom,
|
||||||
|
remark,
|
||||||
|
feedingDistributionBoardId,
|
||||||
|
feedingCircuitId,
|
||||||
|
].some((value) => value !== null) ||
|
||||||
!sameRecord(
|
!sameRecord(
|
||||||
expected.distributionBoard,
|
expected.distributionBoard,
|
||||||
actualDistributionBoard
|
actualDistributionBoard
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class DistributionBoardSubtreeProjectCommandRepository
|
export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
implements DistributionBoardSubtreeProjectCommandStore
|
implements DistributionBoardSubtreeProjectCommandStore
|
||||||
|
|
@ -82,6 +83,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
"Distribution-board subtree changed before deletion."
|
"Distribution-board subtree changed before deletion."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
current.distributionBoard.id,
|
||||||
|
]);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -300,6 +304,69 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const feedingDistributionBoardId =
|
||||||
|
snapshot.distributionBoard.feedingDistributionBoardId;
|
||||||
|
const feedingCircuitId = snapshot.distributionBoard.feedingCircuitId;
|
||||||
|
if (feedingDistributionBoardId === null) {
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (feedingDistributionBoardId === snapshot.distributionBoard.id) {
|
||||||
|
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
|
||||||
|
}
|
||||||
|
const projectBoards = database
|
||||||
|
.select({
|
||||||
|
id: distributionBoards.id,
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
|
||||||
|
if (!boardById.has(feedingDistributionBoardId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die speisende Verteilung gehört nicht zum Projekt."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const visited = new Set<string>([snapshot.distributionBoard.id]);
|
||||||
|
let currentId: string | null = feedingDistributionBoardId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die Einspeisungsverknüpfung würde einen Zyklus erzeugen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
||||||
|
}
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
const feedingCircuit = database
|
||||||
|
.select({
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
distributionBoardId: circuitLists.distributionBoardId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuits.id, feedingCircuitId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!feedingCircuit ||
|
||||||
|
feedingCircuit.projectId !== projectId ||
|
||||||
|
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const linkedProjectDeviceIds = uniqueStrings(
|
const linkedProjectDeviceIds = uniqueStrings(
|
||||||
snapshot.circuits.flatMap((circuit) =>
|
snapshot.circuits.flatMap((circuit) =>
|
||||||
circuit.deviceRows.flatMap((row) =>
|
circuit.deviceRows.flatMap((row) =>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
snapshotsEqual,
|
snapshotsEqual,
|
||||||
} from "./external-object-assignment.persistence.js";
|
} from "./external-object-assignment.persistence.js";
|
||||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class ExternalObjectNewCircuitProjectCommandRepository
|
export class ExternalObjectNewCircuitProjectCommandRepository
|
||||||
implements ExternalObjectNewCircuitProjectCommandStore
|
implements ExternalObjectNewCircuitProjectCommandStore
|
||||||
|
|
@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository
|
||||||
objects,
|
objects,
|
||||||
"External object changed during new-circuit assignment."
|
"External object changed during new-circuit assignment."
|
||||||
);
|
);
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
expectedCircuit.id,
|
||||||
|
]);
|
||||||
const deleted = database.delete(circuits).where(and(
|
const deleted = database.delete(circuits).where(and(
|
||||||
eq(circuits.id, expectedCircuit.id),
|
eq(circuits.id, expectedCircuit.id),
|
||||||
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,14 @@ function replaceProjectState(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
) {
|
) {
|
||||||
|
database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalModelSources)
|
.delete(externalModelSources)
|
||||||
.where(eq(externalModelSources.projectId, state.project.id))
|
.where(eq(externalModelSources.projectId, state.project.id))
|
||||||
|
|
@ -330,7 +338,15 @@ function replaceProjectState(
|
||||||
}
|
}
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
insertMany(database, projectDevices, state.projectDevices);
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
insertMany(database, distributionBoards, state.distributionBoards);
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoards,
|
||||||
|
state.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
insertMany(database, circuitLists, state.circuitLists);
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
insertMany(database, circuitSections, state.circuitSections);
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
insertMany(
|
insertMany(
|
||||||
|
|
@ -341,6 +357,7 @@ function replaceProjectState(
|
||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
restoreDistributionBoardFeedingReferences(database, state);
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
distributionBoardComponents,
|
distributionBoardComponents,
|
||||||
|
|
@ -378,6 +395,31 @@ function replaceProjectState(
|
||||||
insertMany(database, externalModelObjects, state.externalModel.objects);
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreDistributionBoardFeedingReferences(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
for (const board of state.distributionBoards) {
|
||||||
|
if (
|
||||||
|
board.feedingDistributionBoardId === null &&
|
||||||
|
board.feedingCircuitId === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: board.feedingCircuitId,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, board.id))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Distribution-board feeding reference could not be restored.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
table: TTable,
|
table: TTable,
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,15 @@ function insertProjectState(
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
insertMany(database, projectDevices, state.projectDevices);
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
insertMany(database, distributionBoards, state.distributionBoards);
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoards,
|
||||||
|
state.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
insertMany(database, circuitLists, state.circuitLists);
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
insertMany(database, circuitSections, state.circuitSections);
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
insertMany(
|
insertMany(
|
||||||
|
|
@ -184,6 +192,25 @@ function insertProjectState(
|
||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
for (const board of state.distributionBoards) {
|
||||||
|
if (
|
||||||
|
board.feedingDistributionBoardId === null &&
|
||||||
|
board.feedingCircuitId === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: board.feedingCircuitId,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, board.id))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Distribution-board feeding reference could not be imported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
distributionBoardComponents,
|
distributionBoardComponents,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { distributionBoards } from "./distribution-boards.js";
|
import { distributionBoards } from "./distribution-boards.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const circuitLists = sqliteTable(
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
distributionBoardId: text("distribution_board_id")
|
distributionBoardId: text("distribution_board_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => distributionBoards.id, { onDelete: "cascade" }),
|
.references((): AnySQLiteColumn => distributionBoards.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
},
|
},
|
||||||
(table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)]
|
(table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { index, integer, real, sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { circuitLists } from "./circuit-lists.js";
|
import { circuitLists } from "./circuit-lists.js";
|
||||||
import { circuitSections } from "./circuit-sections.js";
|
import { circuitSections } from "./circuit-sections.js";
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const circuits = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
circuitListId: text("circuit_list_id")
|
circuitListId: text("circuit_list_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
.references((): AnySQLiteColumn => circuitLists.id, { onDelete: "cascade" }),
|
||||||
sectionId: text("section_id")
|
sectionId: text("section_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => circuitSections.id, { onDelete: "cascade" }),
|
.references(() => circuitSections.id, { onDelete: "cascade" }),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { index, real, sqliteTable, text, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
|
import { circuits } from "./circuits.js";
|
||||||
import { floors } from "./floors.js";
|
import { floors } from "./floors.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export const distributionBoards = sqliteTable("distribution_boards", {
|
export const distributionBoards = sqliteTable(
|
||||||
|
"distribution_boards",
|
||||||
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
projectId: text("project_id")
|
projectId: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
@ -20,5 +23,22 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
fedFrom: text("fed_from"),
|
fedFrom: text("fed_from"),
|
||||||
remark: text("remark"),
|
remark: text("remark"),
|
||||||
});
|
feedingDistributionBoardId: text("feeding_distribution_board_id").references(
|
||||||
|
(): AnySQLiteColumn => distributionBoards.id,
|
||||||
|
{ onDelete: "restrict" }
|
||||||
|
),
|
||||||
|
feedingCircuitId: text("feeding_circuit_id").references(
|
||||||
|
(): AnySQLiteColumn => circuits.id,
|
||||||
|
{ onDelete: "restrict" }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("distribution_boards_feeding_board_idx").on(
|
||||||
|
table.feedingDistributionBoardId
|
||||||
|
),
|
||||||
|
index("distribution_boards_feeding_circuit_idx").on(
|
||||||
|
table.feedingCircuitId
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
|
||||||
217
src/domain/calculations/distribution-board-load-calculation.ts
Normal file
217
src/domain/calculations/distribution-board-load-calculation.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
export interface DistributionBoardLoadBoardInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
boardNumber: string | null;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardLoadCircuitInput {
|
||||||
|
id: string;
|
||||||
|
distributionBoardId: string;
|
||||||
|
rows: Array<{
|
||||||
|
activePowerKw: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownstreamDistributionBoardLoad {
|
||||||
|
distributionBoardId: string;
|
||||||
|
name: string;
|
||||||
|
boardNumber: string | null;
|
||||||
|
activePowerKw: number;
|
||||||
|
apparentPowerKva: number;
|
||||||
|
cosPhi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculatedCircuitLoad {
|
||||||
|
circuitId: string;
|
||||||
|
deviceActivePowerKw: number;
|
||||||
|
deviceApparentPowerKva: number;
|
||||||
|
activePowerKw: number;
|
||||||
|
apparentPowerKva: number;
|
||||||
|
cosPhi: number;
|
||||||
|
downstreamBoards: DownstreamDistributionBoardLoad[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculatedDistributionBoardLoad {
|
||||||
|
distributionBoardId: string;
|
||||||
|
activePowerBeforeSimultaneityKw: number;
|
||||||
|
apparentPowerBeforeSimultaneityKva: number;
|
||||||
|
demandActivePowerKw: number;
|
||||||
|
demandApparentPowerKva: number;
|
||||||
|
cosPhi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardLoadCalculation {
|
||||||
|
boards: Map<string, CalculatedDistributionBoardLoad>;
|
||||||
|
circuits: Map<string, CalculatedCircuitLoad>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates one project graph. A child board enters exactly one feeder
|
||||||
|
* circuit with its demand after the child's own board simultaneity factor.
|
||||||
|
* Board-only relations form a hierarchy but intentionally add no load.
|
||||||
|
*/
|
||||||
|
export function calculateDistributionBoardLoads(input: {
|
||||||
|
boards: DistributionBoardLoadBoardInput[];
|
||||||
|
circuits: DistributionBoardLoadCircuitInput[];
|
||||||
|
}): DistributionBoardLoadCalculation {
|
||||||
|
const boardById = new Map(input.boards.map((board) => [board.id, board]));
|
||||||
|
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
|
||||||
|
const circuitsByBoardId = groupBy(
|
||||||
|
input.circuits,
|
||||||
|
(circuit) => circuit.distributionBoardId
|
||||||
|
);
|
||||||
|
const childrenByCircuitId = new Map<string, DistributionBoardLoadBoardInput[]>();
|
||||||
|
for (const board of input.boards) {
|
||||||
|
assertBoardInput(board, boardById, circuitById);
|
||||||
|
if (board.feedingCircuitId === null) continue;
|
||||||
|
const children = childrenByCircuitId.get(board.feedingCircuitId) ?? [];
|
||||||
|
children.push(board);
|
||||||
|
childrenByCircuitId.set(board.feedingCircuitId, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boards = new Map<string, CalculatedDistributionBoardLoad>();
|
||||||
|
const circuits = new Map<string, CalculatedCircuitLoad>();
|
||||||
|
const activePath = new Set<string>();
|
||||||
|
|
||||||
|
const calculateBoard = (boardId: string): CalculatedDistributionBoardLoad => {
|
||||||
|
const cached = boards.get(boardId);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (activePath.has(boardId)) {
|
||||||
|
throw new Error("Distribution-board feeding graph contains a cycle.");
|
||||||
|
}
|
||||||
|
const board = boardById.get(boardId);
|
||||||
|
if (!board) throw new Error("Distribution board is missing from load graph.");
|
||||||
|
activePath.add(boardId);
|
||||||
|
|
||||||
|
let activePowerBeforeSimultaneityKw = 0;
|
||||||
|
let apparentPowerBeforeSimultaneityKva = 0;
|
||||||
|
for (const circuit of circuitsByBoardId.get(boardId) ?? []) {
|
||||||
|
let deviceActivePowerKw = 0;
|
||||||
|
let deviceApparentPowerKva = 0;
|
||||||
|
for (const row of circuit.rows) {
|
||||||
|
assertNonNegativeFinite(row.activePowerKw, "row active power");
|
||||||
|
const cosPhi = row.cosPhi ?? 1;
|
||||||
|
if (!Number.isFinite(cosPhi) || cosPhi <= 0 || cosPhi > 1) {
|
||||||
|
throw new Error("Row cos phi must be greater than zero and at most one.");
|
||||||
|
}
|
||||||
|
deviceActivePowerKw += row.activePowerKw;
|
||||||
|
deviceApparentPowerKva += row.activePowerKw / cosPhi;
|
||||||
|
}
|
||||||
|
const downstreamBoards = (childrenByCircuitId.get(circuit.id) ?? [])
|
||||||
|
.map((child) => {
|
||||||
|
const childLoad = calculateBoard(child.id);
|
||||||
|
return {
|
||||||
|
distributionBoardId: child.id,
|
||||||
|
name: child.name,
|
||||||
|
boardNumber: child.boardNumber,
|
||||||
|
activePowerKw: childLoad.demandActivePowerKw,
|
||||||
|
apparentPowerKva: childLoad.demandApparentPowerKva,
|
||||||
|
cosPhi: effectiveCosPhi(
|
||||||
|
childLoad.demandActivePowerKw,
|
||||||
|
childLoad.demandApparentPowerKva
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const activePowerKw =
|
||||||
|
deviceActivePowerKw +
|
||||||
|
downstreamBoards.reduce((sum, child) => sum + child.activePowerKw, 0);
|
||||||
|
const apparentPowerKva =
|
||||||
|
deviceApparentPowerKva +
|
||||||
|
downstreamBoards.reduce((sum, child) => sum + child.apparentPowerKva, 0);
|
||||||
|
circuits.set(circuit.id, {
|
||||||
|
circuitId: circuit.id,
|
||||||
|
deviceActivePowerKw,
|
||||||
|
deviceApparentPowerKva,
|
||||||
|
activePowerKw,
|
||||||
|
apparentPowerKva,
|
||||||
|
cosPhi: effectiveCosPhi(activePowerKw, apparentPowerKva),
|
||||||
|
downstreamBoards,
|
||||||
|
});
|
||||||
|
activePowerBeforeSimultaneityKw += activePowerKw;
|
||||||
|
apparentPowerBeforeSimultaneityKva += apparentPowerKva;
|
||||||
|
}
|
||||||
|
|
||||||
|
activePath.delete(boardId);
|
||||||
|
const result = {
|
||||||
|
distributionBoardId: boardId,
|
||||||
|
activePowerBeforeSimultaneityKw,
|
||||||
|
apparentPowerBeforeSimultaneityKva,
|
||||||
|
demandActivePowerKw:
|
||||||
|
activePowerBeforeSimultaneityKw * board.simultaneityFactor,
|
||||||
|
demandApparentPowerKva:
|
||||||
|
apparentPowerBeforeSimultaneityKva * board.simultaneityFactor,
|
||||||
|
cosPhi: effectiveCosPhi(
|
||||||
|
activePowerBeforeSimultaneityKw,
|
||||||
|
apparentPowerBeforeSimultaneityKva
|
||||||
|
),
|
||||||
|
};
|
||||||
|
boards.set(boardId, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const board of input.boards) calculateBoard(board.id);
|
||||||
|
return { boards, circuits };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBoardInput(
|
||||||
|
board: DistributionBoardLoadBoardInput,
|
||||||
|
boardById: Map<string, DistributionBoardLoadBoardInput>,
|
||||||
|
circuitById: Map<string, DistributionBoardLoadCircuitInput>
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(board.simultaneityFactor) ||
|
||||||
|
board.simultaneityFactor < 0 ||
|
||||||
|
board.simultaneityFactor > 1
|
||||||
|
) {
|
||||||
|
throw new Error("Board simultaneity factor must be between zero and one.");
|
||||||
|
}
|
||||||
|
if (board.feedingDistributionBoardId === board.id) {
|
||||||
|
throw new Error("Distribution board cannot feed itself.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
board.feedingDistributionBoardId !== null &&
|
||||||
|
!boardById.has(board.feedingDistributionBoardId)
|
||||||
|
) {
|
||||||
|
throw new Error("Feeding distribution board is missing from load graph.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
board.feedingCircuitId !== null &&
|
||||||
|
board.feedingDistributionBoardId === null
|
||||||
|
) {
|
||||||
|
throw new Error("Feeding circuit requires a feeding distribution board.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
board.feedingCircuitId !== null &&
|
||||||
|
circuitById.get(board.feedingCircuitId)?.distributionBoardId !==
|
||||||
|
board.feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error("Feeding circuit belongs to another distribution board.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveCosPhi(activePowerKw: number, apparentPowerKva: number) {
|
||||||
|
return activePowerKw > 0 && apparentPowerKva > 0
|
||||||
|
? activePowerKw / apparentPowerKva
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonNegativeFinite(value: number, label: string) {
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`${label} must be a non-negative finite number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const value of values) {
|
||||||
|
const key = keyOf(value);
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(value);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,10 @@ export interface CircuitTreeCircuit {
|
||||||
isReserve: boolean;
|
isReserve: boolean;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
|
circuitApparentPower: number;
|
||||||
|
circuitCosPhi: number;
|
||||||
deviceRows: CircuitTreeDeviceRow[];
|
deviceRows: CircuitTreeDeviceRow[];
|
||||||
|
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
|
||||||
protectionDevice?: CircuitTreeProtectionDevice;
|
protectionDevice?: CircuitTreeProtectionDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +62,11 @@ export interface CircuitTreeSectionBlock {
|
||||||
|
|
||||||
export interface CircuitTreeResponse {
|
export interface CircuitTreeResponse {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
|
distributionBoardId: string;
|
||||||
|
distributionBoardName: string;
|
||||||
|
distributionBoardNumber?: string;
|
||||||
|
distributionBoardSupplyType?: DistributionBoardSupplyType;
|
||||||
|
feedingReference?: CircuitTreeFeedingReference;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
|
|
@ -70,7 +78,36 @@ export interface CircuitTreeResponse {
|
||||||
footerComponents: CircuitTreeComponent[];
|
footerComponents: CircuitTreeComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeDownstreamBoardLoad {
|
||||||
|
distributionBoardId: string;
|
||||||
|
name: string;
|
||||||
|
boardNumber?: string;
|
||||||
|
supplyType?: DistributionBoardSupplyType;
|
||||||
|
activePowerKw: number;
|
||||||
|
apparentPowerKva: number;
|
||||||
|
cosPhi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeFeedingReference {
|
||||||
|
distributionBoardId: string;
|
||||||
|
circuitListId: string;
|
||||||
|
distributionBoardName: string;
|
||||||
|
distributionBoardNumber?: string;
|
||||||
|
circuitId?: string;
|
||||||
|
circuitEquipmentIdentifier?: string;
|
||||||
|
circuitDisplayName?: string;
|
||||||
|
circuitCategory?: CircuitGroupCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCircuitTreesResponse {
|
||||||
|
currentRevision: number;
|
||||||
|
trees: CircuitTreeResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
import type {
|
||||||
|
DistributionBoardSupplyType,
|
||||||
|
} from "../../shared/constants/distribution-board.js";
|
||||||
import type {
|
import type {
|
||||||
DistributionBoardComponentPlacement,
|
DistributionBoardComponentPlacement,
|
||||||
DistributionBoardComponentRole,
|
DistributionBoardComponentRole,
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,18 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
export const distributionBoardUpdateCommandType =
|
export const distributionBoardUpdateCommandType =
|
||||||
"distribution-board.update" as const;
|
"distribution-board.update" as const;
|
||||||
// The info-card fields were added additively to schema version 1: every
|
// Version 2 adds logical feeding references. Version-1 commands remain
|
||||||
// previously stored command remains valid, and the inverse is still derived
|
// executable and can only change the fields they originally knew.
|
||||||
// from the persisted row for exactly the changed fields.
|
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
|
||||||
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
|
const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
export interface DistributionBoardUpdateValues
|
export interface DistributionBoardUpdateValues
|
||||||
extends Record<DistributionBoardInfoField, string | null> {
|
extends Record<DistributionBoardInfoField, string | null> {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: DistributionBoardSupplyType | null;
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DistributionBoardUpdateField =
|
export type DistributionBoardUpdateField =
|
||||||
|
|
@ -41,16 +43,20 @@ export interface DistributionBoardUpdateCommandPayload {
|
||||||
|
|
||||||
export interface DistributionBoardUpdateProjectCommand
|
export interface DistributionBoardUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||||
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
|
schemaVersion:
|
||||||
|
| typeof legacyDistributionBoardUpdateCommandSchemaVersion
|
||||||
|
| typeof distributionBoardUpdateCommandSchemaVersion;
|
||||||
type: typeof distributionBoardUpdateCommandType;
|
type: typeof distributionBoardUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDistributionBoardUpdateProjectCommand(
|
export function createDistributionBoardUpdateProjectCommand(
|
||||||
distributionBoardId: string,
|
distributionBoardId: string,
|
||||||
patch: DistributionBoardUpdatePatch
|
patch: DistributionBoardUpdatePatch,
|
||||||
|
schemaVersion: DistributionBoardUpdateProjectCommand["schemaVersion"] =
|
||||||
|
distributionBoardUpdateCommandSchemaVersion
|
||||||
): DistributionBoardUpdateProjectCommand {
|
): DistributionBoardUpdateProjectCommand {
|
||||||
const command: DistributionBoardUpdateProjectCommand = {
|
const command: DistributionBoardUpdateProjectCommand = {
|
||||||
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
|
schemaVersion,
|
||||||
type: distributionBoardUpdateCommandType,
|
type: distributionBoardUpdateCommandType,
|
||||||
payload: {
|
payload: {
|
||||||
distributionBoardId,
|
distributionBoardId,
|
||||||
|
|
@ -68,7 +74,8 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is DistributionBoardUpdateProjectCommand {
|
): asserts command is DistributionBoardUpdateProjectCommand {
|
||||||
if (
|
if (
|
||||||
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion ||
|
(command.schemaVersion !== legacyDistributionBoardUpdateCommandSchemaVersion &&
|
||||||
|
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
|
||||||
command.type !== distributionBoardUpdateCommandType ||
|
command.type !== distributionBoardUpdateCommandType ||
|
||||||
!isPlainObject(command.payload)
|
!isPlainObject(command.payload)
|
||||||
) {
|
) {
|
||||||
|
|
@ -93,7 +100,12 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
(change.field !== "floorId" &&
|
(change.field !== "floorId" &&
|
||||||
change.field !== "supplyType" &&
|
change.field !== "supplyType" &&
|
||||||
change.field !== "simultaneityFactor" &&
|
change.field !== "simultaneityFactor" &&
|
||||||
|
change.field !== "feedingDistributionBoardId" &&
|
||||||
|
change.field !== "feedingCircuitId" &&
|
||||||
!isInfoField(change.field)) ||
|
!isInfoField(change.field)) ||
|
||||||
|
(command.schemaVersion === legacyDistributionBoardUpdateCommandSchemaVersion &&
|
||||||
|
(change.field === "feedingDistributionBoardId" ||
|
||||||
|
change.field === "feedingCircuitId")) ||
|
||||||
seen.has(change.field)
|
seen.has(change.field)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -105,7 +117,9 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
} else if (change.field === "floorId") {
|
} else if (change.field === "floorId") {
|
||||||
if (
|
if (
|
||||||
change.value !== null &&
|
change.value !== null &&
|
||||||
(typeof change.value !== "string" || !change.value.trim())
|
(typeof change.value !== "string" ||
|
||||||
|
!change.value.trim() ||
|
||||||
|
change.value !== change.value.trim())
|
||||||
) {
|
) {
|
||||||
throw new Error("floorId must be a non-empty string or null.");
|
throw new Error("floorId must be a non-empty string or null.");
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +132,16 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
) {
|
) {
|
||||||
throw new Error("supplyType is invalid.");
|
throw new Error("supplyType is invalid.");
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
change.field === "feedingDistributionBoardId" ||
|
||||||
|
change.field === "feedingCircuitId"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
change.value !== null &&
|
||||||
|
(typeof change.value !== "string" || !change.value.trim())
|
||||||
|
) {
|
||||||
|
throw new Error(`${change.field} must be a non-empty string or null.`);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
typeof change.value !== "number" ||
|
typeof change.value !== "number" ||
|
||||||
!Number.isFinite(change.value) ||
|
!Number.isFinite(change.value) ||
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export interface DistributionBoardSubtreeSnapshot {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: DistributionBoardSupplyType | null;
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
} & Record<DistributionBoardInfoField, string | null>;
|
} & Record<DistributionBoardInfoField, string | null>;
|
||||||
circuitList: {
|
circuitList: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -184,6 +186,9 @@ export function normalizeDistributionBoardSubtreeSnapshot(
|
||||||
for (const field of distributionBoardInfoFields) {
|
for (const field of distributionBoardInfoFields) {
|
||||||
board[field] = board[field] ?? null;
|
board[field] = board[field] ?? null;
|
||||||
}
|
}
|
||||||
|
board.feedingDistributionBoardId =
|
||||||
|
board.feedingDistributionBoardId ?? null;
|
||||||
|
board.feedingCircuitId = board.feedingCircuitId ?? null;
|
||||||
return { ...snapshot, distributionBoard: board };
|
return { ...snapshot, distributionBoard: board };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +232,18 @@ export function assertDistributionBoardSubtreeSnapshot(
|
||||||
assertNullableString(board[field], `distributionBoard.${field}`);
|
assertNullableString(board[field], `distributionBoard.${field}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (board.feedingDistributionBoardId !== undefined) {
|
||||||
|
assertNullableString(
|
||||||
|
board.feedingDistributionBoardId,
|
||||||
|
"distributionBoard.feedingDistributionBoardId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (board.feedingCircuitId !== undefined) {
|
||||||
|
assertNullableString(
|
||||||
|
board.feedingCircuitId,
|
||||||
|
"distributionBoard.feedingCircuitId"
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const field of [
|
for (const field of [
|
||||||
"id",
|
"id",
|
||||||
"projectId",
|
"projectId",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 8 as const;
|
export const projectStateSnapshotSchemaVersion = 9 as const;
|
||||||
|
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
|
||||||
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
||||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||||
|
|
@ -112,7 +113,7 @@ const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
|
||||||
.max(distributionBoardInfoFieldMaxLengths[field])
|
.max(distributionBoardInfoFieldMaxLengths[field])
|
||||||
.nullable();
|
.nullable();
|
||||||
|
|
||||||
const distributionBoardSchema = legacyDistributionBoardSchema
|
const distributionBoardWithoutFeedingSchema = legacyDistributionBoardSchema
|
||||||
.extend({
|
.extend({
|
||||||
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
|
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
|
||||||
location: distributionBoardInfoValueSchema("location"),
|
location: distributionBoardInfoValueSchema("location"),
|
||||||
|
|
@ -121,6 +122,13 @@ const distributionBoardSchema = legacyDistributionBoardSchema
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const distributionBoardSchema = distributionBoardWithoutFeedingSchema
|
||||||
|
.extend({
|
||||||
|
feedingDistributionBoardId: idSchema.nullable(),
|
||||||
|
feedingCircuitId: idSchema.nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
function withEmptyDistributionBoardInfo(
|
function withEmptyDistributionBoardInfo(
|
||||||
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
|
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
|
||||||
) {
|
) {
|
||||||
|
|
@ -133,6 +141,16 @@ function withEmptyDistributionBoardInfo(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withEmptyDistributionBoardFeeding(
|
||||||
|
boards: Array<z.infer<typeof distributionBoardWithoutFeedingSchema>>
|
||||||
|
) {
|
||||||
|
return boards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const circuitListSchema = z
|
const circuitListSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
|
|
@ -553,11 +571,22 @@ export const projectStateSnapshotSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const roomAreaProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
|
externalModel: externalModelStateSchema,
|
||||||
|
...projectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const buildingProjectStateSnapshotSchema = z
|
const buildingProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...buildingProjectStateSnapshotContents,
|
...buildingProjectStateSnapshotContents,
|
||||||
|
|
@ -568,7 +597,7 @@ const coverProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
|
|
@ -655,6 +684,27 @@ export function parseProjectStateSnapshot(
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
|
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
|
||||||
|
if (
|
||||||
|
roomArea !== null &&
|
||||||
|
typeof roomArea === "object" &&
|
||||||
|
!Array.isArray(roomArea) &&
|
||||||
|
(roomArea as { schemaVersion?: unknown }).schemaVersion ===
|
||||||
|
roomAreaProjectStateSnapshotSchemaVersion
|
||||||
|
) {
|
||||||
|
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
distributionBoards: withEmptyDistributionBoardFeeding(
|
||||||
|
previous.distributionBoards
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return roomArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeToRoomAreaProjectStateSnapshot(value: unknown): unknown {
|
||||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
const cover = upgradeToCoverProjectStateSnapshot(value);
|
||||||
if (
|
if (
|
||||||
cover !== null &&
|
cover !== null &&
|
||||||
|
|
@ -666,7 +716,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
const previous = buildingProjectStateSnapshotSchema.parse(cover);
|
const previous = buildingProjectStateSnapshotSchema.parse(cover);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: roomAreaProjectStateSnapshotSchemaVersion,
|
||||||
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
|
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -683,7 +733,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
// building. Build version 7 first so the room-area upgrade above remains a
|
// building. Build version 7 first so the room-area upgrade above remains a
|
||||||
// single exact transition.
|
// single exact transition.
|
||||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
||||||
return upgradePreviousProjectStateSnapshot({
|
return upgradeToRoomAreaProjectStateSnapshot({
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
|
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
|
||||||
buildings: [],
|
buildings: [],
|
||||||
|
|
@ -873,6 +923,15 @@ function assertProjectStateSnapshotRelations(
|
||||||
const sectionById = new Map(
|
const sectionById = new Map(
|
||||||
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
snapshot.circuitSections.map((entry) => [entry.id, entry])
|
||||||
);
|
);
|
||||||
|
const boardById = new Map(
|
||||||
|
snapshot.distributionBoards.map((entry) => [entry.id, entry])
|
||||||
|
);
|
||||||
|
const circuitListById = new Map(
|
||||||
|
snapshot.circuitLists.map((entry) => [entry.id, entry])
|
||||||
|
);
|
||||||
|
const circuitById = new Map(
|
||||||
|
snapshot.circuits.map((entry) => [entry.id, entry])
|
||||||
|
);
|
||||||
const componentById = new Map(
|
const componentById = new Map(
|
||||||
snapshot.distributionBoardComponents.map((entry) => [
|
snapshot.distributionBoardComponents.map((entry) => [
|
||||||
entry.id,
|
entry.id,
|
||||||
|
|
@ -901,6 +960,47 @@ function assertProjectStateSnapshotRelations(
|
||||||
"Snapshot distribution board uses a disabled supply type."
|
"Snapshot distribution board uses a disabled supply type."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (board.feedingDistributionBoardId === board.id) {
|
||||||
|
throw new Error("Snapshot distribution board cannot feed itself.");
|
||||||
|
}
|
||||||
|
if (board.feedingDistributionBoardId !== null) {
|
||||||
|
assertReference(
|
||||||
|
boardIds,
|
||||||
|
board.feedingDistributionBoardId,
|
||||||
|
"feeding distribution board"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (board.feedingCircuitId !== null) {
|
||||||
|
if (board.feedingDistributionBoardId === null) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot feeding circuit requires a feeding distribution board."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertReference(circuitIds, board.feedingCircuitId, "feeding circuit");
|
||||||
|
const feedingCircuit = circuitById.get(board.feedingCircuitId);
|
||||||
|
const feedingList = feedingCircuit
|
||||||
|
? circuitListById.get(feedingCircuit.circuitListId)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
!feedingList ||
|
||||||
|
feedingList.distributionBoardId !== board.feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot feeding circuit belongs to another distribution board."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const board of snapshot.distributionBoards) {
|
||||||
|
const visited = new Set<string>([board.id]);
|
||||||
|
let currentId = board.feedingDistributionBoardId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
throw new Error("Snapshot distribution-board feeding graph contains a cycle.");
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const list of snapshot.circuitLists) {
|
for (const list of snapshot.circuitLists) {
|
||||||
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,14 @@ export function remapProjectState(
|
||||||
...board,
|
...board,
|
||||||
id: requiredId(boardIds, board.id),
|
id: requiredId(boardIds, board.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
board.feedingDistributionBoardId === null
|
||||||
|
? null
|
||||||
|
: requiredId(boardIds, board.feedingDistributionBoardId),
|
||||||
|
feedingCircuitId:
|
||||||
|
board.feedingCircuitId === null
|
||||||
|
? null
|
||||||
|
: requiredId(circuitIds, board.feedingCircuitId),
|
||||||
floorId:
|
floorId:
|
||||||
board.floorId === null
|
board.floorId === null
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ export function deriveCircuitPhase(
|
||||||
* Rows without cos φ count as 1; a circuit without load yields 1.
|
* Rows without cos φ count as 1; a circuit without load yields 1.
|
||||||
*/
|
*/
|
||||||
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
|
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
|
||||||
|
if (Number.isFinite(circuit.circuitCosPhi) && circuit.circuitCosPhi > 0) {
|
||||||
|
return Math.round(circuit.circuitCosPhi * 100) / 100;
|
||||||
|
}
|
||||||
let activePowerKw = 0;
|
let activePowerKw = 0;
|
||||||
let apparentPowerKva = 0;
|
let apparentPowerKva = 0;
|
||||||
for (const row of circuit.deviceRows) {
|
for (const row of circuit.deviceRows) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
|
@ -3293,6 +3294,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
threePhaseVoltageV={data.threePhaseVoltageV}
|
threePhaseVoltageV={data.threePhaseVoltageV}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{data.feedingReference ? (
|
||||||
|
<div className="alert alert-info py-2" role="status">
|
||||||
|
Gespeist von{" "}
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}/circuit-lists/${
|
||||||
|
data.feedingReference.circuitListId
|
||||||
|
}/tree-edit`}
|
||||||
|
>
|
||||||
|
{data.feedingReference.distributionBoardNumber ??
|
||||||
|
data.feedingReference.distributionBoardName}
|
||||||
|
{data.feedingReference.circuitEquipmentIdentifier ?? ""}
|
||||||
|
</Link>
|
||||||
|
{data.feedingReference.circuitId
|
||||||
|
? null
|
||||||
|
: " (speisender Stromkreis noch nicht festgelegt)"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
|
|
@ -4062,6 +4080,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
? "summary-row"
|
? "summary-row"
|
||||||
: row.rowType === "deviceRow"
|
: row.rowType === "deviceRow"
|
||||||
? "device-row"
|
? "device-row"
|
||||||
|
: row.rowType === "downstreamBoardLoad"
|
||||||
|
? "downstream-board-load-row"
|
||||||
: row.rowType === "reserveCircuit"
|
: row.rowType === "reserveCircuit"
|
||||||
? "empty-circuit-row"
|
? "empty-circuit-row"
|
||||||
: row.rowType === "placeholder"
|
: row.rowType === "placeholder"
|
||||||
|
|
@ -4667,7 +4687,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
{row.circuit &&
|
||||||
|
(row.rowType === "circuitCompact" ||
|
||||||
|
row.rowType === "circuitSummary" ||
|
||||||
|
row.rowType === "reserveCircuit") ? (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, stri
|
||||||
const placeholders: Record<DistributionBoardInfoField, string> = {
|
const placeholders: Record<DistributionBoardInfoField, string> = {
|
||||||
boardNumber: "z. B. UV 1.1",
|
boardNumber: "z. B. UV 1.1",
|
||||||
location: "z. B. Raum 003 Technik",
|
location: "z. B. Raum 003 Technik",
|
||||||
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
|
fedFrom: "z. B. HAK, Zähleranlage, Trafo oder NEA",
|
||||||
remark: "",
|
remark: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,8 +98,8 @@ export function DistributionBoardInfoCard({
|
||||||
)}
|
)}
|
||||||
{field === "fedFrom" ? (
|
{field === "fedFrom" ? (
|
||||||
<div className="form-text">
|
<div className="form-text">
|
||||||
Vorerst Freitext. Die logische Verknüpfung mit dem
|
Für externe Einspeisungen oder ergänzende Hinweise. Eine
|
||||||
speisenden Verteiler folgt.
|
speisende Projektverteilung wird oben logisch verknüpft.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ export interface PrintBoardSummary {
|
||||||
circuitCount: number;
|
circuitCount: number;
|
||||||
/** Σ quantity × power per unit, without any simultaneity factor. */
|
/** Σ quantity × power per unit, without any simultaneity factor. */
|
||||||
installedPowerKw: number;
|
installedPowerKw: number;
|
||||||
/** Σ row totals, i.e. with the device-row simultaneity factors. */
|
/** Device-row demand plus downstream-board demand before this board's GZF. */
|
||||||
devicePowerKw: number;
|
powerBeforeBoardSimultaneityKw: number;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
/** Device power with the board simultaneity factor applied. */
|
/** Total active power with this board's simultaneity factor applied. */
|
||||||
demandPowerKw: number;
|
demandPowerKw: number;
|
||||||
/**
|
/**
|
||||||
* Symmetrical three-phase design current from the apparent power
|
* Design current from the apparent power including downstream boards.
|
||||||
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
|
* The selected feeder circuit determines whether one or three phases apply.
|
||||||
*/
|
*/
|
||||||
designCurrentA: number;
|
designCurrentA: number;
|
||||||
}
|
}
|
||||||
|
|
@ -46,8 +46,13 @@ export function summarizePrintBoard(input: {
|
||||||
circuitCount += 1;
|
circuitCount += 1;
|
||||||
for (const row of circuit.deviceRows) {
|
for (const row of circuit.deviceRows) {
|
||||||
installedPowerKw += row.quantity * row.powerPerUnit;
|
installedPowerKw += row.quantity * row.powerPerUnit;
|
||||||
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
|
|
||||||
}
|
}
|
||||||
|
apparentPowerKva +=
|
||||||
|
circuit.circuitApparentPower ??
|
||||||
|
circuit.deviceRows.reduce(
|
||||||
|
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
|
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
|
||||||
|
|
@ -55,12 +60,13 @@ export function summarizePrintBoard(input: {
|
||||||
...input,
|
...input,
|
||||||
circuitCount,
|
circuitCount,
|
||||||
installedPowerKw,
|
installedPowerKw,
|
||||||
devicePowerKw: tree.distributionBoardTotalPower,
|
powerBeforeBoardSimultaneityKw: tree.distributionBoardTotalPower,
|
||||||
simultaneityFactor,
|
simultaneityFactor,
|
||||||
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
|
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
|
||||||
designCurrentA:
|
designCurrentA: calculateDesignCurrent(
|
||||||
(apparentPowerKva * simultaneityFactor * 1000) /
|
apparentPowerKva * simultaneityFactor,
|
||||||
(Math.sqrt(3) * tree.threePhaseVoltageV),
|
tree
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,8 +74,6 @@ export interface PrintBoardHierarchyEntry {
|
||||||
summary: PrintBoardSummary;
|
summary: PrintBoardSummary;
|
||||||
depth: number;
|
depth: number;
|
||||||
parentBoardId: string | null;
|
parentBoardId: string | null;
|
||||||
/** Own demand plus the plain sum of all resolved downstream boards. */
|
|
||||||
cumulatedDemandPowerKw: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertPrintRevisionConsistency(
|
export function assertPrintRevisionConsistency(
|
||||||
|
|
@ -82,31 +86,21 @@ export function assertPrintRevisionConsistency(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function buildBoardHierarchy(
|
||||||
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
|
|
||||||
* decided. A board is shown below another board only when its `fedFrom`
|
|
||||||
* text equals that board's number or name (case-insensitive). The
|
|
||||||
* cumulated value is a plain sum without an upstream simultaneity factor.
|
|
||||||
* Unresolved texts and cycles keep a board at the top level.
|
|
||||||
*/
|
|
||||||
export function buildPreliminaryBoardHierarchy(
|
|
||||||
summaries: PrintBoardSummary[]
|
summaries: PrintBoardSummary[]
|
||||||
): PrintBoardHierarchyEntry[] {
|
): PrintBoardHierarchyEntry[] {
|
||||||
const normalize = (value: string | null) =>
|
const summaryById = new Map(
|
||||||
value?.trim().toLocaleLowerCase("de") || null;
|
summaries.map((summary) => [summary.board.id, summary])
|
||||||
|
);
|
||||||
const parentById = new Map<string, string | null>();
|
const parentById = new Map<string, string | null>();
|
||||||
for (const summary of summaries) {
|
for (const summary of summaries) {
|
||||||
const fedFrom = normalize(summary.board.fedFrom);
|
const parentId = summary.board.feedingDistributionBoardId;
|
||||||
const candidates = fedFrom
|
parentById.set(
|
||||||
? summaries.filter(
|
summary.board.id,
|
||||||
(candidate) =>
|
parentId && parentId !== summary.board.id && summaryById.has(parentId)
|
||||||
candidate !== summary &&
|
? parentId
|
||||||
(normalize(candidate.board.boardNumber) === fedFrom ||
|
: null
|
||||||
normalize(candidate.board.name) === fedFrom)
|
);
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const parent = candidates.length === 1 ? candidates[0] : undefined;
|
|
||||||
parentById.set(summary.board.id, parent?.board.id ?? null);
|
|
||||||
}
|
}
|
||||||
for (const summary of summaries) {
|
for (const summary of summaries) {
|
||||||
const visited = new Set<string>([summary.board.id]);
|
const visited = new Set<string>([summary.board.id]);
|
||||||
|
|
@ -125,20 +119,12 @@ export function buildPreliminaryBoardHierarchy(
|
||||||
summaries.filter(
|
summaries.filter(
|
||||||
(summary) => parentById.get(summary.board.id) === boardId
|
(summary) => parentById.get(summary.board.id) === boardId
|
||||||
);
|
);
|
||||||
const cumulated = (summary: PrintBoardSummary): number =>
|
|
||||||
summary.demandPowerKw +
|
|
||||||
childrenOf(summary.board.id).reduce(
|
|
||||||
(sum, child) => sum + cumulated(child),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const entries: PrintBoardHierarchyEntry[] = [];
|
const entries: PrintBoardHierarchyEntry[] = [];
|
||||||
const walk = (summary: PrintBoardSummary, depth: number) => {
|
const walk = (summary: PrintBoardSummary, depth: number) => {
|
||||||
entries.push({
|
entries.push({
|
||||||
summary,
|
summary,
|
||||||
depth,
|
depth,
|
||||||
parentBoardId: parentById.get(summary.board.id) ?? null,
|
parentBoardId: parentById.get(summary.board.id) ?? null,
|
||||||
cumulatedDemandPowerKw: cumulated(summary),
|
|
||||||
});
|
});
|
||||||
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
|
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
|
||||||
};
|
};
|
||||||
|
|
@ -146,6 +132,25 @@ export function buildPreliminaryBoardHierarchy(
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatFeedingReference(summary: PrintBoardSummary): string {
|
||||||
|
const reference = summary.tree.feedingReference;
|
||||||
|
if (!reference) return summary.board.fedFrom ?? "–";
|
||||||
|
return `${
|
||||||
|
reference.distributionBoardNumber ?? reference.distributionBoardName
|
||||||
|
}${reference.circuitEquipmentIdentifier ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDesignCurrent(
|
||||||
|
apparentPowerKva: number,
|
||||||
|
tree: CircuitTreeResponseDto
|
||||||
|
) {
|
||||||
|
const category = tree.feedingReference?.circuitCategory;
|
||||||
|
return category === "lighting" || category === "single_phase"
|
||||||
|
? (apparentPowerKva * 1000) / tree.singlePhaseVoltageV
|
||||||
|
: (apparentPowerKva * 1000) /
|
||||||
|
(Math.sqrt(3) * tree.threePhaseVoltageV);
|
||||||
|
}
|
||||||
|
|
||||||
const kilowattFormat = new Intl.NumberFormat("de-DE", {
|
const kilowattFormat = new Intl.NumberFormat("de-DE", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
formatCircuitCable,
|
formatCircuitCable,
|
||||||
formatCurrent,
|
formatCurrent,
|
||||||
formatFactor,
|
formatFactor,
|
||||||
|
formatFeedingReference,
|
||||||
formatKilowatt,
|
formatKilowatt,
|
||||||
formatProtectionDevice,
|
formatProtectionDevice,
|
||||||
formatUnitPower,
|
formatUnitPower,
|
||||||
|
|
@ -269,8 +270,8 @@ function OverviewSheet({
|
||||||
stand: string;
|
stand: string;
|
||||||
}) {
|
}) {
|
||||||
const roots = entries.filter((entry) => entry.parentBoardId === null);
|
const roots = entries.filter((entry) => entry.parentBoardId === null);
|
||||||
// Top-level boards whose "fed from" text names no other board are grouped
|
// Root boards may still carry an external feed description such as
|
||||||
// below that text as an external feed (e.g. "HAK / Zähleranlage").
|
// "HAK / Zähleranlage"; logical board links determine the actual tree.
|
||||||
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
|
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const feed = root.summary.board.fedFrom ?? "";
|
const feed = root.summary.board.fedFrom ?? "";
|
||||||
|
|
@ -320,11 +321,10 @@ function OverviewSheet({
|
||||||
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
|
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
|
||||||
<th className="pd-r">SK</th>
|
<th className="pd-r">SK</th>
|
||||||
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
|
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
|
||||||
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
|
<th className="pd-r">P vor Vert.-GZF<br />[kW]</th>
|
||||||
<th className="pd-r">GZF<br />Vert.</th>
|
<th className="pd-r">GZF<br />Vert.</th>
|
||||||
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
|
<th className="pd-r">P<sub>gl</sub> inkl. UV<br />[kW]</th>
|
||||||
<th className="pd-r">I<sub>B</sub><br />[A]</th>
|
<th className="pd-r">I<sub>B</sub><br />[A]</th>
|
||||||
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -340,41 +340,45 @@ function OverviewSheet({
|
||||||
<td>{board.location ?? "–"}</td>
|
<td>{board.location ?? "–"}</td>
|
||||||
<td>{entry.summary.floorName ?? "–"}</td>
|
<td>{entry.summary.floorName ?? "–"}</td>
|
||||||
<td>{board.supplyType ?? "–"}</td>
|
<td>{board.supplyType ?? "–"}</td>
|
||||||
<td>{board.fedFrom ?? "–"}</td>
|
<td>{formatFeedingReference(entry.summary)}</td>
|
||||||
<td className="pd-r">{entry.summary.circuitCount}</td>
|
<td className="pd-r">{entry.summary.circuitCount}</td>
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
|
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
|
<td className="pd-r">{formatKilowatt(entry.summary.powerBeforeBoardSimultaneityKw)}</td>
|
||||||
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
|
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
|
||||||
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
|
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
|
||||||
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
|
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
|
||||||
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
|
<td colSpan={7}>Summe der Einspeisungen</td>
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
|
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
|
<td className="pd-r">{formatKilowatt(sum((entry) =>
|
||||||
<td />
|
entry.parentBoardId === null
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
|
? entry.summary.powerBeforeBoardSimultaneityKw
|
||||||
|
: 0
|
||||||
|
))}</td>
|
||||||
<td />
|
<td />
|
||||||
|
<td className="pd-r">{formatKilowatt(sum((entry) =>
|
||||||
|
entry.parentBoardId === null ? entry.summary.demandPowerKw : 0
|
||||||
|
))}</td>
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
<p className="pd-note">
|
<p className="pd-note">
|
||||||
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
|
Die Struktur folgt den logischen Verknüpfungen. Eine Unterverteilung
|
||||||
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
|
wird im gewählten Abgang mit ihrer Leistung nach eigenem
|
||||||
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
|
Verteiler-GZF berücksichtigt. Ohne gewählten Abgang erscheint sie
|
||||||
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
|
nur in der Hierarchie.
|
||||||
Verknüpfung und Summierung folgt.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="pd-legend">
|
<p className="pd-legend">
|
||||||
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
|
P<sub>inst</sub> = lokale Geräte ohne GZF · P vor Vert.-GZF =
|
||||||
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
|
Geräte-GZF einschließlich gespeister Unterverteilungen · P<sub>gl</sub> =
|
||||||
S aus cos φ je Gerätezeile
|
zusätzlich mit Verteiler-GZF · I<sub>B</sub> aus S und der Phasenart
|
||||||
|
des speisenden Abgangs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -404,6 +408,7 @@ function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
|
||||||
|
|
||||||
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
||||||
const rows = circuit.deviceRows;
|
const rows = circuit.deviceRows;
|
||||||
|
const downstreamBoards = circuit.downstreamBoardLoads ?? [];
|
||||||
const circuitCells = (
|
const circuitCells = (
|
||||||
<>
|
<>
|
||||||
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
|
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
|
||||||
|
|
@ -414,7 +419,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
||||||
<td>{circuit.remark ?? ""}</td>
|
<td>{circuit.remark ?? ""}</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0 && downstreamBoards.length === 0) {
|
||||||
return (
|
return (
|
||||||
<tr className="pd-circuit pd-reserve">
|
<tr className="pd-circuit pd-reserve">
|
||||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
||||||
|
|
@ -426,7 +431,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rows.length === 1) {
|
if (rows.length === 1 && downstreamBoards.length === 0) {
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const title = circuit.displayName ?? row.displayName;
|
const title = circuit.displayName ?? row.displayName;
|
||||||
return (
|
return (
|
||||||
|
|
@ -468,6 +473,16 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
||||||
<td colSpan={4} />
|
<td colSpan={4} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{downstreamBoards.map((load) => (
|
||||||
|
<tr className="pd-device" key={`downstream:${load.distributionBoardId}`}>
|
||||||
|
<td />
|
||||||
|
<td>Unterverteilung {load.boardNumber ?? load.name}</td>
|
||||||
|
<td>–</td>
|
||||||
|
<td /><td /><td />
|
||||||
|
<td className="pd-r">{formatKilowatt(load.activePowerKw)}</td>
|
||||||
|
<td colSpan={4}>Automatisch aus der verknüpften Verteilung</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +516,7 @@ function BoardPlanSheet({
|
||||||
rows={[
|
rows={[
|
||||||
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
|
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
|
||||||
["Netzart", board.supplyType ?? "–"],
|
["Netzart", board.supplyType ?? "–"],
|
||||||
["Gespeist von", board.fedFrom ?? "–"],
|
["Gespeist von", formatFeedingReference(summary)],
|
||||||
[
|
[
|
||||||
<>P<sub>gl</sub></>,
|
<>P<sub>gl</sub></>,
|
||||||
<>
|
<>
|
||||||
|
|
@ -542,7 +557,7 @@ function BoardPlanSheet({
|
||||||
<td colSpan={5}>
|
<td colSpan={5}>
|
||||||
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
|
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
|
||||||
</td>
|
</td>
|
||||||
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
|
<td className="pd-r">{formatKilowatt(summary.powerBeforeBoardSimultaneityKw)}</td>
|
||||||
<td colSpan={4} />
|
<td colSpan={4} />
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: DistributionBoardSupplyType | null;
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentLogoMetadataDto {
|
export interface DocumentLogoMetadataDto {
|
||||||
|
|
@ -442,7 +444,10 @@ export interface CircuitTreeCircuitDto {
|
||||||
isReserve: boolean;
|
isReserve: boolean;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
circuitTotalPower: number;
|
circuitTotalPower: number;
|
||||||
|
circuitApparentPower: number;
|
||||||
|
circuitCosPhi: number;
|
||||||
deviceRows: CircuitTreeDeviceRowDto[];
|
deviceRows: CircuitTreeDeviceRowDto[];
|
||||||
|
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
|
||||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,6 +466,11 @@ export interface CircuitTreeSectionDto {
|
||||||
|
|
||||||
export interface CircuitTreeResponseDto {
|
export interface CircuitTreeResponseDto {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
|
distributionBoardId: string;
|
||||||
|
distributionBoardName: string;
|
||||||
|
distributionBoardNumber?: string;
|
||||||
|
distributionBoardSupplyType?: DistributionBoardSupplyType;
|
||||||
|
feedingReference?: CircuitTreeFeedingReferenceDto;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
|
|
@ -472,6 +482,32 @@ export interface CircuitTreeResponseDto {
|
||||||
footerComponents: CircuitTreeComponentDto[];
|
footerComponents: CircuitTreeComponentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeDownstreamBoardLoadDto {
|
||||||
|
distributionBoardId: string;
|
||||||
|
name: string;
|
||||||
|
boardNumber?: string;
|
||||||
|
supplyType?: DistributionBoardSupplyType;
|
||||||
|
activePowerKw: number;
|
||||||
|
apparentPowerKva: number;
|
||||||
|
cosPhi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitTreeFeedingReferenceDto {
|
||||||
|
distributionBoardId: string;
|
||||||
|
circuitListId: string;
|
||||||
|
distributionBoardName: string;
|
||||||
|
distributionBoardNumber?: string;
|
||||||
|
circuitId?: string;
|
||||||
|
circuitEquipmentIdentifier?: string;
|
||||||
|
circuitDisplayName?: string;
|
||||||
|
circuitCategory?: CircuitGroupCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCircuitTreesResponseDto {
|
||||||
|
currentRevision: number;
|
||||||
|
trees: CircuitTreeResponseDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalCircuitListObjectDto {
|
export interface ExternalCircuitListObjectDto {
|
||||||
id: string;
|
id: string;
|
||||||
ifcGuid: string;
|
ifcGuid: string;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import type {
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
|
ProjectCircuitTreesResponseDto,
|
||||||
ExternalCsvConfigurationSnapshotDto,
|
ExternalCsvConfigurationSnapshotDto,
|
||||||
ExternalCsvPreviewDto,
|
ExternalCsvPreviewDto,
|
||||||
ExternalInitialImportPlanDto,
|
ExternalInitialImportPlanDto,
|
||||||
|
|
@ -372,7 +373,13 @@ export function updateDistributionBoard(
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
} & Partial<DistributionBoardInfoDto>,
|
} & Partial<DistributionBoardInfoDto> &
|
||||||
|
Partial<
|
||||||
|
Pick<
|
||||||
|
DistributionBoardDto,
|
||||||
|
"feedingDistributionBoardId" | "feedingCircuitId"
|
||||||
|
>
|
||||||
|
>,
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<DistributionBoardCommandResultDto>(
|
return request<DistributionBoardCommandResultDto>(
|
||||||
|
|
@ -523,6 +530,12 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
|
||||||
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectCircuitTrees(projectId: string) {
|
||||||
|
return request<ProjectCircuitTreesResponseDto>(
|
||||||
|
`/api/projects/${projectId}/circuit-trees`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function insertDistributionBoardComponentCommand(
|
export function insertDistributionBoardComponentCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export type RowType =
|
||||||
| "circuitCompact"
|
| "circuitCompact"
|
||||||
| "circuitSummary"
|
| "circuitSummary"
|
||||||
| "deviceRow"
|
| "deviceRow"
|
||||||
|
| "downstreamBoardLoad"
|
||||||
| "reserveCircuit"
|
| "reserveCircuit"
|
||||||
| "placeholder"
|
| "placeholder"
|
||||||
| "footerComponent";
|
| "footerComponent";
|
||||||
|
|
@ -614,6 +615,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||||
return "readonly";
|
return "readonly";
|
||||||
}
|
}
|
||||||
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
||||||
|
if (rowType === "downstreamBoardLoad") return "readonly";
|
||||||
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
||||||
if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
|
if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
|
||||||
if (deviceFieldKeys.has(key)) return "deviceField";
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
CircuitTreeCircuitDto,
|
CircuitTreeCircuitDto,
|
||||||
CircuitTreeComponentDto,
|
CircuitTreeComponentDto,
|
||||||
CircuitTreeDeviceRowDto,
|
CircuitTreeDeviceRowDto,
|
||||||
|
CircuitTreeDownstreamBoardLoadDto,
|
||||||
CircuitTreeSectionDto,
|
CircuitTreeSectionDto,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
|
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
|
||||||
|
|
@ -45,6 +46,7 @@ export interface VisibleGridRow {
|
||||||
circuit?: CircuitTreeCircuitDto;
|
circuit?: CircuitTreeCircuitDto;
|
||||||
device?: CircuitTreeDeviceRowDto;
|
device?: CircuitTreeDeviceRowDto;
|
||||||
component?: CircuitTreeComponentDto;
|
component?: CircuitTreeComponentDto;
|
||||||
|
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto;
|
||||||
cells: VisibleGridCell[];
|
cells: VisibleGridCell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,13 +128,16 @@ function makeVisibleGridRow(
|
||||||
rowType: RowType,
|
rowType: RowType,
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
circuit?: CircuitTreeCircuitDto,
|
circuit?: CircuitTreeCircuitDto,
|
||||||
device?: CircuitTreeDeviceRowDto
|
device?: CircuitTreeDeviceRowDto,
|
||||||
|
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto
|
||||||
): VisibleGridRow {
|
): VisibleGridRow {
|
||||||
const rowKey =
|
const rowKey =
|
||||||
rowType === "placeholder"
|
rowType === "placeholder"
|
||||||
? `placeholder:${sectionId}`
|
? `placeholder:${sectionId}`
|
||||||
: rowType === "deviceRow" && device
|
: rowType === "deviceRow" && device
|
||||||
? `device:${device.id}`
|
? `device:${device.id}`
|
||||||
|
: rowType === "downstreamBoardLoad" && downstreamBoardLoad
|
||||||
|
? `downstream-board:${circuit?.id}:${downstreamBoardLoad.distributionBoardId}`
|
||||||
: `${rowType}:${circuit?.id ?? sectionId}`;
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
||||||
|
|
||||||
const cells = allColumns.map((column): VisibleGridCell => {
|
const cells = allColumns.map((column): VisibleGridCell => {
|
||||||
|
|
@ -140,7 +145,17 @@ function makeVisibleGridRow(
|
||||||
const editable = kind === "circuitField" || kind === "deviceField";
|
const editable = kind === "circuitField" || kind === "deviceField";
|
||||||
let value: GridValue;
|
let value: GridValue;
|
||||||
|
|
||||||
if (rowType === "placeholder") {
|
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
|
||||||
|
value = column.key === "displayName"
|
||||||
|
? `Unterverteilung ${downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name}`
|
||||||
|
: column.key === "rowTotalPower"
|
||||||
|
? downstreamBoardLoad.activePowerKw
|
||||||
|
: column.key === "cosPhi"
|
||||||
|
? downstreamBoardLoad.cosPhi
|
||||||
|
: column.key === "remark"
|
||||||
|
? "Automatisch aus der verknüpften Verteilung"
|
||||||
|
: undefined;
|
||||||
|
} else if (rowType === "placeholder") {
|
||||||
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
|
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
|
||||||
} else if (kind === "deviceField" && device) {
|
} else if (kind === "deviceField" && device) {
|
||||||
value = getDeviceValue(device, column.key);
|
value = getDeviceValue(device, column.key);
|
||||||
|
|
@ -166,7 +181,15 @@ function makeVisibleGridRow(
|
||||||
return { cellKey: column.key, editable, kind, value };
|
return { cellKey: column.key, editable, kind, value };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { rowKey, rowType, sectionId, circuit, device, cells };
|
return {
|
||||||
|
rowKey,
|
||||||
|
rowType,
|
||||||
|
sectionId,
|
||||||
|
circuit,
|
||||||
|
device,
|
||||||
|
downstreamBoardLoad,
|
||||||
|
cells,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildVisibleGridRowsWithStructure(
|
export function buildVisibleGridRowsWithStructure(
|
||||||
|
|
@ -221,15 +244,27 @@ export function buildVisibleGridRowsWithStructure(
|
||||||
}
|
}
|
||||||
if (row.rowType === "circuitBlock") {
|
if (row.rowType === "circuitBlock") {
|
||||||
const circuit = row.circuit;
|
const circuit = row.circuit;
|
||||||
if (circuit.deviceRows.length === 0) {
|
const downstreamLoads = circuit.downstreamBoardLoads ?? [];
|
||||||
|
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
|
||||||
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
|
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
|
||||||
} else if (circuit.deviceRows.length === 1) {
|
} else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
|
||||||
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
|
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
|
||||||
} else {
|
} else {
|
||||||
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
|
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
|
||||||
for (const device of circuit.deviceRows) {
|
for (const device of circuit.deviceRows) {
|
||||||
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
|
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
|
||||||
}
|
}
|
||||||
|
for (const load of downstreamLoads) {
|
||||||
|
rows.push(
|
||||||
|
makeVisibleGridRow(
|
||||||
|
"downstreamBoardLoad",
|
||||||
|
section.id,
|
||||||
|
circuit,
|
||||||
|
undefined,
|
||||||
|
load
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import {
|
import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js";
|
||||||
applyDistributionBoardSimultaneityFactor,
|
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
calculateCircuitTotalPower,
|
import type {
|
||||||
calculateDistributionBoardTotalPower,
|
CircuitTreeProtectionDevice,
|
||||||
calculateRowTotalPower,
|
CircuitTreeResponse,
|
||||||
calculateSectionTotalPower,
|
ProjectCircuitTreesResponse,
|
||||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
} from "../../domain/models/circuit-tree.model.js";
|
||||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
|
||||||
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
|
|
||||||
import {
|
import {
|
||||||
circuitDeviceRowRepository,
|
circuitDeviceRowRepository,
|
||||||
circuitListRepository,
|
circuitListRepository,
|
||||||
|
|
@ -20,9 +18,7 @@ import {
|
||||||
} from "../composition/application-repositories.js";
|
} from "../composition/application-repositories.js";
|
||||||
|
|
||||||
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
error.message.includes("no such table: circuit_sections") ||
|
error.message.includes("no such table: circuit_sections") ||
|
||||||
error.message.includes("no such table: circuits") ||
|
error.message.includes("no such table: circuits") ||
|
||||||
|
|
@ -37,70 +33,147 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
|
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
|
||||||
return res.status(400).json({ error: "Invalid parameters" });
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await circuitListRepository.findById(projectId, circuitListId);
|
const list = await circuitListRepository.findById(projectId, circuitListId);
|
||||||
if (!list) {
|
if (!list) return res.status(404).json({ error: "Circuit list not found" });
|
||||||
return res.status(404).json({ error: "Circuit list not found" });
|
|
||||||
|
try {
|
||||||
|
const result = await buildProjectCircuitTrees(projectId);
|
||||||
|
if (!result) return res.status(404).json({ error: "Project not found" });
|
||||||
|
const tree = result.trees.find((entry) => entry.circuitListId === circuitListId);
|
||||||
|
if (!tree) {
|
||||||
|
return res.status(404).json({ error: "Distribution board not found" });
|
||||||
}
|
}
|
||||||
|
return res.json(tree);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingCircuitTreeSchemaError(error)) {
|
||||||
const project = await projectRepository.findById(projectId);
|
const project = await projectRepository.findById(projectId);
|
||||||
if (!project) {
|
const board = await distributionBoardRepository.findById(
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
const distributionBoard =
|
|
||||||
await distributionBoardRepository.findById(
|
|
||||||
projectId,
|
projectId,
|
||||||
list.distributionBoardId
|
list.distributionBoardId
|
||||||
);
|
);
|
||||||
if (!distributionBoard) {
|
if (!project || !board) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ error: "Project structure not found" });
|
||||||
error: "Distribution board not found",
|
}
|
||||||
});
|
return res.json(emptyCircuitTree(project, board, circuitListId));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProjectCircuitTrees(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
const result = await buildProjectCircuitTrees(projectId);
|
||||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
if (!result) return res.status(404).json({ error: "Project not found" });
|
||||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
return res.json(result);
|
||||||
const components =
|
} catch (error) {
|
||||||
await distributionBoardComponentRepository.listByCircuitList(
|
if (isMissingCircuitTreeSchemaError(error)) {
|
||||||
circuitListId
|
const project = await projectRepository.findById(projectId);
|
||||||
);
|
if (!project) return res.status(404).json({ error: "Project not found" });
|
||||||
const [circuitProtections, componentProtections] =
|
return res.json({ currentRevision: project.currentRevision, trees: [] });
|
||||||
await Promise.all([
|
}
|
||||||
circuitProtectionDeviceRepository.listByCircuitIds(
|
throw error;
|
||||||
circuits.map((entry) => entry.id)
|
}
|
||||||
),
|
}
|
||||||
|
|
||||||
|
export async function buildProjectCircuitTrees(
|
||||||
|
projectId: string
|
||||||
|
): Promise<ProjectCircuitTreesResponse | null> {
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
const [lists, boards] = await Promise.all([
|
||||||
|
circuitListRepository.listByProject(projectId),
|
||||||
|
distributionBoardRepository.listByProject(projectId),
|
||||||
|
]);
|
||||||
|
const listIds = lists.map((entry) => entry.id);
|
||||||
|
const [sections, circuits, components] = await Promise.all([
|
||||||
|
circuitSectionRepository.listByCircuitLists(listIds),
|
||||||
|
circuitRepository.listByCircuitLists(listIds),
|
||||||
|
distributionBoardComponentRepository.listByCircuitLists(listIds),
|
||||||
|
]);
|
||||||
|
const [rows, circuitProtections, componentProtections] = await Promise.all([
|
||||||
|
circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)),
|
||||||
|
circuitProtectionDeviceRepository.listByCircuitIds(circuits.map((entry) => entry.id)),
|
||||||
distributionBoardComponentRepository.listProtectionByComponentIds(
|
distributionBoardComponentRepository.listProtectionByComponentIds(
|
||||||
components.map((entry) => entry.id)
|
components.map((entry) => entry.id)
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const boardById = new Map(boards.map((board) => [board.id, board]));
|
||||||
|
const listById = new Map(lists.map((list) => [list.id, list]));
|
||||||
|
const listByBoardId = new Map(
|
||||||
|
lists.map((list) => [list.distributionBoardId, list])
|
||||||
|
);
|
||||||
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit]));
|
||||||
|
const rowsByCircuitId = groupBy(rows, (row) => row.circuitId);
|
||||||
|
const sectionsByListId = groupBy(sections, (section) => section.circuitListId);
|
||||||
|
const circuitsByListId = groupBy(circuits, (circuit) => circuit.circuitListId);
|
||||||
|
const componentsByListId = groupBy(components, (component) => component.circuitListId);
|
||||||
const circuitProtectionById = new Map(
|
const circuitProtectionById = new Map(
|
||||||
circuitProtections.map((entry) => [entry.circuitId, entry])
|
circuitProtections.map((entry) => [entry.circuitId, entry])
|
||||||
);
|
);
|
||||||
const componentProtectionById = new Map(
|
const componentProtectionById = new Map(
|
||||||
componentProtections.map((entry) => [entry.componentId, entry])
|
componentProtections.map((entry) => [entry.componentId, entry])
|
||||||
);
|
);
|
||||||
for (const row of rows) {
|
|
||||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
|
||||||
rowsByCircuitId.set(row.circuitId, []);
|
|
||||||
}
|
|
||||||
rowsByCircuitId.get(row.circuitId)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const loads = calculateDistributionBoardLoads({
|
||||||
|
boards: boards.map((board) => ({
|
||||||
|
id: board.id,
|
||||||
|
name: board.name,
|
||||||
|
boardNumber: board.boardNumber,
|
||||||
|
simultaneityFactor: board.simultaneityFactor,
|
||||||
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: board.feedingCircuitId,
|
||||||
|
})),
|
||||||
|
circuits: circuits.map((circuit) => {
|
||||||
|
const list = listById.get(circuit.circuitListId);
|
||||||
|
if (!list) throw new Error("Circuit list is missing from project tree.");
|
||||||
|
return {
|
||||||
|
id: circuit.id,
|
||||||
|
distributionBoardId: list.distributionBoardId,
|
||||||
|
rows: (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
|
||||||
|
activePowerKw: calculateRowTotalPower(
|
||||||
|
row.quantity,
|
||||||
|
row.powerPerUnit,
|
||||||
|
row.simultaneityFactor
|
||||||
|
),
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trees = lists.map((list): CircuitTreeResponse => {
|
||||||
|
const board = boardById.get(list.distributionBoardId);
|
||||||
|
if (!board) throw new Error("Distribution board is missing from project tree.");
|
||||||
|
const boardLoad = loads.boards.get(board.id);
|
||||||
|
if (!boardLoad) throw new Error("Distribution-board load is missing.");
|
||||||
const tree: CircuitTreeResponse = {
|
const tree: CircuitTreeResponse = {
|
||||||
circuitListId,
|
circuitListId: list.id,
|
||||||
|
distributionBoardId: board.id,
|
||||||
|
distributionBoardName: board.name,
|
||||||
|
distributionBoardNumber: board.boardNumber ?? undefined,
|
||||||
|
distributionBoardSupplyType: board.supplyType ?? undefined,
|
||||||
|
feedingReference: resolveFeedingReference(
|
||||||
|
board,
|
||||||
|
boardById,
|
||||||
|
listByBoardId,
|
||||||
|
circuitById,
|
||||||
|
sectionById
|
||||||
|
),
|
||||||
currentRevision: project.currentRevision,
|
currentRevision: project.currentRevision,
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
distributionBoardSimultaneityFactor:
|
distributionBoardSimultaneityFactor: board.simultaneityFactor,
|
||||||
distributionBoard.simultaneityFactor,
|
distributionBoardTotalPower: boardLoad.activePowerBeforeSimultaneityKw,
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor:
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
boardLoad.demandActivePowerKw,
|
||||||
headerComponents: [],
|
headerComponents: [],
|
||||||
sections: sections.map((section) => ({
|
sections: (sectionsByListId.get(list.id) ?? []).map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
displayName: section.displayName,
|
displayName: section.displayName,
|
||||||
|
|
@ -115,9 +188,8 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
footerComponents: [],
|
footerComponents: [],
|
||||||
};
|
};
|
||||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
||||||
for (const component of components) {
|
for (const component of componentsByListId.get(list.id) ?? []) {
|
||||||
const protection =
|
const protection = componentProtectionById.get(component.id);
|
||||||
componentProtectionById.get(component.id);
|
|
||||||
const dto = {
|
const dto = {
|
||||||
id: component.id,
|
id: component.id,
|
||||||
circuitListId: component.circuitListId,
|
circuitListId: component.circuitListId,
|
||||||
|
|
@ -127,24 +199,18 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
role: component.role,
|
role: component.role,
|
||||||
placement: component.placement,
|
placement: component.placement,
|
||||||
sortOrder: component.sortOrder,
|
sortOrder: component.sortOrder,
|
||||||
protectionDevice: protection
|
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
|
||||||
? toProtectionDeviceDto(protection)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
if (component.placement === "header") {
|
if (component.placement === "header") tree.headerComponents.push(dto);
|
||||||
tree.headerComponents.push(dto);
|
else if (component.placement === "footer") tree.footerComponents.push(dto);
|
||||||
} else if (component.placement === "footer") {
|
else if (component.sectionId !== null) {
|
||||||
tree.footerComponents.push(dto);
|
|
||||||
} else if (component.sectionId !== null) {
|
|
||||||
sectionBlocks.get(component.sectionId)?.components.push(dto);
|
sectionBlocks.get(component.sectionId)?.components.push(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const circuit of circuits) {
|
for (const circuit of circuitsByListId.get(list.id) ?? []) {
|
||||||
const section = sectionById.get(circuit.sectionId);
|
const block = sectionBlocks.get(circuit.sectionId);
|
||||||
if (!section || section.circuitListId !== circuit.circuitListId) {
|
if (!block) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
|
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
||||||
|
|
@ -166,12 +232,16 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
cosPhi: row.cosPhi ?? undefined,
|
cosPhi: row.cosPhi ?? undefined,
|
||||||
remark: row.remark ?? undefined,
|
remark: row.remark ?? undefined,
|
||||||
overriddenFields: row.overriddenFields ?? undefined,
|
overriddenFields: row.overriddenFields ?? undefined,
|
||||||
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
rowTotalPower: calculateRowTotalPower(
|
||||||
|
row.quantity,
|
||||||
|
row.powerPerUnit,
|
||||||
|
row.simultaneityFactor
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
const circuitLoad = loads.circuits.get(circuit.id);
|
||||||
|
if (!circuitLoad) throw new Error("Circuit load is missing.");
|
||||||
const protection = circuitProtectionById.get(circuit.id);
|
const protection = circuitProtectionById.get(circuit.id);
|
||||||
|
block.circuits.push({
|
||||||
sectionBlocks.get(section.id)?.circuits.push({
|
|
||||||
id: circuit.id,
|
id: circuit.id,
|
||||||
circuitListId: circuit.circuitListId,
|
circuitListId: circuit.circuitListId,
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
|
|
@ -186,39 +256,89 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
voltage: circuit.voltage ?? undefined,
|
voltage: circuit.voltage ?? undefined,
|
||||||
controlRequirement: circuit.controlRequirement ?? undefined,
|
controlRequirement: circuit.controlRequirement ?? undefined,
|
||||||
status: circuit.status ?? undefined,
|
status: circuit.status ?? undefined,
|
||||||
isReserve: Boolean(circuit.isReserve),
|
isReserve:
|
||||||
|
Boolean(circuit.isReserve) &&
|
||||||
|
circuitLoad.downstreamBoards.length === 0,
|
||||||
remark: circuit.remark ?? undefined,
|
remark: circuit.remark ?? undefined,
|
||||||
circuitTotalPower,
|
circuitTotalPower: circuitLoad.activePowerKw,
|
||||||
|
circuitApparentPower: circuitLoad.apparentPowerKva,
|
||||||
|
circuitCosPhi: circuitLoad.cosPhi,
|
||||||
deviceRows,
|
deviceRows,
|
||||||
protectionDevice: protection
|
downstreamBoardLoads: circuitLoad.downstreamBoards
|
||||||
? toProtectionDeviceDto(protection)
|
.map((load) => ({
|
||||||
: undefined,
|
distributionBoardId: load.distributionBoardId,
|
||||||
|
name: load.name,
|
||||||
|
boardNumber: load.boardNumber ?? undefined,
|
||||||
|
supplyType: boardById.get(load.distributionBoardId)?.supplyType ?? undefined,
|
||||||
|
activePowerKw: load.activePowerKw,
|
||||||
|
apparentPowerKva: load.apparentPowerKva,
|
||||||
|
cosPhi: load.cosPhi,
|
||||||
|
}))
|
||||||
|
.sort((left, right) =>
|
||||||
|
(left.boardNumber ?? left.name).localeCompare(
|
||||||
|
right.boardNumber ?? right.name,
|
||||||
|
"de"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const section of tree.sections) {
|
for (const section of tree.sections) {
|
||||||
section.sectionTotalPower = calculateSectionTotalPower(
|
section.sectionTotalPower = section.circuits.reduce(
|
||||||
section.circuits
|
(sum, circuit) => sum + circuit.circuitTotalPower,
|
||||||
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tree.distributionBoardTotalPower =
|
return tree;
|
||||||
calculateDistributionBoardTotalPower(tree.sections);
|
});
|
||||||
tree.distributionBoardTotalPowerWithSimultaneityFactor =
|
|
||||||
applyDistributionBoardSimultaneityFactor(
|
|
||||||
tree.distributionBoardTotalPower,
|
|
||||||
tree.distributionBoardSimultaneityFactor
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json(tree);
|
return { currentRevision: project.currentRevision, trees };
|
||||||
} catch (error) {
|
}
|
||||||
if (isMissingCircuitTreeSchemaError(error)) {
|
|
||||||
return res.json({
|
function resolveFeedingReference(
|
||||||
|
board: Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number],
|
||||||
|
boardById: Map<string, Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number]>,
|
||||||
|
listByBoardId: Map<string, Awaited<ReturnType<typeof circuitListRepository.listByProject>>[number]>,
|
||||||
|
circuitById: Map<string, Awaited<ReturnType<typeof circuitRepository.listByCircuitLists>>[number]>,
|
||||||
|
sectionById: Map<string, Awaited<ReturnType<typeof circuitSectionRepository.listByCircuitLists>>[number]>
|
||||||
|
) {
|
||||||
|
if (board.feedingDistributionBoardId === null) return undefined;
|
||||||
|
const feedingBoard = boardById.get(board.feedingDistributionBoardId);
|
||||||
|
if (!feedingBoard) throw new Error("Feeding distribution board is missing.");
|
||||||
|
const feedingList = listByBoardId.get(feedingBoard.id);
|
||||||
|
if (!feedingList) throw new Error("Feeding circuit list is missing.");
|
||||||
|
const feedingCircuit = board.feedingCircuitId === null
|
||||||
|
? undefined
|
||||||
|
: circuitById.get(board.feedingCircuitId);
|
||||||
|
return {
|
||||||
|
distributionBoardId: feedingBoard.id,
|
||||||
|
circuitListId: feedingList.id,
|
||||||
|
distributionBoardName: feedingBoard.name,
|
||||||
|
distributionBoardNumber: feedingBoard.boardNumber ?? undefined,
|
||||||
|
circuitId: feedingCircuit?.id,
|
||||||
|
circuitEquipmentIdentifier: feedingCircuit?.equipmentIdentifier,
|
||||||
|
circuitDisplayName: feedingCircuit?.displayName ?? undefined,
|
||||||
|
circuitCategory: feedingCircuit
|
||||||
|
? sectionById.get(feedingCircuit.sectionId)?.category ?? undefined
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCircuitTree(
|
||||||
|
project: NonNullable<Awaited<ReturnType<typeof projectRepository.findById>>>,
|
||||||
|
board: NonNullable<Awaited<ReturnType<typeof distributionBoardRepository.findById>>>,
|
||||||
|
circuitListId: string
|
||||||
|
): CircuitTreeResponse & { warning: string } {
|
||||||
|
return {
|
||||||
circuitListId,
|
circuitListId,
|
||||||
|
distributionBoardId: board.id,
|
||||||
|
distributionBoardName: board.name,
|
||||||
|
distributionBoardNumber: board.boardNumber ?? undefined,
|
||||||
|
distributionBoardSupplyType: board.supplyType ?? undefined,
|
||||||
currentRevision: project.currentRevision,
|
currentRevision: project.currentRevision,
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
distributionBoardSimultaneityFactor:
|
distributionBoardSimultaneityFactor: board.simultaneityFactor,
|
||||||
distributionBoard.simultaneityFactor,
|
|
||||||
distributionBoardTotalPower: 0,
|
distributionBoardTotalPower: 0,
|
||||||
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
headerComponents: [],
|
headerComponents: [],
|
||||||
|
|
@ -226,10 +346,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||||
footerComponents: [],
|
footerComponents: [],
|
||||||
warning:
|
warning:
|
||||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
});
|
};
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProtectionDeviceDto(value: {
|
function toProtectionDeviceDto(value: {
|
||||||
|
|
@ -243,11 +360,20 @@ function toProtectionDeviceDto(value: {
|
||||||
return {
|
return {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
ratedCurrentA: value.ratedCurrentA,
|
ratedCurrentA: value.ratedCurrentA,
|
||||||
fuseUtilizationCategory:
|
fuseUtilizationCategory: value.fuseUtilizationCategory ?? undefined,
|
||||||
value.fuseUtilizationCategory ?? undefined,
|
|
||||||
tripCharacteristic: value.tripCharacteristic ?? undefined,
|
tripCharacteristic: value.tripCharacteristic ?? undefined,
|
||||||
rcdType: value.rcdType ?? undefined,
|
rcdType: value.rcdType ?? undefined,
|
||||||
ratedResidualCurrentMa:
|
ratedResidualCurrentMa: value.ratedResidualCurrentMa ?? undefined,
|
||||||
value.ratedResidualCurrentMa ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const value of values) {
|
||||||
|
const key = keyOf(value);
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(value);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
||||||
location: null,
|
location: null,
|
||||||
fedFrom: null,
|
fedFrom: null,
|
||||||
remark: null,
|
remark: null,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -98,6 +100,17 @@ export async function updateDistributionBoard(
|
||||||
.filter((field) => parsed.data[field] !== undefined)
|
.filter((field) => parsed.data[field] !== undefined)
|
||||||
.map((field) => [field, parsed.data[field]])
|
.map((field) => [field, parsed.data[field]])
|
||||||
);
|
);
|
||||||
|
const feedingPatch = {
|
||||||
|
...(parsed.data.feedingDistributionBoardId === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
parsed.data.feedingDistributionBoardId,
|
||||||
|
}),
|
||||||
|
...(parsed.data.feedingCircuitId === undefined
|
||||||
|
? {}
|
||||||
|
: { feedingCircuitId: parsed.data.feedingCircuitId }),
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -110,6 +123,7 @@ export async function updateDistributionBoard(
|
||||||
supplyType: parsed.data.supplyType,
|
supplyType: parsed.data.supplyType,
|
||||||
simultaneityFactor: parsed.data.simultaneityFactor,
|
simultaneityFactor: parsed.data.simultaneityFactor,
|
||||||
...infoPatch,
|
...infoPatch,
|
||||||
|
...feedingPatch,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ import {
|
||||||
listEstimateBalances,
|
listEstimateBalances,
|
||||||
updateEstimateBalance,
|
updateEstimateBalance,
|
||||||
} from "../controllers/estimate-balance.controller.js";
|
} from "../controllers/estimate-balance.controller.js";
|
||||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
import {
|
||||||
|
getCircuitTree,
|
||||||
|
getProjectCircuitTrees,
|
||||||
|
} from "../controllers/circuit-tree.controller.js";
|
||||||
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
||||||
import {
|
import {
|
||||||
getProjectHistory,
|
getProjectHistory,
|
||||||
|
|
@ -109,6 +112,7 @@ projectRouter.delete(
|
||||||
deleteDistributionBoard
|
deleteDistributionBoard
|
||||||
);
|
);
|
||||||
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
||||||
|
projectRouter.get("/:projectId/circuit-trees", getProjectCircuitTrees);
|
||||||
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
||||||
projectRouter.get(
|
projectRouter.get(
|
||||||
"/:projectId/circuit-sections/:sectionId/next-identifier",
|
"/:projectId/circuit-sections/:sectionId/next-identifier",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ export const updateDistributionBoardSchema = z
|
||||||
location: distributionBoardInfoValueSchema("location"),
|
location: distributionBoardInfoValueSchema("location"),
|
||||||
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
|
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
|
||||||
remark: distributionBoardInfoValueSchema("remark"),
|
remark: distributionBoardInfoValueSchema("remark"),
|
||||||
|
feedingDistributionBoardId: z.string().trim().min(1).nullable().optional(),
|
||||||
|
feedingCircuitId: z.string().trim().min(1).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,15 @@ describe("cable sizing defaults from the circuit", () => {
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
isReserve: rows.length === 0,
|
isReserve: rows.length === 0,
|
||||||
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0),
|
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0),
|
||||||
|
circuitApparentPower: rows.reduce(
|
||||||
|
(sum, row) => sum + row.power / (row.cosPhi ?? 1),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
circuitCosPhi: rows.length
|
||||||
|
? rows.reduce((sum, row) => sum + row.power, 0) /
|
||||||
|
rows.reduce((sum, row) => sum + row.power / (row.cosPhi ?? 1), 0)
|
||||||
|
: 1,
|
||||||
|
downstreamBoardLoads: [],
|
||||||
deviceRows: rows.map((row, index) => ({
|
deviceRows: rows.map((row, index) => ({
|
||||||
id: `row-${index}`,
|
id: `row-${index}`,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
|
|
@ -288,6 +297,19 @@ describe("cable sizing defaults from the circuit", () => {
|
||||||
assert.equal(deriveCircuitCosPhi(circuit([])), 1);
|
assert.equal(deriveCircuitCosPhi(circuit([])), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the projected cos φ including downstream boards", () => {
|
||||||
|
const feeder = circuit([{ power: 1, cosPhi: 1 }]);
|
||||||
|
feeder.circuitCosPhi = 0.67;
|
||||||
|
feeder.downstreamBoardLoads = [{
|
||||||
|
distributionBoardId: "uv-2",
|
||||||
|
name: "UV 2",
|
||||||
|
activePowerKw: 3,
|
||||||
|
apparentPowerKva: 5,
|
||||||
|
cosPhi: 0.6,
|
||||||
|
}];
|
||||||
|
assert.equal(deriveCircuitCosPhi(feeder), 0.67);
|
||||||
|
});
|
||||||
|
|
||||||
it("derives the phase from the group category or three-phase rows", () => {
|
it("derives the phase from the group category or three-phase rows", () => {
|
||||||
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1);
|
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1);
|
||||||
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);
|
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -611,6 +612,62 @@ describe("circuit device-row move project-command repository", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks undo from removing a generated circuit used as a feeder", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const store = new CircuitDeviceRowMoveProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
const targetCircuit = createMoveTargetSnapshot(fixture);
|
||||||
|
const command = createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
|
"create",
|
||||||
|
targetCircuit,
|
||||||
|
[{
|
||||||
|
rowId: "row-1",
|
||||||
|
expectedCircuitId: "circuit-1",
|
||||||
|
expectedSortOrder: 10,
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
targetSortOrder: 10,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
const moved = store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const downstream = new DistributionBoardFixtureRepository(
|
||||||
|
fixture.context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
|
||||||
|
fixture.context.db
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: fixture.circuitListId,
|
||||||
|
feedingCircuitId: targetCircuit.id,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, downstream.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: moved.revision.changeSetId,
|
||||||
|
command: moved.inverse,
|
||||||
|
}),
|
||||||
|
/Speist UV-02 – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.equal(getRow(fixture.context, "row-1")?.circuitId, targetCircuit.id);
|
||||||
|
assert.ok(
|
||||||
|
fixture.context.db.select().from(circuits)
|
||||||
|
.where(eq(circuits.id, targetCircuit.id)).get()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects deletion when the created target changed outside history", () => {
|
it("rejects deletion when the created target changed outside history", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,10 @@ function makeCircuit(
|
||||||
sortOrder,
|
sortOrder,
|
||||||
isReserve: deviceRows.length === 0,
|
isReserve: deviceRows.length === 0,
|
||||||
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
||||||
|
circuitApparentPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
||||||
|
circuitCosPhi: 1,
|
||||||
deviceRows,
|
deviceRows,
|
||||||
|
downstreamBoardLoads: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +179,44 @@ describe("circuit grid projection", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders linked downstream boards as separate read-only rows", () => {
|
||||||
|
const feeder = {
|
||||||
|
...reserveCircuit,
|
||||||
|
isReserve: false,
|
||||||
|
circuitTotalPower: 4,
|
||||||
|
circuitApparentPower: 5,
|
||||||
|
circuitCosPhi: 0.8,
|
||||||
|
downstreamBoardLoads: [{
|
||||||
|
distributionBoardId: "board-child",
|
||||||
|
name: "Unterverteilung",
|
||||||
|
boardNumber: "UV-02",
|
||||||
|
activePowerKw: 4,
|
||||||
|
apparentPowerKva: 5,
|
||||||
|
cosPhi: 0.8,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const rows = buildVisibleGridRowsWithStructure(
|
||||||
|
[{ ...sections[0], circuits: [feeder] }],
|
||||||
|
{ headerComponents: [], footerComponents: [] }
|
||||||
|
);
|
||||||
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
||||||
|
"section",
|
||||||
|
"circuitSummary",
|
||||||
|
"downstreamBoardLoad",
|
||||||
|
"placeholder",
|
||||||
|
]);
|
||||||
|
const loadRow = rows[2];
|
||||||
|
assert.equal(
|
||||||
|
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value,
|
||||||
|
"Unterverteilung UV-02"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert.equal(loadRow.cells.every((cell) => !cell.editable), true);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows circuit protection once on each visible circuit row", () => {
|
it("shows circuit protection once on each visible circuit row", () => {
|
||||||
const protectedSections = sections.map((section) => ({
|
const protectedSections = sections.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
|
||||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
|
||||||
|
|
@ -228,6 +229,45 @@ describe("circuit-group subtree project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks deleting a group containing a feeding circuit", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const list = context.db.select().from(circuitLists).get()!;
|
||||||
|
const downstream = new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
|
||||||
|
context.db
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: list.distributionBoardId,
|
||||||
|
feedingCircuitId: "subtree-circuit",
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, downstream.id))
|
||||||
|
.run();
|
||||||
|
const repository =
|
||||||
|
new CircuitGroupSubtreeProjectCommandRepository(context.db);
|
||||||
|
const groupId = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "subtree-circuit"))
|
||||||
|
.get()!.sectionId;
|
||||||
|
const snapshot = repository.capture("project-1", groupId);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/Speist Unterverteiler – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.deepEqual(repository.capture("project-1", groupId), snapshot);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back a cascaded deletion when history persistence fails", () => {
|
it("rolls back a cascaded deletion when history persistence fails", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
@ -442,6 +443,48 @@ describe("circuit structure project-command repository", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks deleting a circuit that feeds a distribution board", () => {
|
||||||
|
const fixture = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const downstream = new DistributionBoardFixtureRepository(
|
||||||
|
fixture.context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV Unterverteiler");
|
||||||
|
fixture.context.db
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
boardNumber: "UV-02",
|
||||||
|
feedingDistributionBoardId: fixture.circuitListId,
|
||||||
|
feedingCircuitId: "circuit-existing",
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, downstream.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const store = new CircuitStructureProjectCommandRepository(
|
||||||
|
fixture.context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeleteProjectCommand(
|
||||||
|
"circuit-existing",
|
||||||
|
fixture.circuitListId
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/Speist UV-02 – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.ok(getCircuit(fixture.context, "circuit-existing"));
|
||||||
|
assert.equal(
|
||||||
|
fixture.context.db.select().from(projectRevisions).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fixture.context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back insert and delete for late history failures", () => {
|
it("rolls back insert and delete for late history failures", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,15 @@ describe("circuit tree controller", () => {
|
||||||
it("returns header, grouped and footer components with both protection layers", async () => {
|
it("returns header, grouped and footer components with both protection layers", async () => {
|
||||||
const originals = {
|
const originals = {
|
||||||
findList: circuitListRepository.findById,
|
findList: circuitListRepository.findById,
|
||||||
|
listLists: circuitListRepository.listByProject,
|
||||||
findProject: projectRepository.findById,
|
findProject: projectRepository.findById,
|
||||||
findBoard: distributionBoardRepository.findById,
|
findBoard: distributionBoardRepository.findById,
|
||||||
listSections: circuitSectionRepository.listByCircuitList,
|
listBoards: distributionBoardRepository.listByProject,
|
||||||
listCircuits: circuitRepository.listByCircuitList,
|
listSections: circuitSectionRepository.listByCircuitLists,
|
||||||
|
listCircuits: circuitRepository.listByCircuitLists,
|
||||||
listRows: circuitDeviceRowRepository.listByCircuitList,
|
listRows: circuitDeviceRowRepository.listByCircuitList,
|
||||||
listComponents:
|
listComponents:
|
||||||
distributionBoardComponentRepository.listByCircuitList,
|
distributionBoardComponentRepository.listByCircuitLists,
|
||||||
listComponentProtection:
|
listComponentProtection:
|
||||||
distributionBoardComponentRepository.listProtectionByComponentIds,
|
distributionBoardComponentRepository.listProtectionByComponentIds,
|
||||||
listCircuitProtection:
|
listCircuitProtection:
|
||||||
|
|
@ -57,6 +59,13 @@ describe("circuit tree controller", () => {
|
||||||
distributionBoardId: "board-1",
|
distributionBoardId: "board-1",
|
||||||
name: "UV-01",
|
name: "UV-01",
|
||||||
}) as never;
|
}) as never;
|
||||||
|
circuitListRepository.listByProject = async () =>
|
||||||
|
[{
|
||||||
|
id: "list-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
distributionBoardId: "board-1",
|
||||||
|
name: "UV-01",
|
||||||
|
}] as never;
|
||||||
projectRepository.findById = async () =>
|
projectRepository.findById = async () =>
|
||||||
({
|
({
|
||||||
id: "project-1",
|
id: "project-1",
|
||||||
|
|
@ -66,8 +75,19 @@ describe("circuit tree controller", () => {
|
||||||
threePhaseVoltageV: 400,
|
threePhaseVoltageV: 400,
|
||||||
}) as never;
|
}) as never;
|
||||||
distributionBoardRepository.findById = async () =>
|
distributionBoardRepository.findById = async () =>
|
||||||
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
|
({ id: "board-1", name: "UV-01", simultaneityFactor: 0.8 }) as never;
|
||||||
circuitSectionRepository.listByCircuitList = async () =>
|
distributionBoardRepository.listByProject = async () =>
|
||||||
|
[{
|
||||||
|
id: "board-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "UV-01",
|
||||||
|
boardNumber: "UV-01",
|
||||||
|
supplyType: "AV",
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}] as never;
|
||||||
|
circuitSectionRepository.listByCircuitLists = async () =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
|
|
@ -80,7 +100,7 @@ describe("circuit tree controller", () => {
|
||||||
groupNumber: 1,
|
groupNumber: 1,
|
||||||
},
|
},
|
||||||
] as never;
|
] as never;
|
||||||
circuitRepository.listByCircuitList = async () =>
|
circuitRepository.listByCircuitLists = async () =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "circuit-1",
|
id: "circuit-1",
|
||||||
|
|
@ -90,10 +110,19 @@ describe("circuit tree controller", () => {
|
||||||
displayName: "Licht",
|
displayName: "Licht",
|
||||||
sortOrder: 10,
|
sortOrder: 10,
|
||||||
isReserve: 0,
|
isReserve: 0,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
remark: null,
|
||||||
},
|
},
|
||||||
] as never;
|
] as never;
|
||||||
circuitDeviceRowRepository.listByCircuitList = async () => [];
|
circuitDeviceRowRepository.listByCircuitList = async () => [];
|
||||||
distributionBoardComponentRepository.listByCircuitList =
|
distributionBoardComponentRepository.listByCircuitLists =
|
||||||
async () =>
|
async () =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -170,13 +199,15 @@ describe("circuit tree controller", () => {
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
circuitListRepository.findById = originals.findList;
|
circuitListRepository.findById = originals.findList;
|
||||||
|
circuitListRepository.listByProject = originals.listLists;
|
||||||
projectRepository.findById = originals.findProject;
|
projectRepository.findById = originals.findProject;
|
||||||
distributionBoardRepository.findById = originals.findBoard;
|
distributionBoardRepository.findById = originals.findBoard;
|
||||||
circuitSectionRepository.listByCircuitList =
|
distributionBoardRepository.listByProject = originals.listBoards;
|
||||||
|
circuitSectionRepository.listByCircuitLists =
|
||||||
originals.listSections;
|
originals.listSections;
|
||||||
circuitRepository.listByCircuitList = originals.listCircuits;
|
circuitRepository.listByCircuitLists = originals.listCircuits;
|
||||||
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
|
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
|
||||||
distributionBoardComponentRepository.listByCircuitList =
|
distributionBoardComponentRepository.listByCircuitLists =
|
||||||
originals.listComponents;
|
originals.listComponents;
|
||||||
distributionBoardComponentRepository.listProtectionByComponentIds =
|
distributionBoardComponentRepository.listProtectionByComponentIds =
|
||||||
originals.listComponentProtection;
|
originals.listComponentProtection;
|
||||||
|
|
|
||||||
89
tests/distribution-board-load-calculation.test.ts
Normal file
89
tests/distribution-board-load-calculation.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { calculateDistributionBoardLoads } from "../src/domain/calculations/distribution-board-load-calculation.js";
|
||||||
|
|
||||||
|
const board = (
|
||||||
|
id: string,
|
||||||
|
simultaneityFactor: number,
|
||||||
|
feedingDistributionBoardId: string | null = null,
|
||||||
|
feedingCircuitId: string | null = null
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
boardNumber: id.toUpperCase(),
|
||||||
|
simultaneityFactor,
|
||||||
|
feedingDistributionBoardId,
|
||||||
|
feedingCircuitId,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("distribution-board load calculation", () => {
|
||||||
|
it("rolls recursive demand into the selected feeder circuit exactly once", () => {
|
||||||
|
const result = calculateDistributionBoardLoads({
|
||||||
|
boards: [
|
||||||
|
board("nshv", 0.8),
|
||||||
|
board("uv1", 0.5, "nshv", "nshv-out"),
|
||||||
|
board("uv2", 0.25, "uv1", "uv1-out"),
|
||||||
|
],
|
||||||
|
circuits: [
|
||||||
|
{ id: "nshv-out", distributionBoardId: "nshv", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
|
||||||
|
{ id: "uv1-out", distributionBoardId: "uv1", rows: [{ activePowerKw: 4, cosPhi: 0.8 }] },
|
||||||
|
{ id: "uv2-load", distributionBoardId: "uv2", rows: [{ activePowerKw: 8, cosPhi: 0.8 }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result.boards.get("uv2"), {
|
||||||
|
distributionBoardId: "uv2",
|
||||||
|
activePowerBeforeSimultaneityKw: 8,
|
||||||
|
apparentPowerBeforeSimultaneityKva: 10,
|
||||||
|
demandActivePowerKw: 2,
|
||||||
|
demandApparentPowerKva: 2.5,
|
||||||
|
cosPhi: 0.8,
|
||||||
|
});
|
||||||
|
assert.equal(result.circuits.get("uv1-out")?.activePowerKw, 6);
|
||||||
|
assert.equal(result.boards.get("uv1")?.demandActivePowerKw, 3);
|
||||||
|
assert.equal(result.circuits.get("nshv-out")?.activePowerKw, 5);
|
||||||
|
assert.equal(result.boards.get("nshv")?.demandActivePowerKw, 4);
|
||||||
|
assert.equal(result.circuits.get("nshv-out")?.downstreamBoards.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines apparent power for multiple child boards on one circuit", () => {
|
||||||
|
const result = calculateDistributionBoardLoads({
|
||||||
|
boards: [board("main", 1), board("a", 1, "main", "out"), board("b", 1, "main", "out")],
|
||||||
|
circuits: [
|
||||||
|
{ id: "out", distributionBoardId: "main", rows: [] },
|
||||||
|
{ id: "a-load", distributionBoardId: "a", rows: [{ activePowerKw: 2, cosPhi: 0.5 }] },
|
||||||
|
{ id: "b-load", distributionBoardId: "b", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const feeder = result.circuits.get("out")!;
|
||||||
|
assert.equal(feeder.activePowerKw, 4);
|
||||||
|
assert.equal(feeder.apparentPowerKva, 6);
|
||||||
|
assert.equal(feeder.cosPhi, 2 / 3);
|
||||||
|
assert.deepEqual(feeder.downstreamBoards.map((entry) => entry.distributionBoardId), ["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a board-only hierarchy relation out of load rollup", () => {
|
||||||
|
const result = calculateDistributionBoardLoads({
|
||||||
|
boards: [board("main", 1), board("child", 1, "main", null)],
|
||||||
|
circuits: [
|
||||||
|
{ id: "main-load", distributionBoardId: "main", rows: [{ activePowerKw: 1, cosPhi: 1 }] },
|
||||||
|
{ id: "child-load", distributionBoardId: "child", rows: [{ activePowerKw: 5, cosPhi: 1 }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(result.boards.get("main")?.demandActivePowerKw, 1);
|
||||||
|
assert.equal(result.boards.get("child")?.demandActivePowerKw, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cycles defensively", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => calculateDistributionBoardLoads({
|
||||||
|
boards: [board("a", 1, "b", "b-out"), board("b", 1, "a", "a-out")],
|
||||||
|
circuits: [
|
||||||
|
{ id: "a-out", distributionBoardId: "a", rows: [] },
|
||||||
|
{ id: "b-out", distributionBoardId: "b", rows: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/cycle/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -219,6 +219,54 @@ describe("distribution-board structure project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks undo from removing an empty board used as a feeder", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(context.db);
|
||||||
|
const upstream = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"NSHV"
|
||||||
|
);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(upstream),
|
||||||
|
});
|
||||||
|
const downstream = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
context.db.insert(distributionBoards).values({
|
||||||
|
...downstream.distributionBoard,
|
||||||
|
boardNumber: "UV-01",
|
||||||
|
feedingDistributionBoardId: upstream.distributionBoard.id,
|
||||||
|
}).run();
|
||||||
|
context.db.insert(circuitLists).values(downstream.circuitList).run();
|
||||||
|
context.db.insert(circuitSections).values(downstream.sections).run();
|
||||||
|
context.db.insert(distributionBoardComponents)
|
||||||
|
.values(downstream.components).run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
}),
|
||||||
|
/Speist UV-01 – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
context.db.select().from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, upstream.distributionBoard.id)).get()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("allows only supply types enabled in the project", () => {
|
it("allows only supply types enabled in the project", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
@ -491,6 +539,159 @@ describe("distribution-board structure project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links a feeding board and circuit while preserving version-1 updates", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(context.db);
|
||||||
|
const upstream = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"NSHV",
|
||||||
|
{ supplyType: "AV" }
|
||||||
|
);
|
||||||
|
const downstream = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV EG",
|
||||||
|
{ supplyType: "AV" }
|
||||||
|
);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(upstream),
|
||||||
|
});
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(downstream),
|
||||||
|
});
|
||||||
|
context.db.insert(circuits).values([
|
||||||
|
{
|
||||||
|
id: "feed-circuit",
|
||||||
|
circuitListId: upstream.circuitList.id,
|
||||||
|
sectionId: upstream.sections[2]!.id,
|
||||||
|
equipmentIdentifier: "-3F1.1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "downstream-circuit",
|
||||||
|
circuitListId: downstream.circuitList.id,
|
||||||
|
sectionId: downstream.sections[2]!.id,
|
||||||
|
equipmentIdentifier: "-3F1.1",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const linked = repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
downstream.distributionBoard.id,
|
||||||
|
{
|
||||||
|
feedingDistributionBoardId: upstream.distributionBoard.id,
|
||||||
|
feedingCircuitId: "feed-circuit",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: distributionBoards.feedingCircuitId,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
feedingDistributionBoardId: upstream.distributionBoard.id,
|
||||||
|
feedingCircuitId: "feed-circuit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||||
|
command: linked.inverse,
|
||||||
|
});
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 4,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
downstream.distributionBoard.id,
|
||||||
|
{
|
||||||
|
feedingDistributionBoardId: upstream.distributionBoard.id,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 5,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
upstream.distributionBoard.id,
|
||||||
|
{
|
||||||
|
feedingDistributionBoardId: downstream.distributionBoard.id,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/Zyklus/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 5,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
downstream.distributionBoard.id,
|
||||||
|
{
|
||||||
|
feedingDistributionBoardId: upstream.distributionBoard.id,
|
||||||
|
feedingCircuitId: "downstream-circuit",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/gehört nicht zur gewählten Verteilung/
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 5,
|
||||||
|
source: "user",
|
||||||
|
command: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "distribution-board.update",
|
||||||
|
payload: {
|
||||||
|
distributionBoardId: downstream.distributionBoard.id,
|
||||||
|
changes: [{ field: "boardNumber", value: "UV-01" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const afterLegacyUpdate = context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
|
||||||
|
.get();
|
||||||
|
assert.equal(afterLegacyUpdate?.boardNumber, "UV-01");
|
||||||
|
assert.equal(
|
||||||
|
afterLegacyUpdate?.feedingDistributionBoardId,
|
||||||
|
upstream.distributionBoard.id
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("sends distribution-board writes to the revision-safe API", async () => {
|
it("sends distribution-board writes to the revision-safe API", async () => {
|
||||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,43 @@ describe("distribution-board subtree project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks deleting a board subtree that feeds another board", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const upstream = createPopulatedBoard(context);
|
||||||
|
const downstream = new DistributionBoardFixtureRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
|
||||||
|
context.db
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
boardNumber: "UV-03",
|
||||||
|
feedingDistributionBoardId: upstream.id,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, downstream.id))
|
||||||
|
.run();
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardSubtreeProjectCommandRepository(context.db);
|
||||||
|
const snapshot = repository.capture("project-1", upstream.id);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createDistributionBoardDeleteSubtreeProjectCommand(snapshot),
|
||||||
|
}),
|
||||||
|
/Speist UV-03 – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.deepEqual(repository.capture("project-1", upstream.id), snapshot);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back a cascaded deletion when history persistence fails", () => {
|
it("rolls back a cascaded deletion when history persistence fails", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ function createSnapshot(): DistributionBoardSubtreeSnapshot {
|
||||||
floorId: "floor-1",
|
floorId: "floor-1",
|
||||||
supplyType: "AV",
|
supplyType: "AV",
|
||||||
simultaneityFactor: 0.8,
|
simultaneityFactor: 0.8,
|
||||||
|
feedingDistributionBoardId: "board-upstream",
|
||||||
|
feedingCircuitId: "circuit-upstream",
|
||||||
},
|
},
|
||||||
circuitList: {
|
circuitList: {
|
||||||
id: "list-1",
|
id: "list-1",
|
||||||
|
|
@ -157,6 +159,11 @@ describe("distribution-board subtree snapshot", () => {
|
||||||
assert.equal(clone.distributionBoard.id, "new-1");
|
assert.equal(clone.distributionBoard.id, "new-1");
|
||||||
assert.equal(clone.circuitList.id, "new-1");
|
assert.equal(clone.circuitList.id, "new-1");
|
||||||
assert.equal(clone.distributionBoard.name, "UV 1 Kopie");
|
assert.equal(clone.distributionBoard.name, "UV 1 Kopie");
|
||||||
|
assert.equal(
|
||||||
|
clone.distributionBoard.feedingDistributionBoardId,
|
||||||
|
"board-upstream"
|
||||||
|
);
|
||||||
|
assert.equal(clone.distributionBoard.feedingCircuitId, "circuit-upstream");
|
||||||
assert.equal(clone.circuitList.name, "UV 1 Kopie Stromkreisliste");
|
assert.equal(clone.circuitList.name, "UV 1 Kopie Stromkreisliste");
|
||||||
assert.equal(clone.sections[0].id, "new-2");
|
assert.equal(clone.sections[0].id, "new-2");
|
||||||
assert.equal(clone.circuits[0].id, "new-3");
|
assert.equal(clone.circuits[0].id, "new-3");
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,61 @@ describe("external object new-circuit project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks history removal when the generated circuit became a feeder", () => {
|
||||||
|
const context = createFixture(null);
|
||||||
|
try {
|
||||||
|
const object = objectSnapshot(null);
|
||||||
|
const circuit = newExternalCircuit(context.db);
|
||||||
|
const command = createExternalObjectNewCircuitProjectCommand(
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
{
|
||||||
|
circuit,
|
||||||
|
objects: [{
|
||||||
|
expected: object,
|
||||||
|
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||||
|
}],
|
||||||
|
confirmedConflictObjectIds: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
context.db.insert(distributionBoards).values({
|
||||||
|
id: "board-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Unterverteiler",
|
||||||
|
boardNumber: "UV-02",
|
||||||
|
feedingDistributionBoardId: "board-1",
|
||||||
|
feedingCircuitId: circuit.id,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
}),
|
||||||
|
/Speist UV-02 – erst Einspeisung ändern\./
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
context.db.select().from(circuits)
|
||||||
|
.where(eq(circuits.id, circuit.id)).get()
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
|
||||||
|
"row-new"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back the complete circuit subtree when history persistence fails", () => {
|
it("rolls back the complete circuit subtree when history persistence fails", () => {
|
||||||
const context = createFixture(null);
|
const context = createFixture(null);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import {
|
import {
|
||||||
assertPrintRevisionConsistency,
|
assertPrintRevisionConsistency,
|
||||||
buildPreliminaryBoardHierarchy,
|
buildBoardHierarchy,
|
||||||
|
formatFeedingReference,
|
||||||
formatCircuitCable,
|
formatCircuitCable,
|
||||||
formatProtectionDevice,
|
formatProtectionDevice,
|
||||||
summarizePrintBoard,
|
summarizePrintBoard,
|
||||||
|
|
@ -27,6 +28,8 @@ function board(
|
||||||
location: null,
|
location: null,
|
||||||
fedFrom: null,
|
fedFrom: null,
|
||||||
remark: null,
|
remark: null,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
...info,
|
...info,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +55,8 @@ function tree(
|
||||||
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
||||||
return {
|
return {
|
||||||
circuitListId: "list",
|
circuitListId: "list",
|
||||||
|
distributionBoardId: "board",
|
||||||
|
distributionBoardName: "Board",
|
||||||
currentRevision: 1,
|
currentRevision: 1,
|
||||||
singlePhaseVoltageV: 230,
|
singlePhaseVoltageV: 230,
|
||||||
threePhaseVoltageV: 400,
|
threePhaseVoltageV: 400,
|
||||||
|
|
@ -79,7 +84,13 @@ function tree(
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
isReserve: false,
|
isReserve: false,
|
||||||
circuitTotalPower: total,
|
circuitTotalPower: total,
|
||||||
|
circuitApparentPower: deviceRows.reduce(
|
||||||
|
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
circuitCosPhi: 1,
|
||||||
deviceRows,
|
deviceRows,
|
||||||
|
downstreamBoardLoads: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -117,16 +128,15 @@ describe("print document model", () => {
|
||||||
assert.equal(formatCircuitCable(circuit), null);
|
assert.equal(formatCircuitCable(circuit), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not guess a parent when feed text matches more than one board", () => {
|
it("does not derive hierarchy from the external free text", () => {
|
||||||
const summaries = [
|
const summaries = [
|
||||||
summary("a", { boardNumber: "HV" }, 2),
|
summary("a", { boardNumber: "HV" }, 2),
|
||||||
summary("b", { name: "HV" }, 3),
|
summary("b", { name: "HV" }, 3),
|
||||||
summary("uv", { fedFrom: " hv " }, 1),
|
summary("uv", { fedFrom: " hv " }, 1),
|
||||||
];
|
];
|
||||||
for (const ordered of [summaries, [...summaries].reverse()]) {
|
for (const ordered of [summaries, [...summaries].reverse()]) {
|
||||||
const entries = buildPreliminaryBoardHierarchy(ordered);
|
const entries = buildBoardHierarchy(ordered);
|
||||||
assert.ok(entries.every((entry) => entry.parentBoardId === null));
|
assert.ok(entries.every((entry) => entry.parentBoardId === null));
|
||||||
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -145,17 +155,39 @@ describe("print document model", () => {
|
||||||
});
|
});
|
||||||
assert.equal(result.circuitCount, 1);
|
assert.equal(result.circuitCount, 1);
|
||||||
assert.equal(result.installedPowerKw, 14);
|
assert.equal(result.installedPowerKw, 14);
|
||||||
assert.equal(result.devicePowerKw, 10);
|
assert.equal(result.powerBeforeBoardSimultaneityKw, 10);
|
||||||
assert.equal(result.demandPowerKw, 5);
|
assert.equal(result.demandPowerKw, 5);
|
||||||
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
|
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
|
||||||
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
|
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nests boards by matching free-text feed and sums downstream demand", () => {
|
it("uses the selected one-phase feeder circuit for the design current", () => {
|
||||||
const entries = buildPreliminaryBoardHierarchy([
|
const inputTree = tree([
|
||||||
|
{ quantity: 1, powerPerUnit: 2.3, simultaneityFactor: 1, cosPhi: 1 },
|
||||||
|
]);
|
||||||
|
inputTree.feedingReference = {
|
||||||
|
distributionBoardId: "hv",
|
||||||
|
circuitListId: "hv-list",
|
||||||
|
distributionBoardName: "Hauptverteilung",
|
||||||
|
distributionBoardNumber: "NSHV",
|
||||||
|
circuitId: "out",
|
||||||
|
circuitEquipmentIdentifier: "-2F1.1",
|
||||||
|
circuitCategory: "single_phase",
|
||||||
|
};
|
||||||
|
const result = summarizePrintBoard({
|
||||||
|
board: board("uv"),
|
||||||
|
circuitListId: "uv",
|
||||||
|
floorName: null,
|
||||||
|
tree: inputTree,
|
||||||
|
});
|
||||||
|
assert.ok(Math.abs(result.designCurrentA - 10) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests boards by their logical feeding references", () => {
|
||||||
|
const entries = buildBoardHierarchy([
|
||||||
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
|
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
|
||||||
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
|
summary("uv-a", { boardNumber: "UV 1.1", feedingDistributionBoardId: "hv" }, 4),
|
||||||
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
|
summary("uv-b", { boardNumber: "UV 1.2", feedingDistributionBoardId: "uv-a" }, 2),
|
||||||
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|
@ -163,28 +195,37 @@ describe("print document model", () => {
|
||||||
entry.summary.board.id,
|
entry.summary.board.id,
|
||||||
entry.depth,
|
entry.depth,
|
||||||
entry.parentBoardId,
|
entry.parentBoardId,
|
||||||
entry.cumulatedDemandPowerKw,
|
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
["hv", 0, null, 16],
|
["hv", 0, null],
|
||||||
["uv-a", 1, "hv", 6],
|
["uv-a", 1, "hv"],
|
||||||
["uv-b", 2, "uv-a", 2],
|
["uv-b", 2, "uv-a"],
|
||||||
["uv-c", 0, null, 1],
|
["uv-c", 0, null],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps boards with cyclic feed texts at the top level", () => {
|
it("keeps malformed cyclic logical links visible at the top level", () => {
|
||||||
const entries = buildPreliminaryBoardHierarchy([
|
const entries = buildBoardHierarchy([
|
||||||
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
|
summary("a", { boardNumber: "A", feedingDistributionBoardId: "b" }, 1),
|
||||||
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
|
summary("b", { boardNumber: "B", feedingDistributionBoardId: "a" }, 1),
|
||||||
]);
|
]);
|
||||||
assert.equal(entries.length, 2);
|
assert.equal(entries.length, 2);
|
||||||
assert.ok(entries.some((entry) => entry.parentBoardId === null));
|
assert.ok(entries.some((entry) => entry.parentBoardId === null));
|
||||||
assert.equal(
|
});
|
||||||
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
|
|
||||||
2
|
it("formats the resolved feeding board and circuit instead of free text", () => {
|
||||||
);
|
const value = summary("uv", { fedFrom: "Alttext" }, 1);
|
||||||
|
value.tree.feedingReference = {
|
||||||
|
distributionBoardId: "hv",
|
||||||
|
circuitListId: "hv-list",
|
||||||
|
distributionBoardName: "Hauptverteilung",
|
||||||
|
distributionBoardNumber: "NSHV",
|
||||||
|
circuitId: "out",
|
||||||
|
circuitEquipmentIdentifier: "-3F1.1",
|
||||||
|
circuitCategory: "three_phase",
|
||||||
|
};
|
||||||
|
assert.equal(formatFeedingReference(value), "NSHV-3F1.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats protection devices for the plan table", () => {
|
it("formats protection devices for the plan table", () => {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ it("upgrades a populated pre-print database through the current schema without c
|
||||||
for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value);
|
for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value);
|
||||||
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
|
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
|
||||||
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
|
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
|
||||||
|
assert.equal(board.feeding_distribution_board_id, null);
|
||||||
|
assert.equal(board.feeding_circuit_id, null);
|
||||||
assert.equal(floor.building_id, null);
|
assert.equal(floor.building_id, null);
|
||||||
assert.equal(room.area_m2, null);
|
assert.equal(room.area_m2, null);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
||||||
|
|
|
||||||
|
|
@ -476,7 +476,14 @@ describe("project snapshot repository", () => {
|
||||||
delete payload.project[field];
|
delete payload.project[field];
|
||||||
}
|
}
|
||||||
for (const board of payload.distributionBoards) {
|
for (const board of payload.distributionBoards) {
|
||||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
|
for (const field of [
|
||||||
|
"boardNumber",
|
||||||
|
"location",
|
||||||
|
"fedFrom",
|
||||||
|
"remark",
|
||||||
|
"feedingDistributionBoardId",
|
||||||
|
"feedingCircuitId",
|
||||||
|
]) {
|
||||||
delete board[field];
|
delete board[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,25 @@ import {
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
serializeProjectStateSnapshot,
|
serializeProjectStateSnapshot,
|
||||||
} from "../src/domain/models/project-state-snapshot.model.js";
|
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
|
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
|
||||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||||
|
|
||||||
|
/** Removes the fields introduced by snapshot schema version 9. */
|
||||||
|
function withoutVersionNineFields<
|
||||||
|
T extends { distributionBoards: object[] },
|
||||||
|
>(snapshot: T): T {
|
||||||
|
for (const board of snapshot.distributionBoards) {
|
||||||
|
delete (board as Record<string, unknown>).feedingDistributionBoardId;
|
||||||
|
delete (board as Record<string, unknown>).feedingCircuitId;
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
/** Removes the fields introduced by snapshot schema version 7. */
|
/** Removes the fields introduced by snapshot schema version 7. */
|
||||||
function withoutVersionSevenFields<
|
function withoutVersionSevenFields<
|
||||||
T extends { floors: object[] },
|
T extends { floors: object[]; distributionBoards: object[] },
|
||||||
>(snapshot: T): T {
|
>(snapshot: T): T {
|
||||||
|
withoutVersionNineFields(snapshot);
|
||||||
delete (snapshot as Record<string, unknown>).buildings;
|
delete (snapshot as Record<string, unknown>).buildings;
|
||||||
for (const floor of snapshot.floors) {
|
for (const floor of snapshot.floors) {
|
||||||
delete (floor as Record<string, unknown>).buildingId;
|
delete (floor as Record<string, unknown>).buildingId;
|
||||||
|
|
@ -117,6 +130,8 @@ function protectedSnapshot() {
|
||||||
location: "Raum 003 Technik" as string | null,
|
location: "Raum 003 Technik" as string | null,
|
||||||
fedFrom: "HV 0.1" as string | null,
|
fedFrom: "HV 0.1" as string | null,
|
||||||
remark: null as string | null,
|
remark: null as string | null,
|
||||||
|
feedingDistributionBoardId: null as string | null,
|
||||||
|
feedingCircuitId: null as string | null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
circuitLists: [
|
circuitLists: [
|
||||||
|
|
@ -206,6 +221,96 @@ describe("project state snapshot model", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("upgrades snapshot version 8 with empty feeding references", () => {
|
||||||
|
const previous = withoutVersionNineFields(protectedSnapshot());
|
||||||
|
previous.schemaVersion = 8 as typeof previous.schemaVersion;
|
||||||
|
|
||||||
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
|
assert.equal(
|
||||||
|
upgraded.distributionBoards[0]?.feedingDistributionBoardId,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.equal(upgraded.distributionBoards[0]?.feedingCircuitId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates logical feeding references and cycles", () => {
|
||||||
|
const linked = protectedSnapshot();
|
||||||
|
linked.distributionBoards.push({
|
||||||
|
id: "board-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "UV 2",
|
||||||
|
floorId: null,
|
||||||
|
supplyType: null,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
boardNumber: "UV 2.1",
|
||||||
|
location: null,
|
||||||
|
fedFrom: null,
|
||||||
|
remark: null,
|
||||||
|
feedingDistributionBoardId: "board-1",
|
||||||
|
feedingCircuitId: "circuit-1",
|
||||||
|
});
|
||||||
|
linked.circuitLists.push({
|
||||||
|
id: "list-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
distributionBoardId: "board-2",
|
||||||
|
name: "UV 2 Stromkreisliste",
|
||||||
|
});
|
||||||
|
linked.circuitSections.push({
|
||||||
|
id: "section-2",
|
||||||
|
circuitListId: "list-2",
|
||||||
|
key: "three_phase",
|
||||||
|
displayName: "Dreiphasig 1",
|
||||||
|
prefix: "-3F1.",
|
||||||
|
sortOrder: 10,
|
||||||
|
category: "three_phase",
|
||||||
|
groupNumber: 1,
|
||||||
|
});
|
||||||
|
const parsed = parseProjectStateSnapshot(linked);
|
||||||
|
assert.equal(parsed.distributionBoards[1]?.feedingCircuitId, "circuit-1");
|
||||||
|
let nextId = 0;
|
||||||
|
const remapped = remapProjectState(
|
||||||
|
parsed,
|
||||||
|
"project-copy",
|
||||||
|
() => `copy-${++nextId}`
|
||||||
|
);
|
||||||
|
const remappedUpstream = remapped.distributionBoards.find(
|
||||||
|
(board) => board.name === "UV 1"
|
||||||
|
);
|
||||||
|
const remappedDownstream = remapped.distributionBoards.find(
|
||||||
|
(board) => board.name === "UV 2"
|
||||||
|
);
|
||||||
|
assert.ok(remappedUpstream);
|
||||||
|
assert.ok(remappedDownstream);
|
||||||
|
assert.equal(
|
||||||
|
remappedDownstream.feedingDistributionBoardId,
|
||||||
|
remappedUpstream.id
|
||||||
|
);
|
||||||
|
assert.notEqual(remappedDownstream.feedingCircuitId, "circuit-1");
|
||||||
|
assert.ok(
|
||||||
|
remapped.circuits.some(
|
||||||
|
(circuit) => circuit.id === remappedDownstream.feedingCircuitId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingBoard = structuredClone(linked);
|
||||||
|
missingBoard.distributionBoards[1]!.feedingDistributionBoardId = null;
|
||||||
|
assert.throws(
|
||||||
|
() => parseProjectStateSnapshot(missingBoard),
|
||||||
|
/requires a feeding distribution board/
|
||||||
|
);
|
||||||
|
|
||||||
|
const cycle = structuredClone(linked);
|
||||||
|
cycle.distributionBoards[0]!.feedingDistributionBoardId = "board-2";
|
||||||
|
cycle.distributionBoards[0]!.feedingCircuitId = null;
|
||||||
|
cycle.distributionBoards[1]!.feedingCircuitId = null;
|
||||||
|
assert.throws(
|
||||||
|
() => parseProjectStateSnapshot(cycle),
|
||||||
|
/contains a cycle/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("upgrades the supported baseline snapshot with a safe building default", () => {
|
it("upgrades the supported baseline snapshot with a safe building default", () => {
|
||||||
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue