Compare commits

..

3 Commits

Author SHA1 Message Date
jappel 7580ad0ade Documentation 2026-05-07 22:55:15 +02:00
jappel b1e19a88d5 Started multiline manipulations 2026-05-05 21:20:09 +02:00
jappel 47dec0df39 Added column sorting 2026-05-05 20:48:54 +02:00
19 changed files with 1737 additions and 833 deletions
+10
View File
@@ -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.
-68
View File
@@ -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) 13 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.
+151
View File
@@ -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.
+96
View File
@@ -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.
+109
View File
@@ -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.
+75
View File
@@ -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.
+74
View File
@@ -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
+15
View File
@@ -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);
+15
View File
@@ -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>;
+108
View File
@@ -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({