Compare commits
3 Commits
1aace105c7
...
7580ad0ade
| Author | SHA1 | Date | |
|---|---|---|---|
| 7580ad0ade | |||
| b1e19a88d5 | |||
| 47dec0df39 |
@@ -185,3 +185,13 @@ Siehe auch: `docs/local-db-circuit-first-migration.md`
|
|||||||
|
|
||||||
- Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md)
|
- Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md)
|
||||||
- Abgleich „Anforderung vs. Implementierung“: [docs/anforderungs-abgleich.md](docs/anforderungs-abgleich.md)
|
- Abgleich „Anforderung vs. Implementierung“: [docs/anforderungs-abgleich.md](docs/anforderungs-abgleich.md)
|
||||||
|
|
||||||
|
## Circuit-List Editor Dokumentation
|
||||||
|
|
||||||
|
- Architektur: [docs/circuit-list-editor-architecture.md](docs/circuit-list-editor-architecture.md)
|
||||||
|
- Interaktionen: [docs/circuit-list-editor-interactions.md](docs/circuit-list-editor-interactions.md)
|
||||||
|
- API: [docs/circuit-list-editor-api.md](docs/circuit-list-editor-api.md)
|
||||||
|
- Migration: [docs/circuit-list-editor-migration.md](docs/circuit-list-editor-migration.md)
|
||||||
|
- Bekannte Limitierungen: [docs/circuit-list-editor-known-limitations.md](docs/circuit-list-editor-known-limitations.md)
|
||||||
|
|
||||||
|
Diese Dokumente beschreiben den aktuellen circuit-first Editorstand und dienen als sichere Entwicklungsbasis fuer Folgeschritte.
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
# Anforderungs-Abgleich (Dokumentation vs. Code)
|
|
||||||
|
|
||||||
Dieses Dokument ergänzt die Hauptdokumentation und listet transparent auf:
|
|
||||||
|
|
||||||
1. Was laut Anforderungen noch nicht vollständig umgesetzt ist.
|
|
||||||
2. Was bereits im Code existiert, aber im ursprünglichen Requirements-Dokument nicht oder nur als offener Punkt erwähnt war.
|
|
||||||
|
|
||||||
Basisvergleich:
|
|
||||||
- Anforderungen: `docs/electrical-load-balance-requirements-context-dump.md`
|
|
||||||
- Ist-Stand: aktueller Code in `src/`
|
|
||||||
|
|
||||||
## A) Anforderungen, die noch nicht vollständig umgesetzt sind
|
|
||||||
|
|
||||||
### 1) Bericht/Export/Print für Leistungsbilanzen
|
|
||||||
- Erwartung: später nutzbarer Report-Export/Print.
|
|
||||||
- Stand: Noch nicht umgesetzt.
|
|
||||||
- Wirkung: Der Fokus liegt aktuell auf Erfassung, Bearbeitung und Berechnung im Web-UI.
|
|
||||||
|
|
||||||
### 2) „Keine Pflichtfelder“ vollständig im UI-Flow
|
|
||||||
- Erwartung: Einträge sollen auch ohne zwingende Felder erfassbar sein.
|
|
||||||
- Stand: Backend-seitig weitgehend erfüllt (optionale Felder + Defaults), UI-Schnellerfassung verlangt derzeit einen Namen.
|
|
||||||
- Wirkung: Fachlich ist unvollständige Speicherung möglich, aber die Standard-UI bremst diesen speziellen Fall noch.
|
|
||||||
|
|
||||||
### 3) Einheitliche Textqualität (Mojibake-frei) im gesamten Frontend
|
|
||||||
- Erwartung: saubere Umlaute/UTF-8.
|
|
||||||
- Stand: In mehreren UI-Dateien sind weiterhin fehlerhafte Kodierungsreste vorhanden (z. B. „Gerät“).
|
|
||||||
- Wirkung: Funktionalität ist gegeben, UX/Textqualität ist noch nachzuziehen.
|
|
||||||
|
|
||||||
## B) Bereits umgesetzt, aber im ursprünglichen Requirements-Dokument nicht klar ausformuliert
|
|
||||||
|
|
||||||
### 1) Feste Domänen-Auswahllisten sind final definiert
|
|
||||||
- Im Requirements-Dokument war das als „noch festzulegen“ markiert.
|
|
||||||
- Im Code jetzt vorhanden:
|
|
||||||
- `src/shared/constants/consumer-option-lists.ts`
|
|
||||||
- Validierung in `src/shared/validation/consumer.schemas.ts`
|
|
||||||
- Select-Felder in `src/app/projects/[projectId]/circuit-lists/page.tsx`
|
|
||||||
|
|
||||||
### 2) Sortieren, Filtern und Bulk-Edit sind bereits implementiert
|
|
||||||
- Im Requirements-Dokument als mögliche Zusatzfunktion erwähnt.
|
|
||||||
- Im Code umgesetzt in `src/app/projects/[projectId]/circuit-lists/page.tsx`.
|
|
||||||
|
|
||||||
### 3) Projekt-Spannungsstandards (1-phasig / 3-phasig) inkl. Berechnungsintegration
|
|
||||||
- Im Requirements-Dokument nicht so detailliert als Projekt-Feature beschrieben.
|
|
||||||
- Im Code umgesetzt:
|
|
||||||
- Projekteinstellungen im Projekt-Detail
|
|
||||||
- Verwendung in Berechnungslogik (effektive Spannung je Verbraucher)
|
|
||||||
|
|
||||||
### 4) 1–3 parallele Stromkreislisten mit responsiver Aufteilung
|
|
||||||
- Im Requirements-Dokument nicht als konkrete UI-Regel spezifiziert.
|
|
||||||
- Im Code umgesetzt in `src/app/projects/[projectId]/circuit-lists/page.tsx`.
|
|
||||||
|
|
||||||
### 5) Gerätekopie global ↔ projektbezogen in beide Richtungen
|
|
||||||
- Fachlich erwähnt, aber technisch konkretisiert im API-Design:
|
|
||||||
- `POST /api/project-devices/projects/:projectId/import-global/:globalDeviceId`
|
|
||||||
- `POST /api/global-devices/import-project/:projectId/:projectDeviceId`
|
|
||||||
|
|
||||||
## C) Offene fachliche Entscheidungen mit bereits getroffener Code-Interpretation
|
|
||||||
|
|
||||||
### 1) Verhalten beim Kopieren von Stromkreiseinträgen zwischen Listen
|
|
||||||
- Offener Punkt in den Anforderungen: Link beibehalten oder trennen?
|
|
||||||
- Aktuelle Code-Interpretation: Link-Information wird mitkopiert (`projectDeviceId`, `isLinkedToDevice`).
|
|
||||||
- Relevanz: Sollte fachlich explizit bestätigt werden, damit spätere Änderungen keine Regression verursachen.
|
|
||||||
|
|
||||||
## D) Empfehlung für nächste Dokumentationsrunde
|
|
||||||
|
|
||||||
1. Das Requirements-Dokument um die inzwischen finalen Auswahllistenwerte ergänzen.
|
|
||||||
2. Die Kopierlogik (Link beibehalten/trennen) fachlich eindeutig festschreiben.
|
|
||||||
3. Einen eigenen Abschnitt für bekannte UX-/Textqualitätspunkte (Kodierung/Umlaute) ergänzen.
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Circuit List Editor API
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The circuit-first editor uses tree + circuit + row endpoints. Legacy consumer endpoints still exist for compatibility and migration, but should not be extended for new circuit-list behavior.
|
||||||
|
|
||||||
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
|
### Tree Endpoint
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
|
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
|
||||||
|
|
||||||
|
Response sketch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"circuitListId": "cl_1",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "sec_1",
|
||||||
|
"key": "lighting",
|
||||||
|
"prefix": "-1F",
|
||||||
|
"circuits": [
|
||||||
|
{
|
||||||
|
"id": "cir_1",
|
||||||
|
"equipmentIdentifier": "-1F1",
|
||||||
|
"circuitTotalPower": 4.2,
|
||||||
|
"deviceRows": [
|
||||||
|
{
|
||||||
|
"id": "row_1",
|
||||||
|
"displayName": "Office lights",
|
||||||
|
"rowTotalPower": 1.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circuit CRUD
|
||||||
|
|
||||||
|
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
|
||||||
|
- create circuit in list/section
|
||||||
|
- `PATCH /circuits/:circuitId`
|
||||||
|
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
|
||||||
|
- `DELETE /circuits/:circuitId`
|
||||||
|
- delete circuit (and related rows via DB relations)
|
||||||
|
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||||
|
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||||
|
|
||||||
|
Request sketch (`POST .../circuits`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sectionId": "sec_1",
|
||||||
|
"equipmentIdentifier": "-2F14",
|
||||||
|
"displayName": "Sockets East",
|
||||||
|
"sortOrder": 140
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device Row CRUD
|
||||||
|
|
||||||
|
- `POST /circuits/:circuitId/device-rows`
|
||||||
|
- create row inside a circuit
|
||||||
|
- `PATCH /circuit-device-rows/:rowId`
|
||||||
|
- update row values
|
||||||
|
- `DELETE /circuit-device-rows/:rowId`
|
||||||
|
- delete row
|
||||||
|
|
||||||
|
### Move Device Row
|
||||||
|
|
||||||
|
- `PATCH /circuit-device-rows/:rowId/move`
|
||||||
|
- Purpose: move one row to existing circuit or to newly created circuit in target section.
|
||||||
|
|
||||||
|
Request sketch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetCircuitId": "cir_target"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetSectionId": "sec_target",
|
||||||
|
"createNewCircuit": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Move Device Rows
|
||||||
|
|
||||||
|
- `PATCH /circuit-device-rows/move-bulk`
|
||||||
|
- Purpose: move multiple rows in one command flow.
|
||||||
|
|
||||||
|
Request sketch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rowIds": ["row_1", "row_2"],
|
||||||
|
"targetCircuitId": "cir_target"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reorder Circuits
|
||||||
|
|
||||||
|
- `PATCH /circuit-sections/:sectionId/circuits/reorder`
|
||||||
|
- Purpose: persist explicit circuit order within one section.
|
||||||
|
|
||||||
|
Request sketch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderedCircuitIds": ["cir_2", "cir_1", "cir_3"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renumber Section
|
||||||
|
|
||||||
|
- `POST /circuit-sections/:sectionId/renumber`
|
||||||
|
- Purpose: explicit renumbering by section prefix; never implicit on move/sort.
|
||||||
|
|
||||||
|
### Safe Equipment Identifier Update
|
||||||
|
|
||||||
|
- `PATCH /circuit-sections/:sectionId/equipment-identifiers`
|
||||||
|
- Purpose: apply explicit per-circuit identifiers safely even with unique constraints.
|
||||||
|
|
||||||
|
Request sketch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifiers": [
|
||||||
|
{ "circuitId": "cir_1", "equipmentIdentifier": "-2F1" },
|
||||||
|
{ "circuitId": "cir_2", "equipmentIdentifier": "-2F2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy Endpoints (Temporary)
|
||||||
|
|
||||||
|
- `GET /consumers/projects/:projectId`
|
||||||
|
- `POST /consumers`
|
||||||
|
- `PUT /consumers/:consumerId`
|
||||||
|
- `DELETE /consumers/:consumerId`
|
||||||
|
|
||||||
|
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Circuit List Editor Architecture
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits.
|
||||||
|
|
||||||
|
## Domain Model Overview
|
||||||
|
|
||||||
|
- `CircuitSection`
|
||||||
|
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
|
||||||
|
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
|
||||||
|
- `Circuit`
|
||||||
|
- Core electrical unit in the list.
|
||||||
|
- Owns circuit-level identifiers and technical data:
|
||||||
|
- `equipmentIdentifier` (BMK)
|
||||||
|
- protection data
|
||||||
|
- cable data
|
||||||
|
- reserve state
|
||||||
|
- circuit-level remark/status
|
||||||
|
- `CircuitDeviceRow`
|
||||||
|
- Load/device line inside a circuit.
|
||||||
|
- Owns row-level load and context values:
|
||||||
|
- `quantity`
|
||||||
|
- `powerPerUnit`
|
||||||
|
- `simultaneityFactor`
|
||||||
|
- `cosPhi`
|
||||||
|
- room snapshots
|
||||||
|
- category/cost group and related row attributes
|
||||||
|
- `ProjectDevice`
|
||||||
|
- Reusable device template entity at project level.
|
||||||
|
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||||
|
- Legacy `Consumer`
|
||||||
|
- Old row-first model still present for compatibility/migration.
|
||||||
|
- Should not be extended for new circuit-list behavior.
|
||||||
|
|
||||||
|
## Why A Circuit Is Not One Row
|
||||||
|
|
||||||
|
A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks:
|
||||||
|
|
||||||
|
- BMK ownership (belongs to circuit, not each device row)
|
||||||
|
- circuit-level protection/cable fields
|
||||||
|
- grouped calculations and circuit move/reorder semantics
|
||||||
|
|
||||||
|
The tree model keeps circuit identity stable while allowing row-level load composition.
|
||||||
|
|
||||||
|
## Rendering Model: Single vs Multi Device
|
||||||
|
|
||||||
|
- Single-device circuit:
|
||||||
|
- Rendered as compact combined row (`circuitCompact`) for fast editing.
|
||||||
|
- Multi-device circuit:
|
||||||
|
- Rendered as one circuit summary row (`circuitSummary`) plus indented `deviceRow` entries.
|
||||||
|
|
||||||
|
This keeps visual density high without losing ownership boundaries.
|
||||||
|
|
||||||
|
## Reserve / Empty Circuits
|
||||||
|
|
||||||
|
Circuits with no device rows are rendered as reserve rows (`reserveCircuit`).
|
||||||
|
Section-level `-frei-` placeholder rows represent insertion targets for creating a new circuit in that section.
|
||||||
|
|
||||||
|
## BMK Ownership (`equipmentIdentifier`)
|
||||||
|
|
||||||
|
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
|
||||||
|
|
||||||
|
- Device rows do not have their own BMK.
|
||||||
|
- Existing identifiers must stay stable unless explicitly changed by user action.
|
||||||
|
- Renumbering is explicit, not implicit on move/sort/delete.
|
||||||
|
|
||||||
|
## Data Ownership Split: Circuit vs Device Row
|
||||||
|
|
||||||
|
Circuit-level fields on `Circuit`:
|
||||||
|
|
||||||
|
- protection type/rating/characteristic
|
||||||
|
- cable type/cross-section/length
|
||||||
|
- RCD/terminal/status
|
||||||
|
|
||||||
|
Device-level load fields on `CircuitDeviceRow`:
|
||||||
|
|
||||||
|
- quantity, power per unit, simultaneity, cosPhi
|
||||||
|
- row naming/categorization and room snapshots
|
||||||
|
|
||||||
|
This split matches execution-design workflows where many loads share one protective path.
|
||||||
|
|
||||||
|
## Calculated Totals
|
||||||
|
|
||||||
|
- `rowTotalPower` is calculated per `CircuitDeviceRow`.
|
||||||
|
- `circuitTotalPower` is calculated as sum of all row totals in one circuit.
|
||||||
|
|
||||||
|
Totals are exposed by the tree response and shown in computed read-only cells.
|
||||||
|
|
||||||
|
## Frontend Route
|
||||||
|
|
||||||
|
Primary circuit-first editor route:
|
||||||
|
|
||||||
|
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
|
||||||
|
|
||||||
|
Legacy route remains separate (read-only/preview/migration transition path) and is intentionally not merged into the editable tree editor.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Circuit List Editor Interactions
|
||||||
|
|
||||||
|
## Editing Model
|
||||||
|
|
||||||
|
Inline cells are static text by default. A cell enters edit mode by:
|
||||||
|
|
||||||
|
- double-click
|
||||||
|
- `Enter`
|
||||||
|
- `F2`
|
||||||
|
- typing a printable character (type-to-edit)
|
||||||
|
|
||||||
|
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
||||||
|
|
||||||
|
## `selectedCell` vs `editingCell`
|
||||||
|
|
||||||
|
- `selectedCell` tracks spreadsheet navigation focus.
|
||||||
|
- `editingCell` tracks active input draft (`draft`, edit mode, focus token).
|
||||||
|
|
||||||
|
The editor can have a selected cell without an active editor input. `editingCell` is only set while editing.
|
||||||
|
|
||||||
|
## Normalized Visible Grid
|
||||||
|
|
||||||
|
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
|
||||||
|
|
||||||
|
- section header rows
|
||||||
|
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
|
||||||
|
- device rows (`deviceRow`)
|
||||||
|
- section placeholder rows (`placeholder`)
|
||||||
|
|
||||||
|
Selection, keyboard movement, and editability checks run against this normalized grid, not directly against nested API JSON.
|
||||||
|
|
||||||
|
## Keyboard Behavior
|
||||||
|
|
||||||
|
- `Enter`: start edit (when not editing) or commit (when editing input)
|
||||||
|
- `F2`: start edit on selected cell
|
||||||
|
- `Escape`: cancel edit draft, or clear multi-row selection when not editing
|
||||||
|
- `Tab` / `Shift+Tab`: move between editable cells (commits active edit)
|
||||||
|
- arrow keys: move selected cell in grid when not editing
|
||||||
|
- type-to-edit: printable keys open edit mode and replace current display value with typed character
|
||||||
|
|
||||||
|
## `-frei-` Placeholder Behavior
|
||||||
|
|
||||||
|
Each section has a trailing placeholder row showing `-frei-` in BMK column:
|
||||||
|
|
||||||
|
- serves as drop target for creating new circuits from project devices or moved rows
|
||||||
|
- editable placeholder cells can create a new circuit + first row through the same edit command flow
|
||||||
|
|
||||||
|
## Add Circuit Behavior
|
||||||
|
|
||||||
|
Add-circuit actions create reserve circuits in the active section using the section prefix and next numeric suffix (`max + 1`).
|
||||||
|
No automatic gap-filling or global renumbering is performed.
|
||||||
|
|
||||||
|
## Drag-and-Drop Behavior
|
||||||
|
|
||||||
|
Intent is separated by drag source type:
|
||||||
|
|
||||||
|
- project device drag:
|
||||||
|
- drop to section/placeholder -> create new circuit with linked row
|
||||||
|
- drop to existing circuit row -> append row to that circuit
|
||||||
|
- device row drag:
|
||||||
|
- drop to existing circuit -> move row(s) into that circuit
|
||||||
|
- drop to placeholder -> create new target circuit and move row(s)
|
||||||
|
- circuit drag (BMK handle):
|
||||||
|
- reorder circuits inside same section only
|
||||||
|
- cross-section reorder is rejected
|
||||||
|
- bulk device row move:
|
||||||
|
- supported via multi-selection + drag
|
||||||
|
- multi-circuit move:
|
||||||
|
- supported for same-section selected circuit blocks
|
||||||
|
|
||||||
|
## Filtering and Sorting
|
||||||
|
|
||||||
|
- Per-column filtering works on normalized displayed values.
|
||||||
|
- Sorting is view-level first and treats circuits as blocks.
|
||||||
|
- Multi-device circuits are not split during sort.
|
||||||
|
- Sorting alone does not persist order.
|
||||||
|
|
||||||
|
## Apply Sorted Order
|
||||||
|
|
||||||
|
`Apply sorted order` persists current sorted block order to backend by section.
|
||||||
|
|
||||||
|
- disabled while filters are active
|
||||||
|
- undoable via command history
|
||||||
|
- until applied, sort remains view-only
|
||||||
|
|
||||||
|
## Column Configuration
|
||||||
|
|
||||||
|
- Column visibility and order are configurable.
|
||||||
|
- BMK column (`equipmentIdentifier`) is locked as first column.
|
||||||
|
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
|
||||||
|
|
||||||
|
## Undo/Redo
|
||||||
|
|
||||||
|
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command.
|
||||||
|
|
||||||
|
Covered operations include:
|
||||||
|
|
||||||
|
- insert/delete circuit
|
||||||
|
- insert/delete row
|
||||||
|
- edit cell values
|
||||||
|
- moves (single/bulk rows, circuit reorder)
|
||||||
|
- renumber and identifier update flows
|
||||||
|
- apply sorted order
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- session-local only
|
||||||
|
- no persisted history across browser reload
|
||||||
|
- some multi-step backend flows are not fully transaction-hardened end-to-end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Circuit List Editor Known Limitations
|
||||||
|
|
||||||
|
- Undo/redo history is session-local only.
|
||||||
|
- Undo/redo history is not persisted across reloads or between users.
|
||||||
|
- No linked project-device sync review dialog is implemented yet.
|
||||||
|
- No global cross-project device library workflow is implemented yet.
|
||||||
|
- Final electrical sizing logic is not implemented yet.
|
||||||
|
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||||
|
- Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits.
|
||||||
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
|
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||||
|
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Circuit List Editor Migration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Migrate legacy row-first consumers into the circuit-first model without deleting legacy data.
|
||||||
|
|
||||||
|
## Legacy Mapping
|
||||||
|
|
||||||
|
Legacy `Consumer` rows map into:
|
||||||
|
|
||||||
|
- `Circuit` for shared circuit identity and circuit-level technical fields
|
||||||
|
- `CircuitDeviceRow` for per-device load rows
|
||||||
|
|
||||||
|
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
|
||||||
|
|
||||||
|
## Grouping Strategy (`circuitNumber`)
|
||||||
|
|
||||||
|
Migration groups legacy rows by normalized `circuitNumber`:
|
||||||
|
|
||||||
|
- valid/normalizable values become one target circuit per normalized value
|
||||||
|
- duplicates are grouped under that circuit (multiple `CircuitDeviceRow`s)
|
||||||
|
- missing/invalid values trigger generated identifiers and may fall back to `unassigned` section
|
||||||
|
|
||||||
|
## Default Section Backfill
|
||||||
|
|
||||||
|
Before migration, default sections are created/backfilled per circuit list.
|
||||||
|
This guarantees a valid target section space, including `unassigned` when no section can be inferred.
|
||||||
|
|
||||||
|
## Migration Commands
|
||||||
|
|
||||||
|
Run in this order for local database workflows:
|
||||||
|
|
||||||
|
1. Backup:
|
||||||
|
- `npm run db:backup`
|
||||||
|
2. Migrate schema:
|
||||||
|
- `npm run db:migrate`
|
||||||
|
3. Verify circuit schema:
|
||||||
|
- `npm run db:verify:circuit-schema`
|
||||||
|
4. Backfill missing sections:
|
||||||
|
- `npm run db:backfill:sections`
|
||||||
|
5. Migrate legacy consumers:
|
||||||
|
- `npm run db:migrate:legacy-consumers`
|
||||||
|
|
||||||
|
## Validation Checks
|
||||||
|
|
||||||
|
After migration, verify:
|
||||||
|
|
||||||
|
- tree endpoint returns sections/circuits/rows
|
||||||
|
- grouped duplicate circuit numbers are reported
|
||||||
|
- generated identifiers are reported where expected
|
||||||
|
- migrated rows include `legacyConsumerId` traceability
|
||||||
|
- no duplicate BMKs exist inside one circuit list
|
||||||
|
|
||||||
|
## If Tree Endpoint Returns Empty Sections
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- circuit-first tables missing (migration not run)
|
||||||
|
- sections not backfilled yet
|
||||||
|
- migrated dataset genuinely empty for selected list
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
1. Run schema migration and verification commands.
|
||||||
|
2. Run section backfill command.
|
||||||
|
3. Run legacy-consumer migration command.
|
||||||
|
4. Retry tree endpoint.
|
||||||
|
|
||||||
|
## Legacy Data Retention
|
||||||
|
|
||||||
|
Do not delete legacy consumers yet.
|
||||||
|
|
||||||
|
- legacy endpoints/views may still depend on them
|
||||||
|
- migration trace tables reference old/new mapping
|
||||||
|
- keeping legacy rows allows comparison and rollback validation during transition
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
# Context Dump – Requirements for Electrical Load Balance and Circuit List Web App
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
The application shall be a web app for creating electrical load balances and combined load balance / circuit lists for electrical planning.
|
|
||||||
|
|
||||||
The application shall primarily work in a tabular way.
|
|
||||||
|
|
||||||
## 2. Basic Structure
|
|
||||||
|
|
||||||
The application manages projects.
|
|
||||||
|
|
||||||
A project contains:
|
|
||||||
|
|
||||||
- Multiple distribution boards
|
|
||||||
- One project-specific device list
|
|
||||||
|
|
||||||
Each distribution board contains:
|
|
||||||
|
|
||||||
- Exactly one circuit list
|
|
||||||
|
|
||||||
Additionally, there is:
|
|
||||||
|
|
||||||
- One global device list
|
|
||||||
- One project-specific device list per project
|
|
||||||
|
|
||||||
Basic hierarchy:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
├── ProjectDeviceList
|
|
||||||
└── DistributionBoard
|
|
||||||
└── CircuitList
|
|
||||||
└── CircuitEntries
|
|
||||||
|
|
||||||
Global
|
|
||||||
└── GlobalDeviceList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Projects
|
|
||||||
|
|
||||||
A project is the top-level domain object.
|
|
||||||
|
|
||||||
A project can contain multiple distribution boards.
|
|
||||||
|
|
||||||
A project has one project-specific device list.
|
|
||||||
|
|
||||||
Devices from the project-specific device list can be used in the circuit lists of the distribution boards within that project.
|
|
||||||
|
|
||||||
### Project Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- distributionBoards: List<DistributionBoard>
|
|
||||||
- projectDeviceList: DeviceList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Distribution Boards
|
|
||||||
|
|
||||||
A distribution board belongs to exactly one project.
|
|
||||||
|
|
||||||
A distribution board has exactly one circuit list.
|
|
||||||
|
|
||||||
A project can contain multiple distribution boards.
|
|
||||||
|
|
||||||
Each circuit list is therefore assigned to one distribution board.
|
|
||||||
|
|
||||||
### DistributionBoard Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
DistributionBoard
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- circuitList: CircuitList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Circuit Lists
|
|
||||||
|
|
||||||
A circuit list belongs to one distribution board.
|
|
||||||
|
|
||||||
The circuit list is table-based.
|
|
||||||
|
|
||||||
One row in the table is one circuit entry.
|
|
||||||
|
|
||||||
Multiple rows may reference the same circuit number.
|
|
||||||
|
|
||||||
This is required so that multiple different devices can be assigned to the same circuit.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Circuit 1 | Sockets | 6 pcs
|
|
||||||
Circuit 1 | Lights | 3 pcs
|
|
||||||
Circuit 1 | Fixed load | 1 pc
|
|
||||||
```
|
|
||||||
|
|
||||||
This means that one circuit can be represented by multiple table rows.
|
|
||||||
|
|
||||||
### CircuitList Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- entries: List<CircuitEntry>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Circuit Entries
|
|
||||||
|
|
||||||
A circuit entry is one row in the circuit list.
|
|
||||||
|
|
||||||
A circuit entry can:
|
|
||||||
|
|
||||||
- Be linked to a device from the project-specific device list
|
|
||||||
- Exist without a device
|
|
||||||
- Exist without a device reference
|
|
||||||
- Be detached from the originally linked device
|
|
||||||
- Have its own description
|
|
||||||
- Have its own values
|
|
||||||
|
|
||||||
A circuit entry may be incomplete.
|
|
||||||
|
|
||||||
In principle, there are no required fields.
|
|
||||||
|
|
||||||
### CircuitEntry Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitEntry
|
|
||||||
- id
|
|
||||||
- circuitNumber
|
|
||||||
- description
|
|
||||||
- roomNumber
|
|
||||||
- roomName
|
|
||||||
- device
|
|
||||||
- isLinkedToDevice
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
- cosPhi
|
|
||||||
- totalPower
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionRatedCurrent
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
- comment
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Circuit Entry Description
|
|
||||||
|
|
||||||
The `description` of a circuit entry is independent from:
|
|
||||||
|
|
||||||
- The device name
|
|
||||||
- The device display name
|
|
||||||
- The device link
|
|
||||||
|
|
||||||
Even if a circuit entry is linked to a device, the circuit entry description can always be overwritten manually.
|
|
||||||
|
|
||||||
There are therefore three different naming fields:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.name
|
|
||||||
Device.displayName
|
|
||||||
CircuitEntry.description
|
|
||||||
```
|
|
||||||
|
|
||||||
### Meaning
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.name
|
|
||||||
```
|
|
||||||
|
|
||||||
Internal or unique name of the device.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.displayName
|
|
||||||
```
|
|
||||||
|
|
||||||
Default visible name of the device.
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitEntry.description
|
|
||||||
```
|
|
||||||
|
|
||||||
Concrete description in the circuit list. This can be manually overwritten.
|
|
||||||
|
|
||||||
## 8. Devices
|
|
||||||
|
|
||||||
The application shall support configurable devices.
|
|
||||||
|
|
||||||
A device can represent either:
|
|
||||||
|
|
||||||
- A single device
|
|
||||||
- A collection/group of devices
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Single pendant light
|
|
||||||
- Socket group with six sockets
|
|
||||||
- Workplace connection group
|
|
||||||
- Fixed load
|
|
||||||
|
|
||||||
### Device Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- displayName
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- cosPhi
|
|
||||||
- tradeOrCostGroup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attribute Meaning
|
|
||||||
|
|
||||||
#### `name`
|
|
||||||
|
|
||||||
Internal or unique name.
|
|
||||||
|
|
||||||
Used to distinguish similar devices.
|
|
||||||
|
|
||||||
#### `displayName`
|
|
||||||
|
|
||||||
Default visible name.
|
|
||||||
|
|
||||||
Multiple devices may have the same display name.
|
|
||||||
|
|
||||||
#### `deviceType`
|
|
||||||
|
|
||||||
The type of device.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Light
|
|
||||||
- Socket
|
|
||||||
- Fixed connection
|
|
||||||
- Generic
|
|
||||||
- Other values to be defined later
|
|
||||||
|
|
||||||
#### `phaseType`
|
|
||||||
|
|
||||||
Defines whether the device is single-phase or three-phase.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Single-phase
|
|
||||||
- Three-phase
|
|
||||||
|
|
||||||
#### `quantity`
|
|
||||||
|
|
||||||
Default quantity of the device.
|
|
||||||
|
|
||||||
This allows a device to be used either as a single device or as a collection.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Socket group 6x
|
|
||||||
quantity: 6
|
|
||||||
unitPower: 100 W
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `unitPower`
|
|
||||||
|
|
||||||
Power of one individual device or element.
|
|
||||||
|
|
||||||
#### `cosPhi`
|
|
||||||
|
|
||||||
Power factor.
|
|
||||||
|
|
||||||
#### `tradeOrCostGroup`
|
|
||||||
|
|
||||||
Assignment to a trade or cost group.
|
|
||||||
|
|
||||||
## 9. Device Name and Display Name
|
|
||||||
|
|
||||||
A device needs both a `name` and a `displayName`.
|
|
||||||
|
|
||||||
Reason:
|
|
||||||
|
|
||||||
Two devices may look the same in the circuit list but still be internally different.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device 1:
|
|
||||||
name: Pendant light high occupancy
|
|
||||||
displayName: Pendant light
|
|
||||||
|
|
||||||
Device 2:
|
|
||||||
name: Pendant light low occupancy
|
|
||||||
displayName: Pendant light
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows internally different devices to be displayed with the same name in the circuit list.
|
|
||||||
|
|
||||||
## 10. Device Lists
|
|
||||||
|
|
||||||
There are two types of device lists:
|
|
||||||
|
|
||||||
1. Global device list
|
|
||||||
2. Project-specific device list
|
|
||||||
|
|
||||||
### Global Device List
|
|
||||||
|
|
||||||
The global device list contains generally reusable devices.
|
|
||||||
|
|
||||||
It is intended to be used across projects.
|
|
||||||
|
|
||||||
### Project-Specific Device List
|
|
||||||
|
|
||||||
The project-specific device list belongs to a specific project.
|
|
||||||
|
|
||||||
It contains devices that can be used within that project.
|
|
||||||
|
|
||||||
Devices from this list can be selected in the circuit lists of the project's distribution boards.
|
|
||||||
|
|
||||||
### DeviceList Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
DeviceList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- type
|
|
||||||
- devices: List<Device>
|
|
||||||
```
|
|
||||||
|
|
||||||
Possible values for `type`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
global
|
|
||||||
project
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. Copying Devices Between Device Lists
|
|
||||||
|
|
||||||
Devices shall be copyable between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
A device can be copied:
|
|
||||||
|
|
||||||
- From the global device list to a project-specific device list
|
|
||||||
- From a project-specific device list to the global device list
|
|
||||||
|
|
||||||
Copying means:
|
|
||||||
|
|
||||||
- A separate device is created in the target list
|
|
||||||
- Permanent synchronization between the original device and the copied device is not defined as a requirement
|
|
||||||
|
|
||||||
## 12. Device Link Between Device and Circuit Entry
|
|
||||||
|
|
||||||
A circuit entry can be linked to a device from the project-specific device list.
|
|
||||||
|
|
||||||
When the link is active, device values can be used in the circuit entry.
|
|
||||||
|
|
||||||
If the underlying device is changed, all still-linked circuit entries shall be updated.
|
|
||||||
|
|
||||||
The link can be enabled or disabled per circuit entry.
|
|
||||||
|
|
||||||
If a circuit entry is detached from the device:
|
|
||||||
|
|
||||||
- The circuit entry remains in place
|
|
||||||
- The circuit entry keeps its own values
|
|
||||||
- Future changes to the device no longer affect that circuit entry
|
|
||||||
|
|
||||||
A circuit entry may also exist without a device and without a device reference.
|
|
||||||
|
|
||||||
## 13. Quantity
|
|
||||||
|
|
||||||
There is a quantity on the device and a quantity on the circuit entry.
|
|
||||||
|
|
||||||
### Device Quantity
|
|
||||||
|
|
||||||
The device quantity describes the default scope of a device.
|
|
||||||
|
|
||||||
It allows devices to represent either single devices or collections.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Socket group 6x
|
|
||||||
quantity: 6
|
|
||||||
unitPower: 100 W
|
|
||||||
```
|
|
||||||
|
|
||||||
### Circuit Entry Quantity
|
|
||||||
|
|
||||||
The circuit entry quantity describes the concrete quantity used in the individual circuit entry.
|
|
||||||
|
|
||||||
It can be prefilled from the device quantity when a circuit entry is created from a device.
|
|
||||||
|
|
||||||
It can be changed in the circuit entry.
|
|
||||||
|
|
||||||
### Add Count
|
|
||||||
|
|
||||||
When adding a device to a circuit list, there shall be a separate value defining how many circuit entries shall be created.
|
|
||||||
|
|
||||||
Important distinction:
|
|
||||||
|
|
||||||
```text
|
|
||||||
addCount != quantity
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Workplace socket
|
|
||||||
Add count: 5
|
|
||||||
Quantity per entry: 6
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
5 circuit entries
|
|
||||||
Each circuit entry has quantity = 6
|
|
||||||
```
|
|
||||||
|
|
||||||
## 14. Total Power
|
|
||||||
|
|
||||||
The total power shall be calculated automatically.
|
|
||||||
|
|
||||||
The total power is an attribute of the circuit entry.
|
|
||||||
|
|
||||||
The calculation is based at least on:
|
|
||||||
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
|
|
||||||
The exact calculation logic can be specified later.
|
|
||||||
|
|
||||||
## 15. Rooms
|
|
||||||
|
|
||||||
Rooms are represented by two fields:
|
|
||||||
|
|
||||||
```text
|
|
||||||
roomNumber
|
|
||||||
roomName
|
|
||||||
```
|
|
||||||
|
|
||||||
## 16. Trade / Cost Group and Group
|
|
||||||
|
|
||||||
Entries shall be groupable by domain-specific criteria.
|
|
||||||
|
|
||||||
The planned fields are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tradeOrCostGroup
|
|
||||||
group
|
|
||||||
```
|
|
||||||
|
|
||||||
These fields shall help treat similar elements together.
|
|
||||||
|
|
||||||
## 17. Table Functions
|
|
||||||
|
|
||||||
The circuit list shall support common table operations.
|
|
||||||
|
|
||||||
Defined operations are:
|
|
||||||
|
|
||||||
- Create entry
|
|
||||||
- Edit entry
|
|
||||||
- Delete entry
|
|
||||||
- Duplicate entry
|
|
||||||
- Copy entry to another circuit list
|
|
||||||
- Add device as entry
|
|
||||||
- Add device multiple times as entries
|
|
||||||
- Set quantity within an entry
|
|
||||||
|
|
||||||
General requirement:
|
|
||||||
|
|
||||||
- All common table manipulations shall be possible
|
|
||||||
|
|
||||||
Additional table functions may be specified later, for example:
|
|
||||||
|
|
||||||
- Sorting
|
|
||||||
- Filtering
|
|
||||||
- Reordering
|
|
||||||
- Multi-selection
|
|
||||||
- Bulk editing
|
|
||||||
|
|
||||||
## 18. Required Fields
|
|
||||||
|
|
||||||
In principle, there shall be no required fields.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- Entries may be incomplete
|
|
||||||
- A circuit entry may exist without a device
|
|
||||||
- A circuit entry may exist without a device reference
|
|
||||||
- Values can be completed later
|
|
||||||
|
|
||||||
## 19. Selection Lists
|
|
||||||
|
|
||||||
The application shall support fixed selection lists.
|
|
||||||
|
|
||||||
The exact selection lists will be defined later.
|
|
||||||
|
|
||||||
Current possible candidates based on the requirements are:
|
|
||||||
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
|
|
||||||
These lists are not yet finally specified.
|
|
||||||
|
|
||||||
## 20. Copying and Duplicating
|
|
||||||
|
|
||||||
Circuit entries shall be duplicatable.
|
|
||||||
|
|
||||||
Circuit entries shall be copyable to other circuit lists.
|
|
||||||
|
|
||||||
Devices shall be copyable between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
When adding a device to a circuit list, the user shall be able to define how many circuit entries shall be created from it.
|
|
||||||
|
|
||||||
Open point:
|
|
||||||
|
|
||||||
- Whether a device link is kept or detached when copying a circuit entry to another circuit list is not yet specified.
|
|
||||||
|
|
||||||
## 21. Simplified Class Model
|
|
||||||
|
|
||||||
The model shall use multiple classes but shall not be overly fragmented.
|
|
||||||
|
|
||||||
Current final class model:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- distributionBoards: List<DistributionBoard>
|
|
||||||
- projectDeviceList: DeviceList
|
|
||||||
|
|
||||||
DistributionBoard
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- circuitList: CircuitList
|
|
||||||
|
|
||||||
CircuitList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- entries: List<CircuitEntry>
|
|
||||||
|
|
||||||
CircuitEntry
|
|
||||||
- id
|
|
||||||
- circuitNumber
|
|
||||||
- description
|
|
||||||
- roomNumber
|
|
||||||
- roomName
|
|
||||||
- device: Device
|
|
||||||
- isLinkedToDevice
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
- cosPhi
|
|
||||||
- totalPower
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionRatedCurrent
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
- comment
|
|
||||||
|
|
||||||
Device
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- displayName
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- cosPhi
|
|
||||||
- tradeOrCostGroup
|
|
||||||
|
|
||||||
DeviceList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- type
|
|
||||||
- devices: List<Device>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 22. Central Domain Rules
|
|
||||||
|
|
||||||
1. A project contains multiple distribution boards.
|
|
||||||
|
|
||||||
2. Each distribution board contains exactly one circuit list.
|
|
||||||
|
|
||||||
3. A circuit list consists of circuit entries.
|
|
||||||
|
|
||||||
4. One row in the circuit list is one circuit entry.
|
|
||||||
|
|
||||||
5. Multiple circuit entries may have the same circuit number.
|
|
||||||
|
|
||||||
6. This allows multiple different devices to be assigned to one circuit.
|
|
||||||
|
|
||||||
7. A circuit entry can be linked to a device.
|
|
||||||
|
|
||||||
8. A circuit entry can exist without a device.
|
|
||||||
|
|
||||||
9. A circuit entry can exist without a device reference.
|
|
||||||
|
|
||||||
10. The link between a circuit entry and a device can be detached per entry.
|
|
||||||
|
|
||||||
11. If a device is changed, all still-linked circuit entries are updated.
|
|
||||||
|
|
||||||
12. Detached circuit entries are no longer changed by later device changes.
|
|
||||||
|
|
||||||
13. A device has a default quantity.
|
|
||||||
|
|
||||||
14. A circuit entry has its own quantity.
|
|
||||||
|
|
||||||
15. The quantity on the device can be used as a default value for new circuit entries.
|
|
||||||
|
|
||||||
16. The description of a circuit entry can be overwritten independently from the linked device.
|
|
||||||
|
|
||||||
17. The total power of a circuit entry is calculated automatically.
|
|
||||||
|
|
||||||
18. A project has a project-specific device list.
|
|
||||||
|
|
||||||
19. Additionally, there is a global device list.
|
|
||||||
|
|
||||||
20. Devices can be copied between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
21. Circuit entries can be duplicated.
|
|
||||||
|
|
||||||
22. Circuit entries can be copied to other circuit lists.
|
|
||||||
|
|
||||||
23. When adding a device, the user can define how many circuit entries shall be created.
|
|
||||||
|
|
||||||
24. In principle, there are no required fields.
|
|
||||||
|
|
||||||
25. The application shall support fixed selection lists. Details will be specified later.
|
|
||||||
|
|
||||||
## 23. Open Points
|
|
||||||
|
|
||||||
The following points are not yet finally specified:
|
|
||||||
|
|
||||||
1. Exact formula for calculating `totalPower`.
|
|
||||||
|
|
||||||
2. Exact list of fields synchronized while a circuit entry is linked to a device.
|
|
||||||
|
|
||||||
3. Whether the device link is kept or detached when copying a circuit entry to another circuit list.
|
|
||||||
|
|
||||||
4. Final selection lists and their values.
|
|
||||||
|
|
||||||
5. Exact behavior of table functions such as sorting, filtering, multi-selection, and bulk editing.
|
|
||||||
|
|
||||||
6. Whether global devices and project devices remain permanently independent after copying, or whether optional synchronization should be supported later.
|
|
||||||
@@ -49,6 +49,7 @@ body {
|
|||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button {
|
.editor-toolbar button {
|
||||||
@@ -63,6 +64,75 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-settings-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border: 1px solid #cfd7e5;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 0.45rem;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.drop-target {
|
||||||
|
border-color: #2b6cb0;
|
||||||
|
background: #ebf4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.locked {
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item label {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-order {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-order button {
|
||||||
|
border: 1px solid #c4cddc;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.08rem 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid-wrap {
|
.tree-grid-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d9dee8;
|
border: 1px solid #d9dee8;
|
||||||
@@ -277,6 +347,10 @@ body {
|
|||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.row-selected td {
|
||||||
|
background: #eaf1ff;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .placeholder-row td {
|
.tree-grid .placeholder-row td {
|
||||||
background: #f7f7f7;
|
background: #f7f7f7;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export class CircuitRepository {
|
|||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// better-sqlite3 transactions are synchronous callbacks. Do not make this callback
|
||||||
|
// async or return a Promise, otherwise statements may run outside the transaction scope.
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
const ids = updates.map((entry) => entry.id);
|
const ids = updates.map((entry) => entry.id);
|
||||||
const existing = tx
|
const existing = tx
|
||||||
@@ -152,6 +154,10 @@ export class CircuitRepository {
|
|||||||
throw new Error("One or more circuit ids are invalid for circuit list.");
|
throw new Error("One or more circuit ids are invalid for circuit list.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct identifier swaps can violate UNIQUE(circuit_list_id, equipment_identifier)
|
||||||
|
// mid-update (for example A->B while B->A). Two-phase strategy prevents that:
|
||||||
|
// 1) assign unique temporary identifiers for all affected circuits
|
||||||
|
// 2) assign final user-visible identifiers
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
for (let index = 0; index < updates.length; index += 1) {
|
for (let index = 0; index < updates.length; index += 1) {
|
||||||
const entry = updates[index];
|
const entry = updates[index];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateCircuitDeviceRowInput,
|
CreateCircuitDeviceRowInput,
|
||||||
CreateCircuitInput,
|
CreateCircuitInput,
|
||||||
MoveCircuitDeviceRowInput,
|
MoveCircuitDeviceRowInput,
|
||||||
|
MoveCircuitDeviceRowsBulkInput,
|
||||||
ReorderSectionCircuitsInput,
|
ReorderSectionCircuitsInput,
|
||||||
UpdateSectionEquipmentIdentifiersInput,
|
UpdateSectionEquipmentIdentifiersInput,
|
||||||
UpdateCircuitDeviceRowInput,
|
UpdateCircuitDeviceRowInput,
|
||||||
@@ -38,6 +39,7 @@ export class CircuitWriteService {
|
|||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensures writes never connect a section to the wrong circuit list.
|
||||||
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@@ -49,6 +51,7 @@ export class CircuitWriteService {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforces BMK uniqueness inside one circuit list.
|
||||||
private async assertUniqueEquipmentIdentifier(
|
private async assertUniqueEquipmentIdentifier(
|
||||||
circuitListId: string,
|
circuitListId: string,
|
||||||
equipmentIdentifier: string,
|
equipmentIdentifier: string,
|
||||||
@@ -64,6 +67,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validates linked project-device id against owning project of the circuit list.
|
||||||
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
||||||
if (!linkedProjectDeviceId) {
|
if (!linkedProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -183,6 +187,7 @@ export class CircuitWriteService {
|
|||||||
overriddenFields: input.overriddenFields,
|
overriddenFields: input.overriddenFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reserve circuits become active as soon as at least one device row exists.
|
||||||
if (Boolean(circuit.isReserve)) {
|
if (Boolean(circuit.isReserve)) {
|
||||||
await this.circuitRepository.update(circuit.id, {
|
await this.circuitRepository.update(circuit.id, {
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
@@ -246,6 +251,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
await this.deviceRowRepository.delete(rowId);
|
await this.deviceRowRepository.delete(rowId);
|
||||||
const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId);
|
const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId);
|
||||||
|
// When last row is removed, keep circuit and mark it reserve instead of deleting it.
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
await this.circuitRepository.update(circuit.id, {
|
await this.circuitRepository.update(circuit.id, {
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
@@ -286,6 +292,7 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid target circuit id.");
|
throw new Error("Invalid target circuit id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder-target move creates a new circuit explicitly; no implicit renumbering of others.
|
||||||
if (!targetCircuit) {
|
if (!targetCircuit) {
|
||||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||||
throw new Error("Invalid move target.");
|
throw new Error("Invalid move target.");
|
||||||
@@ -320,6 +327,7 @@ export class CircuitWriteService {
|
|||||||
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10);
|
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10);
|
||||||
|
|
||||||
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
||||||
|
// Source circuit becomes reserve when all rows are moved away.
|
||||||
if (sourceRemaining === 0) {
|
if (sourceRemaining === 0) {
|
||||||
await this.circuitRepository.update(sourceCircuit.id, {
|
await this.circuitRepository.update(sourceCircuit.id, {
|
||||||
sectionId: sourceCircuit.sectionId,
|
sectionId: sourceCircuit.sectionId,
|
||||||
@@ -341,6 +349,7 @@ export class CircuitWriteService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Target circuit is no longer reserve once it receives moved rows.
|
||||||
if (Boolean(targetCircuit.isReserve)) {
|
if (Boolean(targetCircuit.isReserve)) {
|
||||||
await this.circuitRepository.update(targetCircuit.id, {
|
await this.circuitRepository.update(targetCircuit.id, {
|
||||||
sectionId: targetCircuit.sectionId,
|
sectionId: targetCircuit.sectionId,
|
||||||
@@ -365,11 +374,139 @@ export class CircuitWriteService {
|
|||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
|
||||||
|
// Bulk move keeps input order and resolves all source circuits first so undo can
|
||||||
|
// restore per-source assignment deterministically.
|
||||||
|
const uniqueRowIds = [...new Set(input.rowIds)];
|
||||||
|
if (uniqueRowIds.length === 0) {
|
||||||
|
throw new Error("No device rows provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const rowId of uniqueRowIds) {
|
||||||
|
const row = await this.deviceRowRepository.findById(rowId);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Invalid device row id.");
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!sourceCircuits.has(row.circuitId)) {
|
||||||
|
const circuit = await this.circuitRepository.findById(row.circuitId);
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Invalid circuit id.");
|
||||||
|
}
|
||||||
|
sourceCircuits.set(row.circuitId, circuit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
|
||||||
|
let targetCircuit = input.targetCircuitId
|
||||||
|
? await this.circuitRepository.findById(input.targetCircuitId)
|
||||||
|
: null;
|
||||||
|
if (input.targetCircuitId && !targetCircuit) {
|
||||||
|
throw new Error("Invalid target circuit id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk placeholder move creates exactly one new circuit as common target.
|
||||||
|
if (!targetCircuit) {
|
||||||
|
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||||
|
throw new Error("Invalid move target.");
|
||||||
|
}
|
||||||
|
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
|
||||||
|
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
||||||
|
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
||||||
|
const nextSortOrder =
|
||||||
|
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
|
const createdId = await this.circuitRepository.create({
|
||||||
|
circuitListId: referenceSourceCircuit.circuitListId,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: nextIdentifier,
|
||||||
|
displayName: "New circuit",
|
||||||
|
sortOrder: nextSortOrder,
|
||||||
|
isReserve: false,
|
||||||
|
});
|
||||||
|
targetCircuit = await this.circuitRepository.findById(createdId);
|
||||||
|
if (!targetCircuit) {
|
||||||
|
throw new Error("Failed to create target circuit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any source circuit emptied by bulk move is preserved as reserve circuit.
|
||||||
|
for (const sourceCircuit of sourceCircuits.values()) {
|
||||||
|
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
|
||||||
|
throw new Error("All moved rows must belong to same circuit list as target.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id);
|
||||||
|
let offset = 1;
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.deviceRowRepository.moveToCircuit(row.id, targetCircuit.id, (targetCount + offset) * 10);
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceCircuit of sourceCircuits.values()) {
|
||||||
|
const remaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
||||||
|
if (remaining === 0) {
|
||||||
|
await this.circuitRepository.update(sourceCircuit.id, {
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: sourceCircuit.equipmentIdentifier,
|
||||||
|
displayName: sourceCircuit.displayName ?? undefined,
|
||||||
|
sortOrder: sourceCircuit.sortOrder,
|
||||||
|
protectionType: sourceCircuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: sourceCircuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: sourceCircuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: sourceCircuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: sourceCircuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: sourceCircuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: sourceCircuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: sourceCircuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: sourceCircuit.voltage ?? undefined,
|
||||||
|
status: sourceCircuit.status ?? undefined,
|
||||||
|
isReserve: true,
|
||||||
|
remark: sourceCircuit.remark ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(targetCircuit.isReserve)) {
|
||||||
|
await this.circuitRepository.update(targetCircuit.id, {
|
||||||
|
sectionId: targetCircuit.sectionId,
|
||||||
|
equipmentIdentifier: targetCircuit.equipmentIdentifier,
|
||||||
|
displayName: targetCircuit.displayName ?? undefined,
|
||||||
|
sortOrder: targetCircuit.sortOrder,
|
||||||
|
protectionType: targetCircuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: targetCircuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: targetCircuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: targetCircuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: targetCircuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: targetCircuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: targetCircuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: targetCircuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: targetCircuit.voltage ?? undefined,
|
||||||
|
status: targetCircuit.status ?? undefined,
|
||||||
|
isReserve: false,
|
||||||
|
remark: targetCircuit.remark ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
movedRowIds: rows.map((row) => row.id),
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
createdCircuitId: input.targetCircuitId ? undefined : targetCircuit.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getNextIdentifier(sectionId: string) {
|
async getNextIdentifier(sectionId: string) {
|
||||||
return this.numberingService.getNextIdentifier(sectionId);
|
return this.numberingService.getNextIdentifier(sectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async renumberSection(sectionId: string) {
|
async renumberSection(sectionId: string) {
|
||||||
|
// Explicit renumber operation for one section only.
|
||||||
|
// Never renumbers other sections and never runs implicitly during move/sort operations.
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
throw new Error("Invalid section id.");
|
throw new Error("Invalid section id.");
|
||||||
@@ -391,6 +528,7 @@ export class CircuitWriteService {
|
|||||||
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
|
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
finalAssignments,
|
finalAssignments,
|
||||||
@@ -428,6 +566,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
|
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
|
||||||
|
// Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later.
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
throw new Error("Invalid section id.");
|
throw new Error("Invalid section id.");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface LegacyConsumerForPlanning {
|
|||||||
phaseCount: number | null;
|
phaseCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accepts only normalized BMK-like legacy circuit numbers used for grouping.
|
||||||
export function normalizeCircuitNumber(value: string | null): string | null {
|
export function normalizeCircuitNumber(value: string | null): string | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -20,6 +21,8 @@ export function normalizeCircuitNumber(value: string | null): string | null {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort fallback when no valid circuit number exists. Keeps migration deterministic
|
||||||
|
// by preferring explicit category/phase cues over random assignment.
|
||||||
export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null {
|
export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null {
|
||||||
const category = (consumer.category ?? "").toLowerCase();
|
const category = (consumer.category ?? "").toLowerCase();
|
||||||
if (category.includes("light") || category.includes("beleuchtung")) {
|
if (category.includes("light") || category.includes("beleuchtung")) {
|
||||||
@@ -42,6 +45,7 @@ export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanni
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix-based section inference for already normalized equipment identifiers.
|
||||||
export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null {
|
export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null {
|
||||||
if (equipmentIdentifier.startsWith("-1F")) {
|
if (equipmentIdentifier.startsWith("-1F")) {
|
||||||
return "lighting";
|
return "lighting";
|
||||||
@@ -54,4 +58,3 @@ export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: stri
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
private readonly roomRepository = new RoomRepository();
|
private readonly roomRepository = new RoomRepository();
|
||||||
|
|
||||||
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
||||||
|
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
|
||||||
|
// with mapping records so transition remains auditable and reversible.
|
||||||
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
throw new Error("Circuit list not found in project.");
|
throw new Error("Circuit list not found in project.");
|
||||||
@@ -73,6 +75,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Idempotency guard: skip consumers already mapped in previous migration run.
|
||||||
const migratedRows = await db
|
const migratedRows = await db
|
||||||
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
.from(legacyConsumerCircuitMigrations)
|
.from(legacyConsumerCircuitMigrations)
|
||||||
@@ -80,6 +83,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
|
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
|
||||||
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
|
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
|
||||||
|
|
||||||
|
// Legacy rows are grouped by normalized circuit number so duplicates become
|
||||||
|
// multiple device rows within one circuit instead of duplicate circuits.
|
||||||
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
|
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
|
||||||
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
||||||
for (const consumer of consumersToMigrate) {
|
for (const consumer of consumersToMigrate) {
|
||||||
@@ -94,6 +99,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
byNormalizedCircuitNumber.get(normalized)!.push(consumer);
|
byNormalizedCircuitNumber.get(normalized)!.push(consumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duplicate normalized circuit numbers are expected and represented as one circuit
|
||||||
|
// with multiple circuit_device_rows.
|
||||||
report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()]
|
report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()]
|
||||||
.filter(([, grouped]) => grouped.length > 1)
|
.filter(([, grouped]) => grouped.length > 1)
|
||||||
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
|
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
|
||||||
@@ -105,6 +112,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
isGeneratedIdentifier: boolean;
|
isGeneratedIdentifier: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
|
// Stable normalized circuit numbers keep existing intent where available.
|
||||||
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
|
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
|
||||||
groups.push({
|
groups.push({
|
||||||
equipmentIdentifier: normalizedCircuitNumber,
|
equipmentIdentifier: normalizedCircuitNumber,
|
||||||
@@ -114,6 +122,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing/invalid circuit numbers are migrated as single-row groups with
|
||||||
|
// generated identifiers and best-effort section inference.
|
||||||
for (const consumer of withoutNormalizedCircuitNumber) {
|
for (const consumer of withoutNormalizedCircuitNumber) {
|
||||||
groups.push({
|
groups.push({
|
||||||
equipmentIdentifier: null,
|
equipmentIdentifier: null,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,21 @@ export function moveCircuitDeviceRowById(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveCircuitDeviceRowsBulk(input: {
|
||||||
|
rowIds: string[];
|
||||||
|
targetCircuitId?: string;
|
||||||
|
targetSectionId?: string;
|
||||||
|
createNewCircuit?: boolean;
|
||||||
|
}) {
|
||||||
|
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
|
||||||
|
"/api/circuit-device-rows/move-bulk",
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renumberCircuitSection(sectionId: string) {
|
export function renumberCircuitSection(sectionId: string) {
|
||||||
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||||
import {
|
import {
|
||||||
createCircuitDeviceRowSchema,
|
createCircuitDeviceRowSchema,
|
||||||
|
moveCircuitDeviceRowsBulkSchema,
|
||||||
moveCircuitDeviceRowSchema,
|
moveCircuitDeviceRowSchema,
|
||||||
updateCircuitDeviceRowSchema,
|
updateCircuitDeviceRowSchema,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
@@ -78,3 +79,17 @@ export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
|
||||||
|
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
|
||||||
|
return res.json(moved);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
deleteCircuitDeviceRow,
|
deleteCircuitDeviceRow,
|
||||||
|
moveCircuitDeviceRowsBulk,
|
||||||
moveCircuitDeviceRow,
|
moveCircuitDeviceRow,
|
||||||
updateCircuitDeviceRow,
|
updateCircuitDeviceRow,
|
||||||
} from "../controllers/circuit-device-row.controller.js";
|
} from "../controllers/circuit-device-row.controller.js";
|
||||||
|
|
||||||
export const circuitDeviceRowRouter = Router();
|
export const circuitDeviceRowRouter = Router();
|
||||||
|
|
||||||
|
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export const moveCircuitDeviceRowSchema = z
|
|||||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const moveCircuitDeviceRowsBulkSchema = z
|
||||||
|
.object({
|
||||||
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
|
targetCircuitId: z.string().min(1).optional(),
|
||||||
|
targetSectionId: z.string().min(1).optional(),
|
||||||
|
createNewCircuit: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
Boolean(value.targetCircuitId) ||
|
||||||
|
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
||||||
|
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||||
|
);
|
||||||
|
|
||||||
export const reorderSectionCircuitsSchema = z.object({
|
export const reorderSectionCircuitsSchema = z.object({
|
||||||
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
||||||
});
|
});
|
||||||
@@ -82,5 +96,6 @@ export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
|||||||
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
||||||
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
||||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||||
|
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||||
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
||||||
|
|||||||
@@ -373,6 +373,114 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(createdCircuitPayload?.isReserve, false);
|
assert.equal(createdCircuitPayload?.isReserve, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
|
||||||
|
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = [];
|
||||||
|
const reserveUpdates: Array<{ id: string; isReserve: boolean }> = [];
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
deviceRowRepository: {
|
||||||
|
async findById(rowId: string) {
|
||||||
|
if (rowId === "r1") {
|
||||||
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
|
}
|
||||||
|
return { id: "r2", circuitId: "c2" } as never;
|
||||||
|
},
|
||||||
|
async countByCircuit(circuitId: string) {
|
||||||
|
if (circuitId === "c3") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
|
||||||
|
movedCalls.push({ rowId, targetCircuitId, sortOrder });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async findById(circuitId: string) {
|
||||||
|
if (circuitId === "c1") {
|
||||||
|
return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
if (circuitId === "c2") {
|
||||||
|
return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
|
||||||
|
},
|
||||||
|
async update(id: string, payload: { isReserve: boolean }) {
|
||||||
|
reserveUpdates.push({ id, isReserve: payload.isReserve });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
|
||||||
|
assert.deepEqual(movedCalls, [
|
||||||
|
{ rowId: "r1", targetCircuitId: "c3", sortOrder: 20 },
|
||||||
|
{ rowId: "r2", targetCircuitId: "c3", sortOrder: 30 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(reserveUpdates, [
|
||||||
|
{ id: "c1", isReserve: true },
|
||||||
|
{ id: "c2", isReserve: true },
|
||||||
|
{ id: "c3", isReserve: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
|
||||||
|
let createCount = 0;
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
deviceRowRepository: {
|
||||||
|
async findById(rowId: string) {
|
||||||
|
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
|
||||||
|
},
|
||||||
|
async countByCircuit(circuitId: string) {
|
||||||
|
if (circuitId === "c-new") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
async moveToCircuit() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async findById(circuitId: string) {
|
||||||
|
if (circuitId === "c1" || circuitId === "c2") {
|
||||||
|
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
if (circuitId === "c-new") {
|
||||||
|
return { id: "c-new", sectionId: "s2", circuitListId: "l1", equipmentIdentifier: "-2F8", sortOrder: 40, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
return null as never;
|
||||||
|
},
|
||||||
|
async listBySection() {
|
||||||
|
return [{ sortOrder: 30 }] as never[];
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
createCount += 1;
|
||||||
|
return "c-new";
|
||||||
|
},
|
||||||
|
async update() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitSectionRepository: {
|
||||||
|
async findById() {
|
||||||
|
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
numberingService: {
|
||||||
|
async getNextIdentifier() {
|
||||||
|
return "-2F8";
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.moveDeviceRowsBulk({
|
||||||
|
rowIds: ["r1", "r2"],
|
||||||
|
targetSectionId: "s2",
|
||||||
|
createNewCircuit: true,
|
||||||
|
});
|
||||||
|
assert.equal(createCount, 1);
|
||||||
|
assert.equal(result.createdCircuitId, "c-new");
|
||||||
|
});
|
||||||
|
|
||||||
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
||||||
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
|
|||||||
Reference in New Issue
Block a user