Compare commits
11 Commits
eb945a9622
...
30c7555c68
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c7555c68 | |||
| d7fda37576 | |||
| e51c516cf2 | |||
| 6696bba72a | |||
| c7e5f7a80d | |||
| ff84d85eb3 | |||
| b2034c7dfb | |||
| 1c57df46fa | |||
| 0800f984bb | |||
| b0fc2b4fbf | |||
| 7d276d1139 |
@@ -6,6 +6,11 @@ The circuit-first editor uses tree + circuit + row endpoints. Legacy consumer en
|
|||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
|
### Distribution Board Setup
|
||||||
|
|
||||||
|
- `POST /projects/:projectId/distribution-boards`
|
||||||
|
- atomically creates the distribution board, its circuit list and all default circuit sections
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
@@ -44,6 +49,8 @@ Response sketch:
|
|||||||
|
|
||||||
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
|
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits`
|
||||||
- create circuit in list/section
|
- create circuit in list/section
|
||||||
|
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows`
|
||||||
|
- atomically create one circuit and one or more initial device rows
|
||||||
- `PATCH /circuits/:circuitId`
|
- `PATCH /circuits/:circuitId`
|
||||||
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
|
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
|
||||||
- `DELETE /circuits/:circuitId`
|
- `DELETE /circuits/:circuitId`
|
||||||
|
|||||||
@@ -105,3 +105,9 @@ Primary circuit-first editor route:
|
|||||||
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
|
- `/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.
|
Legacy route remains separate (read-only/preview/migration transition path) and is intentionally not merged into the editable tree editor.
|
||||||
|
|
||||||
|
## Future Persistence Direction
|
||||||
|
|
||||||
|
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
|
||||||
|
|
||||||
|
Near-term refactoring must move command and transaction ownership toward server-side, project-scoped change sets. Database backups remain separate from user-visible project snapshots.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Inline cells are static text by default. A cell enters edit mode by:
|
|||||||
|
|
||||||
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
|
||||||
|
|
||||||
|
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
|
||||||
|
|
||||||
## `selectedCell` vs `editingCell`
|
## `selectedCell` vs `editingCell`
|
||||||
|
|
||||||
- `selectedCell` tracks spreadsheet navigation focus.
|
- `selectedCell` tracks spreadsheet navigation focus.
|
||||||
@@ -44,6 +46,8 @@ For insertion, a circuit-level cell creates a reserve circuit directly after the
|
|||||||
|
|
||||||
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
|
For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits.
|
||||||
|
|
||||||
|
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
|
||||||
|
|
||||||
## Equipment Identifier Validation
|
## Equipment Identifier Validation
|
||||||
|
|
||||||
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
||||||
@@ -54,6 +58,7 @@ 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
|
- 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
|
- editable placeholder cells can create a new circuit + first row through the same edit command flow
|
||||||
|
- a new circuit and its initial row(s) are created in one SQLite transaction
|
||||||
|
|
||||||
## Add Circuit Behavior
|
## Add Circuit Behavior
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ Intent is separated by drag source type:
|
|||||||
|
|
||||||
- project device drag:
|
- project device drag:
|
||||||
- drop to section/placeholder -> create new circuit with linked row
|
- drop to section/placeholder -> create new circuit with linked row
|
||||||
|
- the new circuit and linked row are committed atomically
|
||||||
- drop to existing circuit row -> append row to that circuit
|
- drop to existing circuit row -> append row to that circuit
|
||||||
- lighting devices are accepted only by the lighting section
|
- lighting devices are accepted only by the lighting section
|
||||||
- other devices are accepted only by the section matching their phase type
|
- other devices are accepted only by the section matching their phase type
|
||||||
@@ -79,6 +85,7 @@ Intent is separated by drag source type:
|
|||||||
- circuit drag (BMK handle):
|
- circuit drag (BMK handle):
|
||||||
- reorder circuits inside same section only
|
- reorder circuits inside same section only
|
||||||
- cross-section reorder is rejected
|
- cross-section reorder is rejected
|
||||||
|
- the complete section order is validated and stored in one SQLite transaction
|
||||||
- bulk device row move:
|
- bulk device row move:
|
||||||
- supported via multi-selection + drag
|
- supported via multi-selection + drag
|
||||||
- multi-circuit move:
|
- multi-circuit move:
|
||||||
@@ -101,6 +108,7 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
|||||||
|
|
||||||
- disabled while filters are active
|
- disabled while filters are active
|
||||||
- undoable via command history
|
- undoable via command history
|
||||||
|
- each complete section order is stored atomically
|
||||||
- until applied, sort remains view-only
|
- until applied, sort remains view-only
|
||||||
|
|
||||||
## Column Configuration
|
## Column Configuration
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
- Undo/redo history is session-local only.
|
- Undo/redo history is session-local only.
|
||||||
- Undo/redo history is not persisted across reloads or between users.
|
- Undo/redo history is not persisted across reloads or between users.
|
||||||
|
- No project revision or logical snapshot model is implemented yet.
|
||||||
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- No global cross-project device library workflow is implemented yet.
|
- No global cross-project device library workflow is implemented yet.
|
||||||
- Final electrical sizing logic is not implemented yet.
|
- Final electrical sizing logic is not implemented yet.
|
||||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Legacy `Consumer` rows map into:
|
|||||||
|
|
||||||
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
|
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
|
||||||
|
|
||||||
|
For one circuit list, all new circuits, device rows, trace mappings and the migration report are committed in one SQLite transaction. A failed run therefore leaves none of those prepared migration writes behind.
|
||||||
|
|
||||||
## Grouping Strategy (`circuitNumber`)
|
## Grouping Strategy (`circuitNumber`)
|
||||||
|
|
||||||
Migration groups legacy rows by normalized `circuitNumber`:
|
Migration groups legacy rows by normalized `circuitNumber`:
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Project History and External Model Architecture
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
This document records architectural direction for future work. It is not a final database schema and does not authorize implementing Revit exchange or migrating to PostgreSQL during unrelated phases.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- persist undo/redo across page reloads and application restarts
|
||||||
|
- provide a project-wide version history with named restore points
|
||||||
|
- preserve an auditable sequence of user, import, migration and restore operations
|
||||||
|
- support future Revit/CSV round-trips using stable IFCGUID-based links
|
||||||
|
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
||||||
|
|
||||||
|
## Separate Concerns
|
||||||
|
|
||||||
|
The following mechanisms solve different problems and must not be conflated:
|
||||||
|
|
||||||
|
- database backups protect against database loss or corruption
|
||||||
|
- project snapshots provide user-visible restore points
|
||||||
|
- change history records logical application operations
|
||||||
|
- undo/redo navigates or inverses eligible logical operations
|
||||||
|
- external import batches describe data exchanged with another model
|
||||||
|
|
||||||
|
Database backups remain necessary even after project version history exists.
|
||||||
|
|
||||||
|
## Revision and Change-Set Direction
|
||||||
|
|
||||||
|
History is project-wide. Every accepted mutating command should eventually execute through one server-side transaction that:
|
||||||
|
|
||||||
|
1. validates the expected current project revision
|
||||||
|
2. applies all domain writes
|
||||||
|
3. records one immutable change set with sufficient before/after data
|
||||||
|
4. increments the project revision
|
||||||
|
|
||||||
|
The conceptual entities are:
|
||||||
|
|
||||||
|
- `ProjectRevision`
|
||||||
|
- monotonically increasing revision number within one project
|
||||||
|
- timestamp, actor and source (`user`, `undo`, `redo`, `import`, `restore`, `migration`)
|
||||||
|
- optional label or description
|
||||||
|
- `ProjectChangeSet`
|
||||||
|
- one logical user or system command
|
||||||
|
- contains one or more domain operations committed atomically
|
||||||
|
- stores enough before/after state to validate and apply an inverse
|
||||||
|
- `ProjectSnapshot`
|
||||||
|
- a logical, database-independent representation of one project revision
|
||||||
|
- may be created periodically or explicitly named by a user
|
||||||
|
- accelerates restore without replaying the entire history
|
||||||
|
|
||||||
|
Full event sourcing is not required. Current normalized tables remain the primary read model. Immutable change sets plus periodic snapshots provide the required history without forcing every query to rebuild state from events.
|
||||||
|
|
||||||
|
## Undo, Redo and Restore Semantics
|
||||||
|
|
||||||
|
- Undo applies an inverse as a new recorded revision; it does not delete history.
|
||||||
|
- Redo reapplies an eligible undone operation as a new recorded revision.
|
||||||
|
- A new edit after undo may hide the prior redo branch in the normal UI, while the historical revisions remain auditable.
|
||||||
|
- Restoring a snapshot creates a new revision whose state matches the selected historical revision.
|
||||||
|
- The server, not React component state, is the source of truth for available undo/redo operations.
|
||||||
|
- After a page reload, the client requests the current revision and eligible history actions.
|
||||||
|
- Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state.
|
||||||
|
|
||||||
|
The current session-local frontend command stack is transitional. Editor modularization must separate command descriptions from React callbacks so commands can later be sent to and reconstructed by the server.
|
||||||
|
|
||||||
|
## Snapshot Storage
|
||||||
|
|
||||||
|
Project history must use logical project data, not copies of the SQLite database file. This keeps snapshots:
|
||||||
|
|
||||||
|
- project-scoped
|
||||||
|
- portable across SQLite and PostgreSQL
|
||||||
|
- independent from unrelated projects
|
||||||
|
- suitable for validation, comparison and selective restore tooling
|
||||||
|
|
||||||
|
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
|
||||||
|
|
||||||
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
|
|
||||||
|
Conceptual entities:
|
||||||
|
|
||||||
|
- `ExternalModelSource`
|
||||||
|
- identifies a Revit/IFC model or another external source within a project
|
||||||
|
- `ExternalImportBatch`
|
||||||
|
- file name, checksum, import time, format/version and processing status
|
||||||
|
- `ExternalModelObject`
|
||||||
|
- stable IFCGUID and optional Revit element identifiers
|
||||||
|
- source attributes and last-seen import batch
|
||||||
|
- `ExternalObjectLink`
|
||||||
|
- explicit relationship to a project device, circuit device row or another future domain entity
|
||||||
|
- `ExternalParameterMapping`
|
||||||
|
- maps imported/exported columns to application fields
|
||||||
|
- `ExternalExportBatch`
|
||||||
|
- records which project revision and import source produced an exported file
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
|
||||||
|
- IFCGUID uniqueness is scoped to project and external model source.
|
||||||
|
- Parsing writes to staging first.
|
||||||
|
- Users preview additions, changes, missing objects and conflicts before applying them.
|
||||||
|
- Applying an import creates one project revision and one auditable change set.
|
||||||
|
- Imported source values and locally assigned planning values remain distinguishable.
|
||||||
|
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
|
||||||
|
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
|
||||||
|
|
||||||
|
## SQLite and PostgreSQL Direction
|
||||||
|
|
||||||
|
SQLite remains appropriate for the current local, single-process development workflow and can handle initial external-object volumes. Data volume alone is not a reason to migrate immediately.
|
||||||
|
|
||||||
|
PostgreSQL becomes appropriate when one or more of these conditions apply:
|
||||||
|
|
||||||
|
- multiple users edit projects concurrently
|
||||||
|
- the API runs as a shared network service
|
||||||
|
- background import/export jobs need independent workers
|
||||||
|
- revision/history queries or external-object datasets need server-side concurrency and operational tooling
|
||||||
|
- centralized backup, access control and monitoring become required
|
||||||
|
|
||||||
|
To preserve that option:
|
||||||
|
|
||||||
|
- domain services must not depend directly on `better-sqlite3`
|
||||||
|
- new multi-write workflows use an injectable transaction/unit-of-work boundary
|
||||||
|
- database-generated behavior is kept behind repositories
|
||||||
|
- IDs remain stable UUIDs and revisions use explicit project-scoped sequence numbers
|
||||||
|
- history snapshots remain logical and database-independent
|
||||||
|
- SQLite-specific synchronous transaction callbacks are treated as an adapter detail
|
||||||
|
- real persistence tests are separated so PostgreSQL parity tests can be added later
|
||||||
|
|
||||||
|
Drizzle schemas and migrations are dialect-specific and will require a deliberate PostgreSQL schema/migration path. The goal is portable domain behavior, not pretending the current database schema files can be switched without work.
|
||||||
|
|
||||||
|
## Near-Term Refactoring Constraints
|
||||||
|
|
||||||
|
Before implementing the future features:
|
||||||
|
|
||||||
|
- add real SQLite rollback tests around the persistence boundary
|
||||||
|
- stop adding direct imports of the global SQLite client to domain services
|
||||||
|
- extract transaction ownership from oversized repositories incrementally
|
||||||
|
- modularize editor history commands independently from UI rendering
|
||||||
|
- preserve stable domain IDs during legacy cleanup
|
||||||
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
|
## Deferred Decisions
|
||||||
|
|
||||||
|
- exact snapshot frequency and retention policy
|
||||||
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
|
- branch visualization after undo followed by new edits
|
||||||
|
- user/role model and actor attribution
|
||||||
|
- supported Revit CSV dialects and parameter mapping UI
|
||||||
|
- PostgreSQL deployment topology and migration date
|
||||||
@@ -300,3 +300,141 @@ Acceptance criteria:
|
|||||||
- undo/redo does not disrupt the current reading position
|
- undo/redo does not disrupt the current reading position
|
||||||
- the editor remains usable without overflowing the browser layout
|
- the editor remains usable without overflowing the browser layout
|
||||||
- active filters are easy to understand, change and clear
|
- active filters are easy to understand, change and clear
|
||||||
|
|
||||||
|
## Phase 11: Persistence and Transaction Foundation
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- add real SQLite integration tests for transaction commit and rollback
|
||||||
|
- introduce an injectable transaction / unit-of-work boundary
|
||||||
|
- remove direct database-client access from domain services
|
||||||
|
- treat synchronous SQLite transactions as an adapter detail
|
||||||
|
- remove dead persistence methods after coverage exists
|
||||||
|
- verify database backup and restore independently from project history
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- critical multi-write commands roll back completely in a real database test
|
||||||
|
- domain command behavior is not coupled to `better-sqlite3`
|
||||||
|
- adding a PostgreSQL persistence adapter does not require rewriting editor domain rules
|
||||||
|
|
||||||
|
Implemented foundation:
|
||||||
|
|
||||||
|
- database contexts can be created independently from the production singleton
|
||||||
|
- SQLite foreign-key enforcement is enabled explicitly for every context
|
||||||
|
- the distribution-board repository receives its database dependency explicitly
|
||||||
|
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
||||||
|
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
|
||||||
|
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||||
|
- persistence value mapping is separated from the general circuit-device-row repository
|
||||||
|
|
||||||
|
## Phase 12: Project Revisions and Persistent Undo / Redo
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
Persist project-wide change history and restore points across reloads and application restarts.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- add project-scoped monotonic revisions
|
||||||
|
- record immutable server-side change sets with before/after state
|
||||||
|
- add optimistic revision checks for stale commands
|
||||||
|
- persist undo/redo eligibility on the server
|
||||||
|
- add named and periodic logical project snapshots
|
||||||
|
- restore a historical snapshot as a new revision
|
||||||
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- undo/redo remains available after a page reload
|
||||||
|
- every logical command and inverse is atomic and auditable
|
||||||
|
- restoring an older state does not delete intervening history
|
||||||
|
- concurrent stale commands cannot silently overwrite newer project state
|
||||||
|
|
||||||
|
## Phase 13: External Model Round-Trip
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- model external sources, import batches and stable IFCGUID objects
|
||||||
|
- stage and validate imports before applying them
|
||||||
|
- show additions, changes, missing objects and conflicts
|
||||||
|
- link external objects explicitly to project-domain entities
|
||||||
|
- apply an import as one auditable project revision
|
||||||
|
- configure inbound and outbound parameter mappings
|
||||||
|
- export against a known project revision while preserving IFCGUID
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- repeated imports match objects deterministically by source and IFCGUID
|
||||||
|
- imports never silently overwrite local planning values
|
||||||
|
- users approve the diff before it changes the project
|
||||||
|
- exported data can be assigned back to the correct Revit objects
|
||||||
|
|
||||||
|
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
|
||||||
|
|
||||||
|
## Phase 14: Documentation and Collaboration Handoff
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
After the legacy consumer path has been removed, provide one accurate entry point for developers, contributors and LLM-assisted work.
|
||||||
|
|
||||||
|
Timing:
|
||||||
|
|
||||||
|
- perform the final rewrite after legacy cutover so the documentation describes only the supported architecture
|
||||||
|
- update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design
|
||||||
|
|
||||||
|
README target structure:
|
||||||
|
|
||||||
|
- short project purpose and current maturity/status
|
||||||
|
- supported domain workflow at a high level
|
||||||
|
- technology stack
|
||||||
|
- prerequisites
|
||||||
|
- recommended Docker development quick start
|
||||||
|
- direct local development start
|
||||||
|
- test, type-check and build commands
|
||||||
|
- database persistence, migration, backup and restore basics
|
||||||
|
- production deployment/install instructions once a production setup exists
|
||||||
|
- concise documentation map
|
||||||
|
- contribution entry point
|
||||||
|
|
||||||
|
README content to remove or relocate:
|
||||||
|
|
||||||
|
- historical requirement checklists
|
||||||
|
- file-by-file implementation status from early prototypes
|
||||||
|
- obsolete legacy consumer architecture
|
||||||
|
- long domain specifications already maintained in `docs/`
|
||||||
|
- broken links and claims that are not verified by the current code
|
||||||
|
|
||||||
|
Documentation tasks:
|
||||||
|
|
||||||
|
- add a `docs/README.md` index with current, future and archived documents clearly separated
|
||||||
|
- keep current architecture, domain invariants, API behavior and interaction behavior synchronized with code
|
||||||
|
- archive superseded initial requirements under `docs/archive/` when they are still useful for traceability
|
||||||
|
- remove obsolete documentation that has no remaining historical or operational value
|
||||||
|
- update `AGENTS.md` with current entry points, supported/deprecated paths and forward architecture constraints
|
||||||
|
- add a concise contributor workflow for branches, migrations, tests and commits
|
||||||
|
- document how a colleague creates an empty local database and optionally loads safe sample data
|
||||||
|
- avoid duplicating the same architecture facts across README, `AGENTS.md` and detailed docs; link to one source of truth
|
||||||
|
|
||||||
|
Deployment documentation requirements:
|
||||||
|
|
||||||
|
- distinguish development Compose from production deployment
|
||||||
|
- do not label the hot-reload development stack as a production setup
|
||||||
|
- document required environment variables, ports, health checks, persistent storage and migration behavior
|
||||||
|
- verify backup and restore procedures
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- a colleague can clone the repository and start a clean development environment using only the README
|
||||||
|
- a contributor can identify the supported architecture and relevant code entry points without reading historical specs
|
||||||
|
- an LLM receives domain invariants, current module boundaries and deferred architecture direction without relying on stale legacy files
|
||||||
|
- every README command and internal documentation link is verified
|
||||||
|
- production instructions describe a real tested deployment path or clearly state that none is supported yet
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
|
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts",
|
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
import { db } from "../src/db/client.js";
|
||||||
|
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
||||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -9,7 +10,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const circuitRepository = new CircuitRepository();
|
const circuitRepository = new CircuitRepository();
|
||||||
const rowRepository = new CircuitDeviceRowRepository();
|
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
||||||
|
|
||||||
const circuit = await circuitRepository.findById(circuitId);
|
const circuit = await circuitRepository.findById(circuitId);
|
||||||
if (!circuit) {
|
if (!circuit) {
|
||||||
@@ -17,10 +18,8 @@ async function run() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowCount = await rowRepository.countByCircuit(circuitId);
|
const createdRowId = rowTransactionRepository.createInCircuit({
|
||||||
const createdRowId = await rowRepository.create({
|
|
||||||
circuitId,
|
circuitId,
|
||||||
sortOrder: (rowCount + 1) * 10,
|
|
||||||
name: "Test sub device",
|
name: "Test sub device",
|
||||||
displayName: "Beleuchtung WC",
|
displayName: "Beleuchtung WC",
|
||||||
phaseType: "single_phase",
|
phaseType: "single_phase",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { db } from "../src/db/client.js";
|
||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
||||||
|
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const rowId = process.argv[2];
|
const rowId = process.argv[2];
|
||||||
@@ -8,13 +10,14 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rowRepository = new CircuitDeviceRowRepository();
|
const rowRepository = new CircuitDeviceRowRepository();
|
||||||
|
const rowTransactionRepository = new CircuitDeviceRowTransactionRepository(db);
|
||||||
const row = await rowRepository.findById(rowId);
|
const row = await rowRepository.findById(rowId);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
console.error(`Row not found: ${rowId}`);
|
console.error(`Row not found: ${rowId}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rowRepository.delete(rowId);
|
rowTransactionRepository.deleteFromCircuit(rowId, row.circuitId);
|
||||||
console.log(`Deleted row id: ${rowId}`);
|
console.log(`Deleted row id: ${rowId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-9
@@ -1,13 +1,9 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import Database from "better-sqlite3";
|
import { createDatabaseContext } from "./database-context.js";
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
||||||
|
|
||||||
const dataDir = path.resolve("data");
|
const defaultDatabaseContext = createDatabaseContext(
|
||||||
if (!fs.existsSync(dataDir)) {
|
path.resolve("data", "leistungsbilanz.db")
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const sqlite = new Database(path.resolve(dataDir, "leistungsbilanz.db"));
|
export const db = defaultDatabaseContext.db;
|
||||||
export const db = drizzle(sqlite);
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import {
|
||||||
|
drizzle,
|
||||||
|
type BetterSQLite3Database,
|
||||||
|
} from "drizzle-orm/better-sqlite3";
|
||||||
|
|
||||||
|
export interface DatabaseContext {
|
||||||
|
db: BetterSQLite3Database;
|
||||||
|
sqlite: Database.Database;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppDatabase = DatabaseContext["db"];
|
||||||
|
|
||||||
|
export function createDatabaseContext(filename: string): DatabaseContext {
|
||||||
|
if (filename !== ":memory:") {
|
||||||
|
const parentDirectory = path.dirname(path.resolve(filename));
|
||||||
|
if (!fs.existsSync(parentDirectory)) {
|
||||||
|
fs.mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(filename);
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
const database = drizzle(sqlite);
|
||||||
|
return {
|
||||||
|
db: database,
|
||||||
|
sqlite,
|
||||||
|
close: () => sqlite.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
CircuitDeviceRowTransactionStore,
|
||||||
|
CreateCircuitDeviceRowTransactionInput,
|
||||||
|
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import {
|
||||||
|
toCircuitDeviceRowCreateValues,
|
||||||
|
} from "./circuit-device-row.persistence.js";
|
||||||
|
|
||||||
|
export class CircuitDeviceRowTransactionRepository
|
||||||
|
implements CircuitDeviceRowTransactionStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
const [circuit] = tx
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, input.circuitId))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Der Stromkreis ist ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = tx
|
||||||
|
.select({ sortOrder: circuitDeviceRows.sortOrder })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
|
||||||
|
.all();
|
||||||
|
const lastSortOrder = existingRows.reduce(
|
||||||
|
(highest, row) => Math.max(highest, row.sortOrder),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
|
||||||
|
|
||||||
|
tx
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
|
||||||
|
.run();
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: 0 })
|
||||||
|
.where(eq(circuits.id, input.circuitId))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
const [row] = tx
|
||||||
|
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, rowId))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
if (!row || row.circuitId !== expectedCircuitId) {
|
||||||
|
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = tx
|
||||||
|
.delete(circuitDeviceRows)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, rowId),
|
||||||
|
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingRows = tx
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
||||||
|
.all();
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||||
|
.where(eq(circuits.id, expectedCircuitId))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateInput {
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType?: string;
|
||||||
|
connectionKind?: string;
|
||||||
|
costGroup?: string;
|
||||||
|
category?: string;
|
||||||
|
level?: string;
|
||||||
|
roomId?: string;
|
||||||
|
roomNumberSnapshot?: string;
|
||||||
|
roomNameSnapshot?: string;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi?: number;
|
||||||
|
remark?: string;
|
||||||
|
overriddenFields?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||||
|
circuitId: string;
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
legacyConsumerId?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInput) {
|
||||||
|
return {
|
||||||
|
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
||||||
|
name: input.name,
|
||||||
|
displayName: input.displayName,
|
||||||
|
phaseType: input.phaseType ?? null,
|
||||||
|
connectionKind: input.connectionKind ?? null,
|
||||||
|
costGroup: input.costGroup ?? null,
|
||||||
|
category: input.category ?? null,
|
||||||
|
level: input.level ?? null,
|
||||||
|
roomId: input.roomId ?? null,
|
||||||
|
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
||||||
|
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
||||||
|
quantity: input.quantity,
|
||||||
|
powerPerUnit: input.powerPerUnit,
|
||||||
|
simultaneityFactor: input.simultaneityFactor,
|
||||||
|
cosPhi: input.cosPhi ?? null,
|
||||||
|
remark: input.remark ?? null,
|
||||||
|
overriddenFields: input.overriddenFields ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput) {
|
||||||
|
const values: Partial<typeof circuitDeviceRows.$inferInsert> = {};
|
||||||
|
const has = (field: keyof CircuitDeviceRowPatchInput) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field);
|
||||||
|
|
||||||
|
if (has("linkedProjectDeviceId")) {
|
||||||
|
values.linkedProjectDeviceId = input.linkedProjectDeviceId ?? null;
|
||||||
|
}
|
||||||
|
if (input.name !== undefined) values.name = input.name;
|
||||||
|
if (input.displayName !== undefined) values.displayName = input.displayName;
|
||||||
|
if (has("phaseType")) values.phaseType = input.phaseType ?? null;
|
||||||
|
if (has("connectionKind")) values.connectionKind = input.connectionKind ?? null;
|
||||||
|
if (has("costGroup")) values.costGroup = input.costGroup ?? null;
|
||||||
|
if (has("category")) values.category = input.category ?? null;
|
||||||
|
if (has("level")) values.level = input.level ?? null;
|
||||||
|
if (has("roomId")) values.roomId = input.roomId ?? null;
|
||||||
|
if (has("roomNumberSnapshot")) {
|
||||||
|
values.roomNumberSnapshot = input.roomNumberSnapshot ?? null;
|
||||||
|
}
|
||||||
|
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
|
||||||
|
if (input.quantity !== undefined) values.quantity = input.quantity;
|
||||||
|
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
|
||||||
|
if (input.simultaneityFactor !== undefined) {
|
||||||
|
values.simultaneityFactor = input.simultaneityFactor;
|
||||||
|
}
|
||||||
|
if (has("cosPhi")) values.cosPhi = input.cosPhi ?? null;
|
||||||
|
if (has("remark")) values.remark = input.remark ?? null;
|
||||||
|
if (has("overriddenFields")) values.overriddenFields = input.overriddenFields ?? null;
|
||||||
|
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitDeviceRowCreateValues(
|
||||||
|
id: string,
|
||||||
|
input: CircuitDeviceRowCreateInput
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitId: input.circuitId,
|
||||||
|
legacyConsumerId: input.legacyConsumerId ?? null,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
...toCircuitDeviceRowUpdateValues(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,26 +5,25 @@ import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import {
|
||||||
|
toCircuitDeviceRowCreateValues,
|
||||||
|
toCircuitDeviceRowPatchValues,
|
||||||
|
toCircuitDeviceRowUpdateValues,
|
||||||
|
type CircuitDeviceRowCreateInput,
|
||||||
|
type CircuitDeviceRowPatchInput,
|
||||||
|
type CircuitDeviceRowUpdateInput,
|
||||||
|
} from "./circuit-device-row.persistence.js";
|
||||||
|
import {
|
||||||
|
toCircuitCreateValues,
|
||||||
|
type CircuitCreatePersistenceInput,
|
||||||
|
} from "./circuit.repository.js";
|
||||||
|
|
||||||
export interface CircuitDeviceRowUpdateInput {
|
export type {
|
||||||
linkedProjectDeviceId?: string;
|
CircuitDeviceRowCreateInput,
|
||||||
name: string;
|
CircuitDeviceRowPatchInput,
|
||||||
displayName: string;
|
CircuitDeviceRowUpdateInput,
|
||||||
phaseType?: string;
|
} from "./circuit-device-row.persistence.js";
|
||||||
connectionKind?: string;
|
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||||
costGroup?: string;
|
|
||||||
category?: string;
|
|
||||||
level?: string;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumberSnapshot?: string;
|
|
||||||
roomNameSnapshot?: string;
|
|
||||||
quantity: number;
|
|
||||||
powerPerUnit: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi?: number;
|
|
||||||
remark?: string;
|
|
||||||
overriddenFields?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CircuitDeviceRowsBulkMoveInput {
|
export interface CircuitDeviceRowsBulkMoveInput {
|
||||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
rows: Array<{ id: string; expectedCircuitId: string }>;
|
||||||
@@ -38,26 +37,11 @@ export interface CircuitDeviceRowsBulkMoveInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
|
export interface CircuitWithDeviceRowsCreateInput {
|
||||||
return {
|
circuit: CircuitCreatePersistenceInput;
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
deviceRows: Array<
|
||||||
name: input.name,
|
Omit<CircuitDeviceRowCreateInput, "circuitId" | "sortOrder"> & { sortOrder?: number }
|
||||||
displayName: input.displayName,
|
>;
|
||||||
phaseType: input.phaseType ?? null,
|
|
||||||
connectionKind: input.connectionKind ?? null,
|
|
||||||
costGroup: input.costGroup ?? null,
|
|
||||||
category: input.category ?? null,
|
|
||||||
level: input.level ?? null,
|
|
||||||
roomId: input.roomId ?? null,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? null,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
overriddenFields: input.overriddenFields ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CircuitDeviceRowRepository {
|
export class CircuitDeviceRowRepository {
|
||||||
@@ -141,65 +125,66 @@ export class CircuitDeviceRowRepository {
|
|||||||
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
|
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: CircuitDeviceRowCreateInput) {
|
||||||
circuitId: string;
|
|
||||||
linkedProjectDeviceId?: string;
|
|
||||||
legacyConsumerId?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
phaseType?: string;
|
|
||||||
connectionKind?: string;
|
|
||||||
costGroup?: string;
|
|
||||||
category?: string;
|
|
||||||
level?: string;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumberSnapshot?: string;
|
|
||||||
roomNameSnapshot?: string;
|
|
||||||
quantity: number;
|
|
||||||
powerPerUnit: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi?: number;
|
|
||||||
remark?: string;
|
|
||||||
overriddenFields?: string;
|
|
||||||
}) {
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await db.insert(circuitDeviceRows).values({
|
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
|
||||||
id,
|
|
||||||
circuitId: input.circuitId,
|
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
|
||||||
legacyConsumerId: input.legacyConsumerId ?? null,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
name: input.name,
|
|
||||||
displayName: input.displayName,
|
|
||||||
phaseType: input.phaseType ?? null,
|
|
||||||
connectionKind: input.connectionKind ?? null,
|
|
||||||
costGroup: input.costGroup ?? null,
|
|
||||||
category: input.category ?? null,
|
|
||||||
level: input.level ?? null,
|
|
||||||
roomId: input.roomId ?? null,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? null,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
overriddenFields: input.overriddenFields ?? null,
|
|
||||||
});
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createCircuitWithDeviceRowsTransactional(input: CircuitWithDeviceRowsCreateInput) {
|
||||||
|
if (input.deviceRows.length === 0) {
|
||||||
|
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitId = crypto.randomUUID();
|
||||||
|
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx
|
||||||
|
.insert(circuits)
|
||||||
|
.values(
|
||||||
|
toCircuitCreateValues(circuitId, {
|
||||||
|
...input.circuit,
|
||||||
|
isReserve: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
||||||
|
const row = input.deviceRows[index];
|
||||||
|
tx
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values(
|
||||||
|
toCircuitDeviceRowCreateValues(rowIds[index], {
|
||||||
|
...row,
|
||||||
|
circuitId,
|
||||||
|
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { circuitId, rowIds };
|
||||||
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
rowId: string,
|
rowId: string,
|
||||||
input: CircuitDeviceRowUpdateInput
|
input: CircuitDeviceRowUpdateInput
|
||||||
) {
|
) {
|
||||||
await db
|
await db
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(toUpdateValues(input))
|
.set(toCircuitDeviceRowUpdateValues(input))
|
||||||
.where(eq(circuitDeviceRows.id, rowId));
|
.where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
|
||||||
|
const values = toCircuitDeviceRowPatchValues(input);
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
|
||||||
|
}
|
||||||
|
|
||||||
updateLinkedRowsTransactional(
|
updateLinkedRowsTransactional(
|
||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
||||||
@@ -208,7 +193,7 @@ export class CircuitDeviceRowRepository {
|
|||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const result = tx
|
const result = tx
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(toUpdateValues(change.input))
|
.set(toCircuitDeviceRowUpdateValues(change.input))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(circuitDeviceRows.id, change.rowId),
|
eq(circuitDeviceRows.id, change.rowId),
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ import { and, asc, eq } from "drizzle-orm";
|
|||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
|
||||||
|
export function createDefaultCircuitSectionValues(circuitListId: string) {
|
||||||
|
return [
|
||||||
|
{ key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 },
|
||||||
|
{ key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 },
|
||||||
|
{ key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 },
|
||||||
|
{ key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 },
|
||||||
|
].map((entry) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
circuitListId,
|
||||||
|
...entry,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export class CircuitSectionRepository {
|
export class CircuitSectionRepository {
|
||||||
async findById(sectionId: string) {
|
async findById(sectionId: string) {
|
||||||
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
|
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
|
||||||
@@ -18,14 +31,7 @@ export class CircuitSectionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createDefaults(circuitListId: string) {
|
async createDefaults(circuitListId: string) {
|
||||||
const defaults = [
|
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||||
{ key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 },
|
|
||||||
{ key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 },
|
|
||||||
{ key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 },
|
|
||||||
{ key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entry of defaults) {
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ id: circuitSections.id })
|
.select({ id: circuitSections.id })
|
||||||
.from(circuitSections)
|
.from(circuitSections)
|
||||||
@@ -36,11 +42,7 @@ export class CircuitSectionRepository {
|
|||||||
if (existing.length) {
|
if (existing.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await db.insert(circuitSections).values({
|
await db.insert(circuitSections).values(entry);
|
||||||
id: crypto.randomUUID(),
|
|
||||||
circuitListId,
|
|
||||||
...entry,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,7 @@ import { and, asc, eq, inArray, ne } from "drizzle-orm";
|
|||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
|
||||||
export class CircuitRepository {
|
export interface CircuitCreatePersistenceInput {
|
||||||
async findById(circuitId: string) {
|
|
||||||
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listByCircuitList(circuitListId: string) {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.circuitListId, circuitListId))
|
|
||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: {
|
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
equipmentIdentifier: string;
|
equipmentIdentifier: string;
|
||||||
@@ -36,9 +22,30 @@ export class CircuitRepository {
|
|||||||
terminalDesignation?: string;
|
terminalDesignation?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
isReserve?: boolean;
|
isReserve?: boolean;
|
||||||
}) {
|
}
|
||||||
const id = crypto.randomUUID();
|
|
||||||
await db.insert(circuits).values({
|
export interface CircuitPatchPersistenceInput {
|
||||||
|
sectionId?: string;
|
||||||
|
equipmentIdentifier?: string;
|
||||||
|
displayName?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
protectionType?: string;
|
||||||
|
protectionRatedCurrent?: number;
|
||||||
|
protectionCharacteristic?: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableCrossSection?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
voltage?: number;
|
||||||
|
controlRequirement?: string;
|
||||||
|
remark?: string;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
status?: string;
|
||||||
|
isReserve?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
||||||
|
return {
|
||||||
id,
|
id,
|
||||||
circuitListId: input.circuitListId,
|
circuitListId: input.circuitListId,
|
||||||
sectionId: input.sectionId,
|
sectionId: input.sectionId,
|
||||||
@@ -58,7 +65,62 @@ export class CircuitRepository {
|
|||||||
status: input.status ?? null,
|
status: input.status ?? null,
|
||||||
isReserve: input.isReserve ? 1 : 0,
|
isReserve: input.isReserve ? 1 : 0,
|
||||||
remark: input.remark ?? null,
|
remark: input.remark ?? null,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
||||||
|
const values: Partial<typeof circuits.$inferInsert> = {};
|
||||||
|
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field);
|
||||||
|
|
||||||
|
if (input.sectionId !== undefined) values.sectionId = input.sectionId;
|
||||||
|
if (input.equipmentIdentifier !== undefined) {
|
||||||
|
values.equipmentIdentifier = input.equipmentIdentifier;
|
||||||
|
}
|
||||||
|
if (has("displayName")) values.displayName = input.displayName ?? null;
|
||||||
|
if (input.sortOrder !== undefined) values.sortOrder = input.sortOrder;
|
||||||
|
if (has("protectionType")) values.protectionType = input.protectionType ?? null;
|
||||||
|
if (has("protectionRatedCurrent")) {
|
||||||
|
values.protectionRatedCurrent = input.protectionRatedCurrent ?? null;
|
||||||
|
}
|
||||||
|
if (has("protectionCharacteristic")) {
|
||||||
|
values.protectionCharacteristic = input.protectionCharacteristic ?? null;
|
||||||
|
}
|
||||||
|
if (has("cableType")) values.cableType = input.cableType ?? null;
|
||||||
|
if (has("cableCrossSection")) values.cableCrossSection = input.cableCrossSection ?? null;
|
||||||
|
if (has("cableLength")) values.cableLength = input.cableLength ?? null;
|
||||||
|
if (has("rcdAssignment")) values.rcdAssignment = input.rcdAssignment ?? null;
|
||||||
|
if (has("terminalDesignation")) {
|
||||||
|
values.terminalDesignation = input.terminalDesignation ?? null;
|
||||||
|
}
|
||||||
|
if (has("voltage")) values.voltage = input.voltage ?? null;
|
||||||
|
if (has("controlRequirement")) {
|
||||||
|
values.controlRequirement = input.controlRequirement ?? null;
|
||||||
|
}
|
||||||
|
if (has("status")) values.status = input.status ?? null;
|
||||||
|
if (input.isReserve !== undefined) values.isReserve = input.isReserve ? 1 : 0;
|
||||||
|
if (has("remark")) values.remark = input.remark ?? null;
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CircuitRepository {
|
||||||
|
async findById(circuitId: string) {
|
||||||
|
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByCircuitList(circuitListId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.circuitListId, circuitListId))
|
||||||
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CircuitCreatePersistenceInput) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await db.insert(circuits).values(toCircuitCreateValues(id, input));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +170,14 @@ export class CircuitRepository {
|
|||||||
.where(eq(circuits.id, circuitId));
|
.where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
|
||||||
|
const values = toCircuitPatchValues(input);
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
|
||||||
|
}
|
||||||
|
|
||||||
async delete(circuitId: string) {
|
async delete(circuitId: string) {
|
||||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||||
}
|
}
|
||||||
@@ -137,6 +207,46 @@ export class CircuitRepository {
|
|||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSortOrdersSafely(sectionId: string, orderedCircuitIds: string[]) {
|
||||||
|
if (orderedCircuitIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
|
||||||
|
throw new Error("Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
const sectionCircuits = tx
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, sectionId))
|
||||||
|
.all();
|
||||||
|
const sectionCircuitIds = new Set(sectionCircuits.map((circuit) => circuit.id));
|
||||||
|
if (
|
||||||
|
sectionCircuits.length !== orderedCircuitIds.length ||
|
||||||
|
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
|
||||||
|
) {
|
||||||
|
throw new Error("Die Reihenfolge muss alle Stromkreise des Bereichs enthalten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ sortOrder: (index + 1) * 10 })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.sectionId, sectionId),
|
||||||
|
eq(circuits.id, orderedCircuitIds[index])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("Ein Stromkreis wurde vor Abschluss der Sortierung verändert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateEquipmentIdentifiersSafely(
|
async updateEquipmentIdentifiersSafely(
|
||||||
circuitListId: string,
|
circuitListId: string,
|
||||||
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { createDefaultCircuitSectionValues } from "./circuit-section.repository.js";
|
||||||
|
|
||||||
export class DistributionBoardRepository {
|
export class DistributionBoardRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
async listByProject(projectId: string) {
|
async listByProject(projectId: string) {
|
||||||
return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId));
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(projectId: string, name: string) {
|
async create(projectId: string, name: string) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const board = { id, projectId, name };
|
const board = { id, projectId, name };
|
||||||
await db.insert(distributionBoards).values(board);
|
await this.database.insert(distributionBoards).values(board);
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
createWithCircuitListAndDefaultSections(projectId: string, name: string) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const board = { id, projectId, name };
|
||||||
|
const circuitList = {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
distributionBoardId: id,
|
||||||
|
name: `${name} Stromkreisliste`,
|
||||||
|
};
|
||||||
|
const sections = createDefaultCircuitSectionValues(id);
|
||||||
|
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
tx.insert(distributionBoards).values(board).run();
|
||||||
|
tx.insert(circuitLists).values(circuitList).run();
|
||||||
|
tx.insert(circuitSections).values(sections).run();
|
||||||
|
});
|
||||||
|
|
||||||
return board;
|
return board;
|
||||||
}
|
}
|
||||||
|
|
||||||
async existsInProject(projectId: string, distributionBoardId: string) {
|
async existsInProject(projectId: string, distributionBoardId: string) {
|
||||||
const [row] = await db
|
const [row] = await this.database
|
||||||
.select({ id: distributionBoards.id })
|
.select({ id: distributionBoards.id })
|
||||||
.from(distributionBoards)
|
.from(distributionBoards)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
|
import {
|
||||||
|
toCircuitDeviceRowCreateValues,
|
||||||
|
type CircuitDeviceRowCreateInput,
|
||||||
|
} from "./circuit-device-row.repository.js";
|
||||||
|
import {
|
||||||
|
toCircuitCreateValues,
|
||||||
|
type CircuitCreatePersistenceInput,
|
||||||
|
} from "./circuit.repository.js";
|
||||||
|
|
||||||
|
export interface LegacyMigrationCircuitPersistenceInput {
|
||||||
|
circuit: CircuitCreatePersistenceInput;
|
||||||
|
deviceRows: Array<
|
||||||
|
Omit<CircuitDeviceRowCreateInput, "circuitId"> & { legacyConsumerId: string }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyMigrationReportPersistenceInput {
|
||||||
|
legacyConsumerCount: number;
|
||||||
|
createdCircuitCount: number;
|
||||||
|
createdDeviceRowCount: number;
|
||||||
|
duplicateGroupedCount: number;
|
||||||
|
generatedIdentifierCount: number;
|
||||||
|
unassignedRowCount: number;
|
||||||
|
warningsJson: string;
|
||||||
|
generatedIdentifiersJson: string;
|
||||||
|
duplicateGroupsJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LegacyConsumerMigrationRepository {
|
||||||
|
async listMigratedConsumerIds(circuitListId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
|
||||||
|
return rows.map((row) => row.consumerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistCircuitListMigration(input: {
|
||||||
|
circuitListId: string;
|
||||||
|
circuits: LegacyMigrationCircuitPersistenceInput[];
|
||||||
|
report: LegacyMigrationReportPersistenceInput;
|
||||||
|
}) {
|
||||||
|
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
|
||||||
|
throw new Error("All migrated circuits must belong to the target circuit list.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumerIds = input.circuits.flatMap((entry) =>
|
||||||
|
entry.deviceRows.map((row) => row.legacyConsumerId)
|
||||||
|
);
|
||||||
|
if (new Set(consumerIds).size !== consumerIds.length) {
|
||||||
|
throw new Error("A legacy consumer may only be migrated once per operation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedCircuits = input.circuits.map((entry) => {
|
||||||
|
const circuitId = crypto.randomUUID();
|
||||||
|
return {
|
||||||
|
circuitId,
|
||||||
|
circuitValues: toCircuitCreateValues(circuitId, entry.circuit),
|
||||||
|
rows: entry.deviceRows.map((row) => {
|
||||||
|
const rowId = crypto.randomUUID();
|
||||||
|
return {
|
||||||
|
consumerId: row.legacyConsumerId,
|
||||||
|
rowId,
|
||||||
|
rowValues: toCircuitDeviceRowCreateValues(rowId, {
|
||||||
|
...row,
|
||||||
|
circuitId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const createdAtIso = new Date().toISOString();
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
if (consumerIds.length > 0) {
|
||||||
|
const existingMappings = tx
|
||||||
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.where(inArray(legacyConsumerCircuitMigrations.consumerId, consumerIds))
|
||||||
|
.all();
|
||||||
|
if (existingMappings.length > 0) {
|
||||||
|
throw new Error("A legacy consumer was migrated before this operation completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preparedCircuit of preparedCircuits) {
|
||||||
|
tx.insert(circuits).values(preparedCircuit.circuitValues).run();
|
||||||
|
for (const row of preparedCircuit.rows) {
|
||||||
|
tx.insert(circuitDeviceRows).values(row.rowValues).run();
|
||||||
|
tx
|
||||||
|
.insert(legacyConsumerCircuitMigrations)
|
||||||
|
.values({
|
||||||
|
consumerId: row.consumerId,
|
||||||
|
circuitId: preparedCircuit.circuitId,
|
||||||
|
circuitDeviceRowId: row.rowId,
|
||||||
|
circuitListId: input.circuitListId,
|
||||||
|
createdAtIso,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportValues = {
|
||||||
|
...input.report,
|
||||||
|
createdAtIso,
|
||||||
|
};
|
||||||
|
const existingReport = tx
|
||||||
|
.select({ id: legacyConsumerMigrationReports.id })
|
||||||
|
.from(legacyConsumerMigrationReports)
|
||||||
|
.where(eq(legacyConsumerMigrationReports.circuitListId, input.circuitListId))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
if (existingReport.length > 0) {
|
||||||
|
tx
|
||||||
|
.update(legacyConsumerMigrationReports)
|
||||||
|
.set(reportValues)
|
||||||
|
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
tx
|
||||||
|
.insert(legacyConsumerMigrationReports)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
circuitListId: input.circuitListId,
|
||||||
|
...reportValues,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface CreateCircuitDeviceRowTransactionInput {
|
||||||
|
circuitId: string;
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType?: string;
|
||||||
|
connectionKind?: string;
|
||||||
|
costGroup?: string;
|
||||||
|
category?: string;
|
||||||
|
level?: string;
|
||||||
|
roomId?: string;
|
||||||
|
roomNumberSnapshot?: string;
|
||||||
|
roomNameSnapshot?: string;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi?: number;
|
||||||
|
remark?: string;
|
||||||
|
overriddenFields?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowTransactionStore {
|
||||||
|
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
||||||
|
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
|
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
@@ -6,6 +7,7 @@ import { ProjectDeviceRepository } from "../../db/repositories/project-device.re
|
|||||||
import type {
|
import type {
|
||||||
CreateCircuitDeviceRowInput,
|
CreateCircuitDeviceRowInput,
|
||||||
CreateCircuitInput,
|
CreateCircuitInput,
|
||||||
|
CreateCircuitWithDeviceRowsInput,
|
||||||
MoveCircuitDeviceRowInput,
|
MoveCircuitDeviceRowInput,
|
||||||
MoveCircuitDeviceRowsBulkInput,
|
MoveCircuitDeviceRowsBulkInput,
|
||||||
ReorderSectionCircuitsInput,
|
ReorderSectionCircuitsInput,
|
||||||
@@ -25,6 +27,7 @@ export class CircuitWriteService {
|
|||||||
private readonly circuitSectionRepository: CircuitSectionRepository;
|
private readonly circuitSectionRepository: CircuitSectionRepository;
|
||||||
private readonly circuitListRepository: CircuitListRepository;
|
private readonly circuitListRepository: CircuitListRepository;
|
||||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||||
|
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
||||||
private readonly numberingService: CircuitNumberingService;
|
private readonly numberingService: CircuitNumberingService;
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ export class CircuitWriteService {
|
|||||||
circuitSectionRepository?: CircuitSectionRepository;
|
circuitSectionRepository?: CircuitSectionRepository;
|
||||||
circuitListRepository?: CircuitListRepository;
|
circuitListRepository?: CircuitListRepository;
|
||||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||||
|
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
projectDeviceRepository?: ProjectDeviceRepository;
|
projectDeviceRepository?: ProjectDeviceRepository;
|
||||||
numberingService?: CircuitNumberingService;
|
numberingService?: CircuitNumberingService;
|
||||||
}) {
|
}) {
|
||||||
@@ -40,6 +44,7 @@ export class CircuitWriteService {
|
|||||||
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
||||||
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
|
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,26 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validates linked project-device id against owning project of the circuit list.
|
// Validates linked project-device id against owning project of the circuit list.
|
||||||
|
private async assertValidLinkedProjectDeviceForProject(
|
||||||
|
projectId: string,
|
||||||
|
linkedProjectDeviceId?: string
|
||||||
|
) {
|
||||||
|
if (!linkedProjectDeviceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const device = await this.projectDeviceRepository.findById(projectId, linkedProjectDeviceId);
|
||||||
|
if (!device) {
|
||||||
|
throw new Error("Invalid linked project device id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceRowTransactionStore() {
|
||||||
|
if (!this.deviceRowTransactionStore) {
|
||||||
|
throw new Error("Circuit device-row transactions are not configured.");
|
||||||
|
}
|
||||||
|
return this.deviceRowTransactionStore;
|
||||||
|
}
|
||||||
|
|
||||||
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
||||||
if (!linkedProjectDeviceId) {
|
if (!linkedProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -85,10 +110,7 @@ export class CircuitWriteService {
|
|||||||
if (!list) {
|
if (!list) {
|
||||||
throw new Error("Circuit list not found.");
|
throw new Error("Circuit list not found.");
|
||||||
}
|
}
|
||||||
const device = await this.projectDeviceRepository.findById(list.projectId, linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDeviceForProject(list.projectId, linkedProjectDeviceId);
|
||||||
if (!device) {
|
|
||||||
throw new Error("Invalid linked project device id.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) {
|
async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) {
|
||||||
@@ -121,6 +143,48 @@ export class CircuitWriteService {
|
|||||||
return this.circuitRepository.findById(id);
|
return this.circuitRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createCircuitWithDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
circuitListId: string,
|
||||||
|
input: CreateCircuitWithDeviceRowsInput
|
||||||
|
) {
|
||||||
|
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
||||||
|
if (!list) {
|
||||||
|
throw new Error("Circuit list not found in project.");
|
||||||
|
}
|
||||||
|
await this.assertSectionInList(input.circuit.sectionId, circuitListId);
|
||||||
|
await this.assertUniqueEquipmentIdentifier(
|
||||||
|
circuitListId,
|
||||||
|
input.circuit.equipmentIdentifier
|
||||||
|
);
|
||||||
|
for (const row of input.deviceRows) {
|
||||||
|
await this.assertValidLinkedProjectDeviceForProject(
|
||||||
|
list.projectId,
|
||||||
|
row.linkedProjectDeviceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = this.deviceRowRepository.createCircuitWithDeviceRowsTransactional({
|
||||||
|
circuit: {
|
||||||
|
circuitListId,
|
||||||
|
...input.circuit,
|
||||||
|
isReserve: false,
|
||||||
|
},
|
||||||
|
deviceRows: input.deviceRows,
|
||||||
|
});
|
||||||
|
const circuit = await this.circuitRepository.findById(created.circuitId);
|
||||||
|
const deviceRows = await Promise.all(
|
||||||
|
created.rowIds.map((rowId) => this.deviceRowRepository.findById(rowId))
|
||||||
|
);
|
||||||
|
if (!circuit || deviceRows.some((row) => !row)) {
|
||||||
|
throw new Error("Der angelegte Stromkreis konnte nicht geladen werden.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
circuit,
|
||||||
|
deviceRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
|
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
|
||||||
const current = await this.circuitRepository.findById(circuitId);
|
const current = await this.circuitRepository.findById(circuitId);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -129,30 +193,10 @@ export class CircuitWriteService {
|
|||||||
|
|
||||||
const sectionId = input.sectionId ?? current.sectionId;
|
const sectionId = input.sectionId ?? current.sectionId;
|
||||||
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
|
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
|
||||||
const sortOrder = input.sortOrder ?? current.sortOrder;
|
|
||||||
await this.assertSectionInList(sectionId, current.circuitListId);
|
await this.assertSectionInList(sectionId, current.circuitListId);
|
||||||
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
|
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
|
||||||
|
|
||||||
await this.circuitRepository.update(circuitId, {
|
await this.circuitRepository.updateFields(circuitId, input);
|
||||||
sectionId,
|
|
||||||
equipmentIdentifier,
|
|
||||||
displayName: input.displayName ?? current.displayName ?? undefined,
|
|
||||||
sortOrder,
|
|
||||||
protectionType: input.protectionType ?? current.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent ?? current.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic:
|
|
||||||
input.protectionCharacteristic ?? current.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: input.cableType ?? current.cableType ?? undefined,
|
|
||||||
cableCrossSection: input.cableCrossSection ?? current.cableCrossSection ?? undefined,
|
|
||||||
cableLength: input.cableLength ?? current.cableLength ?? undefined,
|
|
||||||
rcdAssignment: input.rcdAssignment ?? current.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: input.terminalDesignation ?? current.terminalDesignation ?? undefined,
|
|
||||||
voltage: input.voltage ?? current.voltage ?? undefined,
|
|
||||||
controlRequirement: input.controlRequirement ?? current.controlRequirement ?? undefined,
|
|
||||||
status: input.status ?? current.status ?? undefined,
|
|
||||||
isReserve: input.isReserve ?? Boolean(current.isReserve),
|
|
||||||
remark: input.remark ?? current.remark ?? undefined,
|
|
||||||
});
|
|
||||||
return this.circuitRepository.findById(circuitId);
|
return this.circuitRepository.findById(circuitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +215,10 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId);
|
||||||
|
|
||||||
const existingRows = await this.deviceRowRepository.countByCircuit(circuitId);
|
const rowId = this.getDeviceRowTransactionStore().createInCircuit({
|
||||||
const rowId = await this.deviceRowRepository.create({
|
|
||||||
circuitId,
|
circuitId,
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId,
|
linkedProjectDeviceId: input.linkedProjectDeviceId,
|
||||||
sortOrder: input.sortOrder ?? (existingRows + 1) * 10,
|
sortOrder: input.sortOrder,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
phaseType: input.phaseType,
|
phaseType: input.phaseType,
|
||||||
@@ -194,29 +237,6 @@ 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)) {
|
|
||||||
await this.circuitRepository.update(circuit.id, {
|
|
||||||
sectionId: circuit.sectionId,
|
|
||||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
|
||||||
displayName: circuit.displayName ?? undefined,
|
|
||||||
sortOrder: circuit.sortOrder,
|
|
||||||
protectionType: circuit.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: circuit.cableType ?? undefined,
|
|
||||||
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
|
||||||
cableLength: circuit.cableLength ?? undefined,
|
|
||||||
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
|
||||||
voltage: circuit.voltage ?? undefined,
|
|
||||||
controlRequirement: circuit.controlRequirement ?? undefined,
|
|
||||||
status: circuit.status ?? undefined,
|
|
||||||
isReserve: false,
|
|
||||||
remark: circuit.remark ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +246,7 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid device row id.");
|
throw new Error("Invalid device row id.");
|
||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||||
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
|
let overriddenFields = input.overriddenFields;
|
||||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||||
const inputValues = input as Record<string, unknown>;
|
const inputValues = input as Record<string, unknown>;
|
||||||
@@ -241,24 +261,9 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
overriddenFields = serializeOverriddenFields(overrides);
|
overriddenFields = serializeOverriddenFields(overrides);
|
||||||
}
|
}
|
||||||
await this.deviceRowRepository.update(rowId, {
|
await this.deviceRowRepository.updateFields(rowId, {
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
|
...input,
|
||||||
name: input.name ?? current.name,
|
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
||||||
displayName: input.displayName ?? current.displayName,
|
|
||||||
phaseType: input.phaseType ?? current.phaseType ?? undefined,
|
|
||||||
connectionKind: input.connectionKind ?? current.connectionKind ?? undefined,
|
|
||||||
costGroup: input.costGroup ?? current.costGroup ?? undefined,
|
|
||||||
category: input.category ?? current.category ?? undefined,
|
|
||||||
level: input.level ?? current.level ?? undefined,
|
|
||||||
roomId: input.roomId ?? current.roomId ?? undefined,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? current.roomNumberSnapshot ?? undefined,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? current.roomNameSnapshot ?? undefined,
|
|
||||||
quantity: input.quantity ?? current.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit ?? current.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
|
|
||||||
remark: input.remark ?? current.remark ?? undefined,
|
|
||||||
overriddenFields,
|
|
||||||
});
|
});
|
||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
@@ -272,30 +277,7 @@ export class CircuitWriteService {
|
|||||||
if (!circuit) {
|
if (!circuit) {
|
||||||
throw new Error("Invalid circuit id.");
|
throw new Error("Invalid circuit id.");
|
||||||
}
|
}
|
||||||
await this.deviceRowRepository.delete(rowId);
|
this.getDeviceRowTransactionStore().deleteFromCircuit(rowId, circuit.id);
|
||||||
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) {
|
|
||||||
await this.circuitRepository.update(circuit.id, {
|
|
||||||
sectionId: circuit.sectionId,
|
|
||||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
|
||||||
displayName: circuit.displayName ?? undefined,
|
|
||||||
sortOrder: circuit.sortOrder,
|
|
||||||
protectionType: circuit.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: circuit.cableType ?? undefined,
|
|
||||||
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
|
||||||
cableLength: circuit.cableLength ?? undefined,
|
|
||||||
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
|
||||||
voltage: circuit.voltage ?? undefined,
|
|
||||||
controlRequirement: circuit.controlRequirement ?? undefined,
|
|
||||||
status: circuit.status ?? undefined,
|
|
||||||
isReserve: true,
|
|
||||||
remark: circuit.remark ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
||||||
@@ -523,32 +505,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < input.orderedCircuitIds.length; index += 1) {
|
this.circuitRepository.updateSortOrdersSafely(sectionId, input.orderedCircuitIds);
|
||||||
const circuitId = input.orderedCircuitIds[index];
|
|
||||||
const circuit = sectionCircuits.find((entry) => entry.id === circuitId);
|
|
||||||
if (!circuit) {
|
|
||||||
throw new Error("Invalid circuit id.");
|
|
||||||
}
|
|
||||||
await this.circuitRepository.update(circuit.id, {
|
|
||||||
sectionId: circuit.sectionId,
|
|
||||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
|
||||||
displayName: circuit.displayName ?? undefined,
|
|
||||||
sortOrder: (index + 1) * 10,
|
|
||||||
protectionType: circuit.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: circuit.cableType ?? undefined,
|
|
||||||
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
|
||||||
cableLength: circuit.cableLength ?? undefined,
|
|
||||||
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
|
||||||
voltage: circuit.voltage ?? undefined,
|
|
||||||
controlRequirement: circuit.controlRequirement ?? undefined,
|
|
||||||
status: circuit.status ?? undefined,
|
|
||||||
isReserve: Boolean(circuit.isReserve),
|
|
||||||
remark: circuit.remark ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
return this.circuitRepository.listBySection(sectionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { db } from "../../db/client.js";
|
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
||||||
|
import {
|
||||||
|
LegacyConsumerMigrationRepository,
|
||||||
|
type LegacyMigrationCircuitPersistenceInput,
|
||||||
|
} from "../../db/repositories/legacy-consumer-migration.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { circuitDeviceRows } from "../../db/schema/circuit-device-rows.js";
|
|
||||||
import { legacyConsumerCircuitMigrations } from "../../db/schema/legacy-consumer-circuit-migrations.js";
|
|
||||||
import { legacyConsumerMigrationReports } from "../../db/schema/legacy-consumer-migration-report.js";
|
|
||||||
import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
|
import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
|
||||||
import {
|
import {
|
||||||
inferSectionKeyFromEquipmentIdentifier,
|
inferSectionKeyFromEquipmentIdentifier,
|
||||||
@@ -35,9 +32,9 @@ export class LegacyConsumerMigrationService {
|
|||||||
private readonly circuitListRepository = new CircuitListRepository();
|
private readonly circuitListRepository = new CircuitListRepository();
|
||||||
private readonly sectionRepository = new CircuitSectionRepository();
|
private readonly sectionRepository = new CircuitSectionRepository();
|
||||||
private readonly circuitRepository = new CircuitRepository();
|
private readonly circuitRepository = new CircuitRepository();
|
||||||
private readonly circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
|
||||||
private readonly consumerRepository = new ConsumerRepository();
|
private readonly consumerRepository = new ConsumerRepository();
|
||||||
private readonly roomRepository = new RoomRepository();
|
private readonly roomRepository = new RoomRepository();
|
||||||
|
private readonly migrationRepository = new LegacyConsumerMigrationRepository();
|
||||||
|
|
||||||
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
|
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
|
||||||
@@ -76,11 +73,9 @@ export class LegacyConsumerMigrationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Idempotency guard: skip consumers already mapped in previous migration run.
|
// Idempotency guard: skip consumers already mapped in previous migration run.
|
||||||
const migratedRows = await db
|
const migratedConsumerIds = new Set(
|
||||||
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
await this.migrationRepository.listMigratedConsumerIds(circuitListId)
|
||||||
.from(legacyConsumerCircuitMigrations)
|
);
|
||||||
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
|
|
||||||
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
|
// Legacy rows are grouped by normalized circuit number so duplicates become
|
||||||
@@ -149,6 +144,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
|
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const migrationCircuits: LegacyMigrationCircuitPersistenceInput[] = [];
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const representative = group.consumers[0];
|
const representative = group.consumers[0];
|
||||||
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
|
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
|
||||||
@@ -177,21 +173,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
|
|
||||||
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
|
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
|
||||||
|
|
||||||
const circuitId = await this.circuitRepository.create({
|
const deviceRows: LegacyMigrationCircuitPersistenceInput["deviceRows"] = [];
|
||||||
circuitListId,
|
const circuitSortOrder = nextSortOrder;
|
||||||
sectionId: section.id,
|
|
||||||
equipmentIdentifier,
|
|
||||||
displayName: representative.description ?? representative.name,
|
|
||||||
sortOrder: nextSortOrder,
|
|
||||||
protectionType: representative.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic: representative.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: representative.cableType ?? undefined,
|
|
||||||
cableCrossSection: representative.cableCrossSection ?? undefined,
|
|
||||||
voltage: representative.voltageV ?? undefined,
|
|
||||||
remark: undefined,
|
|
||||||
});
|
|
||||||
report.createdCircuitCount += 1;
|
|
||||||
nextSortOrder += 10;
|
nextSortOrder += 10;
|
||||||
|
|
||||||
let rowSortOrder = 10;
|
let rowSortOrder = 10;
|
||||||
@@ -203,8 +186,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote;
|
noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.circuitDeviceRowRepository.create({
|
deviceRows.push({
|
||||||
circuitId,
|
|
||||||
linkedProjectDeviceId: consumer.projectDeviceId ?? undefined,
|
linkedProjectDeviceId: consumer.projectDeviceId ?? undefined,
|
||||||
legacyConsumerId: consumer.id,
|
legacyConsumerId: consumer.id,
|
||||||
sortOrder: rowSortOrder,
|
sortOrder: rowSortOrder,
|
||||||
@@ -223,28 +205,32 @@ export class LegacyConsumerMigrationService {
|
|||||||
cosPhi: consumer.powerFactor ?? undefined,
|
cosPhi: consumer.powerFactor ?? undefined,
|
||||||
remark: noteRemark,
|
remark: noteRemark,
|
||||||
});
|
});
|
||||||
report.createdDeviceRowCount += 1;
|
|
||||||
rowSortOrder += 10;
|
rowSortOrder += 10;
|
||||||
|
|
||||||
const [createdRow] = await db
|
|
||||||
.select({ id: circuitDeviceRows.id })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(and(eq(circuitDeviceRows.circuitId, circuitId), eq(circuitDeviceRows.legacyConsumerId, consumer.id)))
|
|
||||||
.orderBy(circuitDeviceRows.sortOrder)
|
|
||||||
.limit(1);
|
|
||||||
if (!createdRow) {
|
|
||||||
throw new Error("Failed to resolve created circuit device row.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(legacyConsumerCircuitMigrations).values({
|
migrationCircuits.push({
|
||||||
consumerId: consumer.id,
|
circuit: {
|
||||||
circuitId,
|
|
||||||
circuitDeviceRowId: createdRow.id,
|
|
||||||
circuitListId,
|
circuitListId,
|
||||||
createdAtIso: new Date().toISOString(),
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier,
|
||||||
|
displayName: representative.description ?? representative.name,
|
||||||
|
sortOrder: circuitSortOrder,
|
||||||
|
protectionType: representative.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: representative.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: representative.cableType ?? undefined,
|
||||||
|
cableCrossSection: representative.cableCrossSection ?? undefined,
|
||||||
|
voltage: representative.voltageV ?? undefined,
|
||||||
|
remark: undefined,
|
||||||
|
},
|
||||||
|
deviceRows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
report.createdCircuitCount = migrationCircuits.length;
|
||||||
|
report.createdDeviceRowCount = migrationCircuits.reduce(
|
||||||
|
(count, entry) => count + entry.deviceRows.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) {
|
if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) {
|
||||||
report.warnings.push(
|
report.warnings.push(
|
||||||
@@ -256,31 +242,10 @@ export class LegacyConsumerMigrationService {
|
|||||||
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
|
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingReport = await db
|
this.migrationRepository.persistCircuitListMigration({
|
||||||
.select({ id: legacyConsumerMigrationReports.id })
|
|
||||||
.from(legacyConsumerMigrationReports)
|
|
||||||
.where(eq(legacyConsumerMigrationReports.circuitListId, circuitListId))
|
|
||||||
.limit(1);
|
|
||||||
if (existingReport.length) {
|
|
||||||
await db
|
|
||||||
.update(legacyConsumerMigrationReports)
|
|
||||||
.set({
|
|
||||||
legacyConsumerCount: report.legacyConsumerCount,
|
|
||||||
createdCircuitCount: report.createdCircuitCount,
|
|
||||||
createdDeviceRowCount: report.createdDeviceRowCount,
|
|
||||||
duplicateGroupedCount: report.groupedDuplicateCircuitNumbers.length,
|
|
||||||
generatedIdentifierCount: report.generatedIdentifiers.length,
|
|
||||||
unassignedRowCount: report.unassignedRows.length,
|
|
||||||
warningsJson: JSON.stringify(report.warnings),
|
|
||||||
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
|
|
||||||
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
|
|
||||||
createdAtIso: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id));
|
|
||||||
} else {
|
|
||||||
await db.insert(legacyConsumerMigrationReports).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
circuitListId,
|
circuitListId,
|
||||||
|
circuits: migrationCircuits,
|
||||||
|
report: {
|
||||||
legacyConsumerCount: report.legacyConsumerCount,
|
legacyConsumerCount: report.legacyConsumerCount,
|
||||||
createdCircuitCount: report.createdCircuitCount,
|
createdCircuitCount: report.createdCircuitCount,
|
||||||
createdDeviceRowCount: report.createdDeviceRowCount,
|
createdDeviceRowCount: report.createdDeviceRowCount,
|
||||||
@@ -290,9 +255,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
warningsJson: JSON.stringify(report.warnings),
|
warningsJson: JSON.stringify(report.warnings),
|
||||||
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
|
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
|
||||||
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
|
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
|
||||||
createdAtIso: new Date().toISOString(),
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
|
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
|
createCircuitWithDeviceRows,
|
||||||
createCircuitDeviceRow,
|
createCircuitDeviceRow,
|
||||||
deleteCircuitById,
|
deleteCircuitById,
|
||||||
deleteCircuitDeviceRowById,
|
deleteCircuitDeviceRowById,
|
||||||
@@ -1201,16 +1202,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const sortOrder =
|
const sortOrder =
|
||||||
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
const isDeviceField = deviceFieldKeys.has(key);
|
const isDeviceField = deviceFieldKeys.has(key);
|
||||||
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
|
||||||
|
if (isDeviceField) {
|
||||||
|
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
|
||||||
|
circuit: {
|
||||||
sectionId,
|
sectionId,
|
||||||
equipmentIdentifier: next.nextIdentifier,
|
equipmentIdentifier: next.nextIdentifier,
|
||||||
displayName: "Neuer Stromkreis",
|
displayName: "Neuer Stromkreis",
|
||||||
sortOrder,
|
sortOrder,
|
||||||
isReserve: !isDeviceField,
|
isReserve: false,
|
||||||
})) as CircuitTreeCircuitDto;
|
},
|
||||||
|
deviceRows: [
|
||||||
if (isDeviceField) {
|
{
|
||||||
const createdRow = (await createCircuitDeviceRow(createdCircuit.id, {
|
|
||||||
name: "Manuelles Gerät",
|
name: "Manuelles Gerät",
|
||||||
displayName: "Manuelles Gerät",
|
displayName: "Manuelles Gerät",
|
||||||
phaseType: "single_phase",
|
phaseType: "single_phase",
|
||||||
@@ -1218,11 +1221,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
powerPerUnit: 0,
|
powerPerUnit: 0,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
cosPhi: 1,
|
cosPhi: 1,
|
||||||
})) as { id: string };
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const createdCircuit = created.circuit;
|
||||||
|
const createdRow = created.deviceRows[0];
|
||||||
await patchDeviceRow(createdRow.id, key, draft);
|
await patchDeviceRow(createdRow.id, key, draft);
|
||||||
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
|
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
||||||
|
sectionId,
|
||||||
|
equipmentIdentifier: next.nextIdentifier,
|
||||||
|
displayName: "Neuer Stromkreis",
|
||||||
|
sortOrder,
|
||||||
|
isReserve: true,
|
||||||
|
})) as CircuitTreeCircuitDto;
|
||||||
await patchCircuit(createdCircuit.id, key, draft);
|
await patchCircuit(createdCircuit.id, key, draft);
|
||||||
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
|
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
|
||||||
}
|
}
|
||||||
@@ -1654,15 +1668,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const next = await getNextCircuitIdentifier(sectionId);
|
const next = await getNextCircuitIdentifier(sectionId);
|
||||||
const sortOrder =
|
const sortOrder =
|
||||||
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
|
||||||
|
circuit: {
|
||||||
sectionId,
|
sectionId,
|
||||||
equipmentIdentifier: next.nextIdentifier,
|
equipmentIdentifier: next.nextIdentifier,
|
||||||
displayName: device.displayName || device.name,
|
displayName: device.displayName || device.name,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
isReserve: false,
|
isReserve: false,
|
||||||
})) as CircuitTreeCircuitDto;
|
},
|
||||||
|
deviceRows: [
|
||||||
const createdRow = (await createCircuitDeviceRow(createdCircuit.id, {
|
{
|
||||||
linkedProjectDeviceId: device.id,
|
linkedProjectDeviceId: device.id,
|
||||||
name: device.name,
|
name: device.name,
|
||||||
displayName: device.displayName,
|
displayName: device.displayName,
|
||||||
@@ -1675,7 +1690,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
cosPhi: device.cosPhi ?? undefined,
|
cosPhi: device.cosPhi ?? undefined,
|
||||||
category: device.category ?? undefined,
|
category: device.category ?? undefined,
|
||||||
remark: device.remark ?? undefined,
|
remark: device.remark ?? undefined,
|
||||||
})) as { id: string };
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const createdCircuit = created.circuit;
|
||||||
|
const createdRow = created.deviceRows[0];
|
||||||
|
|
||||||
setActiveSectionId(sectionId);
|
setActiveSectionId(sectionId);
|
||||||
setTargetCircuitId(createdCircuit.id);
|
setTargetCircuitId(createdCircuit.id);
|
||||||
@@ -1732,7 +1751,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function recreateCircuit(snapshot: CircuitSnapshot) {
|
async function recreateCircuit(snapshot: CircuitSnapshot) {
|
||||||
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
const circuitInput = {
|
||||||
sectionId: snapshot.sectionId,
|
sectionId: snapshot.sectionId,
|
||||||
equipmentIdentifier: snapshot.equipmentIdentifier,
|
equipmentIdentifier: snapshot.equipmentIdentifier,
|
||||||
displayName: snapshot.displayName,
|
displayName: snapshot.displayName,
|
||||||
@@ -1750,11 +1769,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
isReserve: snapshot.isReserve,
|
isReserve: snapshot.isReserve,
|
||||||
remark: snapshot.remark,
|
remark: snapshot.remark,
|
||||||
})) as CircuitTreeCircuitDto;
|
};
|
||||||
|
|
||||||
const createdRowIds: string[] = [];
|
if (snapshot.deviceRows.length === 0) {
|
||||||
for (const row of snapshot.deviceRows) {
|
const createdCircuit = (await createCircuit(
|
||||||
const created = (await createCircuitDeviceRow(createdCircuit.id, {
|
projectId,
|
||||||
|
circuitListId,
|
||||||
|
circuitInput
|
||||||
|
)) as CircuitTreeCircuitDto;
|
||||||
|
return { circuitId: createdCircuit.id, rowIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
|
||||||
|
circuit: {
|
||||||
|
...circuitInput,
|
||||||
|
isReserve: false,
|
||||||
|
},
|
||||||
|
deviceRows: snapshot.deviceRows.map((row) => ({
|
||||||
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
@@ -1773,10 +1804,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
overriddenFields: row.overriddenFields,
|
overriddenFields: row.overriddenFields,
|
||||||
sortOrder: row.sortOrder,
|
sortOrder: row.sortOrder,
|
||||||
})) as { id: string };
|
})),
|
||||||
createdRowIds.push(created.id);
|
});
|
||||||
}
|
return {
|
||||||
return { circuitId: createdCircuit.id, rowIds: createdRowIds };
|
circuitId: created.circuit.id,
|
||||||
|
rowIds: created.deviceRows.map((row) => row.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
|
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type {
|
|||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
UpdateConsumerInput,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
CreateCircuitInputDto,
|
CreateCircuitInputDto,
|
||||||
UpdateCircuitInputDto,
|
UpdateCircuitInputDto,
|
||||||
CreateCircuitDeviceRowInputDto,
|
CreateCircuitDeviceRowInputDto,
|
||||||
@@ -98,6 +100,26 @@ export function createCircuit(projectId: string, circuitListId: string, input: C
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCircuitWithDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
circuitListId: string,
|
||||||
|
input: {
|
||||||
|
circuit: CreateCircuitInputDto;
|
||||||
|
deviceRows: CreateCircuitDeviceRowInputDto[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<{
|
||||||
|
circuit: CircuitTreeCircuitDto;
|
||||||
|
deviceRows: CircuitTreeDeviceRowDto[];
|
||||||
|
}>(
|
||||||
|
`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits-with-device-rows`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
||||||
return request(`/api/circuits/${circuitId}`, {
|
return request(`/api/circuits/${circuitId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function filterAndSortCircuitSections(
|
|||||||
|
|
||||||
return { ...section, circuits };
|
return { ...section, circuits };
|
||||||
})
|
})
|
||||||
.filter((section) => section.circuits.length > 0);
|
.filter((section) => activeFilters.length === 0 || section.circuits.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeVisibleGridRow(
|
function makeVisibleGridRow(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
|
||||||
|
|
||||||
|
export const circuitWriteService = new CircuitWriteService({
|
||||||
|
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||||
import {
|
import {
|
||||||
createCircuitDeviceRowSchema,
|
createCircuitDeviceRowSchema,
|
||||||
moveCircuitDeviceRowsBulkSchema,
|
moveCircuitDeviceRowsBulkSchema,
|
||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
updateCircuitDeviceRowSchema,
|
updateCircuitDeviceRowSchema,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
const circuitWriteService = new CircuitWriteService();
|
|
||||||
|
|
||||||
export async function createCircuitDeviceRow(req: Request, res: Response) {
|
export async function createCircuitDeviceRow(req: Request, res: Response) {
|
||||||
const { circuitId } = req.params;
|
const { circuitId } = req.params;
|
||||||
if (typeof circuitId !== "string") {
|
if (typeof circuitId !== "string") {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||||
import {
|
import {
|
||||||
reorderSectionCircuitsSchema,
|
reorderSectionCircuitsSchema,
|
||||||
updateSectionEquipmentIdentifiersSchema,
|
updateSectionEquipmentIdentifiersSchema,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
const circuitWriteService = new CircuitWriteService();
|
|
||||||
|
|
||||||
export async function renumberCircuitSection(req: Request, res: Response) {
|
export async function renumberCircuitSection(req: Request, res: Response) {
|
||||||
const { sectionId } = req.params;
|
const { sectionId } = req.params;
|
||||||
if (typeof sectionId !== "string") {
|
if (typeof sectionId !== "string") {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||||
import { createCircuitSchema, updateCircuitSchema } from "../../shared/validation/circuit.schemas.js";
|
import {
|
||||||
|
createCircuitSchema,
|
||||||
const circuitWriteService = new CircuitWriteService();
|
createCircuitWithDeviceRowsSchema,
|
||||||
|
updateCircuitSchema,
|
||||||
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
export async function createCircuit(req: Request, res: Response) {
|
export async function createCircuit(req: Request, res: Response) {
|
||||||
const { projectId, circuitListId } = req.params;
|
const { projectId, circuitListId } = req.params;
|
||||||
@@ -23,6 +25,34 @@ export async function createCircuit(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCircuitWithDeviceRows(req: Request, res: Response) {
|
||||||
|
const { projectId, circuitListId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Ungültige Parameter." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createCircuitWithDeviceRowsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await circuitWriteService.createCircuitWithDeviceRows(
|
||||||
|
projectId,
|
||||||
|
circuitListId,
|
||||||
|
parsed.data
|
||||||
|
);
|
||||||
|
return res.status(201).json(created);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Stromkreis und Gerätezeilen konnten nicht erstellt werden.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCircuit(req: Request, res: Response) {
|
export async function updateCircuit(req: Request, res: Response) {
|
||||||
const { circuitId } = req.params;
|
const { circuitId } = req.params;
|
||||||
if (typeof circuitId !== "string") {
|
if (typeof circuitId !== "string") {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { db } from "../../db/client.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
|
|
||||||
const circuitListRepository = new CircuitListRepository();
|
const circuitListRepository = new CircuitListRepository();
|
||||||
const consumerRepository = new ConsumerRepository();
|
const consumerRepository = new ConsumerRepository();
|
||||||
const distributionBoardRepository = new DistributionBoardRepository();
|
const distributionBoardRepository = new DistributionBoardRepository(db);
|
||||||
const floorRepository = new FloorRepository();
|
const floorRepository = new FloorRepository();
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||||
const projectRepository = new ProjectRepository();
|
const projectRepository = new ProjectRepository();
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { db } from "../../db/client.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
||||||
|
|
||||||
const circuitListRepository = new CircuitListRepository();
|
const distributionBoardRepository = new DistributionBoardRepository(db);
|
||||||
const circuitSectionRepository = new CircuitSectionRepository();
|
|
||||||
const distributionBoardRepository = new DistributionBoardRepository();
|
|
||||||
|
|
||||||
export async function listDistributionBoardsByProject(req: Request, res: Response) {
|
export async function listDistributionBoardsByProject(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
@@ -29,12 +26,9 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const board = await distributionBoardRepository.create(projectId, parsed.data.name);
|
const board = distributionBoardRepository.createWithCircuitListAndDefaultSections(
|
||||||
const list = await circuitListRepository.createForDistributionBoard({
|
|
||||||
projectId,
|
projectId,
|
||||||
distributionBoardId: board.id,
|
parsed.data.name
|
||||||
name: `${board.name} Stromkreisliste`,
|
);
|
||||||
});
|
|
||||||
await circuitSectionRepository.createDefaults(list.id);
|
|
||||||
return res.status(201).json(board);
|
return res.status(201).json(board);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
|
createCircuitWithDeviceRows,
|
||||||
deleteCircuit,
|
deleteCircuit,
|
||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
updateCircuit,
|
updateCircuit,
|
||||||
@@ -10,6 +11,10 @@ import { createCircuitDeviceRow } from "../controllers/circuit-device-row.contro
|
|||||||
export const circuitRouter = Router();
|
export const circuitRouter = Router();
|
||||||
|
|
||||||
circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit);
|
circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit);
|
||||||
|
circuitRouter.post(
|
||||||
|
"/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows",
|
||||||
|
createCircuitWithDeviceRows
|
||||||
|
);
|
||||||
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
|
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
|
||||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
||||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export const createCircuitDeviceRowSchema = z.object({
|
|||||||
sortOrder: z.number().optional(),
|
sortOrder: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createCircuitWithDeviceRowsSchema = z.object({
|
||||||
|
circuit: createCircuitSchema,
|
||||||
|
deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
|
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
|
||||||
|
|
||||||
export const moveCircuitDeviceRowSchema = z
|
export const moveCircuitDeviceRowSchema = z
|
||||||
@@ -95,6 +100,7 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
|
|||||||
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
|
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
|
||||||
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
||||||
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
||||||
|
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
|
||||||
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 MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitDeviceRowTransactionRepository } from "../src/db/repositories/circuit-device-row-transaction.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
const [section] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Reserve",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDeviceRow(context: DatabaseContext) {
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: 0 })
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit device-row transaction repository", () => {
|
||||||
|
it("commits a new device row and clears the circuit reserve status together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
const rowId = repository.createInCircuit({
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [row] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, rowId))
|
||||||
|
.all();
|
||||||
|
const [circuit] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
assert.equal(row.circuitId, "circuit-1");
|
||||||
|
assert.equal(row.sortOrder, 10);
|
||||||
|
assert.equal(circuit.isReserve, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the row insert when clearing the reserve status fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_reserve_clear
|
||||||
|
BEFORE UPDATE OF is_reserve ON circuits
|
||||||
|
WHEN NEW.is_reserve = 0
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced reserve clear failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.createInCircuit({
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
name: "Steckdose",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
}),
|
||||||
|
/forced reserve clear failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits the last-row deletion and activates the circuit reserve status together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertDeviceRow(context);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
repository.deleteFromCircuit("row-1", "circuit-1");
|
||||||
|
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the row deletion when activating the reserve status fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertDeviceRow(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_reserve_activation
|
||||||
|
BEFORE UPDATE OF is_reserve ON circuits
|
||||||
|
WHEN NEW.is_reserve = 1
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced reserve activation failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.deleteFromCircuit("row-1", "circuit-1"),
|
||||||
|
/forced reserve activation failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 1);
|
||||||
|
assert.equal(context.db.select().from(circuits).all()[0].isReserve, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,6 +78,41 @@ const sections: CircuitTreeSectionDto[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("circuit grid projection", () => {
|
describe("circuit grid projection", () => {
|
||||||
|
it("keeps empty sections and their free placeholders when no filter is active", () => {
|
||||||
|
const emptySections: CircuitTreeSectionDto[] = [
|
||||||
|
{
|
||||||
|
id: "empty-lighting",
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Lighting",
|
||||||
|
prefix: "-1F",
|
||||||
|
sortOrder: 10,
|
||||||
|
circuits: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "empty-single-phase",
|
||||||
|
key: "single_phase",
|
||||||
|
displayName: "Single-phase circuits",
|
||||||
|
prefix: "-2F",
|
||||||
|
sortOrder: 20,
|
||||||
|
circuits: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
|
||||||
|
const rows = buildVisibleGridRows(projectedSections);
|
||||||
|
|
||||||
|
assert.equal(projectedSections.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.rowKey),
|
||||||
|
[
|
||||||
|
"section:empty-lighting",
|
||||||
|
"placeholder:empty-lighting",
|
||||||
|
"section:empty-single-phase",
|
||||||
|
"placeholder:empty-single-phase",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps every device row in a circuit block when one device matches a filter", () => {
|
it("keeps every device row in a circuit block when one device matches a filter", () => {
|
||||||
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
|
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("circuit write service rules", () => {
|
|||||||
async existsByEquipmentIdentifier() {
|
async existsByEquipmentIdentifier() {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
async update(_id: string, payload: Record<string, unknown>) {
|
async updateFields(_id: string, payload: Record<string, unknown>) {
|
||||||
updatePayload = payload;
|
updatePayload = payload;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -52,8 +52,10 @@ describe("circuit write service rules", () => {
|
|||||||
|
|
||||||
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
|
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
|
||||||
|
|
||||||
assert.equal(updatePayload.voltage, 400);
|
assert.deepEqual(updatePayload, {
|
||||||
assert.equal(updatePayload.controlRequirement, "DALI");
|
voltage: 400,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate equipment identifiers in same circuit list", async () => {
|
it("rejects duplicate equipment identifiers in same circuit list", async () => {
|
||||||
@@ -117,17 +119,16 @@ describe("circuit write service rules", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deleting last device row keeps circuit and sets reserve", async () => {
|
it("deleting last device row keeps circuit and sets reserve", async () => {
|
||||||
let reserveFlag = false;
|
let transactionalDelete: { rowId: string; circuitId: string } | undefined;
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
deviceRowRepository: {
|
deviceRowRepository: {
|
||||||
async findById() {
|
async findById() {
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
},
|
},
|
||||||
async delete() {
|
} as never,
|
||||||
return;
|
deviceRowTransactionStore: {
|
||||||
},
|
deleteFromCircuit(rowId: string, circuitId: string) {
|
||||||
async countByCircuit() {
|
transactionalDelete = { rowId, circuitId };
|
||||||
return 0;
|
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
circuitRepository: {
|
circuitRepository: {
|
||||||
@@ -141,18 +142,22 @@ describe("circuit write service rules", () => {
|
|||||||
isReserve: 0,
|
isReserve: 0,
|
||||||
} as never;
|
} as never;
|
||||||
},
|
},
|
||||||
async update(_id: string, payload: { isReserve: boolean }) {
|
|
||||||
reserveFlag = payload.isReserve;
|
|
||||||
},
|
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.deleteDeviceRow("r1");
|
await service.deleteDeviceRow("r1");
|
||||||
assert.equal(reserveFlag, true);
|
assert.deepEqual(transactionalDelete, { rowId: "r1", circuitId: "c1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creating device row in reserve circuit clears reserve status", async () => {
|
it("creating device row in reserve circuit clears reserve status", async () => {
|
||||||
let reserveFlag = true;
|
let transactionalCreate:
|
||||||
|
| {
|
||||||
|
circuitId: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
circuitRepository: {
|
circuitRepository: {
|
||||||
async findById() {
|
async findById() {
|
||||||
@@ -165,21 +170,18 @@ describe("circuit write service rules", () => {
|
|||||||
isReserve: 1,
|
isReserve: 1,
|
||||||
} as never;
|
} as never;
|
||||||
},
|
},
|
||||||
async update(_id: string, payload: { isReserve: boolean }) {
|
|
||||||
reserveFlag = payload.isReserve;
|
|
||||||
},
|
|
||||||
} as never,
|
} as never,
|
||||||
deviceRowRepository: {
|
deviceRowRepository: {
|
||||||
async countByCircuit() {
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
async create() {
|
|
||||||
return "row1";
|
|
||||||
},
|
|
||||||
async findById() {
|
async findById() {
|
||||||
return { id: "row1" } as never;
|
return { id: "row1" } as never;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
createInCircuit(input: typeof transactionalCreate) {
|
||||||
|
transactionalCreate = input;
|
||||||
|
return "row1";
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
circuitListRepository: {} as never,
|
circuitListRepository: {} as never,
|
||||||
circuitSectionRepository: {} as never,
|
circuitSectionRepository: {} as never,
|
||||||
projectDeviceRepository: {
|
projectDeviceRepository: {
|
||||||
@@ -196,7 +198,106 @@ describe("circuit write service rules", () => {
|
|||||||
powerPerUnit: 1,
|
powerPerUnit: 1,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
});
|
});
|
||||||
assert.equal(reserveFlag, false);
|
assert.deepEqual(transactionalCreate, {
|
||||||
|
circuitId: "c1",
|
||||||
|
linkedProjectDeviceId: undefined,
|
||||||
|
sortOrder: undefined,
|
||||||
|
name: "Load",
|
||||||
|
displayName: "Load",
|
||||||
|
phaseType: undefined,
|
||||||
|
connectionKind: undefined,
|
||||||
|
costGroup: undefined,
|
||||||
|
category: undefined,
|
||||||
|
level: undefined,
|
||||||
|
roomId: undefined,
|
||||||
|
roomNumberSnapshot: undefined,
|
||||||
|
roomNameSnapshot: undefined,
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: undefined,
|
||||||
|
remark: undefined,
|
||||||
|
overriddenFields: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a circuit and all first device rows through one repository command", async () => {
|
||||||
|
let transactionalCreate: {
|
||||||
|
circuit: { circuitListId: string; sectionId: string; equipmentIdentifier: string };
|
||||||
|
deviceRows: Array<{ linkedProjectDeviceId?: string; name: string }>;
|
||||||
|
} | undefined;
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
circuitListRepository: {
|
||||||
|
async findById() {
|
||||||
|
return { id: "l1", projectId: "p1" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitSectionRepository: {
|
||||||
|
async findById() {
|
||||||
|
return { id: "s1", circuitListId: "l1" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async existsByEquipmentIdentifier() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
async findById() {
|
||||||
|
return { id: "c-new", circuitListId: "l1", sectionId: "s1" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
projectDeviceRepository: {
|
||||||
|
async findById(projectId: string, deviceId: string) {
|
||||||
|
return projectId === "p1" && deviceId === "pd1" ? ({ id: "pd1" } as never) : null;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
deviceRowRepository: {
|
||||||
|
createCircuitWithDeviceRowsTransactional(input: typeof transactionalCreate) {
|
||||||
|
transactionalCreate = input;
|
||||||
|
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
|
||||||
|
},
|
||||||
|
async findById(rowId: string) {
|
||||||
|
return { id: rowId, circuitId: "c-new" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await service.createCircuitWithDeviceRows("p1", "l1", {
|
||||||
|
circuit: {
|
||||||
|
sectionId: "s1",
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
displayName: "Steckdosen",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
linkedProjectDeviceId: "pd1",
|
||||||
|
name: "Socket",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Manual",
|
||||||
|
displayName: "Manuell",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(transactionalCreate?.circuit.circuitListId, "l1");
|
||||||
|
assert.equal(transactionalCreate?.circuit.sectionId, "s1");
|
||||||
|
assert.equal(transactionalCreate?.circuit.equipmentIdentifier, "-2F1");
|
||||||
|
assert.deepEqual(
|
||||||
|
transactionalCreate?.deviceRows.map((row) => row.name),
|
||||||
|
["Socket", "Manual"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
created.deviceRows.map((row) => row?.id),
|
||||||
|
["r1", "r2"]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
|
it("renumber uses safe bulk identifier update for swapped identifiers", async () => {
|
||||||
@@ -564,7 +665,7 @@ describe("circuit write service rules", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 }> = [];
|
let safeReorder: { sectionId: string; circuitIds: string[] } | undefined;
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
circuitSectionRepository: {
|
circuitSectionRepository: {
|
||||||
async findById() {
|
async findById() {
|
||||||
@@ -579,18 +680,17 @@ describe("circuit write service rules", () => {
|
|||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
||||||
] as never[];
|
] as never[];
|
||||||
},
|
},
|
||||||
async update(id: string, payload: { sortOrder: number; equipmentIdentifier: string }) {
|
updateSortOrdersSafely(sectionId: string, circuitIds: string[]) {
|
||||||
updates.push({ id, sortOrder: payload.sortOrder, equipmentIdentifier: payload.equipmentIdentifier });
|
safeReorder = { sectionId, circuitIds };
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] });
|
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] });
|
||||||
assert.deepEqual(updates, [
|
assert.deepEqual(safeReorder, {
|
||||||
{ id: "c3", sortOrder: 10, equipmentIdentifier: "-2F5" },
|
sectionId: "s1",
|
||||||
{ id: "c1", sortOrder: 20, equipmentIdentifier: "-2F7" },
|
circuitIds: ["c3", "c1", "c2"],
|
||||||
{ id: "c2", sortOrder: 30, equipmentIdentifier: "-2F9" },
|
});
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safe section identifier bulk update validates full section set (undo safety)", async () => {
|
it("safe section identifier bulk update validates full section set (undo safety)", async () => {
|
||||||
@@ -682,6 +782,61 @@ describe("circuit write service rules", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("safe circuit reorder uses one synchronous transaction callback", () => {
|
||||||
|
const repository = new CircuitRepository();
|
||||||
|
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
||||||
|
|
||||||
|
let callbackReturnedPromise = false;
|
||||||
|
const sortOrders: number[] = [];
|
||||||
|
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
||||||
|
const fakeTx = {
|
||||||
|
select() {
|
||||||
|
return {
|
||||||
|
from() {
|
||||||
|
return {
|
||||||
|
where() {
|
||||||
|
return {
|
||||||
|
all() {
|
||||||
|
return [{ id: "c1" }, { id: "c2" }, { id: "c3" }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
update() {
|
||||||
|
return {
|
||||||
|
set(values: { sortOrder: number }) {
|
||||||
|
sortOrders.push(values.sortOrder);
|
||||||
|
return {
|
||||||
|
where() {
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const callbackResult = cb(fakeTx);
|
||||||
|
callbackReturnedPromise = Boolean(
|
||||||
|
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.updateSortOrdersSafely("s1", ["c3", "c1", "c2"]);
|
||||||
|
assert.equal(callbackReturnedPromise, false);
|
||||||
|
assert.deepEqual(sortOrders, [10, 20, 30]);
|
||||||
|
} finally {
|
||||||
|
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("bulk device row move uses one synchronous transaction callback", () => {
|
it("bulk device row move uses one synchronous transaction callback", () => {
|
||||||
const repository = new CircuitDeviceRowRepository();
|
const repository = new CircuitDeviceRowRepository();
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
||||||
@@ -770,8 +925,78 @@ describe("circuit write service rules", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("new circuit and initial device rows share one synchronous transaction", () => {
|
||||||
|
const repository = new CircuitDeviceRowRepository();
|
||||||
|
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
||||||
|
|
||||||
|
let callbackReturnedPromise = false;
|
||||||
|
const insertedValues: Array<Record<string, unknown>> = [];
|
||||||
|
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
||||||
|
const fakeTx = {
|
||||||
|
insert() {
|
||||||
|
return {
|
||||||
|
values(values: Record<string, unknown>) {
|
||||||
|
insertedValues.push(values);
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const callbackResult = cb(fakeTx);
|
||||||
|
callbackReturnedPromise = Boolean(
|
||||||
|
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = repository.createCircuitWithDeviceRowsTransactional({
|
||||||
|
circuit: {
|
||||||
|
circuitListId: "l1",
|
||||||
|
sectionId: "s1",
|
||||||
|
equipmentIdentifier: "-2F1",
|
||||||
|
displayName: "Steckdosen",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
name: "Socket",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
sortOrder: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Manual",
|
||||||
|
displayName: "Manuell",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(callbackReturnedPromise, false);
|
||||||
|
assert.equal(insertedValues.length, 3);
|
||||||
|
assert.equal(insertedValues[0].id, created.circuitId);
|
||||||
|
assert.equal(insertedValues[0].isReserve, 0);
|
||||||
|
assert.equal(insertedValues[1].id, created.rowIds[0]);
|
||||||
|
assert.equal(insertedValues[1].circuitId, created.circuitId);
|
||||||
|
assert.equal(insertedValues[1].sortOrder, 15);
|
||||||
|
assert.equal(insertedValues[2].id, created.rowIds[1]);
|
||||||
|
assert.equal(insertedValues[2].circuitId, created.circuitId);
|
||||||
|
assert.equal(insertedValues[2].sortOrder, 20);
|
||||||
|
} finally {
|
||||||
|
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks local edits on linked device rows as overridden fields", async () => {
|
it("tracks local edits on linked device rows as overridden fields", async () => {
|
||||||
let savedOverrides: string | undefined;
|
let updatePayload: Record<string, unknown> = {};
|
||||||
const current = {
|
const current = {
|
||||||
id: "r1",
|
id: "r1",
|
||||||
circuitId: "c1",
|
circuitId: "c1",
|
||||||
@@ -789,14 +1014,17 @@ describe("circuit write service rules", () => {
|
|||||||
async findById() {
|
async findById() {
|
||||||
return current as never;
|
return current as never;
|
||||||
},
|
},
|
||||||
async update(_rowId: string, input: { overriddenFields?: string }) {
|
async updateFields(_rowId: string, input: Record<string, unknown>) {
|
||||||
savedOverrides = input.overriddenFields;
|
updatePayload = input;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateDeviceRow("r1", { quantity: 2 });
|
await service.updateDeviceRow("r1", { quantity: 2 });
|
||||||
|
|
||||||
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
|
assert.deepEqual(updatePayload, {
|
||||||
|
quantity: 2,
|
||||||
|
overriddenFields: "[\"quantity\"]",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("distribution board repository integration", () => {
|
||||||
|
it("commits board, circuit list and default sections together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new DistributionBoardRepository(context.db);
|
||||||
|
const board = repository.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV-01"
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistedBoards = context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.id, board.id))
|
||||||
|
.all();
|
||||||
|
const persistedLists = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||||
|
.all();
|
||||||
|
const persistedSections = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
assert.deepEqual(persistedBoards, [board]);
|
||||||
|
assert.equal(persistedLists.length, 1);
|
||||||
|
assert.equal(persistedLists[0].name, "UV-01 Stromkreisliste");
|
||||||
|
assert.deepEqual(
|
||||||
|
persistedSections
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
|
.map((section) => [section.key, section.prefix]),
|
||||||
|
[
|
||||||
|
["lighting", "-1F"],
|
||||||
|
["single_phase", "-2F"],
|
||||||
|
["three_phase", "-3F"],
|
||||||
|
["unassigned", "-UF"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back board and circuit list when default section creation fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_default_section_insert
|
||||||
|
BEFORE INSERT ON circuit_sections
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced section failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new DistributionBoardRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.createWithCircuitListAndDefaultSections(
|
||||||
|
"project-1",
|
||||||
|
"UV rollback"
|
||||||
|
),
|
||||||
|
/forced section failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(context.db.select().from(distributionBoards).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(circuitLists).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(circuitSections).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { db } from "../src/db/client.js";
|
||||||
|
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
||||||
|
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
||||||
|
|
||||||
|
describe("legacy consumer migration repository", () => {
|
||||||
|
it("writes circuits, rows, mappings and report in one synchronous transaction", () => {
|
||||||
|
const originalTransaction = db.transaction;
|
||||||
|
const inserts: Array<{ table: unknown; values: unknown }> = [];
|
||||||
|
let transactionCalls = 0;
|
||||||
|
|
||||||
|
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => unknown }).transaction =
|
||||||
|
(callback) => {
|
||||||
|
transactionCalls += 1;
|
||||||
|
const emptySelectChain = {
|
||||||
|
from() {
|
||||||
|
return emptySelectChain;
|
||||||
|
},
|
||||||
|
where() {
|
||||||
|
return emptySelectChain;
|
||||||
|
},
|
||||||
|
limit() {
|
||||||
|
return emptySelectChain;
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return callback({
|
||||||
|
select() {
|
||||||
|
return emptySelectChain;
|
||||||
|
},
|
||||||
|
insert(table: unknown) {
|
||||||
|
return {
|
||||||
|
values(values: unknown) {
|
||||||
|
inserts.push({ table, values });
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repository = new LegacyConsumerMigrationRepository();
|
||||||
|
repository.persistCircuitListMigration({
|
||||||
|
circuitListId: "list-1",
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
circuit: {
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Legacy lighting",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
legacyConsumerId: "consumer-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Light 1",
|
||||||
|
displayName: "Light 1",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyConsumerId: "consumer-2",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Light 2",
|
||||||
|
displayName: "Light 2",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.2,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
report: {
|
||||||
|
legacyConsumerCount: 2,
|
||||||
|
createdCircuitCount: 1,
|
||||||
|
createdDeviceRowCount: 2,
|
||||||
|
duplicateGroupedCount: 1,
|
||||||
|
generatedIdentifierCount: 0,
|
||||||
|
unassignedRowCount: 0,
|
||||||
|
warningsJson: "[]",
|
||||||
|
generatedIdentifiersJson: "[]",
|
||||||
|
duplicateGroupsJson: "[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(transactionCalls, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
inserts.map((entry) => entry.table),
|
||||||
|
[
|
||||||
|
circuits,
|
||||||
|
circuitDeviceRows,
|
||||||
|
legacyConsumerCircuitMigrations,
|
||||||
|
circuitDeviceRows,
|
||||||
|
legacyConsumerCircuitMigrations,
|
||||||
|
legacyConsumerMigrationReports,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const circuit = inserts[0].values as { id: string };
|
||||||
|
const firstRow = inserts[1].values as { id: string; circuitId: string; legacyConsumerId: string };
|
||||||
|
const firstMapping = inserts[2].values as {
|
||||||
|
consumerId: string;
|
||||||
|
circuitId: string;
|
||||||
|
circuitDeviceRowId: string;
|
||||||
|
circuitListId: string;
|
||||||
|
};
|
||||||
|
assert.equal(firstRow.circuitId, circuit.id);
|
||||||
|
assert.equal(firstRow.legacyConsumerId, "consumer-1");
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
consumerId: firstMapping.consumerId,
|
||||||
|
circuitId: firstMapping.circuitId,
|
||||||
|
circuitDeviceRowId: firstMapping.circuitDeviceRowId,
|
||||||
|
circuitListId: firstMapping.circuitListId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
consumerId: "consumer-1",
|
||||||
|
circuitId: circuit.id,
|
||||||
|
circuitDeviceRowId: firstRow.id,
|
||||||
|
circuitListId: "list-1",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user