95 Commits

Author SHA1 Message Date
jappel 74bea1a3c9 Add Revit circuit list object feed 2026-08-02 18:59:01 +02:00
jappel a69b7b603f Add external object new circuit command 2026-08-02 18:55:57 +02:00
jappel 347b3717c3 Add external object new row command 2026-08-02 18:44:46 +02:00
jappel 1a7dd7fe03 Add external object row assignment command 2026-08-02 18:38:38 +02:00
jappel dac19b093c Add external row quantity foundation 2026-08-02 18:26:22 +02:00
jappel bc2bc7ece5 Add Revit catalog creation flow 2026-08-02 18:08:18 +02:00
jappel ab2449419b Add Revit initial import wizard 2026-08-02 17:52:21 +02:00
jappel 8646f346e6 Add Revit initial import apply API 2026-08-02 17:42:29 +02:00
jappel c9e5fdd876 Add Revit initial import planning 2026-08-02 17:38:17 +02:00
jappel c634570018 Add atomic Revit initial import 2026-08-02 17:33:32 +02:00
jappel e87a3289fa Include external models in project snapshots 2026-08-02 17:25:25 +02:00
jappel 76493b573e Add external model persistence foundation 2026-08-02 17:18:24 +02:00
jappel d0fc1a7caf Widen Revit CSV dialog 2026-08-02 17:10:22 +02:00
jappel a9d6301753 Add Revit CSV project dialog 2026-08-02 16:59:18 +02:00
jappel caa6caf99f Add Revit CSV preview API 2026-08-02 16:49:42 +02:00
jappel 219da5ec3b Persist Revit CSV configuration 2026-08-02 16:33:18 +02:00
jappel da689af518 Verify Revit reference CSV 2026-08-02 16:01:05 +02:00
jappel bfe8b790b2 Add Revit CSV transport 2026-08-02 15:56:02 +02:00
jappel 43cee890e7 Define Revit CSV contracts 2026-08-02 15:51:52 +02:00
jappel 927b868dcb Plan Revit CSV integration 2026-08-02 15:47:12 +02:00
jappel c96ba1cbaf Added revit specs 2026-08-02 15:38:02 +02:00
jappel 44b6fde940 Add public building project setting 2026-07-31 17:41:30 +02:00
jappel 43a3bb0b69 Align documentation with clean baseline 2026-07-31 16:41:37 +02:00
jappel facf788ce8 Remove circuit structure preview 2026-07-31 14:52:37 +02:00
jappel 1eed19ef6b Add editable project floors and rooms 2026-07-31 14:46:06 +02:00
jappel 1e3c721cfa Derive project device phases from category 2026-07-31 14:37:45 +02:00
jappel 5fb81bab04 Support decimal commas and cell units 2026-07-31 14:33:47 +02:00
jappel 734a2bcfc4 Create clean release database baseline 2026-07-31 14:07:26 +02:00
jappel 5bee3cb103 Add distribution board copy and delete controls 2026-07-31 12:03:52 +02:00
jappel 86f1d42e60 Expose distribution board copy and delete API 2026-07-31 11:59:31 +02:00
jappel 0fd564a930 Persist complete distribution board subtrees 2026-07-31 11:57:10 +02:00
jappel 13693f64b1 Model complete distribution board subtrees 2026-07-31 11:51:46 +02:00
jappel bd8dd09f79 Open circuit protection from grid cell 2026-07-31 10:02:11 +02:00
jappel 54874f9fcb Render circuit protection values 2026-07-31 09:56:49 +02:00
jappel 2964205a27 Show circuit protection in saved layouts 2026-07-31 09:45:58 +02:00
jappel f4bfef9071 Merge branch 'main' into feature/multiplesections 2026-07-31 09:00:39 +02:00
jappel 4a859f31b8 Complete protected circuit group phase 2026-07-31 08:59:12 +02:00
jappel 2d97fafce9 Restore circuit drag placement 2026-07-31 08:33:41 +02:00
jappel 7c8d4a7ddf Document protected circuit groups 2026-07-31 08:06:10 +02:00
jappel ab989cc637 Delete populated circuit groups safely 2026-07-31 08:01:55 +02:00
jappel 93ec294759 Move circuits between groups 2026-07-31 07:58:42 +02:00
jappel be9c6f0d52 Renumber circuit groups explicitly 2026-07-31 07:53:07 +02:00
jappel 0cb280ddb2 Reorder circuit groups 2026-07-31 07:47:45 +02:00
jappel 8898117ed4 Configure circuit protection 2026-07-31 07:45:48 +02:00
jappel 18ca5eb3d4 Persist circuit protection updates 2026-07-31 07:37:38 +02:00
jappel 0c92fbd09e Manage circuit groups 2026-07-31 07:32:46 +02:00
jappel 756e307bd8 Manage distribution board components 2026-07-31 07:29:25 +02:00
jappel 31589875b5 Render protected circuit structure 2026-07-31 07:22:26 +02:00
jappel ac21d6eb5d WIP multiple sections 2026-07-30 20:27:17 +02:00
jappel 7a9530a914 Expose protected tree structure 2026-07-30 20:14:45 +02:00
jappel 99bdf6491b Persist populated group deletion 2026-07-30 20:10:10 +02:00
jappel db4c757018 Define circuit group subtrees 2026-07-30 20:06:09 +02:00
jappel 3399fcd88b Persist circuit group moves 2026-07-30 20:03:11 +02:00
jappel 9ad91887b2 Plan circuit group moves 2026-07-30 19:58:45 +02:00
jappel 07925b2fd9 Persist circuit group renumbering 2026-07-30 19:56:59 +02:00
jappel 63650a623c Plan circuit group renumbering 2026-07-30 19:51:44 +02:00
jappel e702712bd8 Add circuit group reordering 2026-07-30 19:49:27 +02:00
jappel 1250f9a6af Add circuit group commands 2026-07-30 19:46:55 +02:00
jappel 69b020339e Add component update commands 2026-07-30 19:42:09 +02:00
jappel 604604f056 Add component structure commands 2026-07-30 19:38:48 +02:00
jappel 9d4d4082fe Create protected board defaults 2026-07-30 19:30:53 +02:00
jappel 012308984d Include components in project snapshots 2026-07-30 19:24:28 +02:00
jappel e7607c90c4 Add component persistence foundation 2026-07-30 19:15:00 +02:00
jappel a88c4b2086 Add protection domain foundation 2026-07-30 19:03:36 +02:00
jappel a72d2c76e0 Specify protected circuit groups 2026-07-30 18:52:26 +02:00
Grovy311 704f4df2a8 Apply Petrol/Volt/Kupfer design system and project sidebar
Bring the Econsult-family design tokens (see design-system-econsult.md)
into leistungsbilanz-ts and add a persistent sidebar for the projects
area, mirroring elt-planung-suite / Einsatz-Orga. The sidebar lists all
projects and, inside a project, jumps to its sections; the tree editor
stays sidebar-free since it needs the full width. The projects overview
page is decluttered to a single stacked column (create/import/global
devices) now that the project list lives in the sidebar.
2026-07-30 17:40:11 +02:00
jappel cfd4778305 Add Revit requirements handoff 2026-07-29 19:26:39 +02:00
jappel fac6c9350f Document first release baseline 2026-07-29 19:23:07 +02:00
jappel 9ea081179d Add distribution power summaries 2026-07-29 19:02:18 +02:00
jappel dcb303284c Describe snapshot source revisions 2026-07-29 11:52:26 +02:00
jappel 602ae6da0b Compact circuit grid columns 2026-07-29 11:12:24 +02:00
jappel 1b2ca84258 Preserve focus inside grid editors 2026-07-29 11:02:30 +02:00
jappel 096ba8aa1c Standardize phase type labels 2026-07-29 10:46:00 +02:00
jappel 084103bf54 Derive device voltages from project settings 2026-07-29 09:53:58 +02:00
jappel b1a11397b3 Add configurable distribution supply types 2026-07-29 09:31:20 +02:00
jappel 194bc9c0b1 Add project overview import 2026-07-29 08:36:57 +02:00
jappel 0180f768ac Improve editor totals and formatting 2026-07-29 08:26:03 +02:00
jappel a04c2c04d4 Document current product backlog 2026-07-28 22:28:05 +02:00
jappel edf245a21c Use full-width editor device drawer 2026-07-28 20:43:08 +02:00
jappel feec814dc5 Add project transfer controls 2026-07-28 19:36:26 +02:00
jappel da8115fe1d Add portable project transfer API 2026-07-28 18:34:57 +02:00
jappel 5fa2a701dc Streamline project management UI 2026-07-28 18:30:44 +02:00
jappel d77cfbeb49 Add versioned project settings modal 2026-07-28 18:16:22 +02:00
jappel e1fc81a083 Complete persistence foundation 2026-07-26 12:33:13 +02:00
jappel d7b98de56a Inject application database contexts 2026-07-26 12:30:42 +02:00
jappel 859fa4a42a Decouple legacy migration service 2026-07-26 12:11:05 +02:00
jappel c1be85e408 Decouple runtime domain services 2026-07-26 12:04:50 +02:00
jappel 0cedeaa959 Remove unused revision repository 2026-07-26 11:40:04 +02:00
jappel 4789e01aac Centralize snapshot restore transactions 2026-07-26 11:35:45 +02:00
jappel 214ad728cd Centralize device row update transactions 2026-07-26 11:28:06 +02:00
jappel e4d22caf09 Centralize update command transactions 2026-07-26 11:24:19 +02:00
jappel 0f306d9a90 Reuse command transaction boundary 2026-07-26 11:21:01 +02:00
jappel 266bdb4749 Centralize structural command transactions 2026-07-26 11:14:24 +02:00
jappel d23e7d990c Remove obsolete repository writes 2026-07-26 11:05:26 +02:00
jappel ac465a1cc0 Persist project locations 2026-07-26 10:56:29 +02:00
316 changed files with 43888 additions and 8186 deletions
+226 -12
View File
@@ -21,11 +21,24 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
`src/frontend/components/circuit-grid-*.ts` modules.
- Critical multi-write commands use injected transaction repositories with real
SQLite commit/rollback tests.
- All supported runtime project-command stores, including full snapshot
restoration, share
`src/db/repositories/project-command-transaction.persistence.ts` for the
atomic domain-write, revision and history transition boundary. Its applied
forward-command variant preserves derived CircuitDeviceRow override metadata.
- Low-level `appendProjectRevision` persistence is adapter-internal and tested
directly; do not reintroduce a standalone runtime revision repository.
- Runtime domain services receive narrow reader/store dependencies explicitly;
concrete SQLite repositories are instantiated only under `src/server/composition`.
- General application repositories also require an explicit `AppDatabase`;
`src/server/composition/application-repositories.ts` owns their runtime
instances, and controllers never import the global SQLite client.
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`.
The supported runtime model is Circuit-First. The former Consumer UI and API are
removed. Retained `consumers` rows, migration mappings and reports are upgrade-only
data accessed by `npm run db:migrate:legacy-consumers`; do not build application
features on them.
The supported runtime model is Circuit-First. The former Consumer UI, API,
tables and upgrade tooling are removed; do not reintroduce them.
See `docs/current-architecture.md` for the complete module and request flow.
@@ -232,11 +245,97 @@ Immutable revision metadata is available through the paginated
stored command payloads.
Named logical snapshots can be created and listed through project-scoped API
endpoints. Their schema-versioned payload contains the complete supported
project runtime state and a SHA-256, excludes global/upgrade-only data and does
project runtime state and a SHA-256, excludes global data outside the project and does
not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 5 additionally stores the manual quantity share
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3
snapshots are upgraded with an empty external-model state; version 2 snapshots
are additionally upgraded with an empty external CSV configuration. Supported
baseline version 1 snapshots are additionally upgraded
with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All
supported versions contain circuit-group identity, distribution-board components and the
separate circuit/component protection records. Portable duplicate imports
remap component ids and protection-owner references together with the existing
project graph.
Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
project duplication preserve the configuration; duplication remaps its internal
id and project link.
The project page exposes the Revit CSV modal. It edits the complete
transport/column/family-rule configuration through the revision-safe endpoint,
requests a stateless preview and then shows the Phase 14.2 initial-import
decisions. Preview responses include all mapped objects, while the UI renders
only the first 25. No preview or plan creates external objects, rows, revisions
or server-side drafts.
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration
version introduced by additive migration `0004`; it is distinct from the CSV
configuration JSON schema version.
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
existing rows from `quantity` and keeps `quantity` as the materialized total.
Normal manual row inserts and quantity edits update both values together.
`external-object.update-row-assignment` atomically assigns external objects to
existing rows, moves them between existing rows or detaches them. It preserves
the manual quantity, validates the materialized total against every target
link, rejects project/board/category/room/selection-marker mixing and requires
explicitly listed confirmations for other planning-value conflicts. Its exact
inverse supports persistent Undo/Redo, and detach never deletes the row.
`external-object.assign-to-new-row` atomically inserts unassigned external
objects as one external-only row (`manualQuantity = 0`) into an existing
circuit. Its history-only inverse removes the row only while its complete
snapshot and exact object-link set remain unchanged. Ordinary detach continues
to preserve the row.
`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit
with stable BMK/UUIDs, derived voltage, one external-only row, required default
protection and its external object links. Its history-only inverse requires the
complete unchanged circuit snapshot and exact link set. Redo never recalculates
the BMK. All three external assignment stores share
`external-object-assignment.persistence.ts` for compatibility and link safety.
The read-only
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
projection resolves the board through the owned CircuitList and returns only
that board's compact external objects, unassigned first. It exposes no original
CSV bytes or command payloads and returns an empty list before optional import.
Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
unchanged external state, and Redo restores the same UUIDs and bytes.
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless
read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
domain row. The wide modal renders these groups and catalogs as explicit
decisions and identifies exact, ambiguous and new room matches. Missing rooms
and ProjectDevices are created through the reused project forms with mapped
Revit values as visible defaults. Each catalog creation remains its own project
command; the wizard adds and selects the result without discarding its plan.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
version and project revision. It requires one explicit decision per source-room
and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The UI
blocks unclassified families, requires a final confirmation and applies the
whole import as one persistent Undo/Redo step.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
@@ -265,13 +364,69 @@ that have remained unchanged. ProjectDevice create, update, delete and
global-to-project copy API/UI paths use these persistent commands and track the
returned project revision. ProjectDevice synchronization/disconnect API and UI
paths do the same; their undo action uses the project-wide history endpoint.
Project voltage settings use the persistent `project.update-settings` command.
Both voltage defaults change in one revision and Undo/Redo restores them
together; the project PUT route requires `expectedRevision`.
Distribution-board setup uses `distribution-board.insert` with a complete
stable snapshot of the board, circuit list and four default sections. Its
Project settings use the persistent `project.update-settings` command. Project
metadata, the public-building classification, both voltage defaults and the
enabled distribution-board supply types change in one revision and Undo/Redo
restores them together; the project PUT route requires `expectedRevision`.
The public-building flag is the future sizing input for requiring halogen-free
cables and lines. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
cannot be disabled.
Distribution-board setup uses `distribution-board.insert` schema version 1
with a complete stable snapshot of the board, circuit list, three default
groups, main switch `-Q0` and surge protective device `-FA`. Its
inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Complete populated distribution-board copying and deletion use
`distribution-board.insert-subtree` and `distribution-board.delete-subtree`.
Their exact snapshots include the board, circuit list, groups, circuits,
device rows, components and all one-to-one protection data. Copy remaps all
owning ids but preserves project-device and room links. Both operations are
atomic persistent commands with restart-safe Undo/Redo; deletion requires an
explicit UI warning.
Mutable group-protection and auxiliary distribution-board components use
`distribution-board-component.insert` and
`distribution-board-component.delete`; edits and footer reordering use
`distribution-board-component.update`. Their complete expected/target
snapshots include the optional one-to-one protection-device state and reject
stale data. Updates preserve id, ownership, role and placement. Fixed
main-switch and surge-protection header roles are excluded from these general
component commands.
Empty circuit-group creation, display-name updates and deletion use
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
Their snapshots keep category, positive group number and derived prefix
consistent. General deletion rejects groups containing circuits or group
components; populated deletion remains a separate confirmed command.
`circuit-group.reorder` requires a complete expected/target sort assignment for
every group in one circuit list. It changes only sort positions and never
renumbers groups, prefixes or circuits.
Explicit group-number changes use `circuit-group.renumber`. The command carries
the complete expected/target group, circuit-BMK and optional group-component
BMK plan, applies swaps through collision-safe temporary values and preserves
circuit suffixes. Undo/Redo uses the exact inverse plan.
Cross-group circuit moves use `circuit.move-group` and are limited to distinct
groups of the same category. The stored target BMK is highest target suffix
plus one at planning time and is never recalculated for Redo. Only circuit
group, BMK and sort order change; device rows and circuit protection retain the
stable circuit id.
Confirmed populated-group deletion uses
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
snapshot contains the group, optional component protections, complete circuits,
circuit protections and all device-row link/override metadata. Delete
re-captures and compares the subtree before cascading; restore preserves every
UUID in foreign-key-safe order.
The circuit-tree read model exposes fixed `headerComponents`, group-owned
`components`, `footerComponents`, optional group category/number and optional
one-to-one protection DTOs for circuits and group components. These are
the only supported circuit-protection source; no flat compatibility fields remain.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
Floor and room setup uses persistent insert, update and delete commands with
complete stable snapshots. Their inverses remove or restore only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by
device rows. All write routes require `expectedRevision` and return the updated
history state.
Required operations:
@@ -287,6 +442,60 @@ Required operations:
- edit equipment identifier
- synchronization changes
## Revit / CSV External Model Work
When explicitly working on Phase 14, use
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
truth together with the current architecture documents.
Critical rules:
- Revit integration is optional. Do not make imported external data mandatory
for projects, rooms, project devices, circuits or device rows.
- Parse imports into staging first. Never write CSV rows directly into circuits
or device rows.
- Keep external object identity separate from internal UUIDs and circuit BMKs.
`IfcGUID` identifies the external Revit object and must never replace an
internal UUID or `equipmentIdentifier`.
- One imported Revit object may exist without a `CircuitDeviceRow`.
Import, room assignment, distribution-board assignment, classification and
ProjectDevice linking do not create a row.
- Create or change a `CircuitDeviceRow` only after an explicit user action in
the circuit-list editor.
- Several external objects may link to one `CircuitDeviceRow`, while every
external object remains individually traceable for follow-up imports and
object-specific export.
- Multi-socket Revit objects are indivisible. Their effective quantity may be
greater than one, but one external object must never be split across circuits.
- Existing ProjectDevices are canonical project templates and must not be
overwritten from Revit values. When creating a new ProjectDevice, mapped
Revit values may prefill the creation form.
- Store imported source values separately from local planning values and local
overrides. Imports never silently overwrite local values.
- Repeated imports match deterministically by project/source and `IfcGUID`.
New, changed, missing and conflicting objects require preview and explicit
user decisions.
- The first implementation assumes one complete Revit model export per project.
Do not add visible multi-source, linked-model or partial-import management.
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
and unknown rows and columns for the return export.
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
Do not report Revit subtotal rows as missing-identity objects.
- Apply each confirmed import diff through a typed project command with
`expectedRevision`, complete inverse data and the shared project command
transaction boundary.
- External-model state must be added to logical project snapshots and portable
project transfer when it becomes part of the supported runtime.
- Keep CSV parsing/serialization in a transport adapter. Domain services must
not depend on CSV syntax, React, `better-sqlite3` or concrete repositories.
- Do not implement automatic circuit planning, Dynamo/API exchange,
PostgreSQL, background jobs or permissions as part of the initial CSV phase.
Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
domain objects and commands before adding migrations.
## Future Sizing
Do not implement full cable/protection sizing unless explicitly requested.
@@ -310,7 +519,10 @@ Users must be able to override sizing suggestions.
## Persistence and Migration Rules
- SQLite is the currently supported database.
- Never edit an already applied migration.
- Migration `0000` is the clean release baseline for empty databases.
- Pre-baseline development databases, snapshots and stored commands are not
supported.
- After the first release, never edit an already applied migration.
- Back up an existing database before applying a new migration.
- Inspect generated SQL; it must contain only the intended schema change.
- Keep database backups separate from logical project snapshots.
@@ -321,7 +533,8 @@ Users must be able to override sizing suggestions.
## Current Deferred Work
- Revit/CSV/IFCGUID round-trip
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing
- multi-user/PostgreSQL operation
- supported production deployment
@@ -341,6 +554,7 @@ For a normal code change run the relevant focused tests plus:
- `npm test`
- `npm run build:api`
- `npm run build:web`
- `npm run typecheck:scripts`
- `npx tsc --noEmit -p tsconfig.next.json`
Use a concise imperative commit message for each completed, verified work package.
+10 -6
View File
@@ -12,7 +12,11 @@ implementiert.
## Unterstützter Arbeitsablauf
- Projekte, Verteilungen, Etagen und Räume verwalten
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
3-phasige Stromkreise verwalten
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
Verteilergeräte in der Stromkreisliste abbilden
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
- leere, einzeilige und mehrzeilige Stromkreise abbilden
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
@@ -90,6 +94,7 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
@@ -103,11 +108,10 @@ npm run db:generate
```
`db:backup` verwendet die SQLite-Online-Backup-API und prüft das Ergebnis auf
Integrität und Fremdschlüsselverletzungen. Vor jeder Migration einer bestehenden
Datenbank ist ein Backup erforderlich.
`db:migrate:legacy-consumers` und `db:backfill:sections` sind ausschließlich
Upgrade-Werkzeuge für ältere Datenbanken. Neue Installationen benötigen sie nicht.
Integrität und Fremdschlüsselverletzungen. Migration `0000` ist die
Release-Baseline für eine leere Datenbank; nachfolgende nummerierte Migrationen
entwickeln diese Baseline additiv weiter. Datenbanken aus Entwicklungsständen
vor der Baseline werden nicht unterstützt.
## Dokumentation
+5 -3
View File
@@ -20,15 +20,16 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
- [Deployment und Betrieb](deployment.md)
- [Lokale Circuit-First-Datenbankmigration](local-db-circuit-first-migration.md)
- [Retained Legacy-Datenmigration](circuit-list-editor-migration.md)
ausschließlich für Upgrades alter Datenbanken
## Zukunftsarchitektur
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
## Fachliche Referenzspezifikation
@@ -41,6 +42,7 @@ Limitierungen und Roadmap.
- [Datenmodellkonzept](spec/03-data-model-concept.md)
- [UI- und Interaktionsanforderungen](spec/04-ui-interaction-requirements.md)
- [Verknüpfte Projektgeräte und Synchronisierung](spec/05-linked-devices-and-sync.md)
- [Verteilerkomponenten und Schutzgruppen](spec/09-distribution-board-components-and-protection-groups.md)
## Archiv
+183 -22
View File
@@ -59,15 +59,24 @@ this value for optimistic concurrency checks.
- clients cannot submit arbitrary `project.restore-state` payloads through
the generic command endpoint
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.reorder-sections`,
`circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` and
`project-device.sync-rows` as well as `project.update-settings`, all with
schema version `1`. Other command types
The public dispatcher supports the Circuit and CircuitDeviceRow commands
`circuit.update`, `circuit.insert`, `circuit.delete`,
`circuit-device-row.update`, `circuit-device-row.insert`,
`circuit-device-row.delete`, `circuit-device-row.move`,
`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`,
`circuit.reorder-sections` and `circuit.renumber-section`.
Protected-board structure uses
`distribution-board-component.insert|update|delete`,
`circuit-group.insert|update|delete|reorder|renumber`,
`circuit.move-group`, `circuit-group.delete-subtree` and
`circuit-protection.update`. A subtree restore is normally emitted only as the
persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`.
`project.update-settings` uses version `2` and still accepts supported baseline
version `1` commands without changing the newer building classification.
Pre-baseline formats remain unsupported. Unsupported types or schema versions
are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands
@@ -101,6 +110,24 @@ targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged.
`circuit-group.reorder` changes only complete group sort assignments.
`circuit-group.renumber` carries every affected group, child circuit and group
component with expected and target identifiers so swaps can be applied
collision-safely. `circuit.move-group` changes one complete circuit's group,
BMK and position and stores the deterministic target BMK. The target group must
have the same category.
`circuit-group.delete-subtree` carries the complete warned group state,
including both protection layers and every device-row link/override value. The
server captures the current subtree again inside the transaction and rejects a
stale or incomplete snapshot. Undo restores the same UUIDs through the stored
inverse command.
`circuit-protection.update` inserts or replaces the complete validated
one-to-one circuit protection state. Users cannot remove the required
protection row; removal is available only to a stored inverse when undoing its
initial creation.
`project-device.sync-rows` represents `synchronize`, `disconnect` and
`reconnect` operations. Every selected row carries complete expected and target
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
@@ -145,11 +172,18 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId`
- request:
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo/Redo restores both voltage values together
- persistent Undo/Redo restores metadata, the public-building flag, voltage
values and enabled distribution-board supply types together; project-device and circuit
voltages are recalculated from the restored project settings in the same
transaction
- project-device and circuit voltage are derived values and are not accepted
as editable frontend fields
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
a supply type currently used by a distribution board cannot be disabled
- unchanged values are rejected without creating a revision; a stale
revision returns `409 PROJECT_REVISION_CONFLICT`
@@ -181,19 +215,77 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
### Distribution Board Setup
- `POST /projects/:projectId/distribution-boards`
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
- body:
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
- `floorId` may be `null`; a non-null floor must belong to the project
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
be enabled in the project settings
- executes `distribution-board.insert` with stable ids for the distribution
board, its circuit list and all four default circuit sections
board, its circuit list, the three default circuit groups, main switch
`-Q0` and surge protective device `-FA`
- response:
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo removes only the unchanged and still-empty generated
structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored
together by persistent Undo/Redo
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs
while preserving project-device and room links
- response includes the new `distributionBoard`, its `circuitList`, the
revision and current history state
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
- body: `{ "expectedRevision": 15 }`
- removes the complete, exactly captured distribution-board subtree in one
transaction
- response includes `distributionBoardId`, the revision and current history
state
- Copy and deletion use `distribution-board.insert-subtree` and
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
removes the same complete subtree, including protection devices.
### Project Floors and Rooms
- `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/floors/:floorId`
- body: `{ "name": "1. OG", "expectedRevision": 14 }`
- executes an exact persistent floor update
- `DELETE /projects/:projectId/floors/:floorId`
- body: `{ "expectedRevision": 15 }`
- rejects floors with assigned rooms or distribution boards
- `POST /projects/:projectId/rooms`
- body:
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
- executes `project-room.insert` with a stable room id; `floorId` is optional
and must belong to the project when present
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/rooms/:roomId`
- body:
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
- updates number, name and optional floor as one persistent command
- `DELETE /projects/:projectId/rooms/:roomId`
- body: `{ "expectedRevision": 17 }`
- rejects rooms referenced by device rows
- Persistent Undo/Redo restores exact previous and target records without
silently clearing foreign keys.
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals.
- Purpose: returns fixed header components, grouped sections with optional
protection components, complete circuit/device-row blocks and auxiliary
footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included.
Response sketch:
@@ -201,16 +293,47 @@ Response sketch:
{
"circuitListId": "cl_1",
"currentRevision": 12,
"headerComponents": [
{
"id": "cmp_main",
"equipmentIdentifier": "-Q0",
"name": "Hauptschalter",
"role": "main_switch",
"placement": "header"
}
],
"sections": [
{
"id": "sec_1",
"key": "lighting",
"prefix": "-1F",
"prefix": "-1F1.",
"category": "lighting",
"groupNumber": 1,
"components": [
{
"id": "cmp_rcd",
"equipmentIdentifier": "-1Q1.0",
"name": "Gruppen-FI",
"role": "group_residual_current_protection",
"placement": "group",
"protectionDevice": {
"type": "FI",
"ratedCurrentA": 40,
"rcdType": "A",
"ratedResidualCurrentMa": 30
}
}
],
"circuits": [
{
"id": "cir_1",
"equipmentIdentifier": "-1F1",
"equipmentIdentifier": "-1F1.1",
"circuitTotalPower": 4.2,
"protectionDevice": {
"type": "LS",
"ratedCurrentA": 10,
"tripCharacteristic": "B"
},
"deviceRows": [
{
"id": "row_1",
@@ -221,6 +344,15 @@ Response sketch:
}
]
}
],
"footerComponents": [
{
"id": "cmp_actor",
"equipmentIdentifier": "-K1",
"name": "KNX Schaltaktor",
"role": "auxiliary",
"placement": "footer"
}
]
}
```
@@ -237,12 +369,6 @@ available only as the corresponding versioned commands through
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
CircuitDeviceRow DELETE routes.
## Removed Legacy Endpoints
The former `/consumers` read/write endpoints were removed after every retained
consumer had a verified Circuit-First migration mapping. Database upgrade tooling
reads retained legacy rows directly; application features must use the Circuit-First
endpoints above.
## Linked Project Device Review
@@ -263,3 +389,38 @@ inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
## Revit CSV Foundation
- `GET /projects/:projectId/external-csv/configuration`
- returns the complete versioned project configuration or `null`
- `PUT /projects/:projectId/external-csv/configuration`
- request: `expectedRevision` and the complete validated configuration
- executes `external-csv-configuration.update`
- returns configuration, revision and project history state
- `POST /projects/:projectId/external-csv/preview`
- request: file name and Base64-encoded CSV content up to 18 MB
- requires a stored project configuration
- returns transport metadata, SHA-256, row classifications, suspect row
numbers and mapped source values for every recognized IFC object
- does not persist a draft, mutate project state or create a revision
- `POST /projects/:projectId/external-csv/initial-import/plan`
- reparses the file and returns grouped room/family decisions plus current
room, distribution-board and ProjectDevice catalogs
- is stateless and rejects an already imported external source
- `POST /projects/:projectId/external-csv/initial-import/apply`
- requires the expected revision, configuration version, file hash and one
decision for every room and exact family/type group
- reparses the file, blocks unknown family/type values and executes
`external-import.apply-initial` as one undoable project revision
- creates no Circuit or CircuitDeviceRow
- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects`
- resolves the circuit list's distribution board server-side
- returns only external objects assigned to that board, with unassigned
objects first, their source room/marker values and current row-link status
- returns an empty list when the optional Revit import does not exist
- is read-only and exposes no stored command payloads or import bytes
Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
import uploads and parses the file again and verifies its hash.
+23 -17
View File
@@ -17,13 +17,15 @@ The pure grid modules have no React state and are covered by focused unit tests.
## Domain Model Overview
- `CircuitSection`
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
- Represents a protected circuit group in one of the categories lighting,
single-phase or three-phase.
- Owns category, positive group number, generated prefix, display name and
ordering.
- `Circuit`
- Core electrical unit in the list.
- Owns circuit-level identifiers and technical data:
- `equipmentIdentifier` (BMK)
- protection data
- one explicit one-to-one protection device
- cable data
- reserve state
- voltage and optional control requirement for future sizing
@@ -37,14 +39,16 @@ The pure grid modules have no React state and are covered by focused unit tests.
- `cosPhi`
- room snapshots
- category/cost group and related row attributes
- canonical `phaseType` values `single_phase` or `three_phase`; the
frontend always presents these as `1-phasig` or `3-phasig` and edits
them through a selection control
- `ProjectDevice`
- Reusable device template entity at project level.
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
- Retained legacy migration source
- Old `consumers` rows, mappings and reports are available only to explicit
database upgrade tooling.
- They are not application-domain entities and have no UI or API.
- `DistributionBoardComponent`
- Represents fixed header components, optional group protection and manually
named auxiliary footer devices with their own unique BMK.
- Group protection may own a separate one-to-one protection configuration.
## Why A Circuit Is Not One Row
A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks:
@@ -74,7 +78,9 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
- Device rows do not have their own BMK.
- Existing identifiers must stay stable unless explicitly changed by user action.
- Existing identifiers stay stable on sort, insert and delete. Explicit
renumbering may change them, and a user-initiated complete-circuit move to
another same-category group assigns the next target-group identifier.
- Renumbering is explicit, not implicit on move/sort/delete.
## Data Ownership Split: Circuit vs Device Row
@@ -105,14 +111,14 @@ Primary circuit-first editor route:
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
The sibling `/tree` route is a read-only structure preview. Old
`/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
Old `/projects/:projectId/circuit-lists` bookmarks redirect to the project
page. A future print view will provide a dedicated read-only presentation.
## 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).
Critical multi-write commands already use explicit persistence transaction
adapters. The next step is to compose these command boundaries into server-side,
project-scoped revisions and change sets. Database backups remain separate from
user-visible project snapshots.
Persistent Undo/Redo, project revisions and logical snapshots are implemented
through project-scoped commands and the shared transaction boundary. Database
backups remain separate from user-visible project snapshots. External-model
exchange and PostgreSQL readiness are specified in
[Project History and External Model Architecture](./project-history-and-external-model-architecture.md)
and remain future work.
+37 -7
View File
@@ -28,6 +28,7 @@ The editor can have a selected cell without an active editor input. `editingCell
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
- section header rows
- fixed header, group-protection and auxiliary footer component rows
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
- device rows (`deviceRow`)
- section placeholder rows (`placeholder`)
@@ -82,13 +83,21 @@ Intent is separated by drag source type:
- device row drag:
- drop to existing circuit -> move row(s) into that circuit
- drop to placeholder -> create new target circuit and move row(s)
- drop on the upper or lower edge of a circuit row -> create a new target
circuit directly before or after that circuit and move the row(s)
- the center of a circuit row remains the explicit target for adding the
row(s) to that existing circuit
- crossing a section boundary requires explicit confirmation
- phase type, category, linked project device and local row values remain unchanged
- target creation, row assignments and reserve-state updates use one SQLite transaction
- circuit drag (BMK handle):
- reorder circuits inside same section only
- cross-section reorder is rejected
- the complete section order is validated and stored in one SQLite transaction
- reorder one or multiple circuits inside the same group without changing BMKs
- move exactly one circuit into another group of the same category
- a cross-group move assigns the highest existing target suffix plus one;
gaps are not filled
- cross-category and cross-group multi-circuit moves are rejected
- same-group reorder validates the complete group order; cross-group move
stores the one exact source/target transition
- bulk device row move:
- supported via multi-selection + drag
- multi-circuit move:
@@ -96,7 +105,25 @@ Intent is separated by drag source type:
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move.
## Protected Group and Component Controls
- New groups are created manually in one of the three supported categories and
receive the highest existing category group number plus one.
- Group headings always show the stored editable display name; category labels
do not overwrite names of the default first groups.
- Group reorder buttons move only relative to category peers and never change a
group number or BMK.
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
It follows the persisted group order and updates group prefixes, optional
`.0` protection BMKs and circuit BMKs together.
- Optional group upstream protection and group FI as well as auxiliary footer
components use labeled, type-dependent modals.
- Each circuit protection device uses its own modal; only fields valid for the
selected type are shown.
- Removing a populated group warns with circuit, device-row and group
protection counts. The complete deletion remains persistently undoable.
## Filtering and Sorting
@@ -118,7 +145,8 @@ Cross-section device moves show confirmation-required feedback before drop. The
- Column visibility and order are configurable.
- BMK column (`equipmentIdentifier`) is locked as first column.
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
- Layout is saved in local storage under a project-specific key so switching
distribution boards preserves the same project layout.
## Undo/Redo
@@ -138,8 +166,10 @@ Covered operations include:
- insert/delete circuit
- insert/delete row
- edit cell values
- moves (single/bulk rows, circuit reorder)
- renumber and identifier update flows
- moves (single/bulk rows, circuit reorder and same-category group move)
- circuit and group renumber/identifier flows
- component, group and protection editing
- warned populated-group deletion and exact restoration
- apply sorted order
The editor toolbar exposes availability through its Undo/Redo buttons. The
+12 -3
View File
@@ -9,12 +9,21 @@
- Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- The Revit/CSV foundation and confirmed initial import are implemented.
Existing-row assignment, move and detach plus creation of an external-only
row in an existing or newly created circuit have atomic persistent commands.
Their API planning and editor UI, follow-up import, conflict review and return
export are pending.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model.
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
- Sorting is view-only until users explicitly apply sorted order.
- Cross-section circuit drag-reorder is intentionally blocked.
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
- Complete-circuit drag supports one circuit at a time between groups of the
same category. Cross-category moves and multi-circuit moves across group
boundaries are intentionally rejected; multi-circuit reorder inside one
group remains supported.
- Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or
voltage-drop rules.
-90
View File
@@ -1,90 +0,0 @@
# Circuit List Editor Migration
## Status
Upgrade-only. New installations already use the Circuit-First schema and do not
run this workflow.
## Goal
Migrate legacy row-first consumers into the circuit-first model without deleting legacy data.
## Legacy Mapping
Legacy `Consumer` rows map into:
- `Circuit` for shared circuit identity and circuit-level technical fields
- `CircuitDeviceRow` for per-device load rows
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
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`)
Migration groups legacy rows by normalized `circuitNumber`:
- valid/normalizable values become one target circuit per normalized value
- duplicates are grouped under that circuit (multiple `CircuitDeviceRow`s)
- missing/invalid values trigger generated identifiers and may fall back to `unassigned` section
## Default Section Backfill
Before migration, default sections are created/backfilled per circuit list.
This guarantees a valid target section space, including `unassigned` when no section can be inferred.
## Migration Commands
Run in this order for local database workflows:
1. Backup:
- `npm run db:backup`
2. Migrate schema:
- `npm run db:migrate`
3. Verify circuit schema:
- `npm run db:verify:circuit-schema`
4. Backfill missing sections:
- `npm run db:backfill:sections`
5. Migrate legacy consumers:
- `npm run db:migrate:legacy-consumers`
The migration command finishes with a cutover verification across the complete
database. It exits with an error while any legacy consumer lacks a migration
mapping, including consumers that cannot be migrated because they have no circuit
list assignment. The legacy multi-list UI was removed only after this check passed;
old `/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
## Validation Checks
After migration, verify:
- tree endpoint returns sections/circuits/rows
- grouped duplicate circuit numbers are reported
- generated identifiers are reported where expected
- migrated rows include `legacyConsumerId` traceability
- no duplicate BMKs exist inside one circuit list
## If Tree Endpoint Returns Empty Sections
Likely causes:
- circuit-first tables missing (migration not run)
- sections not backfilled yet
- migrated dataset genuinely empty for selected list
Actions:
1. Run schema migration and verification commands.
2. Run section backfill command.
3. Run legacy-consumer migration command.
4. Retry tree endpoint.
## Legacy Data Retention
Do not delete legacy consumers yet.
- legacy read/write endpoints have been removed from the application server
- migration trace tables reference old/new mapping
- retained rows allow upgrade verification and audit of the completed mapping
- a future automatic cleanup migration must migrate and verify old databases
before dropping these source and trace tables
+425 -30
View File
@@ -9,10 +9,8 @@ Der unterstützte Editor ist Circuit-First:
Ein Stromkreis ist nicht dasselbe wie eine Gerätezeile. BMK, Schutz- und Kabeldaten
gehören zum Stromkreis; Last-, Raum- und Kategoriedaten gehören zur Gerätezeile.
Die frühere Consumer-Oberfläche und ihre API sind entfernt. Die Tabelle
`consumers` sowie Mappings und Reports bleiben ausschließlich erhalten, damit
ältere Datenbanken über den expliziten Upgrade-Befehl migriert und geprüft werden
können.
Die frühere Consumer-Oberfläche, ihre API, Tabellen und Upgrade-Werkzeuge sind
entfernt. Neue Funktionen bauen ausschließlich auf dem Circuit-First-Modell auf.
## Laufzeit
@@ -47,6 +45,8 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
- `src/server/index.ts` und `src/server/routes/` API-Komposition
- `src/domain/services/` fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` Verdrahtung fachlicher Services mit konkreten
SQLite-Repositories
- `src/db/repositories/` Abfragen, Persistenzmapper und Transaktionsadapter
- `src/db/schema/` und `src/db/migrations/` SQLite-Schema und Migrationen
@@ -66,11 +66,13 @@ Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
Tabellen. Eine direkt getestete Persistence-Funktion schreibt diese
Historienmetadaten innerhalb der zentralen Command-Transaktion optimistisch und
atomar fort. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
Circuit-, Gerätezeilen-, Projektgeräte- und Projekteinstellungsänderungen
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und
Projekteinstellungsänderungen
verwenden typisierte Command-Stores, die Fachänderung, automatisch erzeugtes
inverses Kommando, Revision und Historienstapel gemeinsam committen
beziehungsweise zurückrollen.
@@ -83,6 +85,26 @@ absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler
Dispatcher führt die nachfolgend beschriebenen typisierten Kommandos über
öffentliche Command-, Undo- und Redo-Endpunkte aus.
Alle unterstützten Runtime-Project-Command-Stores einschließlich der
vollständigen Snapshot-Wiederherstellung verwenden dabei
`project-command-transaction.persistence.ts` als gemeinsame äußere
Transaktionsgrenze. Sie führt Fachänderung, Revisions-Append und
History-Transition in fester Reihenfolge innerhalb derselben SQLite-Transaktion
aus. Für CircuitDeviceRow-Feldänderungen übernimmt eine typisierte Variante den
während der Fachänderung um Override-Metadaten ergänzten Forward-Command. Die
fachlichen Validierungs-, Forward- und Inversenregeln bleiben im jeweiligen
Store.
Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser
Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet.
Ein eigenständiges Runtime-Revisions-Repository existiert nicht.
`ProjectDeviceSyncService` und `CircuitNumberingService` kennen nur schmale,
fachlich benannte Reader-Interfaces. Ihre SQLite-Repositories werden
ausschließlich in `src/server/composition/` erzeugt und injiziert.
Auch die allgemeinen Projekt-, Geräte-, Raum-, Geschoss- und Circuit-
Repositories verlangen einen expliziten `AppDatabase`-Kontext. Ihre
Anwendungsinstanzen werden zentral in
`src/server/composition/application-repositories.ts` erzeugt; Controller
importieren weder den globalen SQLite-Client noch konkrete Repository-Klassen.
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
@@ -106,8 +128,8 @@ Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte sind nicht
Teil des Projekt-Snapshots.
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
`kind` unterscheidet benannte und automatische Stände. Die zentrale
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
@@ -120,13 +142,17 @@ Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
nicht Bestandteil des logischen Snapshots sind.
Restore nach einem Neustart exakt zurücknehmen oder wiederholen.
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
benennen, jeden aufgeführten benannten oder automatischen Stand nach
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
abzuhängen. Revision null wird als Projektstart dargestellt.
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
@@ -143,6 +169,26 @@ Felder und vollständiger Zeilenbestand unverändert sind.
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
Toolbar-Undo/Redo über die projektweite Historie aus.
Die Projektgerätepalette belegt keine permanente Layoutspalte mehr. Sie wird
über die Editor-Toolbar als überlagernder Drawer geöffnet, während das
Stromkreis-Grid standardmäßig die gesamte verfügbare Breite nutzt. Auswahl,
Schnelleinfügen und die vorhandenen Drag-and-drop-Payloads bleiben im Drawer
unverändert verfügbar; während eines aktiven Projektgeräte-Drags kann er nicht
geschlossen werden. Der Drawer liegt am rechten Fensterrand und verdeckt damit
nicht die führenden BMK- und Anzeigenamenspalten. Das Grid zeigt Geräte- und
Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
und Filter verändern diese fachlichen Summen nicht.
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
@@ -185,25 +231,218 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert die beiden Projekt-Standardspannungen als
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
besitzt keinen separaten direkten Settings-Schreibweg mehr.
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
`project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
damit Änderungen weiterhin niemals still synchronisiert werden.
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben
erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
projektgebundenen Endpunkt beschränkt.
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
Command-Schema 1 als vollständigen Block aus Verteilung, Stromkreisliste, den
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
und Überspannungsableiter `-FA`.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt.
Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen.
Bereits befüllte Verteilungen werden über
`distribution-board.insert-subtree` und `distribution-board.delete-subtree`
als vollständiger Unterbaum kopiert beziehungsweise gelöscht. Der Snapshot
umfasst Verteilung, Stromkreisliste, Gruppen, Stromkreise, Gerätezeilen,
Verteilerkomponenten und alle zugehörigen Schutzgeräte. Beim Kopieren werden
sämtliche besitzenden UUIDs neu vergeben, während fachliche Verknüpfungen zu
Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare
Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die
Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an;
Löschen verlangt dort eine ausdrückliche Bestätigung.
`distribution-board-component.insert` und
`distribution-board-component.delete` sowie
`distribution-board-component.update` versionieren Anlage, Entfernung,
Bearbeitung und Sortierung veränderlicher Gruppen-Schutzgeräte und zusätzlicher
Verteilergeräte. Die vollständigen Snapshots enthalten stabile
Komponenten-UUIDs und, für Gruppen-Schutzgeräte, die getrennte
1:1-Schutzgerätekonfiguration. Updates tauschen einen exakt erwarteten gegen
einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten
werden im Editor über eigene Modale und diese Commands verwaltet.
Leere Stromkreisgruppen werden über `circuit-group.insert`,
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
Gruppenkomponenten. Befüllte Gruppen verwenden getrennt davon den vollständigen
`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung.
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
Revision und einen Undo/Redo-Schritt.
`src/domain/services/circuit-group-renumbering.ts` plant explizite
Gruppennummerierungen deterministisch. Der reine Plan erhält
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen.
`circuit-group.renumber` führt diesen Plan über die gemeinsame
Projekt-Command-Transaktion aus. Vor dem Schreiben müssen alle betroffenen
Gruppen, Stromkreise und Gruppenkomponenten exakt dem erwarteten Zustand
entsprechen. Nummerntausch verwendet kollisionsfreie temporäre Präfixe und
BMKs über beide BMK-Tabellen; anschließend werden alle Zielwerte finalisiert.
Der vollständige inverse Plan ermöglicht dauerhaftes Undo/Redo.
`src/domain/services/circuit-group-move-planning.ts` plant den Wechsel eines
vollständigen Stromkreises zwischen zwei verschiedenen Gruppen derselben
Kategorie. Die Ziel-BMK verwendet die höchste vorhandene Stromkreis-Endnummer
der Zielgruppe plus eins; Lücken werden nicht gefüllt. Quell-/Zielgruppe,
Quell-/Zielposition und beide BMKs werden festgeschrieben, damit eine spätere
Wiederholung nichts neu berechnet.
`circuit.move-group` führt den geplanten Wechsel als eine Projekt-Revision aus.
Nur `sectionId`, `equipmentIdentifier` und `sortOrder` des Stromkreises ändern
sich. Gerätezeilen und das getrennte 1:1-Schutzgerät bleiben über die stabile
Stromkreis-UUID verbunden. Der inverse Wechsel speichert Quellgruppe, BMK und
Position vollständig für dauerhaftes Undo/Redo.
`CircuitGroupSubtreeSnapshot` ist der kanonische Vertrag für destruktive
Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt
1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche
Gerätezeilen einschließlich Link- und Override-Metadaten. Seine
Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und
Gerätezeilenanzahl.
`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor
dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten
Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge
mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse
Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch
Teilwiederherstellungen.
Der Circuit-Tree liefert zusätzlich `headerComponents`,
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
enthalten ihre getrennte Schutzgerätekonfiguration. Die
1:1-Schutzgerätetabellen sind die einzige unterstützte Quelle dieser Daten.
Separate aktive Read-Repositories lesen Komponenten- und beide
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
`distribution-board-component.*`-Commands; die verbindliche
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
gesperrt.
`circuit-protection.update` bildet die eigene persistente Schreibgrenze für
die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen
erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die
Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
wiederherstellen.
Der vollständige `CircuitSnapshot` enthält den zugehörigen
`protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und
durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
Im Editor sind die Schutzspalten deshalb eine
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
vollständige erwartete und gewünschte Sortierreihenfolge über
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen
Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
Lücken werden nicht automatisch gefüllt.
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
Client sendet dafür den vollständigen aktuellen Unterbaum an
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
wieder her.
`distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte
Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit
leerer externer Konfiguration hochgestuft. Die unterstützte
Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor
dieser Baseline werden nicht eingelesen.
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
Historienstand.
## Projektgeräte
`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder:
`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie
optionale technische und kategorisierende Felder.
Die Projektgerätekategorie `lighting`, `single_phase` oder `three_phase` ist die
fachliche Klassifikation und Zielgruppe. `phaseType` ist keine separate
Benutzereingabe, sondern wird daraus für Spannung, Verknüpfung und spätere
Dimensionierung abgeleitet.
Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird
kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät
@@ -217,20 +456,176 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
- SQLite ist die aktuell unterstützte Datenbank.
- Fremdschlüssel werden für jeden Datenbankkontext aktiviert.
- `npm run db:migrate` wendet Drizzle-Migrationen an.
- `npm run db:migrate` richtet mit Baseline-Migration `0000` und den folgenden
additiven Migrationen das aktuelle Schema ein.
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
- Angewendete Migrationen werden niemals nachträglich verändert.
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad.
- `npm run typecheck:scripts` prüft die TypeScript-Wartungsskripte, ohne Code zu
erzeugen.
- Die Baseline ersetzt alle vorigen Entwicklungsmigrationen. Datenbanken,
Snapshots und persistierte Commands aus Vor-Baseline-Ständen werden bewusst
nicht unterstützt. Ab der ersten veröffentlichten Version sind angewendete
Migrationen unveränderlich und Änderungen erfolgen additiv.
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories.
- Die vollständige Verteilungs-Testfixture liegt unter
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
Produktions-Schreibweg.
- Die Baseline bildet die relationale Grundlage für geschützte Stromkreisgruppen
und Verteilerkomponenten. Separate
1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren
flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped.
Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite
Konfigurationsversion am Batch, getrennt von der Schema-Version des
Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit
dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes,
Matrix und alle internen Links werden weiterhin vollständig erfasst,
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen
Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes
erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und
validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
erzeugen weiterhin keine dieser Zeilen.
Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
`external-object.update-row-assignment` ist diese atomare Schreibgrenze für
bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt
sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden
die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit,
Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft.
Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung;
unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht
still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
bestehen.
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
Die drei Zuordnungsadapter teilen sich
`external-object-assignment.persistence.ts` für Snapshotvergleich,
Kompatibilitätsregeln und link-sichere Updates.
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer.
Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur
dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte
Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige
Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen
Revit-Import ist die Projektion leer.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet
denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen
noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei.
Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen
Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte,
liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die
vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge.
Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben
sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt.
`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei,
erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen.
Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen
Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional
einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende
Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt
stabile interne UUIDs und übergibt den vollständigen Zustand an
`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte
und alle `circuitDeviceRowId` bleiben `null`.
Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der
Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende
Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er
zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume,
Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die
Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog
wird der vollständige Import als eine persistente, rückgängig machbare
Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen.
Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über
die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese
Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt.
Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
ihn für die betroffene Gruppe.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
strukturtreuer Revit-Rückexport
- vollständige elektrische Dimensionierung
- Produktionsdeployment
+21 -10
View File
@@ -45,10 +45,16 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
Testbestand:
1. In der UI ein Projekt `Demo` anlegen.
2. Im Projekt eine Verteilung `UV-01` erstellen.
2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält
danach Hauptschalter, Überspannungsableiter und je eine Gruppe für
Beleuchtung, 1-phasige und 3-phasige Stromkreise.
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe
ziehen.
5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein
zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung,
explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne
reale Projekt- oder Kundendaten prüfen.
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
@@ -70,6 +76,7 @@ Beispiel `Preserve circuit blocks during filtering`.
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
git diff --check
```
@@ -83,21 +90,25 @@ Invoke-WebRequest http://localhost:3000/health
```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
Testaufbau, der vollständige Projektstrukturen direkt einfügt, gehört nach
`tests/support`. Produktions-Repositories dürfen nicht nur für Testfixtures
erneut direkte Schreibmethoden erhalten.
## Migrationen
1. Bestehende lokale Datenbank sichern: `npm run db:backup`.
2. Schema unter `src/db/schema/` ändern.
3. `npm run db:generate` ausführen.
4. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung
1. Schema unter `src/db/schema/` ändern.
2. `npm run db:generate` ausführen.
3. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung
enthalten.
5. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert
4. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert
werden.
6. `npm test`, `npm run db:migrate` und
5. `npm test`, `npm run db:migrate` und
`npm run db:verify:circuit-schema` ausführen.
Bereits angewendete SQL-Migrationen dürfen nicht geändert oder neu sortiert
werden. Korrekturen erfolgen immer über eine neue Migration.
`0000` ist die saubere Baseline für leere Neuinstallationen. Vor-Baseline-
Entwicklungsdatenbanken werden nicht aktualisiert. Nach Veröffentlichung der
ersten Version dürfen angewendete SQL-Migrationen nicht geändert oder neu
sortiert werden; Korrekturen erfolgen dann immer über eine neue Migration.
## Pull-Request-Handoff
-137
View File
@@ -1,137 +0,0 @@
# Local DB Migration: Circuit-First Schema
This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in `src/db/migrations`.
This document is an upgrade runbook for databases created before the Circuit-First
cutover. A clean installation only needs the normal `db:migrate` and schema
verification steps documented in the main README.
## Safe command order
1. Backup local DB (required before schema/data migration)
```bash
npm run db:backup
```
The command uses SQLite's online backup API, so committed WAL changes are included
even while the application is running. It opens the resulting standalone database
and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass.
Database backups are operational recovery files and remain separate from the future
user-visible project version history.
2. Apply pending schema migrations (includes `0008_circuit_first_model`, the additive
`0009_project_device_circuit_fields` transition and the post-cutover
`0011_project_device_canonical_fields` cleanup)
```bash
npm run db:migrate
```
3. Verify circuit-first tables exist
```bash
npm run db:verify:circuit-schema
```
4. Backfill default sections for existing `circuit_lists`
```bash
npm run db:backfill:sections
```
5. Run legacy consumer -> circuit/device-row migration explicitly
```bash
npm run db:migrate:legacy-consumers
```
## Verification SQL
Run these against `data/leistungsbilanz.db`:
```sql
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name IN (
'circuit_sections',
'circuits',
'circuit_device_rows',
'legacy_consumer_circuit_migrations',
'legacy_consumer_migration_reports'
)
ORDER BY name;
```
The verification command also checks the circuit-first project-device columns added by migration `0009`.
```sql
SELECT circuit_list_id, key, prefix, sort_order
FROM circuit_sections
ORDER BY circuit_list_id, sort_order;
```
```sql
SELECT circuit_list_id, COUNT(*) AS circuits
FROM circuits
GROUP BY circuit_list_id;
```
```sql
SELECT c.circuit_list_id, COUNT(r.id) AS device_rows
FROM circuits c
LEFT JOIN circuit_device_rows r ON r.circuit_id = c.id
GROUP BY c.circuit_list_id;
```
## API verification
After the steps above:
`GET /api/projects/:projectId/circuit-lists/:circuitListId/tree`
Expected response shape:
```json
{
"circuitListId": "string",
"sections": [
{
"id": "string",
"key": "lighting|single_phase|three_phase|unassigned|...",
"displayName": "string",
"prefix": "-1F|-2F|-3F|-UF|...",
"sortOrder": 10,
"circuits": [
{
"id": "string",
"equipmentIdentifier": "-2F1",
"displayName": "string",
"sortOrder": 10,
"isReserve": false,
"circuitTotalPower": 1.23,
"deviceRows": [
{
"id": "string",
"displayName": "string",
"quantity": 1,
"powerPerUnit": 0.3,
"simultaneityFactor": 1,
"rowTotalPower": 0.3
}
]
}
]
}
]
}
```
If migrations were not applied, endpoint may return an empty fallback with a warning.
## Visual verification
Manual device rows and multi-device circuits can be created directly in the
circuit-list editor. The former development scripts for direct database writes
were removed because they bypassed project revisions and persistent undo/redo.
@@ -84,7 +84,8 @@ Implemented named-snapshot foundation:
stores the complete project state in one SQLite transaction
- the versioned payload contains project settings, distribution boards,
circuit lists, sections, circuits with device rows, project devices, floors
and rooms while excluding global and upgrade-only data
and rooms while excluding the global device catalog and other
non-project-owned data
- snapshot creation/listing does not create a project revision or affect
undo/redo eligibility
- public Create/List endpoints expose metadata but not the logical payload
@@ -93,8 +94,6 @@ Implemented named-snapshot foundation:
- restore records a new `restore` revision containing both the selected target
state and the complete pre-restore inverse; undo/redo therefore remains
available after application restarts
- compatible upgrade-only Consumer links, mappings and reports are preserved
separately and are never imported into the logical snapshot payload
- arbitrary restore payloads are rejected by the generic user-command
endpoint; only server-stored snapshots can initiate a forward restore
- the project page provides an initially collapsed snapshot/timeline panel,
@@ -263,18 +262,28 @@ Completed foundation:
- `project-device.insert` and `project-device.delete` preserve stable device
ids; delete undo restores links only from complete unchanged disconnected-row
snapshots in the same transaction
- `project.update-settings` persists both project voltage defaults, its exact
inverse, revision and history transition atomically; the project page and
existing PUT route require the current expected revision
- `project.update-settings` persists the public-building classification and
both project voltage defaults with their exact inverse, revision and history
transition atomically; the project page and
existing PUT route require the current expected revision; the same
transaction derives every project-device and circuit voltage again, so
Undo/Redo cannot leave mixed voltage states
- `distribution-board.insert` persists the complete generated board, circuit
list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the
returned revision
- `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly
- the floor and room POST routes require the current expected revision and the
project page tracks the returned history state
Remaining constraints before completing project version history:
- extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete
- all current normal project-scoped runtime writes are behind the revision
boundary; extend it whenever further project mutations are introduced
- project creation remains initialization at revision zero, while global device
templates remain outside any single project history
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
+269
View File
@@ -0,0 +1,269 @@
# Kontext für die Revit-Anforderungsplanung
## Zweck dieser Datei
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
für einen zukünftigen Revit-Datenaustausch zu klären.
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
## Produkt und fachliches Modell
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
Das unterstützte Laufzeitmodell ist:
```text
Project
├── Floor
│ └── Room
├── ProjectDevice
└── DistributionBoard
└── CircuitList
└── CircuitSection
└── Circuit
└── CircuitDeviceRow
```
Wichtige fachliche Grenzen:
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
und Kategorie gehören zur Gerätezeile.
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
lokale Werte und ausdrücklich erfasste Abweichungen.
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
erlaubt.
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
werden.
Für einen späteren Revit-Austausch besonders relevante Daten sind:
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
Kennzeichnung als öffentliches Gebäude und Projektspannungen.
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
Raumname.
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
aktuellen Produkt nicht vorgesehen.
## Aktuelle technische Architektur
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
`src/frontend`.
- API: Express und Zod unter `src/server`.
- Fachregeln und typisierte Commands: `src/domain`.
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
- Laufzeitkomposition konkreter Repositories:
`src/server/composition`.
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
SQLite-Datei unter `data/leistungsbilanz.db`.
- Docker Compose wird für die lokale Entwicklung unterstützt.
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
Der zentrale Editor liegt unter:
```text
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
src/frontend/components/circuit-tree-editor.tsx
```
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
## Revisionen, Undo/Redo und Sicherungspunkte
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
Command- und Transaktionsgrenze:
```text
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
```
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
geben die erwartete Revision mit, damit veraltete Clients keine neueren
Änderungen überschreiben.
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
Anwendungsneustart. Zusätzlich gibt es:
- eine unveränderliche Revisions-Timeline;
- benannte und automatische logische Projektsnapshots;
- wiederherstellbare Snapshots mit Prüfsumme;
- einen portablen, versionierten JSON-Projekttransfer.
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
revisioniert und rückgängig machbar sein.
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
Projekts innerhalb von Leistungsbilanz.
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
für CSV-Dateien oder Revit-Parameter verwendet werden.
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
zuweisen kann.
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
Implementierungsdesign:
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
Gerätezeilen.
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
widersprüchliche Objekte angezeigt.
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
unterscheidbar bleiben.
- Lokale Planungswerte dürfen niemals still überschrieben werden.
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
ausdrücklich modelliert werden.
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
automatisch einem anderen Objekt zugeordnet.
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
die Werte deterministisch zurückschreiben kann.
- Ein Export bezieht sich auf eine bekannte Projektrevision.
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
Serverbetrieb oder unabhängigen Hintergrundjobs.
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
- `ExternalModelSource`
- `ExternalImportBatch`
- `ExternalModelObject`
- `ExternalObjectLink`
- `ExternalParameterMapping`
- `ExternalExportBatch`
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
Tabellen behandeln.
## Auftrag an das andere LLM
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
realen Ablauf in Revit und kläre danach schrittweise mindestens:
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
Revit-Versionen)?
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
aktualisierte Revit-Dateien unterschieden?
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
Leistungsbilanz geplant?
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
Projektgerät, Raum oder einer Verteilung zugeordnet?
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
Einheiten und unter welchen Revit-Parameternamen?
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
Rückgabedatei erzeugt werden?
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
wiederverwendet und zwischen Projekten geteilt werden?
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
doppelte oder nicht mehr sichtbare Revit-Objekte?
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
eine Auswahl?
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
Anwender vor der Übernahme?
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
verhindern oder nur eine Warnung erzeugen?
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
16. Müssen Import und Export synchron im Browser laufen oder werden später
Hintergrundjobs benötigt?
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
Versionshistorie und Support gespeichert werden?
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
Abnahmekriterien?
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
Notizen ausdrücklich:
- vom Anwender bestätigte Anforderungen;
- sinnvolle Vorschläge;
- offene Entscheidungen;
- technische Folgerungen;
- bewusst nicht betrachtete spätere Ausbaustufen.
## Erwartetes Ergebnis der Anforderungsrunde
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
soll mindestens enthalten:
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
Rückexport;
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
- Identitäts-, Matching- und Quellmodell;
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
- Konflikt-, Validierungs- und Freigaberegeln;
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
Architekturregeln zu umgehen;
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
müssen.
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
nur Architekturvorschläge des LLM sind.
## Weiterführende Projektdokumente
- `AGENTS.md` verbindliche Domänen- und Implementierungsregeln
- `docs/current-architecture.md` vollständiger aktueller Laufzeit- und
Command-Pfad
- `docs/project-history-and-external-model-architecture.md` bisherige
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
- `docs/spec/01-domain-context.md` fachlicher Kontext
- `docs/spec/03-data-model-concept.md` Datenmodellkonzept
- `docs/spec/05-linked-devices-and-sync.md` Regeln für Projektgeräte und
kontrollierte Synchronisierung
- `docs/spec/07-implementation-phases-todo.md` Roadmap, insbesondere Phase 14
- `docs/circuit-list-editor-known-limitations.md` noch nicht implementierte
Funktionen
+11 -11
View File
@@ -6,9 +6,10 @@ This is a conceptual model for Codex. It is not a final database schema, but the
A distribution board contains a circuit list.
A circuit list contains sections.
A circuit list contains circuit groups. `CircuitSection` is the persisted name
of that group aggregate.
A section contains circuits.
A circuit group contains circuits of one category.
A circuit contains zero, one or multiple device rows.
@@ -60,29 +61,28 @@ User-facing fields:
- `section`
- `displayName` optional
- `circuitTotalPower`
- `protectionType`
- `protectionRatedCurrent`
- `protectionCharacteristic`
- `protectionDevice`
- `cableType`
- `cableCrossSection`
- `cableLength`
- `rcdAssignment` optional
- `terminalDesignation` optional
- `voltage` optional
- `voltage`, derived from project settings and phase category
- `controlRequirement` optional, for example DALI or KNX
- `status` optional
- `isReserve` optional
- `remark` optional
`protectionDevice` is the circuit-owned one-to-one protection configuration.
It contains the device type, rated current and only the type-dependent values
such as trip characteristic or residual current. Newly created circuits
receive the category default defined in the protection catalog.
Circuit-level fields only:
- protection type
- protection rated current
- protection characteristic
- protection device and all of its technical values
- cable type
- cable cross-section
- cable length
- RCD assignment
- terminal designation
- circuit total power
@@ -14,12 +14,18 @@ Future suggestions or checks may include:
- cable type
- number of cores
- cable cross-section
- halogen-free cable or line requirement for public buildings
- minimum cable cross-section based on current
- maximum permissible cable length based on voltage drop
- warning messages if cable length, cross-section or protection device do not match
Users must remain able to manually override suggestions.
The project-owned `isPublicBuilding` setting is the authoritative input for
the later halogen-free selection rule. The setting is persisted now; this
document does not yet define or implement the complete cable catalog or sizing
algorithm.
## Initial Design Rule Examples
These are common planning defaults, not a replacement for full norm-compliant calculation.
+78 -14
View File
@@ -303,6 +303,8 @@ Acceptance criteria:
## Phase 11: Persistence and Transaction Foundation
Status: Complete.
Goal:
Prepare the current SQLite implementation for persistent history and a later PostgreSQL adapter without changing user-facing behavior.
@@ -326,8 +328,9 @@ 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
- the read-only distribution-board repository receives its database dependency explicitly
- distribution-board setup uses its persistent command repository with real
in-memory SQLite commit and rollback coverage using production migrations
- database backups use SQLite's online backup API and include committed WAL data
- every backup is opened independently and checked for integrity and foreign-key violations
- an integration test restores the backup into a separate database and verifies its snapshot contents
@@ -339,11 +342,28 @@ Implemented foundation:
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
- BMK swaps and section reorder have real SQLite commit and rollback coverage
- the legacy consumer migration receives its database dependency at the script entry point
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
- the former Consumer migration service, mappings and reports were removed with
the pre-release database cutover
- circuit and device-row persistence value mapping is separated from the general repositories
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
- retained legacy rows are accessible only through explicit database upgrade tooling
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
Project repository writes are removed; integration fixtures live under
`tests/support` instead of production repositories
- all supported runtime project-command stores, including complete snapshot
restore, share one tested transaction wrapper for domain mutation, revision
append and history transition; its applied-forward variant retains derived
CircuitDeviceRow override metadata
- low-level revision append persistence is tested directly; the unused
standalone runtime revision repository and store interface are removed
- runtime project-device synchronization and circuit-numbering services depend
on narrow domain readers; server composition injects the SQLite repositories
- all general application repositories require an explicit database context;
server composition owns their runtime instances and controllers contain no
direct global SQLite access
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
typecheck, including all referenced application modules
- the former Consumer UI, API, tables and upgrade tooling are removed
- migration `0000` is the clean baseline for new installations; pre-baseline
development databases are intentionally unsupported
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
## Phase 12: Project Revisions and Persistent Undo / Redo
@@ -445,12 +465,15 @@ Implemented foundation:
- ProjectDevice synchronization and disconnect endpoints also require
`expectedRevision`; their UI undo uses project-wide persistent history and
the obsolete direct restore/reconnect write paths are removed
- project voltage settings use `project.update-settings`; both values, inverse,
revision and history transition commit atomically and the former direct
settings write is removed
- project settings use `project.update-settings`; metadata, public-building
classification, voltage values, inverse, revision and history transition
commit atomically and the former direct settings write is removed
- distribution-board setup uses `distribution-board.insert` with a complete
stable board/list/default-section snapshot; Undo removes only an unchanged
empty setup and the former direct controller write is removed
- floor and room setup use `project-floor.insert` and `project-room.insert`
with stable ids; their inverses refuse assigned or referenced records and the
former direct repository create methods are removed
- revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction
- a stale expected revision produces no history writes
@@ -473,7 +496,47 @@ Acceptance criteria:
- restoring an older state does not delete intervening history
- concurrent stale commands cannot silently overwrite newer project state
## Phase 13: External Model Round-Trip
## Phase 13: Distribution Board Components and Protection Groups
Goal:
Extend the circuit list into the structural basis of a tabular single-line
diagram before defining Revit parameter exchange.
Detailed specification:
- `docs/spec/09-distribution-board-components-and-protection-groups.md`
Delivery:
- add typed protection-device catalogs and defaults
- evolve sections into repeatable category-specific circuit groups
- add fixed, group and auxiliary distribution-board components
- give every new circuit one explicit protection device
- implement nested BMK generation, same-category group moves and explicit
collision-safe group renumbering
- preserve all new state through commands, snapshots, transfer and persistent
Undo/Redo
- project the header, groups and auxiliary footer in the spreadsheet editor
Acceptance criteria:
- new boards contain the main switch, surge protective device and three default
groups
- all circuits and components have list-wide unique BMKs
- circuit reordering keeps identifiers while cross-group moves assign the next
target-group identifier
- destructive group operations are confirmed, atomic and fully undoable
- existing supported project data migrates without silently changing BMKs or
protection selections
## Phase 14: External Model Round-Trip
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
und die verbindlichen
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
Goal:
@@ -498,15 +561,15 @@ Acceptance criteria:
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
## Phase 15: 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.
With the former Consumer path 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
- keep the post-cleanup documentation limited to the supported architecture
- update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design
README target structure:
@@ -561,7 +624,8 @@ Implemented handoff:
- README is a concise setup and project entry point without prototype status lists
- `docs/README.md` separates current, operational, future, specification and archived material
- current runtime, module boundaries and upgrade-only legacy data are documented explicitly
- current runtime and module boundaries are documented without compatibility
paths for unsupported pre-release data formats
- contributor workflow covers clean databases, safe demo data, migrations, tests and commits
- development Compose is clearly separated from the currently unsupported production deployment
- `AGENTS.md` points LLM-assisted work to supported code paths and deferred architecture
+144
View File
@@ -0,0 +1,144 @@
# Current Product Backlog
This document records the next agreed product improvements. It describes
requirements and intended sequencing, not proof of implementation.
## Circuit List Editor
- Collect the remaining editor interaction issues before changing additional
behavior.
## Distribution Board Components and Protection Groups
- [x] Implement the agreed protected circuit-group and distribution-board
component model in phased work packages.
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
BMK rules.
- [x] Phase B1: relational group, component and protection tables with shared
BMK uniqueness.
- [x] Phase B2: complete snapshot/transfer schema for the supported project
state.
- [x] Phase C1: new-board command with three groups and fixed header
components.
- [x] Phase C2a1: persistent insert/delete commands for mutable group
protection and auxiliary components.
- [x] Phase C2a2: component update and reorder commands.
- [x] Phase C2b1: empty-group insert/update/delete commands.
- [x] Phase C2b2: complete group reorder command.
- [x] Phase D1a: deterministic collision-aware group-renumber plan.
- [x] Phase D1b: persistent collision-safe group-renumber command.
- [x] Phase D2a: deterministic same-category circuit-move plan.
- [x] Phase D2b: persistent same-category circuit-move command.
- [x] Phase D3a: complete validated group-subtree snapshot and warning summary.
- [x] Phase D3b: persistent populated-group delete/restore command.
- [x] Phase E1: tree read model for components, groups and protection devices.
- [x] Phase E2a: pure header/group/footer structure projection.
- [x] Phase E2b: render the structure projection in the editor grid.
- [x] Phase E3a: create, edit and delete mutable group and footer components.
- [x] Phase E3b1: create, rename and safely delete empty circuit groups.
- [x] Phase E3b2a: persistent circuit-protection update command.
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
- [x] Phase E4a: persistent same-category group reorder controls.
- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
- [x] Populated-group delete warning and explicit subtree deletion.
- [x] Phase E: editor projection and editing.
- [x] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
the planner supplies the calculation requirements.
- Detailed specification:
`docs/spec/09-distribution-board-components-and-protection-groups.md`
## First Release Baseline
The pre-release cleanup established the following compatibility boundary:
- [x] Pre-baseline databases, portable JSON exports, logical snapshots and
persisted command histories are explicitly unsupported.
- [x] Migration `0000` builds the complete supported schema on an empty
database.
- [x] The development migration chain and Drizzle metadata were replaced
before any supported release depended on them.
- [x] Upgrade-only database, snapshot and command compatibility paths were
removed.
- [x] Pre-cleanup development history remains available through Git history.
- [x] Clean installation, migration, startup and core workflows were verified
against an empty database.
- [ ] Mark this compatibility boundary with the first supported release tag.
- After that release, every published migration is immutable and later schema
changes must use new additive migrations.
## Project History
- [x] Every listed logical snapshot, including automatic snapshots, must be
explicitly restorable from the project history UI.
- [x] Show which project change produced a snapshot. Prefer immutable revision
metadata over storing a second free-form description of the same change.
- [x] Keep restoration auditable as a new project revision and preserve persistent
undo/redo.
## Distribution Boards
- [x] Assign an optional project floor to a distribution board.
- [x] Store a supply classification for each distribution board:
- `AV` (Allgemeine Stromversorgung)
- `SV` (Sicherheitsstromversorgung)
- `EV` (Ersatzstromversorgung)
- `USV` (Unterbrechungsfreie Stromversorgung)
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
- `SiBe` (Sicherheitsbeleuchtung)
- [x] Let project settings select which catalog supply types are used in a
project. Distribution-board dialogs offer only that selection; a type in
use cannot be disabled.
- [x] Include both fields in project snapshots and portable project
export/import.
- [x] Persist changes through project commands so they remain undoable after a
restart.
## Device Voltage Derivation
- [x] Derive voltage from the selected phase type and the project settings:
- single-phase uses the project's single-phase voltage;
- three-phase uses the project's three-phase voltage.
- [x] Voltage is not editable on global devices, project devices or circuits.
Global devices store only their phase; copying one into a project derives
the target project's voltage.
- [x] Project-device voltage follows its phase. Circuit voltage follows the
section phase; an unassigned circuit is three-phase only when all assigned
device rows with a valid phase are three-phase.
- [x] Changing project voltage settings updates all project devices and circuits
atomically in the same persistent Undo/Redo step.
- [x] Baseline migration `0000` stores only canonical derived values.
Pre-baseline imports are intentionally unsupported.
## Building Classification
- [x] Store whether a project represents a public building.
- [x] Edit the value in the project settings and preserve it through persistent
Undo/Redo, snapshots and portable project transfers.
- [x] Provide the value as an explicit future sizing input for selecting
halogen-free cables and lines; automatic sizing remains deferred.
## Circuit List Power Summary
- [x] Show the total power of every circuit section.
- [x] Show the unadjusted total power of the complete distribution board.
- [x] Store a distribution-board simultaneity factor between zero and one in
the distribution-board settings.
- [x] Show the distribution-board total after applying that factor.
- [x] Preserve the factor in project commands, snapshots and portable project
transfers.
- [x] Keep column visibility and order as a project-specific UI preference so
all distribution boards in the same project open with the same columns.
## Recommended Sequence
1. [x] Distribution-board floor and supply fields.
2. [x] Device voltage derivation without overrides.
3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
foundation as a separate jointly planned phase and must not be inferred from
these tasks.
@@ -0,0 +1,821 @@
# Distribution Board Components and Protection Groups
## Status
This document records the agreed and implemented behavior for
distribution-board components, protected circuit groups and circuit
protection devices. Delivery phases A through F are complete. The current
runtime paths are documented in `docs/current-architecture.md`.
Full rule-based protection and cable sizing is deliberately deferred. This
phase establishes the structure and manually selected technical values that a
later calculation and warning engine will evaluate.
## Goals
- represent the circuit list as the basis of a tabular single-line diagram
- list incoming, group-level and auxiliary distribution-board components with
stable equipment identifiers
- support multiple independently protected groups for lighting, single-phase
and three-phase outgoing circuits
- give every newly created outgoing circuit one explicit protection device
- preserve the existing distinction between a circuit and its device rows
- keep every structural change atomic, revisioned and persistently undoable
- prepare stable target fields for the later Revit round-trip
## Non-Goals
- norm-compliant protection or cable sizing
- automatic creation of additional groups based on the incoming rating
- automatic phase balancing
- manufacturer-specific product catalogs
- automatic equipment-identifier generation for auxiliary components
- channel-level relationships between actors and controlled circuits
- PostgreSQL or multi-user operation
## Implemented Model Boundary
### Keep
- `Circuit` remains the outgoing-circuit aggregate.
- `CircuitDeviceRow` remains a load assigned to a circuit and never receives
its own protection device or equipment identifier.
- Circuit-level load, cable and control fields remain circuit-owned.
- Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo
remain authoritative.
- `CircuitSection` is the persisted representation of a protected circuit
group.
- Grid projection remains separate from persisted domain structure.
### Change
- A `CircuitSection` has an explicit category and group number and represents
one protected circuit group.
- Circuit protection uses an explicit
one-to-one protection-device model.
- Distribution-board components become independent BMK-bearing list entries.
- Equipment-identifier uniqueness covers circuits and all distribution-board
components in the complete circuit list.
- Moving a circuit to another group of the same category atomically assigns the
next identifier of the target group.
- New distribution boards create exactly three default groups.
### Remaining Model Cleanup
- The older optional `rcdAssignment` circuit field still exists separately
from the explicit group relationship. Removing it requires its own reviewed
domain and UI change; it is not a compatibility path for pre-release data.
## Target Domain Structure
```text
Project
└── DistributionBoard
└── CircuitList
├── fixed header components
│ ├── Main switch
│ └── Surge protective device
├── CircuitGroup 1..n
│ ├── optional upstream protection component
│ ├── optional group RCD component
│ └── Circuit 0..n
│ ├── ProtectionDevice exactly 1
│ └── CircuitDeviceRow 0..n
└── auxiliary components 0..n
```
The hierarchy defines ownership and electrical grouping. It does not require
every level to be visually indented in the table.
## Circuit Groups
`CircuitSection` evolves into the circuit-group entity. A group has:
- stable UUID
- circuit-list ownership
- category
- positive group number
- display name
- sort order
- optional upstream protection component
- optional group RCD component
- zero or more outgoing circuits
Supported categories:
- `lighting`
- `single_phase`
- `three_phase`
Each new distribution board creates exactly:
1. `Beleuchtung 1`
2. `1-phasig 1`
3. `3-phasig 1`
Additional groups are created manually. A new group receives the highest
existing group number in its category plus one. Gaps are not filled
automatically.
A group may have at most one upstream protection component and at most one
group RCD. Both are optional. Every circuit in the group remains downstream of
those group components until it is moved to another group.
## Distribution-Board Components
Every distribution-board component has:
- stable UUID
- circuit-list ownership
- unique equipment identifier
- name
- role
- placement
- sort order within its placement
Initial roles:
- `main_switch`
- `surge_protective_device`
- `group_upstream_protection`
- `group_residual_current_protection`
- `auxiliary`
Initial placement zones:
- `header`
- `group`
- `footer`
New distribution boards create exactly:
- `-Q0`, name `Hauptschalter`, role `main_switch`
- `-FA`, name `Überspannungsableiter`, role
`surge_protective_device`
These fixed components appear at the top and may initially have no further
technical product data.
Auxiliary components initially require only:
- manually entered equipment identifier
- name
- sort order
Examples include KNX switching actuators, blind actuators, phase-monitoring
modules, DALI gateways, relays and other installed board equipment. They appear
after all circuit groups and can be reordered by drag-and-drop. Automatic BMK
suggestions and channel-to-circuit relationships are deferred.
## Circuit Protection
Every newly created outgoing circuit owns exactly one protection device.
`CircuitDeviceRow` does not own a protection device.
The circuit and its protection device are projected as one circuit row. The
circuit equipment identifier is also the displayed identifier of its
protection device and must not be stored twice.
Supported protection-device types:
- `D02`
- `NH000`
- `NH00`
- `NH0`
- `NH1`
- `NH2`
- `NH3`
- `LS`
- `FI`
- `FI_LS`
- `AFDD`
`D01` and `NH4` are intentionally excluded.
## Protection-Device Fields
All protection devices have:
- type
- rated current in ampere
Type-dependent fields:
- D02 and NH:
- fuse utilization category `gG` or `gR`
- LS:
- trip characteristic `B`, `C`, `D` or `Z`
- FI:
- RCD type `A`, `AC`, `B` or `B+`
- rated residual current in milliampere
- FI/LS:
- trip characteristic `B`, `C`, `D` or `Z`
- RCD type `A`, `AC`, `B` or `B+`
- rated residual current in milliampere
- AFDD:
- trip characteristic `B`, `C`, `D` or `Z`
- no RCD type
- no rated residual current
Poles are intentionally not stored. The circuit-group category supplies the
required phase context for the current scope.
## Allowed Ratings
### D02
`20, 25, 32, 35, 40, 50, 63 A`
### NH000 and NH00
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160 A`
### NH0
`6, 10, 16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
### NH1
`16, 20, 25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250 A`
### NH2
`25, 32, 35, 40, 50, 63, 80, 100, 125, 160, 200, 224, 250, 300, 315, 355, 400 A`
### NH3
`50, 63, 80, 100, 125, 160, 200, 224, 250, 315, 355, 400, 500, 630, 800 A`
### LS, FI/LS and AFDD
`6, 10, 13, 16, 20, 25, 32, 40, 50, 63, 80, 100, 125 A`
### FI/RCD
Rated currents:
`16, 25, 40, 63, 80, 100, 125 A`
Rated residual currents:
`10, 30, 100, 300, 500 mA`
The device type determines which values and characteristics are valid. UI,
request validation and domain validation use one shared catalog rather than
duplicated free-text rules.
All supported devices that provide overcurrent protection may be selected as a
group upstream protection device. A standalone FI belongs in the separate
optional group-RCD role.
## Default Protection Devices
New circuits receive:
- lighting:
- LS
- 10 A
- characteristic B
- single-phase:
- FI/LS
- 16 A
- characteristic B
- RCD type A
- 30 mA
- three-phase:
- FI/LS
- 16 A
- characteristic B
- RCD type A
- 30 mA
A newly added group RCD defaults to:
- FI
- 40 A
- RCD type A
- 30 mA
An upstream protection component has no automatic default and is created only
when the planner requests it.
## Equipment-Identifier Rules
The leading hyphen is part of every generated identifier.
Group 1 examples:
```text
-1F1.0 upstream protection, lighting
-1Q1.0 group RCD, lighting
-1F1.1 first lighting circuit
-2F1.0 upstream protection, single-phase
-2Q1.0 group RCD, single-phase
-2F1.1 first single-phase circuit
-3F1.0 upstream protection, three-phase
-3Q1.0 group RCD, three-phase
-3F1.1 first three-phase circuit
```
Category numbers:
- lighting: `1`
- single-phase: `2`
- three-phase: `3`
Function letters:
- overcurrent protection and outgoing circuit: `F`
- group RCD: `Q`
The group number follows the function letter. `.0` is reserved for the group
component. Outgoing-circuit suffixes start at `.1`.
Equipment identifiers must be unique across all circuits and components of the
complete circuit list, including header and footer components.
Manual BMK edits must validate that same shared uniqueness boundary.
## Reorder, Move and Renumber Behavior
### Reorder Within One Group
Changing circuit order changes only `sortOrder`. It never changes an equipment
identifier.
### Move to Another Group
A circuit may move only to another group of the same category.
The move:
1. finds the highest existing outgoing-circuit suffix in the target group
2. assigns that suffix plus one
3. moves the complete circuit, its one-to-one protection device and all device
rows
4. commits old and new group, position and identifier atomically
Gaps are not filled. Undo restores the original group, sort position and
equipment identifier. Redo reapplies the same previously determined target
identifier rather than calculating another one.
This automatic identifier change is specific to a cross-group move initiated
by the user. A reorder inside one group never renumbers.
### Reorder Groups
Changing group display order changes only group `sortOrder`. Group numbers and
all equipment identifiers remain unchanged.
### Explicitly Renumber Groups
Group renumbering is a separate explicit action. It:
- changes group numbers according to the requested mapping
- preserves every outgoing circuit suffix
- updates optional `.0` group-component identifiers
- updates every affected circuit identifier
- resolves swaps through collision-safe temporary identifiers
- commits the complete mapping as one project revision
Undo restores the exact previous group numbers and identifiers.
## Delete Behavior
A group may be deleted even when populated, but only after an explicit warning
that summarizes:
- optional upstream protection
- optional group RCD
- number of circuits
- number of circuit device rows
Confirmation deletes the complete group subtree atomically. The inverse
command stores the complete stable snapshots needed to restore all UUIDs,
fields, links, protection devices, group relationships, identifiers and sort
positions.
A stale or partially changed subtree must not be silently deleted or restored.
## UI Projection
The circuit-list editor projects three fixed zones:
1. header:
- `-Q0 Hauptschalter`
- `-FA Überspannungsableiter`
2. groups:
- group heading
- optional upstream protection row
- optional group RCD row
- circuit rows and their optional expanded device rows
3. footer:
- auxiliary distribution-board components
Group indentation is optional presentation. Group ownership must remain clear
through headings, separators or a dedicated group indication.
The grid remains a custom spreadsheet component:
- static text by default
- edit mode only when invoked
- group and auxiliary-component drag handles
- clear valid and invalid drop indicators
- German user-facing labels
Protection editing must show only fields valid for the selected device type.
Changing the type resets or explicitly confirms removal of fields that the new
type cannot use.
## History and Command Boundary
New project-scoped writes must use typed persistent commands and the existing
shared project-command transaction boundary.
Required logical operations:
- insert/update/delete auxiliary component
- reorder auxiliary components
- insert/update/delete group protection component
- insert/delete group
- reorder groups
- explicitly renumber groups
- move circuit between same-category groups with deterministic BMK assignment
- update circuit protection device
Creating a distribution board includes its fixed components, three default
groups, circuit list and all stable UUIDs in one `distribution-board.insert`
command.
Every command must:
- require `expectedRevision`
- validate complete project/list/group ownership
- validate shared BMK uniqueness inside the write transaction
- contain deterministic forward and inverse data
- commit the domain mutation, revision and history transition atomically
- remain undoable and redoable after application restart
## Snapshot and Transfer Boundary
The supported logical project snapshot and portable JSON transfer must include:
- group category and group number
- fixed, group and auxiliary components
- circuit protection-device state
- all new component relationships and sort positions
Current project-state schema version `2` contains the complete supported
structure above and the project building classification. It is shared by
logical snapshots, restore, Undo/Redo and portable JSON transfer. Supported
baseline version `1` is upgraded with the non-public default; payloads from
before that baseline are rejected before any write.
Migration `0000` creates the complete relational model on an empty database.
New defaults apply when boards, groups or circuits are created; they never
silently overwrite a planner's later selection.
## Later Sizing and Warning Engine
The selected protection device is the planner-owned value. Later sizing logic
will calculate a recommendation separately.
Target behavior:
```text
stored selection
-> calculation and validation
-> valid: no warning
-> invalid: colored warning plus recommended protection
-> user explicitly accepts or rejects the recommendation
```
The same pattern applies later to cable cross-section and length. Suggestions
never silently overwrite selected values.
Dimensioning rules and additional inputs are defined in a separate future
specification supplied by the planner.
## Delivery Phases
### A. Catalog and Pure Domain Rules
Status: Complete.
- introduce typed component roles, categories and protection-device types
- centralize rating and characteristic catalogs
- validate device-specific field combinations
- define default circuit and group-RCD protection values
- test numbering parsing and formatting independently from persistence
Implemented foundation:
- shared circuit-group categories, German labels and category numbers
- one protection-device catalog with all agreed ratings and characteristics
- strict device-dependent validation for fuse, LS, FI, FI/LS and AFDD fields
- pure factories for circuit and group-RCD defaults
- pure nested BMK formatting, parsing and highest-number-plus-one rules
- no runtime database or editor integration before delivery phase B
Acceptance:
- every agreed valid combination is accepted
- invalid rating/type/field combinations are rejected
- defaults produce the exact agreed values
### B. Persistence and Snapshot Model
Status: Complete.
- add group category and group number
- add distribution-board component persistence
- add one-to-one circuit protection persistence
- implement shared BMK uniqueness
- create the complete relational baseline schema
- include the model in snapshots and portable transfers
- preserve identifiers and protection values within supported project state
Implemented persistence:
- baseline migration `0000` creates group identity, components and protection
state directly for clean installations
- separate component and circuit/component protection tables preserve the
one-to-one ownership boundaries
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
across circuits and distribution-board components
- snapshot schema version `2` includes group identity, distribution-board
components and both one-to-one protection collections
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
transfers preserve the complete new relational state
- duplicate imports remap component UUIDs and both protection-owner references
together with the existing project structure
- snapshots reject invalid ownership, duplicate group numbers,
cross-entity BMKs and invalid protection configurations before persistence
Acceptance:
- an empty database creates the complete schema
- unsupported pre-release snapshots and transfers are rejected before writing
- BMK collisions across circuits and components are rejected
### C. Persistent Commands and Board Defaults
Status: Complete.
- extend `distribution-board.insert` with fixed components and three groups
- implement component and group CRUD commands
- implement group and footer reordering
- add complete inverse snapshots and rollback tests
Implemented in C1:
- `distribution-board.insert` schema version `1` carries stable UUIDs for three
initial groups, main switch `-Q0` and surge protective device `-FA`
- its inverse verifies and removes the unchanged complete structure; Redo
restores the same UUIDs
- a late revision/history failure rolls back board, list, groups and components
together
- unsupported command schema versions are rejected
Implemented in C2a1:
- `distribution-board-component.insert` and
`distribution-board-component.delete` persist complete component snapshots
with their optional one-to-one protection-device configuration
- mutable group protection components validate their group ownership and
protection-device combination before writing
- auxiliary components remain footer entries without protection-device data
- fixed header roles are excluded from these general CRUD commands
- deletion requires an exact unchanged snapshot; Undo/Redo restores the same
component UUID and a late revision/history failure rolls back the complete
write
Implemented in C2a2:
- `distribution-board-component.update` exchanges one complete expected
component/protection snapshot for one complete target snapshot
- name, BMK, protection configuration and `sortOrder` can change atomically
- component id, list/group ownership, role and placement zone remain immutable
through this general update command
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
restores the exact previous snapshot
Implemented in C2b1:
- `circuit-group.insert`, `circuit-group.update` and
`circuit-group.delete` persist complete group snapshots with stable UUIDs
- category, group number and prefix are validated as one consistent identity;
general updates change only the display name
- deletion requires an exact unchanged and empty group; populated destructive
deletion remains the separately confirmed Phase-D operation
- project/list ownership, stale state, Undo/Redo and late rollback are covered
at the shared transaction boundary
Implemented in C2b2:
- `circuit-group.reorder` requires every group in the circuit list with its
exact expected and target `sortOrder`
- sorting changes no group number, prefix or circuit equipment identifier
- incomplete or stale assignments are rejected before history is recorded
- the complete reorder is one project revision and one persistent Undo/Redo
step; late failures roll back every position
Acceptance:
- new boards contain the agreed fixed structure
- every structural write survives reload and supports Undo/Redo
- forced late failures leave no partial components or groups
### D. Group Numbering and Circuit Moves
Status: Complete.
- implement nested identifier generation
- support same-category cross-group circuit moves
- implement explicit collision-safe group renumbering
- implement confirmed populated-group deletion
Implemented in D1a:
- a pure renumber planner derives group prefixes, circuit BMKs and optional
upstream-protection/RCD BMKs from category and target group number
- outgoing-circuit suffixes remain unchanged
- number swaps are allowed only when every conflicting group participates
- collisions with unchanged groups, no-op mappings and mismatched current BMKs
are rejected before persistence
Implemented in D1b:
- `circuit-group.renumber` persists the complete expected/target plan as one
project revision
- group-number swaps use collision-safe temporary group identities and BMKs
across both circuit and component tables
- every current circuit and group component must be included unchanged before
execution starts
- Undo/Redo uses the exact inverse plan; late revision/history failures roll
back every group, prefix and BMK
Implemented in D2a:
- a pure planner permits moves only between distinct groups of the same
category
- it validates the source BMK and stores source/target group and sort position
- the target BMK uses the highest existing target-group circuit suffix plus one
and never fills gaps
- the computed target BMK is part of the plan and is not recalculated for Redo
Implemented in D2b:
- `circuit.move-group` atomically changes only the circuit's group, BMK and
sort position
- the circuit UUID remains stable, so its device rows and one-to-one protection
device remain connected without copying
- execution revalidates project/list ownership, both group identities and the
complete expected circuit state
- Undo/Redo uses the stored inverse move and late failures restore source
group, BMK and position
Implemented in D3a:
- one validated subtree snapshot combines the group, optional group components
and their protection devices, complete circuits and their protection devices,
and all circuit device rows
- linked-project-device and override metadata remain part of every row snapshot
- ownership and BMK uniqueness are checked across the complete subtree
- the snapshot derives the confirmation summary for upstream protection, RCD,
circuit count and device-row count
Implemented in D3b:
- `circuit-group.delete-subtree` requires the exact previously confirmed
snapshot and re-captures the full current subtree before deletion
- `circuit-group.restore-subtree` restores stable UUIDs in foreign-key-safe
order, including both protection layers and row link/override metadata
- deletion and restoration are exact persistent inverses for Undo/Redo
- stale confirmation state and late history failures leave no partial delete
or restore
Acceptance:
- target identifiers use highest suffix plus one
- reordering never renumbers
- cross-category moves are rejected
- group renumbering preserves circuit suffixes
- delete, move and renumber Undo restore exact prior UUIDs and BMKs
### E. Editor Projection and Editing
Status: Complete.
- render fixed header components
- render group components and circuit blocks
- render sortable auxiliary footer components
- add group management and destructive confirmations
- add type-dependent protection editing
- keep spreadsheet selection, keyboard and drag behavior coherent
Implemented in E1/E2a/E2b/E3a:
- the tree response exposes fixed header, group and auxiliary footer
components plus both one-to-one protection layers
- a pure projection produces the stable order header, complete groups and
footer without mixing zones
- each group keeps its heading, optional protection components, complete
circuit blocks and free placeholder together
- empty groups remain visible and circuit device rows remain owned by their
circuit block
- the editor renders fixed header, group protection and auxiliary footer
components as read-only full-width rows
- structural component rows are excluded from spreadsheet cell navigation,
insertion and deletion commands until their dedicated editing controls exist
- group headings offer at most one optional upstream protection device and one
optional group RCD; the agreed group-RCD default is applied only when it is
created
- mutable group protection and auxiliary footer components use labeled modals,
type-dependent protection fields and persistent project commands
- fixed main-switch and surge-protection header components remain read-only
- users can create a group in any supported category; its number is the
category's highest existing number plus one and its prefix is generated
- group editing changes only the display name; category, number, prefix and
child BMKs remain stable
- an exactly empty group can be removed through the compact persistent group
command
- `circuit-protection.update` inserts or updates one validated 1:1 protection
snapshot, rejects stale state and preserves exact persistent Undo/Redo
- user commands cannot remove circuit protection; a nullable target exists
only as the inverse of adding protection to a retained circuit without it
- newly inserted circuits and placeholder targets carry the category-specific
default protection in their complete atomic snapshot
- circuit deletion and restoration preserve the exact 1:1 protection row
together with the circuit and all device rows
- the editor modal exposes only fields valid for the selected protection type;
the old flat protection columns now project the new 1:1 data read-only
- group controls move a group only to the adjacent peer of the same category;
the complete list order is one persistent command and no number or BMK changes
- explicit category-wide group renumbering follows the visible persisted group
order, updates every affected prefix and child BMK atomically and preserves
outgoing-circuit suffixes
- dragging one circuit into another group of the same category moves the
complete circuit and assigns the highest target suffix plus one; same-group
multi-circuit drag remains a pure reorder
- populated-group deletion shows the affected circuit, device-row and group
protection counts before sending the complete subtree to the guarded
delete/restore command
Acceptance:
- the visible order is header, groups, footer
- users can create, configure, reorder and delete groups
- users can configure circuit and group protection without invalid fields
- device rows remain visually and technically subordinate to their circuit
### F. Documentation and Full Verification
Status: Complete. Documentation, automated verification and the agreed GUI
checklist are complete.
- update current architecture, API and interaction documentation
- update the safe local demo-data instructions; no versioned customer-like
seed data is introduced
- run focused domain and transaction tests
- run the complete required test, build and type-check suite
- perform the agreed GUI test checklist
Acceptance:
- documentation distinguishes implemented behavior from later sizing
- clean installation and baseline migration work
- snapshots, JSON transfer and Undo/Redo cover all new state
## Risks and Mitigations
- Incorrect implicit BMK changes:
preserve identifiers during sorting and require explicit renumbering.
- Cross-entity BMK collisions:
enforce one transaction-level uniqueness boundary for the complete list.
- Partial destructive writes:
persist complete subtree commands through the shared transaction wrapper.
- Bloated grid logic:
add pure projection and ownership modules before React rendering changes.
- Invalid protection combinations:
use one shared typed catalog for UI, API and domain validation.
- Silent planning changes:
apply defaults only on creation and keep later recommendations explicit.
- Future snapshot evolution:
introduce a new explicit schema version and upgrader only after defining its
supported compatibility window.
## Resolved Implementation Decisions
- a trigger-maintained circuit-list registry enforces shared BMK uniqueness
across circuits and distribution-board components
- group headings and full-width structural rows show ownership without adding
a permanent group column
- structural component rows remain outside editable-cell filtering and
spreadsheet navigation; circuit filtering continues to preserve complete
circuit blocks
- explicit group renumbering updates grouped identifiers collision-safely
- fixed header components remain read-only; mutable group and auxiliary
components use their dedicated modals
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,402 @@
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung
## Status
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
noch nicht.
Fachliche Quelle bleibt
[Revit-CSV-Integration Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die
Reihenfolge kleiner Arbeitspakete.
## Ergebnis des Bestandsaudits
### Beibehalten
- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis
viele `CircuitDeviceRow`-Datensätze.
- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf
diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht
ausdrücklich bestätigt.
- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung
zwischen einer Gerätezeile und genau einer Projektgerätevorlage.
- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte,
Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen
vierten, fachlich eigenen Pfad.
- Alle bestätigten Änderungen verwenden typisierte Commands mit
`expectedRevision`, vollständiger Gegenrichtung und
`project-command-transaction.persistence.ts` als gemeinsamer
Transaktionsgrenze.
- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit
fester Fremdschlüsselreihenfolge und UUID-Remapping.
- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte
Laufzeitadapter.
### Erweitern oder gezielt refaktorieren
- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den
bestätigten externen Projektzustand erweitert. Bereits veröffentlichte
Snapshot-Versionen werden nicht verändert.
- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration,
Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine
konkreten SQLite-Abhängigkeiten.
- Der Stromkreislisteneditor erhält später einen separaten Drawer für
unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht
dafür zweckentfremdet.
- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer
`CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“.
### Hinzufügen
- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain;
- projektweite, versionierte Revit-CSV-Konfiguration;
- eine zunächst implizite externe Modellquelle je Projekt;
- Importbatches einschließlich Originaltransport und akzeptiertem
Konfigurationsstand;
- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und
Planungswerten und einzelnem Lebenszyklus;
- Quellraum-Mappings auf interne Räume und Standardverteilungen;
- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler
abweichender Verteilung;
- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices;
- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und
End-to-End-Tests.
### Entfernen
Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14
kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt.
## Vorgeschlagenes Domänenmodell
Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die
jeweilige englische Pluralform in `snake_case`.
### `ExternalModelSource`
- stabile interne UUID;
- `projectId`;
- technischer Name und Quelltyp `revit_csv`;
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
abgeleitet und nicht als redundanter Zeiger gespeichert.
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an
`(sourceId, ifcGuid)`.
### `ExternalCsvConfiguration`
- stabile UUID und `projectId` mit genau einer aktiven Konfiguration;
- Konfigurationsschema-Version;
- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen,
Dezimalformat und Kopfzeilenerkennung;
- exakte Spaltenzuordnung;
- geordnete, exakte Familie-und-Typ-Regeln;
- monotoner Konfigurationsstand.
Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in
einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei
verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar,
ohne eine zweite veränderbare Historientabelle einzuführen.
### `ExternalImportBatch`
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
- Dateiname, SHA-256 und zugehörige Projektrevision;
- verwendete monotone Konfigurationsversion und vollständiger
Konfigurationssnapshot;
- Originaldatei als Bytes;
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes
belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten
Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten,
Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren.
### `ExternalRoomMapping`
- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel;
- Quellgeschoss-, Raumnummer- und Raumname-Snapshot;
- optionales `roomId`;
- optionale `defaultDistributionBoardId`.
Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält
das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung.
### `ExternalModelObject`
- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`;
- letzter gesehener und letzter akzeptierter Importbatch;
- akzeptierte Quellwerte getrennt als strukturierter Snapshot;
- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame
Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe
und Bemerkung;
- explizite Override-Metadaten;
- optionales `externalRoomMappingId`;
- optionale abweichende `distributionBoardId`;
- optionales `linkedProjectDeviceId`;
- optionales `circuitDeviceRowId`;
- Anwesenheitsstatus für bestätigte fehlende Objekte.
Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele
einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet
sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre
wirksame Menge größer eins.
Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem
Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter
Anwesenheits- oder Planungszustand wird persistiert.
## Mengenmodell der Gerätezeile
Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller
Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines
Objekts manuelle Mengen löschen oder doppelt zählen.
Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt:
```text
quantity = manualQuantity
+ Summe(effectiveQuantity aller verknüpften ExternalModelObjects)
```
- Bei der Migration erhält jede bestehende Row
`manualQuantity = quantity`; ihr Ergebnis bleibt unverändert.
- Eine ausschließlich aus externen Objekten erstellte Row startet mit
`manualQuantity = 0`.
- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende
Berechnungen, Filter und Exporte und wird im selben Command aktualisiert.
- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet
sein.
- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte
übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht
automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine
ausdrückliche Nutzerentscheidung.
- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim
Lösen bleibt der manuelle Anteil erhalten.
Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere
Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller
Row-Lebenszyklus ist ausgeschlossen.
## Command-Schnittstellen
Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte
Snapshots und werden mit der aktuellen Projektrevision geplant. Große
Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch
bleiben und dürfen spätere Quellstände nicht erneut auswerten.
- `external-csv-configuration.update`: vollständige projektweite
Konfiguration ändern.
- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe
Objekte und bestätigte Links atomar einfügen.
- `external-import.apply-follow-up`: neuen Batch und die vollständigen
erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte
atomar übernehmen.
- `external-model-object.update-planning`: lokale Werte und Overrides eines
oder mehrerer Objekte ändern.
- `external-model-object.update-project-device-link`: ProjectDevice-Links
explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
und alle betroffenen materialisierten Mengen atomar aktualisieren.
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
Command gespeichert und bei Redo nicht neu berechnet.
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
weiterhin unveränderte fehlende Objekte löschen.
Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen
`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare
Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und
Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern
keine Projektrevision und laufen über einen eigenen Audit-Store.
## Snapshot-, Transfer- und Migrationsfolgen
- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das
Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2
ergänzt `externalCsvConfiguration = null`.
- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und
Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt
leere externe Collections.
- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende
Werte werden einmalig aus `quantity` übernommen.
- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte
Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge.
- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links
remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte
bleiben unverändert.
- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene
Project-State-Schema-Version wird additiv erhöht.
- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert.
## Implementierungsfolge
### 14.1 CSV-Konfiguration und reine Vorschau
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine
synthetische Fixtures ergänzen. **Erledigt.**
2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
synthetische Testdaten; die Referenzdatei steht noch aus.**
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
gegen die Vorschau geprüft.
**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme
erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
einen Commit aufgenommen.
### 14.2 Persistente Objekte und Erstimport
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
weiterhin keinen Import-Write außerhalb eines Commands.
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
lassen IFCGUID und Quelltransport jedoch unverändert.
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
### 14.3 Editor und explizite Zuordnung
1. `manualQuantity` und Mengeninvarianten migrieren und testen.
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
**Teilweise erledigt:** `external-object.update-row-assignment` deckt
Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und
Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum,
Selektionskennzeichen und Planungswerte, verlangt für bestätigbare
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
Standardschutz und genau einer externen Row wird gemeinsam mit den
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
**Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene
GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers,
unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker,
Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden;
Drawer, Filter und Mengenwirkung folgen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen.
Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl,
Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt.
### 14.4 Strukturtreuer Rückexport
1. Exportprojektion gegen eine bekannte Projektrevision ergänzen.
2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen.
3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten.
4. Download und append-only Exportnachweis ergänzen.
Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich
festgelegt.
### 14.5 Folgeimport und Konflikte
1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen.
2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren.
3. Einzel- und Sammelentscheidungen im Wizard ergänzen.
4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo
übernehmen.
### 14.6 Härtung und End-to-End-Abnahme
1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren.
2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport,
Snapshot/Restore und Projektduplikat prüfen.
3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen.
4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich
ausgelieferten Stand nachziehen.
## Risiken und Schutzmaßnahmen
- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile;
Commands ändern Links und materialisierte Menge gemeinsam.
- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind
getrennt; jede Übernahme benötigt eine Nutzerentscheidung.
- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage;
deren bestehende Commands bleiben alleinige Schreibgrenze.
- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix,
Dialekt, Konfiguration, Hash und Projektrevision.
- **Sehr große Undo-Payloads:** Importcommands speichern vollständige
erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen,
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
## Freigabepunkt
Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine
Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket
ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus.
+4 -4
View File
@@ -13,15 +13,15 @@
"build": "npm run build:api",
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test": "node scripts/run-tests.mjs",
"test:watch": "node scripts/run-tests.mjs --watch",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts"
},
"keywords": [],
"author": "",
-25
View File
@@ -1,25 +0,0 @@
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
const projectRepository = new ProjectRepository();
const circuitListRepository = new CircuitListRepository();
const circuitSectionRepository = new CircuitSectionRepository();
async function run() {
const projects = await projectRepository.list();
let totalLists = 0;
for (const project of projects) {
const lists = await circuitListRepository.listByProject(project.id);
for (const list of lists) {
await circuitSectionRepository.createDefaults(list.id);
totalLists += 1;
}
}
console.log(`Section backfill done for ${totalLists} circuit list(s).`);
}
run().catch((error) => {
console.error("Section backfill failed:", error);
process.exit(1);
});
-54
View File
@@ -1,54 +0,0 @@
import { db } from "../src/db/client.js";
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
const projectRepository = new ProjectRepository();
const circuitListRepository = new CircuitListRepository();
const migrationRepository = new LegacyConsumerMigrationRepository(db);
const migrationService = new LegacyConsumerMigrationService(
migrationRepository
);
async function run() {
const projects = await projectRepository.list();
const reports = [];
for (const project of projects) {
const lists = await circuitListRepository.listByProject(project.id);
for (const list of lists) {
const report = await migrationService.migrateCircuitList(project.id, list.id);
reports.push({
projectId: project.id,
circuitListId: list.id,
legacyConsumerCount: report.legacyConsumerCount,
createdCircuitCount: report.createdCircuitCount,
createdDeviceRowCount: report.createdDeviceRowCount,
generatedIdentifiers: report.generatedIdentifiers.length,
unassignedRows: report.unassignedRows.length,
});
}
}
const unmigratedConsumers =
await migrationRepository.listUnmigratedConsumers();
if (unmigratedConsumers.length > 0) {
const withoutCircuitList = unmigratedConsumers.filter(
(consumer) => !consumer.circuitListId
).length;
throw new Error(
`Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` +
` (${withoutCircuitList} without a circuit list).`
);
}
console.log("Legacy consumer migration summary:");
console.table(reports);
console.log("Cutover verification passed: all legacy consumers are mapped.");
}
run().catch((error) => {
console.error("Legacy consumer migration failed:", error);
process.exit(1);
});
+155 -4
View File
@@ -8,12 +8,19 @@ const requiredTables = [
"circuit_sections",
"circuits",
"circuit_device_rows",
"legacy_consumer_circuit_migrations",
"legacy_consumer_migration_reports",
"circuit_list_equipment_identifiers",
"circuit_protection_devices",
"distribution_board_components",
"distribution_board_component_protection_devices",
];
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)")
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table' AND name IN (${tablePlaceholders})`
)
.all(...requiredTables);
const existing = new Set(rows.map((row) => row.name));
@@ -33,6 +40,13 @@ const projectDeviceColumns = new Set(
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
(name) => !projectDeviceColumns.has(name)
);
const requiredCircuitDeviceRowColumns = ["manual_quantity"];
const circuitDeviceRowColumns = new Set(
db.prepare("PRAGMA table_info(circuit_device_rows)").all().map((column) => column.name)
);
const missingCircuitDeviceRowColumns = requiredCircuitDeviceRowColumns.filter(
(name) => !circuitDeviceRowColumns.has(name)
);
const removedProjectDeviceColumns = [
"installed_power_per_unit_kw",
"demand_factor",
@@ -48,12 +62,99 @@ const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const removedCircuitColumns = [
"protection_type",
"protection_rated_current",
"protection_characteristic",
];
const remainingRemovedCircuitColumns = removedCircuitColumns.filter((name) =>
circuitColumns.has(name)
);
const requiredCircuitSectionColumns = ["category", "group_number"];
const circuitSectionColumns = new Set(
db
.prepare("PRAGMA table_info(circuit_sections)")
.all()
.map((column) => column.name)
);
const missingCircuitSectionColumns = requiredCircuitSectionColumns.filter(
(name) => !circuitSectionColumns.has(name)
);
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
"building_owner",
"description",
"is_public_building",
"enabled_distribution_board_supply_types",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
);
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredDistributionBoardColumns = [
"floor_id",
"supply_type",
"simultaneity_factor",
];
const distributionBoardColumns = new Set(
db
.prepare("PRAGMA table_info(distribution_boards)")
.all()
.map((column) => column.name)
);
const missingDistributionBoardColumns =
requiredDistributionBoardColumns.filter(
(name) => !distributionBoardColumns.has(name)
);
const requiredEquipmentIdentifierTriggers = [
"circuits_equipment_identifier_insert",
"circuits_equipment_identifier_update",
"circuits_equipment_identifier_delete",
"distribution_board_components_equipment_identifier_insert",
"distribution_board_components_equipment_identifier_update",
"distribution_board_components_equipment_identifier_delete",
];
const equipmentIdentifierTriggers = new Set(
db
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'trigger'`
)
.all()
.map((trigger) => trigger.name)
);
const missingEquipmentIdentifierTriggers =
requiredEquipmentIdentifierTriggers.filter(
(name) => !equipmentIdentifierTriggers.has(name)
);
const integrityCheck = db.pragma("integrity_check", { simple: true });
const foreignKeyViolations = db.pragma("foreign_key_check");
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit-device-row columns:", requiredCircuitDeviceRowColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log(
"Required circuit-section columns:",
requiredCircuitSectionColumns.join(", ")
);
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
);
console.log(
"Required BMK synchronization triggers:",
requiredEquipmentIdentifierTriggers.join(", ")
);
console.log("Integrity:", integrityCheck);
console.log("Foreign-key violations:", foreignKeyViolations.length);
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
@@ -78,4 +179,54 @@ if (missingCircuitColumns.length > 0) {
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
if (missingCircuitDeviceRowColumns.length > 0) {
console.error(
"Missing circuit-device-row columns:",
missingCircuitDeviceRowColumns.join(", ")
);
process.exit(1);
}
if (remainingRemovedCircuitColumns.length > 0) {
console.error(
"Transitional circuit protection columns still present:",
remainingRemovedCircuitColumns.join(", ")
);
process.exit(1);
}
if (missingCircuitSectionColumns.length > 0) {
console.error(
"Missing circuit-section columns:",
missingCircuitSectionColumns.join(", ")
);
process.exit(1);
}
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
}
if (missingDistributionBoardColumns.length > 0) {
console.error(
"Missing distribution-board columns:",
missingDistributionBoardColumns.join(", ")
);
process.exit(1);
}
if (missingEquipmentIdentifierTriggers.length > 0) {
console.error(
"Missing BMK synchronization triggers:",
missingEquipmentIdentifierTriggers.join(", ")
);
process.exit(1);
}
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
console.error("Database integrity or foreign-key verification failed.");
process.exit(1);
}
console.log("Circuit-first and distribution-component schema verification passed.");
+30
View File
@@ -0,0 +1,30 @@
import { readdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
const testFiles = readdirSync(path.resolve("tests"), {
withFileTypes: true,
})
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts"))
.map((entry) => path.join("tests", entry.name))
.sort();
if (testFiles.length === 0) {
throw new Error("No TypeScript test files found.");
}
const watch = process.argv.includes("--watch");
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
"--test",
"--test-concurrency=1",
...(watch ? ["--watch"] : []),
...testFiles,
],
{ stdio: "inherit" }
);
process.exit(result.status ?? 1);
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import {
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
const filePath = resolve(
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
);
const source = readFileSync(filePath);
const configuration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
configuration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const document = parseExternalCsv(source, configuration);
const counts = Object.fromEntries(
["metadata", "header", "passthrough", "object", "suspect-object"].map(
(classification) => [
classification,
document.rows.filter((row) => row.classification === classification).length,
]
)
);
const nonEmptyPassthroughRows = document.rows.filter(
(row) =>
row.classification === "passthrough" &&
row.cells.some((cell) => cell.value !== "")
).length;
const serialized = serializeExternalCsv(document);
const preview = createExternalCsvPreview({
fileName: filePath,
bytes: source,
configuration,
});
assert.equal(counts.object, 897, "Expected 897 object rows.");
assert.equal(preview.objectCount, 897, "Expected 897 preview objects.");
assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
assert.equal(
Buffer.compare(Buffer.from(serialized), source),
0,
"Expected a byte-identical CSV round-trip."
);
const sha256 = createHash("sha256").update(source).digest("hex");
process.stdout.write(
`${JSON.stringify(
{
file: filePath,
bytes: source.length,
sha256,
dialect: document.dialect,
rows: counts,
nonEmptyPassthroughRows,
roundTripByteIdentical: true,
previewObjectCount: preview.objectCount,
},
null,
2
)}\n`
);
+476 -45
View File
@@ -1,9 +1,303 @@
/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md),
projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite).
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
:root {
--color-primary: #1c3f52; /* Petrol */
--color-primary-dark: #0f2733; /* Petrol Dark */
--color-primary-deep: #0a1920; /* Petrol Deep */
--color-accent: #4e7e93; /* Petrol Light */
--color-accent-pale: #9dbac9; /* Petrol Pale */
--color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */
--color-signal-dark: #237a47;
--color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */
--color-attention-dark: #9c5a26;
--color-ink: #182229; /* Graphite */
--color-ink-soft: #59666d; /* Slate */
--color-bg: #ffffff;
--color-surface: #f1f2ee; /* Fog */
--color-border: #dee2dc;
--color-danger: #b04035;
--color-danger-bg: #f7e6e3;
--color-warn: #a9791e;
--font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
--font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--radius: 12px;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #f5f7fb;
background-color: var(--color-surface);
color: var(--color-ink);
}
a {
color: var(--color-primary);
}
/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */
.btn-primary {
--bs-btn-bg: var(--color-primary);
--bs-btn-border-color: var(--color-primary);
--bs-btn-hover-bg: var(--color-primary-dark);
--bs-btn-hover-border-color: var(--color-primary-dark);
--bs-btn-active-bg: var(--color-primary-deep);
--bs-btn-active-border-color: var(--color-primary-deep);
--bs-btn-disabled-bg: var(--color-primary);
--bs-btn-disabled-border-color: var(--color-primary);
}
.btn-outline-primary {
--bs-btn-color: var(--color-primary);
--bs-btn-border-color: var(--color-primary);
--bs-btn-hover-bg: var(--color-primary);
--bs-btn-hover-border-color: var(--color-primary);
--bs-btn-active-bg: var(--color-primary-dark);
--bs-btn-active-border-color: var(--color-primary-dark);
}
.btn-success,
.btn-outline-success {
--bs-btn-bg: var(--color-signal);
--bs-btn-border-color: var(--color-signal);
--bs-btn-color: #fff;
--bs-btn-hover-bg: var(--color-signal-dark);
--bs-btn-hover-border-color: var(--color-signal-dark);
--bs-btn-active-bg: var(--color-signal-dark);
--bs-btn-active-border-color: var(--color-signal-dark);
}
.btn-outline-success {
--bs-btn-bg: transparent;
--bs-btn-color: var(--color-signal-dark);
--bs-btn-hover-color: #fff;
}
.form-control:focus,
.form-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15);
}
.form-check-input:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.text-bg-secondary {
background-color: var(--color-surface) !important;
color: var(--color-ink-soft) !important;
}
.alert-success {
--bs-alert-color: var(--color-signal-dark);
--bs-alert-bg: #eaf6f0;
--bs-alert-border-color: #b6e2ce;
}
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: linear-gradient(
160deg,
var(--color-primary-deep) 0%,
var(--color-primary-dark) 45%,
var(--color-primary) 100%
);
color: #fff;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-brand {
padding: 22px 22px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.sidebar-wordmark {
font-size: 1.2rem;
font-weight: 700;
color: #fff;
}
.sidebar-bar {
height: 3px;
background: linear-gradient(90deg, var(--color-signal), var(--color-attention));
margin-top: 9px;
width: 42px;
border-radius: 2px;
}
.sidebar-tag {
font-size: 0.72rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--color-accent-pale);
margin-top: 10px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px;
}
.sidebar-section-label {
font-size: 0.66rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(199, 214, 224, 0.7);
padding: 16px 14px 4px;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
color: #c7d6e0;
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
border: 0;
background: transparent;
width: 100%;
text-align: left;
cursor: pointer;
}
.sidebar-link:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
text-decoration: none;
}
.sidebar-link.active {
background: rgba(255, 255, 255, 0.14);
color: #fff;
box-shadow: inset 3px 0 0 var(--color-signal);
}
.sidebar-icon {
width: 1.1em;
display: inline-flex;
justify-content: center;
}
.app-shell-content {
width: 100%;
min-width: 0;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent);
margin-bottom: 2px;
font-weight: 600;
}
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.kpi {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 13px 15px;
display: block;
color: inherit;
text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s;
}
a.kpi:hover {
color: inherit;
text-decoration: none;
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
transform: translateY(-1px);
}
.kpi-label {
font-size: 0.66rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-ink-soft);
margin-bottom: var(--space-xs);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.kpi-value {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.1;
color: var(--color-primary);
}
.card {
border-radius: 0.5rem;
border-radius: var(--radius);
border-top: 3px solid var(--color-primary);
box-shadow:
0 1px 2px rgba(15, 39, 51, 0.05),
0 10px 24px -14px rgba(15, 39, 51, 0.22);
}
.card-header {
font-weight: 700;
color: var(--color-primary);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.table td input.form-control-sm,
@@ -11,35 +305,6 @@ body {
min-width: 8rem;
}
.circuit-tree-table .section-row td {
background: #e9eef8;
border-top: 2px solid #c6d3ea;
font-weight: 600;
}
.circuit-tree-table .summary-row td {
background: #f5f8fd;
font-weight: 600;
}
.circuit-tree-table .device-row td {
background: #ffffff;
}
.circuit-tree-table .reserve-row td {
background: #f8fbff;
}
.circuit-tree-table .placeholder-row td {
background: #f7f7f7;
color: #6c757d;
font-style: italic;
}
.circuit-tree-table .indented-cell {
padding-left: 1.5rem;
}
.tree-editor-shell {
display: flex;
flex-direction: column;
@@ -71,6 +336,13 @@ body {
opacity: 0.45;
}
.editor-toolbar .project-device-drawer-toggle {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-weight: 600;
}
.active-view-summary {
display: flex;
flex-wrap: wrap;
@@ -109,6 +381,39 @@ body {
font-weight: 600;
}
.distribution-power-summary {
display: grid;
grid-template-columns: repeat(3, minmax(12rem, 1fr));
gap: 0.5rem;
padding: 0.55rem;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #f8fafc;
}
.distribution-power-summary > div {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.distribution-power-summary span {
color: #475569;
font-size: 0.74rem;
line-height: 1.2;
}
.distribution-power-summary strong {
color: #172033;
font-size: 0.95rem;
}
@media (max-width: 900px) {
.distribution-power-summary {
grid-template-columns: 1fr;
}
}
.column-settings-menu {
position: absolute;
z-index: 9;
@@ -271,12 +576,10 @@ body {
}
.tree-editor-layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 0.75rem;
display: block;
min-height: 560px;
min-width: 0;
align-items: start;
position: relative;
}
.project-device-sidebar {
@@ -354,7 +657,7 @@ body {
.tree-grid {
width: max-content;
min-width: 100%;
min-width: 0;
border-collapse: collapse;
font-size: 0.9rem;
}
@@ -362,37 +665,50 @@ body {
.tree-grid th,
.tree-grid td {
border: 1px solid #e4e9f2;
padding: 0.35rem 0.5rem;
white-space: nowrap;
padding: 0.35rem 0.4rem;
vertical-align: middle;
}
.tree-grid th {
width: 1px;
background: #f4f7fb;
position: sticky;
top: 0;
z-index: 2;
vertical-align: top;
white-space: normal;
}
.tree-grid td {
white-space: nowrap;
}
.tree-grid .header-cell {
display: flex;
align-items: center;
gap: 0.3rem;
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
min-width: 0;
}
.tree-grid .header-filter-btn {
display: block;
width: 100%;
border: 1px solid #c4cddc;
background: #fff;
border-radius: 3px;
font-size: 0.75rem;
padding: 0.15rem 0.35rem;
white-space: nowrap;
}
.tree-grid .header-sort-btn {
display: inline-flex;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
padding: 0;
@@ -400,10 +716,46 @@ body {
font: inherit;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.15;
overflow-wrap: anywhere;
white-space: normal;
text-align: left;
cursor: pointer;
}
.project-device-drawer {
position: fixed;
z-index: 1040;
top: 5.5rem;
right: 1rem;
left: auto;
width: min(360px, calc(100vw - 2rem));
max-height: calc(100vh - 7rem);
overflow: auto;
border-radius: 0.5rem;
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
}
.project-device-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.project-device-drawer-header span {
color: #6b7280;
font-size: 0.75rem;
}
.project-device-drawer-header button {
border: 1px solid #c4cddc;
background: #fff;
border-radius: 4px;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
}
.tree-grid .header-sort-btn:hover span:first-child,
.tree-grid .header-sort-btn:focus-visible span:first-child {
text-decoration: underline;
@@ -570,6 +922,57 @@ body {
font-weight: 600;
}
.tree-grid .structure-component-row td {
padding: 0.38rem 0.6rem;
border-bottom-color: #d8dee9;
}
.tree-grid .structure-component-row.headerComponent td {
background: #e2e8f0;
}
.tree-grid .structure-component-row.groupComponent td {
background: #f8fafc;
}
.tree-grid .structure-component-row.footerComponent td {
background: #f1f5f9;
}
.tree-grid .structure-component-content {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.tree-grid .structure-component-content strong {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.tree-grid .structure-component-protection {
color: #475569;
font-size: 0.78rem;
}
.tree-grid .structure-component-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
}
.tree-grid .structure-component-actions button {
font-size: 0.75rem;
padding: 0.18rem 0.45rem;
}
.tree-grid .structure-component-fixed {
color: #64748b;
font-size: 0.75rem;
}
.tree-grid .section-drop-cell {
padding: 0.45rem 0.6rem;
}
@@ -620,6 +1023,20 @@ body {
cursor: text;
}
.tree-grid .cell-protection-trigger {
color: var(--color-primary);
cursor: pointer;
font-weight: 600;
transition:
background-color 0.12s ease,
box-shadow 0.12s ease;
}
.tree-grid .cell-protection-trigger:hover {
background: #eaf6f0;
box-shadow: inset 0 0 0 1px var(--color-signal);
}
.tree-grid .device-drag-handle {
cursor: grab;
}
@@ -641,6 +1058,18 @@ body {
outline-offset: -2px;
}
.tree-grid .section-title {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.tree-grid .section-title span {
color: #475569;
font-size: 0.78rem;
font-weight: 500;
}
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline-offset: -2px;
@@ -651,7 +1080,8 @@ body {
border-color: #c2410c;
}
.tree-grid input {
.tree-grid input,
.tree-grid select {
width: 100%;
min-width: 5rem;
border: 1px solid #9fb6e0;
@@ -783,8 +1213,9 @@ body {
margin: 0;
}
@media (max-width: 1200px) {
.tree-editor-layout {
grid-template-columns: 1fr;
@media (max-width: 720px) {
.project-device-drawer {
top: 4.5rem;
max-height: calc(100vh - 5.5rem);
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
import { AppShell } from "../frontend/components/AppShell";
export const metadata: Metadata = {
title: "Leistungsbilanz",
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>{children}</body>
<body>
<AppShell>{children}</AppShell>
</body>
</html>
);
}
@@ -8,19 +8,13 @@ export default function CircuitTreeEditPage() {
const params = useParams<{ projectId: string; circuitListId: string }>();
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
<div className="d-flex gap-2">
<Link
href={`/projects/${params.projectId}/circuit-lists/${params.circuitListId}/tree`}
className="btn btn-outline-secondary btn-sm"
>
Strukturvorschau
</Link>
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt
</Link>
@@ -1,38 +0,0 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CircuitTreePreview } from "../../../../../../frontend/components/circuit-tree-preview";
export default function CircuitTreePreviewPage() {
const params = useParams<{ projectId: string; circuitListId: string }>();
const projectId = params.projectId;
const circuitListId = params.circuitListId;
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 className="h4 mb-1">Vorschau der Stromkreisstruktur</h1>
<p className="text-secondary mb-0">
Schreibgeschützte Vorschau der Bereiche, Stromkreise und Gerätezeilen.
</p>
</div>
<Link
href={`/projects/${projectId}/circuit-lists/${circuitListId}/tree-edit`}
className="btn btn-outline-primary btn-sm"
>
Editor öffnen
</Link>
<Link
href={`/projects/${projectId}`}
className="btn btn-outline-secondary btn-sm"
>
Zum Projekt
</Link>
</div>
<CircuitTreePreview projectId={projectId} circuitListId={circuitListId} />
</main>
);
}
File diff suppressed because it is too large Load Diff
+180 -73
View File
@@ -6,16 +6,17 @@ import {
createGlobalDevice,
createProject,
deleteGlobalDevice,
importProjectTransferAsNew,
listGlobalDevices,
listProjects,
updateGlobalDevice,
} from "../../frontend/utils/api";
import type {
CreateGlobalDeviceInput,
GlobalDeviceDto,
ProjectDto,
} from "../../frontend/types";
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
const emptyGlobalDevice: CreateGlobalDeviceInput = {
name: "",
displayName: "",
@@ -23,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
voltageV: 230,
phaseCount: 1,
powerFactor: 1,
note: "",
@@ -38,7 +38,6 @@ function toOptionalNumber(value: string) {
}
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [projectName, setProjectName] = useState("");
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
@@ -48,19 +47,30 @@ export default function ProjectsPage() {
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
});
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [projectImport, setProjectImport] = useState<unknown>(null);
const [projectImportFilename, setProjectImportFilename] = useState("");
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
const [projectImportFileError, setProjectImportFileError] = useState<
string | null
>(null);
const [importedProject, setImportedProject] = useState<{
projectId: string;
name: string;
} | null>(null);
const [createdProject, setCreatedProject] = useState<{
id: string;
name: string;
} | null>(null);
async function loadData() {
setError(null);
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
setProjects(loadedProjects);
setGlobalDevices(loadedGlobalDevices);
setGlobalDevices(await listGlobalDevices());
}
useEffect(() => {
@@ -76,10 +86,12 @@ export default function ProjectsPage() {
}
setIsSaving(true);
setError(null);
setCreatedProject(null);
try {
const created = await createProject(projectName.trim());
setProjects((current) => [...current, created]);
setCreatedProject({ id: created.id, name: created.name });
setProjectName("");
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
} catch (err) {
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
} finally {
@@ -87,6 +99,49 @@ export default function ProjectsPage() {
}
}
async function handleProjectImportFile(file: File | undefined) {
setProjectImport(null);
setProjectImportFilename("");
setProjectImportFileError(null);
setImportedProject(null);
if (!file) {
return;
}
try {
setProjectImport(JSON.parse(await file.text()) as unknown);
setProjectImportFilename(file.name);
} catch {
setProjectImportFileError(
"Die ausgewählte Datei enthält kein gültiges JSON."
);
}
}
async function handleImportProject() {
if (projectImport === null) {
return;
}
setIsSaving(true);
setError(null);
setImportedProject(null);
try {
const imported = await importProjectTransferAsNew(projectImport);
setImportedProject(imported);
setProjectImport(null);
setProjectImportFilename("");
setProjectImportInputKey((current) => current + 1);
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projektdatei konnte nicht importiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!globalDeviceForm.name.trim()) {
@@ -99,7 +154,6 @@ export default function ProjectsPage() {
quantity: Number(globalDeviceForm.quantity),
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
demandFactor: Number(globalDeviceForm.demandFactor),
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
note: globalDeviceForm.note.trim() || undefined,
@@ -116,7 +170,6 @@ export default function ProjectsPage() {
quantity: String(emptyGlobalDevice.quantity),
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
demandFactor: String(emptyGlobalDevice.demandFactor),
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
note: emptyGlobalDevice.note ?? "",
@@ -143,7 +196,7 @@ export default function ProjectsPage() {
async function handleQuickUpdateGlobalDevice(
device: GlobalDeviceDto,
key: "name" | "displayName" | "category",
key: "name" | "displayName" | "category" | "phaseCount",
value: string
) {
const payload: CreateGlobalDeviceInput = {
@@ -153,8 +206,12 @@ export default function ProjectsPage() {
quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor,
voltageV: device.voltageV ?? undefined,
phaseCount: device.phaseCount ?? undefined,
phaseCount:
key === "phaseCount"
? value === "3"
? 3
: 1
: device.phaseCount ?? 1,
powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined,
};
@@ -168,76 +225,107 @@ export default function ProjectsPage() {
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="page-header">
<div>
<h1 className="h3 mb-1">Projekte</h1>
<div className="kicker">Leistungsbilanz</div>
<h1>Projekte</h1>
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
</div>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4">
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card-header">Neues Projekt</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}>
<div>
<label className="form-label">Projektname</label>
<input
className="form-control"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="z. B. Neubau Schule"
/>
<div className="d-flex flex-column gap-4">
<section className="card shadow-sm">
<div className="card-header">Neues Projekt</div>
<div className="card-body" style={{ maxWidth: 480 }}>
<form className="vstack gap-3" onSubmit={handleCreateProject}>
<div>
<label className="form-label">Projektname</label>
<input
className="form-control"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="z. B. Neubau Schule"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Projekt erstellen
</button>
</form>
{createdProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{createdProject.name} wurde angelegt.
</div>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Projekt erstellen
</button>
</form>
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${createdProject.id}`}
>
Projekt öffnen
</Link>
</div>
) : null}
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header">Alle Projekte</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Projekt</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td className="text-end">
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
Projekt öffnen
</Link>
</td>
</tr>
))}
{!projects.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
Noch keine Projekte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<section className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body" style={{ maxWidth: 480 }}>
<p className="text-secondary small">
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
mit neuen internen IDs angelegt.
</p>
<label className="form-label" htmlFor="project-overview-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
disabled={isSaving}
id="project-overview-import"
key={projectImportInputKey}
onChange={(event) =>
void handleProjectImportFile(event.target.files?.[0])
}
type="file"
/>
{projectImportFilename ? (
<div className="form-text">
{projectImportFilename} ist bereit.
</div>
) : null}
{projectImportFileError ? (
<div className="text-danger small mt-1">
{projectImportFileError}
</div>
) : null}
<button
className="btn btn-outline-primary mt-3 w-100"
disabled={isSaving || projectImport === null}
onClick={() => void handleImportProject()}
type="button"
>
Als neues Projekt importieren
</button>
{importedProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{importedProject.name} wurde angelegt.
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${importedProject.projectId}`}
>
Importiertes Projekt öffnen
</Link>
</div>
) : null}
</div>
</section>
</div>
<section className="card shadow-sm mt-4">
<div className="card-header">Globale Geräte / Verbraucher</div>
<section className="card shadow-sm">
<div className="card-header">Globale Geräte / Verbraucher</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
<div className="col-12 col-md-3">
@@ -313,8 +401,8 @@ export default function ProjectsPage() {
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</div>
<div className="col-6 col-md-2">
@@ -334,6 +422,7 @@ export default function ProjectsPage() {
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Phasenart</th>
<th>Aktion</th>
</tr>
</thead>
@@ -376,6 +465,23 @@ export default function ProjectsPage() {
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>
<select
className="form-select form-select-sm"
aria-label={`Phasenart für ${device.displayName}`}
value={String(device.phaseCount ?? 1)}
onChange={(event) =>
void handleQuickUpdateGlobalDevice(
device,
"phaseCount",
event.target.value
)
}
>
<option value="1">1-phasig</option>
<option value="3">3-phasig</option>
</select>
</td>
<td>
<button
className="btn btn-sm btn-outline-danger"
@@ -389,7 +495,7 @@ export default function ProjectsPage() {
))}
{!globalDevices.length ? (
<tr>
<td colSpan={7} className="text-center text-secondary py-4">
<td colSpan={8} className="text-center text-secondary py-4">
Noch keine globalen Geräte vorhanden.
</td>
</tr>
@@ -398,6 +504,7 @@ export default function ProjectsPage() {
</table>
</div>
</section>
</div>
</main>
);
}
@@ -1,28 +0,0 @@
CREATE TABLE `consumers` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`distribution_board_id` text,
`name` text NOT NULL,
`category` text,
`quantity` integer NOT NULL,
`installed_power_per_unit_kw` real NOT NULL,
`demand_factor` real NOT NULL,
`voltage_v` real,
`phase_count` integer,
`power_factor` real,
`note` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `distribution_boards` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL
);
+300
View File
@@ -0,0 +1,300 @@
CREATE TABLE `circuit_device_rows` (
`id` text PRIMARY KEY NOT NULL,
`circuit_id` text NOT NULL,
`linked_project_device_id` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`name` text NOT NULL,
`display_name` text NOT NULL,
`phase_type` text,
`connection_kind` text,
`cost_group` text,
`category` text,
`level` text,
`room_id` text,
`room_number_snapshot` text,
`room_name_snapshot` text,
`quantity` integer NOT NULL,
`power_per_unit` real NOT NULL,
`simultaneity_factor` real NOT NULL,
`cos_phi` real,
`remark` text,
`overridden_fields` text,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `circuit_list_equipment_identifiers` (
`owner_type` text NOT NULL,
`owner_id` text NOT NULL,
`circuit_list_id` text NOT NULL,
`equipment_identifier` text NOT NULL,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_owner_unique` ON `circuit_list_equipment_identifiers` (`owner_type`,`owner_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_list_identifier_unique` ON `circuit_list_equipment_identifiers` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
CREATE TABLE `circuit_lists` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`distribution_board_id` text NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_lists_distribution_board_id_unique` ON `circuit_lists` (`distribution_board_id`);--> statement-breakpoint
CREATE TABLE `circuit_protection_devices` (
`circuit_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real NOT NULL,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `circuit_sections` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`key` text NOT NULL,
`display_name` text NOT NULL,
`prefix` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`category` text,
`group_number` integer,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_key_unique` ON `circuit_sections` (`circuit_list_id`,`key`);--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_prefix_unique` ON `circuit_sections` (`circuit_list_id`,`prefix`);--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_category_group_unique` ON `circuit_sections` (`circuit_list_id`,`category`,`group_number`);--> statement-breakpoint
CREATE TABLE `circuits` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`section_id` text NOT NULL,
`equipment_identifier` text NOT NULL,
`display_name` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`cable_type` text,
`cable_cross_section` text,
`cable_length` real,
`rcd_assignment` text,
`terminal_designation` text,
`voltage` real,
`control_requirement` text,
`status` text,
`is_reserve` integer DEFAULT 0 NOT NULL,
`remark` text,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuits_list_equipment_identifier_unique` ON `circuits` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
CREATE TABLE `distribution_board_component_protection_devices` (
`component_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real NOT NULL,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`component_id`) REFERENCES `distribution_board_components`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `distribution_board_components` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`section_id` text,
`equipment_identifier` text NOT NULL,
`name` text NOT NULL,
`role` text NOT NULL,
`placement` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_list_identifier_unique` ON `distribution_board_components` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_section_role_unique` ON `distribution_board_components` (`section_id`,`role`);--> statement-breakpoint
CREATE TABLE `distribution_boards` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`floor_id` text,
`supply_type` text,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `floors` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `global_devices` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`display_name` text NOT NULL,
`category` text,
`quantity` integer NOT NULL,
`installed_power_per_unit_kw` real NOT NULL,
`demand_factor` real NOT NULL,
`voltage_v` real,
`phase_count` integer,
`power_factor` real,
`note` text
);
--> statement-breakpoint
CREATE TABLE `project_change_sets` (
`id` text PRIMARY KEY NOT NULL,
`project_revision_id` text NOT NULL,
`command_type` text NOT NULL,
`payload_schema_version` integer NOT NULL,
`forward_payload_json` text NOT NULL,
`inverse_payload_json` text NOT NULL,
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
CREATE TABLE `project_devices` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`display_name` text NOT NULL,
`phase_type` text DEFAULT 'single_phase' NOT NULL,
`connection_kind` text,
`cost_group` text,
`category` text,
`quantity` integer NOT NULL,
`power_per_unit` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real,
`remark` text,
`voltage_v` real,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `project_history_stack_entries` (
`change_set_id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`stack` text NOT NULL,
`position` integer NOT NULL,
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);--> statement-breakpoint
CREATE TABLE `project_revisions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`revision_number` integer NOT NULL,
`created_at_iso` text NOT NULL,
`actor_id` text,
`source` text NOT NULL,
`description` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
CREATE TABLE `project_snapshots` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_revision` integer NOT NULL,
`schema_version` integer NOT NULL,
`kind` text DEFAULT 'named' NOT NULL,
`name` text NOT NULL,
`description` text,
`payload_json` text NOT NULL,
`payload_sha256` text NOT NULL,
`created_at_iso` text NOT NULL,
`created_by_actor_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);--> statement-breakpoint
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`internal_project_number` text,
`external_project_number` text,
`building_owner` text,
`description` text,
`single_phase_voltage_v` integer DEFAULT 230 NOT NULL,
`three_phase_voltage_v` integer DEFAULT 400 NOT NULL,
`enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL,
`current_revision` integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE `rooms` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`floor_id` text,
`room_number` text NOT NULL,
`room_name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_normalized_unique`
ON `circuit_list_equipment_identifiers` (`circuit_list_id`, lower(trim(`equipment_identifier`)));
--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_fixed_role_unique`
ON `distribution_board_components` (`circuit_list_id`, `role`)
WHERE `role` IN ('main_switch', 'surge_protective_device');
--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_insert`
AFTER INSERT ON `circuits`
BEGIN
INSERT INTO `circuit_list_equipment_identifiers`
(`owner_type`, `owner_id`, `circuit_list_id`, `equipment_identifier`)
VALUES ('circuit', NEW.`id`, NEW.`circuit_list_id`, NEW.`equipment_identifier`);
END;
--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_update`
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `circuits`
BEGIN
UPDATE `circuit_list_equipment_identifiers`
SET `circuit_list_id` = NEW.`circuit_list_id`,
`equipment_identifier` = NEW.`equipment_identifier`
WHERE `owner_type` = 'circuit' AND `owner_id` = OLD.`id`;
END;
--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_delete`
AFTER DELETE ON `circuits`
BEGIN
DELETE FROM `circuit_list_equipment_identifiers`
WHERE `owner_type` = 'circuit' AND `owner_id` = OLD.`id`;
END;
--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_insert`
AFTER INSERT ON `distribution_board_components`
BEGIN
INSERT INTO `circuit_list_equipment_identifiers`
(`owner_type`, `owner_id`, `circuit_list_id`, `equipment_identifier`)
VALUES ('distribution_board_component', NEW.`id`, NEW.`circuit_list_id`, NEW.`equipment_identifier`);
END;
--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_update`
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `distribution_board_components`
BEGIN
UPDATE `circuit_list_equipment_identifiers`
SET `circuit_list_id` = NEW.`circuit_list_id`,
`equipment_identifier` = NEW.`equipment_identifier`
WHERE `owner_type` = 'distribution_board_component' AND `owner_id` = OLD.`id`;
END;
--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_delete`
AFTER DELETE ON `distribution_board_components`
BEGIN
DELETE FROM `circuit_list_equipment_identifiers`
WHERE `owner_type` = 'distribution_board_component' AND `owner_id` = OLD.`id`;
END;
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `is_public_building` integer DEFAULT false NOT NULL;
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE `global_devices` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`category` text,
`quantity` integer NOT NULL,
`installed_power_per_unit_kw` real NOT NULL,
`demand_factor` real NOT NULL,
`voltage_v` real,
`phase_count` integer,
`power_factor` real,
`note` text
);
@@ -0,0 +1,9 @@
CREATE TABLE `external_csv_configurations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`configuration_version` integer NOT NULL,
`configuration` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `external_csv_configurations_project_id_unique` ON `external_csv_configurations` (`project_id`);
@@ -1,3 +0,0 @@
ALTER TABLE `projects` ADD COLUMN `single_phase_voltage_v` integer NOT NULL DEFAULT 230;
--> statement-breakpoint
ALTER TABLE `projects` ADD COLUMN `three_phase_voltage_v` integer NOT NULL DEFAULT 400;
@@ -0,0 +1,74 @@
CREATE TABLE `external_import_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`import_kind` text NOT NULL,
`imported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`applied_project_revision` integer NOT NULL,
`configuration_snapshot` text NOT NULL,
`original_bytes` blob NOT NULL,
`document` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
CREATE TABLE `external_model_objects` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`ifc_guid` text NOT NULL,
`last_seen_import_batch_id` text NOT NULL,
`last_accepted_import_batch_id` text NOT NULL,
`accepted_source_values` text NOT NULL,
`planning_values` text NOT NULL,
`overridden_fields` text NOT NULL,
`external_room_mapping_id` text,
`distribution_board_id` text,
`linked_project_device_id` text,
`circuit_device_row_id` text,
`presence_status` text DEFAULT 'present' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_objects_source_ifc_guid_unique` ON `external_model_objects` (`source_id`,`ifc_guid`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
CREATE TABLE `external_model_sources` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`source_type` text DEFAULT 'revit_csv' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_sources_project_type_unique` ON `external_model_sources` (`project_id`,`source_type`);--> statement-breakpoint
CREATE TABLE `external_room_mappings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`normalized_source_room_key` text NOT NULL,
`source_floor_name` text,
`source_room_number` text NOT NULL,
`source_room_name` text NOT NULL,
`room_id` text,
`default_distribution_board_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);
@@ -1,19 +0,0 @@
CREATE TABLE `floors` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`sort_order` integer NOT NULL DEFAULT 0,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `rooms` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`floor_id` text,
`room_number` text NOT NULL,
`room_name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `room_id` text REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null;
@@ -1,44 +0,0 @@
CREATE TABLE `circuit_lists` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`distribution_board_id` text NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_lists_distribution_board_id_unique` ON `circuit_lists` (`distribution_board_id`);
--> statement-breakpoint
INSERT INTO `circuit_lists` (`id`, `project_id`, `distribution_board_id`, `name`)
SELECT `id`, `project_id`, `id`, `name` || ' Stromkreisliste'
FROM `distribution_boards`;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `circuit_list_id` text REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE set null;
--> statement-breakpoint
UPDATE `consumers` SET `circuit_list_id` = `distribution_board_id` WHERE `distribution_board_id` IS NOT NULL;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `circuit_number` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `description` text;
--> statement-breakpoint
UPDATE `consumers` SET `description` = `name` WHERE `description` IS NULL;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `device_type` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `phase_type` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `trade_or_cost_group` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `group_name` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `protection_type` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `protection_rated_current` real;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `protection_characteristic` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `cable_type` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `cable_cross_section` text;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `comment` text;
@@ -0,0 +1 @@
ALTER TABLE `external_import_batches` ADD `configuration_version` integer NOT NULL;
@@ -1,14 +0,0 @@
CREATE TABLE `project_devices` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`category` text,
`quantity` integer NOT NULL,
`installed_power_per_unit_kw` real NOT NULL,
`demand_factor` real NOT NULL,
`voltage_v` real,
`phase_count` integer,
`power_factor` real,
`note` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,3 @@
ALTER TABLE `circuit_device_rows` ADD `manual_quantity` integer DEFAULT 0 NOT NULL;
--> statement-breakpoint
UPDATE `circuit_device_rows` SET `manual_quantity` = `quantity`;
@@ -1,7 +0,0 @@
ALTER TABLE `global_devices` ADD COLUMN `display_name` text;
--> statement-breakpoint
UPDATE `global_devices` SET `display_name` = `name` WHERE `display_name` IS NULL;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `display_name` text;
--> statement-breakpoint
UPDATE `project_devices` SET `display_name` = `name` WHERE `display_name` IS NULL;
@@ -1,3 +0,0 @@
ALTER TABLE `consumers` ADD COLUMN `project_device_id` text REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null;
--> statement-breakpoint
ALTER TABLE `consumers` ADD COLUMN `is_linked_to_device` integer NOT NULL DEFAULT 0;
@@ -1,99 +0,0 @@
CREATE TABLE `circuit_sections` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`key` text NOT NULL,
`display_name` text NOT NULL,
`prefix` text NOT NULL,
`sort_order` integer NOT NULL DEFAULT 0,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_key_unique` ON `circuit_sections` (`circuit_list_id`,`key`);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_prefix_unique` ON `circuit_sections` (`circuit_list_id`,`prefix`);
--> statement-breakpoint
CREATE TABLE `circuits` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`section_id` text NOT NULL,
`equipment_identifier` text NOT NULL,
`display_name` text,
`sort_order` integer NOT NULL DEFAULT 0,
`protection_type` text,
`protection_rated_current` real,
`protection_characteristic` text,
`cable_type` text,
`cable_cross_section` text,
`cable_length` real,
`rcd_assignment` text,
`terminal_designation` text,
`voltage` real,
`status` text,
`is_reserve` integer NOT NULL DEFAULT 0,
`remark` text,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuits_list_equipment_identifier_unique` ON `circuits` (`circuit_list_id`,`equipment_identifier`);
--> statement-breakpoint
CREATE TABLE `circuit_device_rows` (
`id` text PRIMARY KEY NOT NULL,
`circuit_id` text NOT NULL,
`linked_project_device_id` text,
`legacy_consumer_id` text,
`sort_order` integer NOT NULL DEFAULT 0,
`name` text NOT NULL,
`display_name` text NOT NULL,
`phase_type` text,
`connection_kind` text,
`cost_group` text,
`category` text,
`level` text,
`room_id` text,
`room_number_snapshot` text,
`room_name_snapshot` text,
`quantity` integer NOT NULL,
`power_per_unit` real NOT NULL,
`simultaneity_factor` real NOT NULL,
`cos_phi` real,
`remark` text,
`overridden_fields` text,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `legacy_consumer_circuit_migrations` (
`consumer_id` text PRIMARY KEY NOT NULL,
`circuit_id` text NOT NULL,
`circuit_device_row_id` text NOT NULL,
`circuit_list_id` text NOT NULL,
`created_at_iso` text NOT NULL,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `legacy_consumer_migration_reports` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`legacy_consumer_count` integer NOT NULL,
`created_circuit_count` integer NOT NULL,
`created_device_row_count` integer NOT NULL,
`duplicate_grouped_count` integer NOT NULL,
`generated_identifier_count` integer NOT NULL,
`unassigned_row_count` integer NOT NULL,
`warnings_json` text NOT NULL,
`generated_identifiers_json` text NOT NULL,
`duplicate_groups_json` text NOT NULL,
`created_at_iso` text NOT NULL,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `legacy_consumer_migration_reports_list_unique` ON `legacy_consumer_migration_reports` (`circuit_list_id`);
@@ -1,24 +0,0 @@
ALTER TABLE `project_devices` ADD COLUMN `phase_type` text NOT NULL DEFAULT 'single_phase';
--> statement-breakpoint
UPDATE `project_devices`
SET `phase_type` = CASE WHEN `phase_count` = 3 THEN 'three_phase' ELSE 'single_phase' END;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `connection_kind` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cost_group` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `power_per_unit` real NOT NULL DEFAULT 0;
--> statement-breakpoint
UPDATE `project_devices` SET `power_per_unit` = `installed_power_per_unit_kw`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `simultaneity_factor` real NOT NULL DEFAULT 1;
--> statement-breakpoint
UPDATE `project_devices` SET `simultaneity_factor` = `demand_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cos_phi` real;
--> statement-breakpoint
UPDATE `project_devices` SET `cos_phi` = `power_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `remark` text;
--> statement-breakpoint
UPDATE `project_devices` SET `remark` = `note`;
@@ -1 +0,0 @@
ALTER TABLE `circuits` ADD COLUMN `control_requirement` text;
@@ -1,9 +0,0 @@
ALTER TABLE `project_devices` DROP COLUMN `installed_power_per_unit_kw`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `demand_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `phase_count`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `power_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` DROP COLUMN `note`;
@@ -1,24 +0,0 @@
CREATE TABLE `project_change_sets` (
`id` text PRIMARY KEY NOT NULL,
`project_revision_id` text NOT NULL,
`command_type` text NOT NULL,
`payload_schema_version` integer NOT NULL,
`forward_payload_json` text NOT NULL,
`inverse_payload_json` text NOT NULL,
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
CREATE TABLE `project_revisions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`revision_number` integer NOT NULL,
`created_at_iso` text NOT NULL,
`actor_id` text,
`source` text NOT NULL,
`description` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
@@ -1,11 +0,0 @@
CREATE TABLE `project_history_stack_entries` (
`change_set_id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`stack` text NOT NULL,
`position` integer NOT NULL,
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
@@ -1,16 +0,0 @@
CREATE TABLE `project_snapshots` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_revision` integer NOT NULL,
`schema_version` integer NOT NULL,
`name` text NOT NULL,
`description` text,
`payload_json` text NOT NULL,
`payload_sha256` text NOT NULL,
`created_at_iso` text NOT NULL,
`created_by_actor_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
@@ -1,2 +0,0 @@
ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bd9bc99c-0cc0-44e3-bff0-819b1cbbf8ac",
"prevId": "b4029fef-ed7b-47ed-a185-d151ad3f46da",
"id": "62320e94-1976-4288-8640-d37cfc07f1ee",
"prevId": "2f464eb0-b1c4-4d2d-bfc8-a1466788c961",
"tables": {
"circuit_device_rows": {
"name": "circuit_device_rows",
@@ -28,13 +28,6 @@
"notNull": false,
"autoincrement": false
},
"legacy_consumer_id": {
"name": "legacy_consumer_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
@@ -202,6 +195,75 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_list_equipment_identifiers": {
"name": "circuit_list_equipment_identifiers",
"columns": {
"owner_type": {
"name": "owner_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"equipment_identifier": {
"name": "equipment_identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"circuit_list_equipment_identifiers_owner_unique": {
"name": "circuit_list_equipment_identifiers_owner_unique",
"columns": [
"owner_type",
"owner_id"
],
"isUnique": true
},
"circuit_list_equipment_identifiers_list_identifier_unique": {
"name": "circuit_list_equipment_identifiers_list_identifier_unique",
"columns": [
"circuit_list_id",
"equipment_identifier"
],
"isUnique": true
}
},
"foreignKeys": {
"circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": {
"name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "circuit_list_equipment_identifiers",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_lists": {
"name": "circuit_lists",
"columns": {
@@ -275,6 +337,79 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_protection_devices": {
"name": "circuit_protection_devices",
"columns": {
"circuit_id": {
"name": "circuit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rated_current_a": {
"name": "rated_current_a",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fuse_utilization_category": {
"name": "fuse_utilization_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trip_characteristic": {
"name": "trip_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rcd_type": {
"name": "rcd_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rated_residual_current_ma": {
"name": "rated_residual_current_ma",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"circuit_protection_devices_circuit_id_circuits_id_fk": {
"name": "circuit_protection_devices_circuit_id_circuits_id_fk",
"tableFrom": "circuit_protection_devices",
"tableTo": "circuits",
"columnsFrom": [
"circuit_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_sections": {
"name": "circuit_sections",
"columns": {
@@ -320,6 +455,20 @@
"notNull": true,
"autoincrement": false,
"default": 0
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_number": {
"name": "group_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@@ -338,6 +487,15 @@
"prefix"
],
"isUnique": true
},
"circuit_sections_list_category_group_unique": {
"name": "circuit_sections_list_category_group_unique",
"columns": [
"circuit_list_id",
"category",
"group_number"
],
"isUnique": true
}
},
"foreignKeys": {
@@ -405,27 +563,6 @@
"autoincrement": false,
"default": 0
},
"protection_type": {
"name": "protection_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_rated_current": {
"name": "protection_rated_current",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_characteristic": {
"name": "protection_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_type": {
"name": "cable_type",
"type": "text",
@@ -540,8 +677,81 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"consumers": {
"name": "consumers",
"distribution_board_component_protection_devices": {
"name": "distribution_board_component_protection_devices",
"columns": {
"component_id": {
"name": "component_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rated_current_a": {
"name": "rated_current_a",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fuse_utilization_category": {
"name": "fuse_utilization_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trip_characteristic": {
"name": "trip_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rcd_type": {
"name": "rcd_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rated_residual_current_ma": {
"name": "rated_residual_current_ma",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": {
"name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk",
"tableFrom": "distribution_board_component_protection_devices",
"tableTo": "distribution_board_components",
"columnsFrom": [
"component_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distribution_board_components": {
"name": "distribution_board_components",
"columns": {
"id": {
"name": "id",
@@ -550,61 +760,25 @@
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_board_id": {
"name": "distribution_board_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"project_device_id": {
"name": "project_device_id",
"section_id": {
"name": "section_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_linked_to_device": {
"name": "is_linked_to_device",
"type": "integer",
"equipment_identifier": {
"name": "equipment_identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"circuit_number": {
"name": "circuit_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
@@ -614,164 +788,51 @@
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"placement": {
"name": "placement",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"phase_type": {
"name": "phase_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trade_or_cost_group": {
"name": "trade_or_cost_group",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_name": {
"name": "group_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_type": {
"name": "protection_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_rated_current": {
"name": "protection_rated_current",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_characteristic": {
"name": "protection_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_type": {
"name": "cable_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_cross_section": {
"name": "cable_cross_section",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"installed_power_per_unit_kw": {
"name": "installed_power_per_unit_kw",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demand_factor": {
"name": "demand_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"voltage_v": {
"name": "voltage_v",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phase_count": {
"name": "phase_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"power_factor": {
"name": "power_factor",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"autoincrement": false,
"default": 0
}
},
"indexes": {
"distribution_board_components_list_identifier_unique": {
"name": "distribution_board_components_list_identifier_unique",
"columns": [
"circuit_list_id",
"equipment_identifier"
],
"isUnique": true
},
"distribution_board_components_section_role_unique": {
"name": "distribution_board_components_section_role_unique",
"columns": [
"section_id",
"role"
],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"consumers_project_id_projects_id_fk": {
"name": "consumers_project_id_projects_id_fk",
"tableFrom": "consumers",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"consumers_distribution_board_id_distribution_boards_id_fk": {
"name": "consumers_distribution_board_id_distribution_boards_id_fk",
"tableFrom": "consumers",
"tableTo": "distribution_boards",
"columnsFrom": [
"distribution_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"consumers_circuit_list_id_circuit_lists_id_fk": {
"name": "consumers_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "consumers",
"distribution_board_components_circuit_list_id_circuit_lists_id_fk": {
"name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "distribution_board_components",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
@@ -779,33 +840,20 @@
"columnsTo": [
"id"
],
"onDelete": "set null",
"onDelete": "cascade",
"onUpdate": "no action"
},
"consumers_project_device_id_project_devices_id_fk": {
"name": "consumers_project_device_id_project_devices_id_fk",
"tableFrom": "consumers",
"tableTo": "project_devices",
"distribution_board_components_section_id_circuit_sections_id_fk": {
"name": "distribution_board_components_section_id_circuit_sections_id_fk",
"tableFrom": "distribution_board_components",
"tableTo": "circuit_sections",
"columnsFrom": [
"project_device_id"
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"consumers_room_id_rooms_id_fk": {
"name": "consumers_room_id_rooms_id_fk",
"tableFrom": "consumers",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@@ -836,6 +884,28 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"floor_id": {
"name": "floor_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supply_type": {
"name": "supply_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"simultaneity_factor": {
"name": "simultaneity_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
@@ -852,6 +922,19 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"distribution_boards_floor_id_floors_id_fk": {
"name": "distribution_boards_floor_id_floors_id_fk",
"tableFrom": "distribution_boards",
"tableTo": "floors",
"columnsFrom": [
"floor_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -998,207 +1081,6 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_consumer_circuit_migrations": {
"name": "legacy_consumer_circuit_migrations",
"columns": {
"consumer_id": {
"name": "consumer_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"circuit_id": {
"name": "circuit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_device_row_id": {
"name": "circuit_device_row_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at_iso": {
"name": "created_at_iso",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuits",
"columnsFrom": [
"circuit_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuit_device_rows",
"columnsFrom": [
"circuit_device_row_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_consumer_migration_reports": {
"name": "legacy_consumer_migration_reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"legacy_consumer_count": {
"name": "legacy_consumer_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_circuit_count": {
"name": "created_circuit_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_device_row_count": {
"name": "created_device_row_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duplicate_grouped_count": {
"name": "duplicate_grouped_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_identifier_count": {
"name": "generated_identifier_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"unassigned_row_count": {
"name": "unassigned_row_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"warnings_json": {
"name": "warnings_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_identifiers_json": {
"name": "generated_identifiers_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duplicate_groups_json": {
"name": "duplicate_groups_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at_iso": {
"name": "created_at_iso",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"legacy_consumer_migration_reports_list_unique": {
"name": "legacy_consumer_migration_reports_list_unique",
"columns": [
"circuit_list_id"
],
"isUnique": true
}
},
"foreignKeys": {
"legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk": {
"name": "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "legacy_consumer_migration_reports",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_change_sets": {
"name": "project_change_sets",
"columns": {
@@ -1591,6 +1473,14 @@
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'named'"
},
"name": {
"name": "name",
"type": "text",
@@ -1643,6 +1533,15 @@
],
"isUnique": false
},
"project_snapshots_project_kind_revision_idx": {
"name": "project_snapshots_project_kind_revision_idx",
"columns": [
"project_id",
"kind",
"source_revision"
],
"isUnique": false
},
"project_snapshots_project_name_unique": {
"name": "project_snapshots_project_name_unique",
"columns": [
@@ -1688,6 +1587,42 @@
"notNull": true,
"autoincrement": false
},
"internal_project_number": {
"name": "internal_project_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"external_project_number": {
"name": "external_project_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"building_owner": {
"name": "building_owner",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_public_building": {
"name": "is_public_building",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"single_phase_voltage_v": {
"name": "single_phase_voltage_v",
"type": "integer",
@@ -1704,6 +1639,14 @@
"autoincrement": false,
"default": 400
},
"enabled_distribution_board_supply_types": {
"name": "enabled_distribution_board_supply_types",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'"
},
"current_revision": {
"name": "current_revision",
"type": "integer",
@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3248cb0b-5512-43d3-8193-e7218cb8ae66",
"prevId": "bd9bc99c-0cc0-44e3-bff0-819b1cbbf8ac",
"id": "45d3523a-a37a-4c68-8fa1-abd6585108ab",
"prevId": "62320e94-1976-4288-8640-d37cfc07f1ee",
"tables": {
"circuit_device_rows": {
"name": "circuit_device_rows",
@@ -28,13 +28,6 @@
"notNull": false,
"autoincrement": false
},
"legacy_consumer_id": {
"name": "legacy_consumer_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
@@ -202,6 +195,75 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_list_equipment_identifiers": {
"name": "circuit_list_equipment_identifiers",
"columns": {
"owner_type": {
"name": "owner_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"equipment_identifier": {
"name": "equipment_identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"circuit_list_equipment_identifiers_owner_unique": {
"name": "circuit_list_equipment_identifiers_owner_unique",
"columns": [
"owner_type",
"owner_id"
],
"isUnique": true
},
"circuit_list_equipment_identifiers_list_identifier_unique": {
"name": "circuit_list_equipment_identifiers_list_identifier_unique",
"columns": [
"circuit_list_id",
"equipment_identifier"
],
"isUnique": true
}
},
"foreignKeys": {
"circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": {
"name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "circuit_list_equipment_identifiers",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_lists": {
"name": "circuit_lists",
"columns": {
@@ -275,6 +337,79 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_protection_devices": {
"name": "circuit_protection_devices",
"columns": {
"circuit_id": {
"name": "circuit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rated_current_a": {
"name": "rated_current_a",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fuse_utilization_category": {
"name": "fuse_utilization_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trip_characteristic": {
"name": "trip_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rcd_type": {
"name": "rcd_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rated_residual_current_ma": {
"name": "rated_residual_current_ma",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"circuit_protection_devices_circuit_id_circuits_id_fk": {
"name": "circuit_protection_devices_circuit_id_circuits_id_fk",
"tableFrom": "circuit_protection_devices",
"tableTo": "circuits",
"columnsFrom": [
"circuit_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"circuit_sections": {
"name": "circuit_sections",
"columns": {
@@ -320,6 +455,20 @@
"notNull": true,
"autoincrement": false,
"default": 0
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_number": {
"name": "group_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@@ -338,6 +487,15 @@
"prefix"
],
"isUnique": true
},
"circuit_sections_list_category_group_unique": {
"name": "circuit_sections_list_category_group_unique",
"columns": [
"circuit_list_id",
"category",
"group_number"
],
"isUnique": true
}
},
"foreignKeys": {
@@ -405,27 +563,6 @@
"autoincrement": false,
"default": 0
},
"protection_type": {
"name": "protection_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_rated_current": {
"name": "protection_rated_current",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_characteristic": {
"name": "protection_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_type": {
"name": "cable_type",
"type": "text",
@@ -540,8 +677,81 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"consumers": {
"name": "consumers",
"distribution_board_component_protection_devices": {
"name": "distribution_board_component_protection_devices",
"columns": {
"component_id": {
"name": "component_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rated_current_a": {
"name": "rated_current_a",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fuse_utilization_category": {
"name": "fuse_utilization_category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trip_characteristic": {
"name": "trip_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rcd_type": {
"name": "rcd_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rated_residual_current_ma": {
"name": "rated_residual_current_ma",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": {
"name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk",
"tableFrom": "distribution_board_component_protection_devices",
"tableTo": "distribution_board_components",
"columnsFrom": [
"component_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distribution_board_components": {
"name": "distribution_board_components",
"columns": {
"id": {
"name": "id",
@@ -550,61 +760,25 @@
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_board_id": {
"name": "distribution_board_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"project_device_id": {
"name": "project_device_id",
"section_id": {
"name": "section_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_linked_to_device": {
"name": "is_linked_to_device",
"type": "integer",
"equipment_identifier": {
"name": "equipment_identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"circuit_number": {
"name": "circuit_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
@@ -614,164 +788,51 @@
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"placement": {
"name": "placement",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"phase_type": {
"name": "phase_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"trade_or_cost_group": {
"name": "trade_or_cost_group",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"group_name": {
"name": "group_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_type": {
"name": "protection_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_rated_current": {
"name": "protection_rated_current",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"protection_characteristic": {
"name": "protection_characteristic",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_type": {
"name": "cable_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cable_cross_section": {
"name": "cable_cross_section",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"installed_power_per_unit_kw": {
"name": "installed_power_per_unit_kw",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demand_factor": {
"name": "demand_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"voltage_v": {
"name": "voltage_v",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phase_count": {
"name": "phase_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"power_factor": {
"name": "power_factor",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
"autoincrement": false,
"default": 0
}
},
"indexes": {
"distribution_board_components_list_identifier_unique": {
"name": "distribution_board_components_list_identifier_unique",
"columns": [
"circuit_list_id",
"equipment_identifier"
],
"isUnique": true
},
"distribution_board_components_section_role_unique": {
"name": "distribution_board_components_section_role_unique",
"columns": [
"section_id",
"role"
],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"consumers_project_id_projects_id_fk": {
"name": "consumers_project_id_projects_id_fk",
"tableFrom": "consumers",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"consumers_distribution_board_id_distribution_boards_id_fk": {
"name": "consumers_distribution_board_id_distribution_boards_id_fk",
"tableFrom": "consumers",
"tableTo": "distribution_boards",
"columnsFrom": [
"distribution_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"consumers_circuit_list_id_circuit_lists_id_fk": {
"name": "consumers_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "consumers",
"distribution_board_components_circuit_list_id_circuit_lists_id_fk": {
"name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "distribution_board_components",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
@@ -779,33 +840,20 @@
"columnsTo": [
"id"
],
"onDelete": "set null",
"onDelete": "cascade",
"onUpdate": "no action"
},
"consumers_project_device_id_project_devices_id_fk": {
"name": "consumers_project_device_id_project_devices_id_fk",
"tableFrom": "consumers",
"tableTo": "project_devices",
"distribution_board_components_section_id_circuit_sections_id_fk": {
"name": "distribution_board_components_section_id_circuit_sections_id_fk",
"tableFrom": "distribution_board_components",
"tableTo": "circuit_sections",
"columnsFrom": [
"project_device_id"
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"consumers_room_id_rooms_id_fk": {
"name": "consumers_room_id_rooms_id_fk",
"tableFrom": "consumers",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@@ -836,6 +884,28 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"floor_id": {
"name": "floor_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supply_type": {
"name": "supply_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"simultaneity_factor": {
"name": "simultaneity_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
@@ -852,6 +922,79 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"distribution_boards_floor_id_floors_id_fk": {
"name": "distribution_boards_floor_id_floors_id_fk",
"tableFrom": "distribution_boards",
"tableTo": "floors",
"columnsFrom": [
"floor_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"external_csv_configurations": {
"name": "external_csv_configurations",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"configuration_version": {
"name": "configuration_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"external_csv_configurations_project_id_unique": {
"name": "external_csv_configurations_project_id_unique",
"columns": [
"project_id"
],
"isUnique": true
}
},
"foreignKeys": {
"external_csv_configurations_project_id_projects_id_fk": {
"name": "external_csv_configurations_project_id_projects_id_fk",
"tableFrom": "external_csv_configurations",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -998,207 +1141,6 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_consumer_circuit_migrations": {
"name": "legacy_consumer_circuit_migrations",
"columns": {
"consumer_id": {
"name": "consumer_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"circuit_id": {
"name": "circuit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_device_row_id": {
"name": "circuit_device_row_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at_iso": {
"name": "created_at_iso",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_id_circuits_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuits",
"columnsFrom": [
"circuit_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_device_row_id_circuit_device_rows_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuit_device_rows",
"columnsFrom": [
"circuit_device_row_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk": {
"name": "legacy_consumer_circuit_migrations_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "legacy_consumer_circuit_migrations",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_consumer_migration_reports": {
"name": "legacy_consumer_migration_reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"circuit_list_id": {
"name": "circuit_list_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"legacy_consumer_count": {
"name": "legacy_consumer_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_circuit_count": {
"name": "created_circuit_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_device_row_count": {
"name": "created_device_row_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duplicate_grouped_count": {
"name": "duplicate_grouped_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_identifier_count": {
"name": "generated_identifier_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"unassigned_row_count": {
"name": "unassigned_row_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"warnings_json": {
"name": "warnings_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_identifiers_json": {
"name": "generated_identifiers_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duplicate_groups_json": {
"name": "duplicate_groups_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at_iso": {
"name": "created_at_iso",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"legacy_consumer_migration_reports_list_unique": {
"name": "legacy_consumer_migration_reports_list_unique",
"columns": [
"circuit_list_id"
],
"isUnique": true
}
},
"foreignKeys": {
"legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk": {
"name": "legacy_consumer_migration_reports_circuit_list_id_circuit_lists_id_fk",
"tableFrom": "legacy_consumer_migration_reports",
"tableTo": "circuit_lists",
"columnsFrom": [
"circuit_list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_change_sets": {
"name": "project_change_sets",
"columns": {
@@ -1705,6 +1647,42 @@
"notNull": true,
"autoincrement": false
},
"internal_project_number": {
"name": "internal_project_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"external_project_number": {
"name": "external_project_number",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"building_owner": {
"name": "building_owner",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_public_building": {
"name": "is_public_building",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"single_phase_voltage_v": {
"name": "single_phase_voltage_v",
"type": "integer",
@@ -1721,6 +1699,14 @@
"autoincrement": false,
"default": 400
},
"enabled_distribution_board_supply_types": {
"name": "enabled_distribution_board_supply_types",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'"
},
"current_revision": {
"name": "current_revision",
"type": "integer",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -82
View File
@@ -5,113 +5,43 @@
{
"idx": 0,
"version": "6",
"when": 1777565414148,
"tag": "0000_bizarre_colossus",
"when": 1785499119431,
"tag": "0000_whole_surge",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1777577000000,
"tag": "0001_global_devices",
"when": 1785511894897,
"tag": "0001_add_public_building_setting",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1777580000000,
"tag": "0002_project_voltage_defaults",
"when": 1785680771603,
"tag": "0002_awesome_madripoor",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1777589000000,
"tag": "0003_project_floors_rooms",
"when": 1785683710155,
"tag": "0003_outstanding_maddog",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1777594000000,
"tag": "0004_circuit_lists_and_entry_fields",
"when": 1785684457208,
"tag": "0004_illegal_the_stranger",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1777597000000,
"tag": "0005_project_devices",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777652000000,
"tag": "0006_device_display_name",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1777680000000,
"tag": "0007_consumer_device_link",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1777800000000,
"tag": "0008_circuit_first_model",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1784743200000,
"tag": "0009_project_device_circuit_fields",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1784746800000,
"tag": "0010_circuit_control_requirement",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1784832541202,
"tag": "0011_project_device_canonical_fields",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1784833957929,
"tag": "0012_project_revision_foundation",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1785009799979,
"tag": "0014_project_snapshots",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1785014383032,
"tag": "0015_modern_madame_masque",
"when": 1785687503453,
"tag": "0005_stale_gorilla_man",
"breakpoints": true
}
]
@@ -17,15 +17,18 @@ import type {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
interface PersistedMoveRow {
id: string;
circuitId: string;
sortOrder: number;
phaseType: string | null;
}
export class CircuitDeviceRowMoveProjectCommandRepository
@@ -36,29 +39,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private assertSupportedCommand(
@@ -103,6 +89,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
);
this.applyMoves(database, command.payload.moves);
this.updateReserveStates(database, circuitIds);
this.updateVoltages(database, projectId, circuitIds);
return inverse;
}
@@ -112,7 +99,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
const { targetCircuit, targetCircuitAction, moves } =
command.payload;
const rowsById = this.loadExpectedRows(database, moves);
this.assertCircuitLocation(database, projectId, targetCircuit);
const appliedTargetCircuit = {
...targetCircuit,
voltage: resolveCircuitVoltage(
database,
projectId,
targetCircuit.sectionId,
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
),
};
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
if (targetCircuitAction === "create") {
const sourceCircuitIds = [
@@ -124,13 +120,13 @@ export class CircuitDeviceRowMoveProjectCommandRepository
sourceCircuitIds,
targetCircuit.circuitListId
);
this.assertTargetCircuitAvailable(database, targetCircuit);
this.insertTargetCircuit(database, targetCircuit);
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
this.insertTargetCircuit(database, appliedTargetCircuit);
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
targetCircuit,
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
@@ -138,12 +134,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
...sourceCircuitIds,
targetCircuit.id,
]);
this.updateVoltages(database, projectId, [
...sourceCircuitIds,
targetCircuit.id,
]);
return inverse;
}
this.assertTargetCircuitUnchanged(
database,
targetCircuit,
appliedTargetCircuit,
moves.map((move) => move.rowId)
);
const destinationCircuitIds = [
@@ -158,7 +158,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
appliedTargetCircuit,
this.reverseMoves(moves, rowsById)
);
this.applyMoves(database, moves);
@@ -166,6 +166,11 @@ export class CircuitDeviceRowMoveProjectCommandRepository
targetCircuit.id,
...destinationCircuitIds,
]);
this.updateVoltages(
database,
projectId,
destinationCircuitIds
);
const deleted = database
.delete(circuits)
.where(
@@ -187,6 +192,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
return inverse;
}
private updateVoltages(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
for (const circuitId of new Set(circuitIds)) {
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
private loadExpectedRows(
database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[]
@@ -197,6 +212,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder,
phaseType: circuitDeviceRows.phaseType,
})
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
@@ -366,9 +382,6 @@ export class CircuitDeviceRowMoveProjectCommandRepository
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
@@ -381,6 +394,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
remark: snapshot.remark,
})
.run();
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private assertTargetCircuitUnchanged(
@@ -401,11 +420,6 @@ export class CircuitDeviceRowMoveProjectCommandRepository
snapshot.equipmentIdentifier ||
current.displayName !== snapshot.displayName ||
current.sortOrder !== snapshot.sortOrder ||
current.protectionType !== snapshot.protectionType ||
current.protectionRatedCurrent !==
snapshot.protectionRatedCurrent ||
current.protectionCharacteristic !==
snapshot.protectionCharacteristic ||
current.cableType !== snapshot.cableType ||
current.cableCrossSection !== snapshot.cableCrossSection ||
current.cableLength !== snapshot.cableLength ||
@@ -423,6 +437,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
"Created target circuit changed before command execution."
);
}
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, snapshot.id))
.get();
if (
snapshot.protectionDevice === undefined
? protectionDevice !== undefined
: snapshot.protectionDevice === null
? protectionDevice !== undefined
: !protectionDevice ||
Object.entries(snapshot.protectionDevice).some(
([key, value]) =>
(protectionDevice as Record<string, unknown>)[key] !==
value
)
) {
throw new Error(
"Created target circuit protection changed before command execution."
);
}
const currentRowIds = database
.select({ id: circuitDeviceRows.id })
@@ -11,6 +11,7 @@ import type {
ExecuteCircuitDeviceRowUpdateCommandInput,
} from "../../domain/ports/circuit-device-row-project-command.store.js";
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
@@ -21,8 +22,8 @@ import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -34,99 +35,117 @@ export class CircuitDeviceRowProjectCommandRepository
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.get();
if (!current) {
throw new Error("Invalid device row id.");
}
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
const circuit = tx
.select({ circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, current.circuitId))
.get();
const owningList = circuit
? tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitDeviceRowUpdateCommandInput
) {
const current = tx
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
.get();
if (!current) {
throw new Error("Invalid device row id.");
}
const circuit = tx
.select({ circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, current.circuitId))
.get();
const owningList = circuit
? tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
.get()
: null;
if (!owningList) {
throw new Error("Circuit device row does not belong to project.");
}
)
.get()
: null;
if (!owningList) {
throw new Error("Circuit device row does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitDeviceRowPatchInput;
this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
patch
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitDeviceRowPatchInput;
const targetQuantity = patch.quantity ?? current.quantity;
const targetManualQuantity =
patch.manualQuantity ?? current.manualQuantity;
if (targetManualQuantity > targetQuantity) {
throw new Error(
"Device-row manual quantity must not exceed total quantity."
);
if (
overriddenFields !== undefined &&
patch.overriddenFields === undefined
) {
patch.overriddenFields = overriddenFields;
}
}
if (
input.source === "user" &&
patch.phaseType !== undefined &&
!isElectricalPhaseType(patch.phaseType)
) {
throw new Error("Device-row phase type is invalid.");
}
this.assertLinkedProjectDevice(tx, input.projectId, patch);
this.assertRoom(tx, input.projectId, patch);
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
patch
);
if (
overriddenFields !== undefined &&
patch.overriddenFields === undefined
) {
patch.overriddenFields = overriddenFields;
}
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
current.id,
patch as CircuitDeviceRowUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitDeviceRowFieldValue(current, change.field),
])
) as CircuitDeviceRowUpdatePatch;
const inverse = createCircuitDeviceRowUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution.");
}
if (patch.phaseType !== undefined) {
updateDerivedCircuitVoltage(
tx,
input.projectId,
current.circuitId
);
}
const update = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowPatchValues(patch))
.where(eq(circuitDeviceRows.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution.");
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: appliedForward,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return {
forward: appliedForward,
inverse,
};
}
private assertLinkedProjectDevice(
@@ -14,15 +14,17 @@ import type {
ExecuteCircuitDeviceRowStructureCommandInput,
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
@@ -30,30 +32,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.source,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
private applyCommand(
@@ -89,10 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot
) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
this.assertCircuitInProject(database, projectId, row.circuitId);
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
@@ -106,7 +93,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
throw new Error("Circuit device-row id already exists.");
}
database.insert(circuitDeviceRows).values(row).run();
database
.insert(circuitDeviceRows)
.values(toCircuitDeviceRowInsertValues(row))
.run();
const circuitUpdate = database
.update(circuits)
.set({ isReserve: 0 })
@@ -115,6 +105,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row insertion.");
}
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand(
row.id,
@@ -167,6 +158,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row deletion.");
}
updateDerivedCircuitVoltage(
database,
projectId,
expectedCircuitId
);
return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row)
@@ -49,7 +49,6 @@ export function toCircuitDeviceRowSnapshot(
id: row.id,
circuitId: row.circuitId,
linkedProjectDeviceId: row.linkedProjectDeviceId,
legacyConsumerId: row.legacyConsumerId,
sortOrder: row.sortOrder,
name: row.name,
displayName: row.displayName,
@@ -62,6 +61,7 @@ export function toCircuitDeviceRowSnapshot(
roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity,
manualQuantity: row.manualQuantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
@@ -69,3 +69,10 @@ export function toCircuitDeviceRowSnapshot(
overriddenFields: row.overriddenFields,
};
}
export function toCircuitDeviceRowInsertValues(row: CircuitDeviceRowSnapshot) {
return {
...row,
manualQuantity: row.manualQuantity ?? row.quantity,
};
}
@@ -13,6 +13,7 @@ export interface CircuitDeviceRowUpdateInput {
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
manualQuantity?: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
@@ -34,6 +35,7 @@ export interface CircuitDeviceRowPatchInput {
roomNumberSnapshot?: string | null;
roomNameSnapshot?: string | null;
quantity?: number;
manualQuantity?: number;
powerPerUnit?: number;
simultaneityFactor?: number;
cosPhi?: number | null;
@@ -44,7 +46,6 @@ export interface CircuitDeviceRowPatchInput {
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
circuitId: string;
linkedProjectDeviceId?: string;
legacyConsumerId?: string;
sortOrder: number;
}
@@ -62,6 +63,7 @@ export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInpu
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity,
manualQuantity: input.manualQuantity ?? input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
@@ -91,6 +93,7 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
}
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
if (input.quantity !== undefined) values.quantity = input.quantity;
if (input.manualQuantity !== undefined) values.manualQuantity = input.manualQuantity;
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
if (input.simultaneityFactor !== undefined) {
values.simultaneityFactor = input.simultaneityFactor;
@@ -110,7 +113,6 @@ export function toCircuitDeviceRowCreateValues(
return {
id,
circuitId: input.circuitId,
legacyConsumerId: input.legacyConsumerId ?? null,
sortOrder: input.sortOrder,
...toCircuitDeviceRowUpdateValues(input),
};
@@ -1,38 +1,18 @@
import crypto from "node:crypto";
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import {
toCircuitDeviceRowCreateValues,
toCircuitDeviceRowUpdateValues,
type CircuitDeviceRowCreateInput,
type CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export type {
CircuitDeviceRowCreateInput,
CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
export class CircuitDeviceRowRepository {
async findById(rowId: string) {
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
return row ?? null;
}
async countByCircuit(circuitId: string) {
const rows = await db.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows).where(eq(circuitDeviceRows.circuitId, circuitId));
return rows.length;
}
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitIds: string[]) {
if (!circuitIds.length) {
return [];
}
return db
return this.database
.select()
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.circuitId, circuitIds))
@@ -40,12 +20,11 @@ export class CircuitDeviceRowRepository {
}
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
return db
return this.database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
@@ -58,6 +37,7 @@ export class CircuitDeviceRowRepository {
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
manualQuantity: circuitDeviceRows.manualQuantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
@@ -83,34 +63,4 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
}
async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
return id;
}
async update(
rowId: string,
input: CircuitDeviceRowUpdateInput
) {
await db
.update(circuitDeviceRows)
.set(toCircuitDeviceRowUpdateValues(input))
.where(eq(circuitDeviceRows.id, rowId));
}
async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
await db
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder,
})
.where(eq(circuitDeviceRows.id, rowId));
}
}
@@ -0,0 +1,104 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupMoveProjectCommand,
createCircuitGroupMoveProjectCommand,
invertCircuitGroupMovePlan,
} from "../../domain/models/circuit-group-move-project-command.model.js";
import type {
CircuitGroupMoveProjectCommandStore,
ExecuteCircuitGroupMoveCommandInput,
} from "../../domain/ports/circuit-group-move-project-command.store.js";
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupMoveProjectCommandRepository
implements CircuitGroupMoveProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitGroupMoveCommandInput) {
assertCircuitGroupMoveProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
private applyCommand(
database: AppDatabase,
input: ExecuteCircuitGroupMoveCommandInput
) {
const plan = input.command.payload;
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, plan.circuitListId))
.get();
if (!list || list.projectId !== input.projectId) {
throw new Error("Circuit list does not belong to project.");
}
const source = database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, plan.expectedSectionId))
.get();
const target = database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, plan.targetSectionId))
.get();
const expectedBmk = parseGroupedEquipmentIdentifier(
plan.expectedEquipmentIdentifier
);
const targetBmk = parseGroupedEquipmentIdentifier(
plan.targetEquipmentIdentifier
);
if (
!source ||
!target ||
source.circuitListId !== plan.circuitListId ||
target.circuitListId !== plan.circuitListId ||
source.category === null ||
source.category !== target.category ||
source.category !== expectedBmk?.category ||
source.groupNumber !== expectedBmk.groupNumber ||
target.category !== targetBmk?.category ||
target.groupNumber !== targetBmk.groupNumber
) {
throw new Error(
"Circuit move requires matching source and target groups."
);
}
const updated = database
.update(circuits)
.set({
sectionId: plan.targetSectionId,
equipmentIdentifier: plan.targetEquipmentIdentifier,
sortOrder: plan.targetSortOrder,
})
.where(
and(
eq(circuits.id, plan.circuitId),
eq(circuits.circuitListId, plan.circuitListId),
eq(circuits.sectionId, plan.expectedSectionId),
eq(
circuits.equipmentIdentifier,
plan.expectedEquipmentIdentifier
),
eq(circuits.sortOrder, plan.expectedSortOrder)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit changed before group move.");
}
return createCircuitGroupMoveProjectCommand(
invertCircuitGroupMovePlan(plan)
);
}
}
@@ -0,0 +1,348 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupRenumberProjectCommand,
createCircuitGroupRenumberProjectCommand,
type CircuitGroupRenumberProjectCommand,
} from "../../domain/models/circuit-group-renumber-project-command.model.js";
import type { CircuitGroupRenumberProjectCommandStore } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
import type { ExecuteCircuitGroupRenumberCommandInput } from "../../domain/ports/circuit-group-renumber-project-command.store.js";
import type { CircuitGroupRenumberPlan } from "../../domain/services/circuit-group-renumbering.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupRenumberProjectCommandRepository
implements CircuitGroupRenumberProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitGroupRenumberCommandInput) {
assertCircuitGroupRenumberProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupRenumberProjectCommand
) {
const { circuitListId, groups } = command.payload;
this.assertCurrentState(database, projectId, circuitListId, groups);
const inverse = createCircuitGroupRenumberProjectCommand(
circuitListId,
invertPlan(groups)
);
const occupiedIdentifiers = new Set(
[
...database
.select({ value: circuits.equipmentIdentifier })
.from(circuits)
.where(eq(circuits.circuitListId, circuitListId))
.all(),
...database
.select({ value: distributionBoardComponents.equipmentIdentifier })
.from(distributionBoardComponents)
.where(
eq(distributionBoardComponents.circuitListId, circuitListId)
)
.all(),
].map(({ value }) => normalizeIdentifier(value))
);
const temporaryIdentifiers = new Map<string, string>();
for (const group of groups) {
for (const assignment of [
...group.circuits.map((entry) => ({
id: entry.circuitId,
expected: entry.expectedEquipmentIdentifier,
table: "circuit" as const,
})),
...group.components.map((entry) => ({
id: entry.componentId,
expected: entry.expectedEquipmentIdentifier,
table: "component" as const,
})),
]) {
const key = `${assignment.table}:${assignment.id}`;
const temporary = createTemporaryIdentifier(
key,
occupiedIdentifiers
);
temporaryIdentifiers.set(key, temporary);
this.updateEntityIdentifier(
database,
assignment.table,
assignment.id,
circuitListId,
assignment.expected,
temporary
);
}
}
const occupiedPrefixes = new Set(
database
.select({ prefix: circuitSections.prefix })
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all()
.map(({ prefix }) => prefix)
);
const temporaryPrefixes = new Map<string, string>();
for (const group of groups) {
const temporaryPrefix = createTemporaryValue(
`__tmp_group_prefix_${group.groupId}`,
occupiedPrefixes
);
temporaryPrefixes.set(group.groupId, temporaryPrefix);
const updated = database
.update(circuitSections)
.set({ groupNumber: null, prefix: temporaryPrefix })
.where(
and(
eq(circuitSections.id, group.groupId),
eq(circuitSections.circuitListId, circuitListId),
eq(circuitSections.groupNumber, group.expectedGroupNumber),
eq(circuitSections.prefix, group.expectedPrefix)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group changed during renumbering.");
}
}
for (const group of groups) {
const updated = database
.update(circuitSections)
.set({
groupNumber: group.targetGroupNumber,
prefix: group.targetPrefix,
})
.where(
and(
eq(circuitSections.id, group.groupId),
eq(circuitSections.circuitListId, circuitListId),
eq(circuitSections.prefix, temporaryPrefixes.get(group.groupId)!)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group changed during final renumbering.");
}
}
for (const group of groups) {
for (const assignment of group.circuits) {
this.updateEntityIdentifier(
database,
"circuit",
assignment.circuitId,
circuitListId,
temporaryIdentifiers.get(`circuit:${assignment.circuitId}`)!,
assignment.targetEquipmentIdentifier
);
}
for (const assignment of group.components) {
this.updateEntityIdentifier(
database,
"component",
assignment.componentId,
circuitListId,
temporaryIdentifiers.get(`component:${assignment.componentId}`)!,
assignment.targetEquipmentIdentifier
);
}
}
return inverse;
}
private assertCurrentState(
database: AppDatabase,
projectId: string,
circuitListId: string,
groups: CircuitGroupRenumberPlan["groups"]
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit-group list does not belong to project.");
}
const selectedIds = new Set(groups.map((group) => group.groupId));
const allGroups = database
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
for (const group of groups) {
const current = allGroups.find((entry) => entry.id === group.groupId);
if (
!current ||
current.category !== group.category ||
current.groupNumber !== group.expectedGroupNumber ||
current.prefix !== group.expectedPrefix
) {
throw new Error("Circuit group changed before renumbering.");
}
const currentCircuits = database
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, group.groupId))
.all();
const currentComponents = database
.select({
id: distributionBoardComponents.id,
equipmentIdentifier:
distributionBoardComponents.equipmentIdentifier,
})
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, group.groupId))
.all();
if (
!matchesAssignments(currentCircuits, group.circuits, "circuitId") ||
!matchesAssignments(
currentComponents,
group.components,
"componentId"
)
) {
throw new Error(
"Circuit-group renumber must include every unchanged child."
);
}
}
for (const group of groups) {
if (
allGroups.some(
(entry) =>
!selectedIds.has(entry.id) &&
entry.category === group.category &&
entry.groupNumber === group.targetGroupNumber
)
) {
throw new Error(
"Target group number belongs to an unchanged group."
);
}
}
}
private updateEntityIdentifier(
database: AppDatabase,
table: "circuit" | "component",
id: string,
circuitListId: string,
expected: string,
target: string
) {
const schema =
table === "circuit" ? circuits : distributionBoardComponents;
const updated = database
.update(schema)
.set({ equipmentIdentifier: target })
.where(
and(
eq(schema.id, id),
eq(schema.circuitListId, circuitListId),
eq(schema.equipmentIdentifier, expected)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit-group child changed during renumbering."
);
}
}
}
function matchesAssignments<
TAssignment extends {
expectedEquipmentIdentifier: string;
},
>(
current: Array<{ id: string; equipmentIdentifier: string }>,
assignments: TAssignment[],
idField: keyof TAssignment
) {
if (current.length !== assignments.length) {
return false;
}
const currentById = new Map(current.map((entry) => [entry.id, entry]));
return assignments.every((assignment) => {
const id = assignment[idField];
return (
typeof id === "string" &&
currentById.get(id)?.equipmentIdentifier ===
assignment.expectedEquipmentIdentifier
);
});
}
function invertPlan(
groups: CircuitGroupRenumberPlan["groups"]
): CircuitGroupRenumberPlan {
return {
groups: groups.map((group) => ({
...group,
expectedGroupNumber: group.targetGroupNumber,
targetGroupNumber: group.expectedGroupNumber,
expectedPrefix: group.targetPrefix,
targetPrefix: group.expectedPrefix,
circuits: group.circuits.map((circuit) => ({
...circuit,
expectedEquipmentIdentifier:
circuit.targetEquipmentIdentifier,
targetEquipmentIdentifier:
circuit.expectedEquipmentIdentifier,
})),
components: group.components.map((component) => ({
...component,
expectedEquipmentIdentifier:
component.targetEquipmentIdentifier,
targetEquipmentIdentifier:
component.expectedEquipmentIdentifier,
})),
})),
};
}
function createTemporaryIdentifier(
key: string,
occupied: Set<string>
) {
return createTemporaryValue(
`__tmp_group_renumber_${key.replace(":", "_")}`,
occupied,
normalizeIdentifier
);
}
function createTemporaryValue(
base: string,
occupied: Set<string>,
normalize: (value: string) => string = (value) => value
) {
let candidate = base;
while (occupied.has(normalize(candidate))) {
candidate += "_";
}
occupied.add(normalize(candidate));
return candidate;
}
function normalizeIdentifier(value: string) {
return value.trim().toLocaleLowerCase();
}
@@ -0,0 +1,261 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType,
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupReorderAssignment,
type CircuitGroupSnapshot,
type CircuitGroupStructureProjectCommand,
} from "../../domain/models/circuit-group-structure-project-command.model.js";
import type {
CircuitGroupStructureProjectCommandStore,
ExecuteCircuitGroupStructureCommandInput,
} from "../../domain/ports/circuit-group-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupStructureProjectCommandRepository
implements CircuitGroupStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitGroupStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupStructureProjectCommand
): CircuitGroupStructureProjectCommand {
if (command.type === circuitGroupInsertCommandType) {
assertCircuitGroupInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupDeleteCommandType) {
assertCircuitGroupDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createCircuitGroupInsertProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupUpdateCommandType) {
assertCircuitGroupUpdateProjectCommand(command);
this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
return createCircuitGroupUpdateProjectCommand(
command.payload.target,
command.payload.expected
);
}
if (command.type === circuitGroupReorderCommandType) {
assertCircuitGroupReorderProjectCommand(command);
const inverseAssignments = this.reorder(
database,
projectId,
command.payload.circuitListId,
command.payload.assignments
);
return createCircuitGroupReorderProjectCommand(
command.payload.circuitListId,
inverseAssignments
);
}
throw new Error("Unsupported circuit-group structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const existing = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get();
if (existing) {
throw new Error("Circuit-group id already exists.");
}
database.insert(circuitSections).values(snapshot).run();
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const current = this.getCurrent(database, snapshot.id);
if (!current || !sameRecord(snapshot, current)) {
throw new Error("Circuit group changed before deletion.");
}
const circuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, snapshot.id))
.get();
const component = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
.get();
if (circuit || component) {
throw new Error(
"Only empty circuit groups can be deleted by this command."
);
}
const deleted = database
.delete(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.id),
eq(circuitSections.circuitListId, snapshot.circuitListId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Circuit group could not be deleted.");
}
}
private update(
database: AppDatabase,
projectId: string,
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, expected.circuitListId);
const current = this.getCurrent(database, expected.id);
if (!current || !sameRecord(expected, current)) {
throw new Error("Circuit group changed before update.");
}
const updated = database
.update(circuitSections)
.set({ displayName: target.displayName })
.where(
and(
eq(circuitSections.id, expected.id),
eq(circuitSections.circuitListId, expected.circuitListId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group could not be updated.");
}
}
private assertListOwnership(
database: AppDatabase,
projectId: string,
circuitListId: string
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit group does not belong to project.");
}
}
private reorder(
database: AppDatabase,
projectId: string,
circuitListId: string,
assignments: CircuitGroupReorderAssignment[]
) {
this.assertListOwnership(database, projectId, circuitListId);
const persisted = database
.select({
id: circuitSections.id,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
const persistedById = new Map(
persisted.map((group) => [group.id, group])
);
if (
persisted.length !== assignments.length ||
assignments.some((assignment) => {
const group = persistedById.get(assignment.groupId);
return !group || group.sortOrder !== assignment.expectedSortOrder;
})
) {
throw new Error(
"Circuit-group reorder must include every unchanged group in the list."
);
}
for (const assignment of assignments) {
if (assignment.expectedSortOrder === assignment.targetSortOrder) {
continue;
}
const updated = database
.update(circuitSections)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuitSections.id, assignment.groupId),
eq(circuitSections.circuitListId, circuitListId),
eq(circuitSections.sortOrder, assignment.expectedSortOrder)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group changed during reorder.");
}
}
return assignments.map((assignment) => ({
groupId: assignment.groupId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
}));
}
private getCurrent(database: AppDatabase, id: string) {
return database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, id))
.get();
}
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,261 @@
import { asc, eq } from "drizzle-orm";
import {
circuitGroupDeleteSubtreeCommandType,
circuitGroupRestoreSubtreeCommandType,
assertCircuitGroupDeleteSubtreeProjectCommand,
assertCircuitGroupRestoreSubtreeProjectCommand,
createCircuitGroupDeleteSubtreeProjectCommand,
createCircuitGroupRestoreSubtreeProjectCommand,
type CircuitGroupSubtreeProjectCommand,
} from "../../domain/models/circuit-group-subtree-project-command.model.js";
import type {
CircuitGroupSubtreeSnapshot,
CircuitProtectionDeviceSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model.js";
import type {
CircuitGroupSubtreeProjectCommandStore,
ExecuteCircuitGroupSubtreeCommandInput,
} from "../../domain/ports/circuit-group-subtree-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
capture(projectId: string, groupId: string) {
return this.captureFrom(this.database, projectId, groupId);
}
execute(input: ExecuteCircuitGroupSubtreeCommandInput) {
if (input.command.type === circuitGroupDeleteSubtreeCommandType) {
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
} else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupSubtreeProjectCommand
) {
if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.group.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
throw new Error(
"Circuit-group subtree changed before deletion."
);
}
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))
.run();
if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted.");
}
return createCircuitGroupRestoreSubtreeProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteSubtreeProjectCommand(
command.payload.snapshot
);
}
throw new Error("Unsupported circuit-group subtree command.");
}
private captureFrom(
database: AppDatabase,
projectId: string,
groupId: string
): CircuitGroupSubtreeSnapshot {
const group = database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, groupId))
.get();
if (!group || group.category === null || group.groupNumber === null) {
throw new Error("Circuit group not found.");
}
this.assertListOwnership(database, projectId, group.circuitListId);
const components = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, group.id))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all()
.map((component) => ({
component: {
...component,
role: component.role as
| "group_upstream_protection"
| "group_residual_current_protection",
},
protectionDevice:
database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
component.id
)
)
.get() ?? null,
}));
const circuitSnapshots = database
.select()
.from(circuits)
.where(eq(circuits.sectionId, group.id))
.orderBy(asc(circuits.sortOrder), asc(circuits.id))
.all()
.map((circuit) => {
const { isReserve, ...values } = circuit;
return {
circuit: {
...values,
isReserve: Boolean(isReserve),
deviceRows: database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all(),
},
protectionDevice:
(database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get() as CircuitProtectionDeviceSnapshot | undefined) ??
null,
};
});
return {
group: {
...group,
category: group.category,
groupNumber: group.groupNumber,
},
components,
circuits: circuitSnapshots,
};
}
private restore(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSubtreeSnapshot
) {
this.assertListOwnership(
database,
projectId,
snapshot.group.circuitListId
);
const existing = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.group.id))
.get();
if (existing) {
throw new Error("Circuit group already exists before restoration.");
}
database.insert(circuitSections).values(snapshot.group).run();
for (const component of snapshot.components) {
database
.insert(distributionBoardComponents)
.values(component.component)
.run();
if (component.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(component.protectionDevice)
.run();
}
}
for (const entry of snapshot.circuits) {
const { deviceRows, isReserve, ...circuit } = entry.circuit;
database
.insert(circuits)
.values({ ...circuit, isReserve: isReserve ? 1 : 0 })
.run();
if (entry.protectionDevice !== null) {
database
.insert(circuitProtectionDevices)
.values(entry.protectionDevice)
.run();
}
if (deviceRows.length > 0) {
database
.insert(circuitDeviceRows)
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
}
}
}
private assertListOwnership(
database: AppDatabase,
projectId: string,
circuitListId: string
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit-group list does not belong to project.");
}
}
}
function sameSnapshot(
left: CircuitGroupSubtreeSnapshot,
right: CircuitGroupSubtreeSnapshot
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([key, child]) =>
`${JSON.stringify(key)}:${canonicalJson(child)}`
)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
+5 -48
View File
@@ -1,51 +1,17 @@
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";
export class CircuitListRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
return db.select().from(circuitLists).where(eq(circuitLists.projectId, projectId));
}
async createForDistributionBoard(input: {
projectId: string;
distributionBoardId: string;
name: string;
}) {
const entry = {
id: input.distributionBoardId,
projectId: input.projectId,
distributionBoardId: input.distributionBoardId,
name: input.name,
};
await db.insert(circuitLists).values(entry);
return entry;
}
async findByDistributionBoardId(projectId: string, distributionBoardId: string) {
const [row] = await db
.select()
.from(circuitLists)
.where(
and(
eq(circuitLists.projectId, projectId),
eq(circuitLists.distributionBoardId, distributionBoardId)
)
)
.limit(1);
return row ?? null;
}
async existsInProject(projectId: string, circuitListId: string) {
const [row] = await db
.select({ id: circuitLists.id })
.from(circuitLists)
.where(and(eq(circuitLists.projectId, projectId), eq(circuitLists.id, circuitListId)))
.limit(1);
return Boolean(row);
}
async findById(projectId: string, circuitListId: string) {
const db = this.database;
const [row] = await db
.select()
.from(circuitLists)
@@ -53,13 +19,4 @@ export class CircuitListRepository {
.limit(1);
return row ?? null;
}
async findByIdByListIdOnly(circuitListId: string) {
const [row] = await db
.select()
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.limit(1);
return row ?? null;
}
}
@@ -18,8 +18,9 @@ import {
toCircuitPatchValues,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
type CircuitRow = typeof circuits.$inferSelect;
@@ -31,81 +32,104 @@ export class CircuitProjectCommandRepository
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command);
return this.database.transaction((tx) => {
const current = tx
.select()
.from(circuits)
.where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
return executeProjectCommandTransactionWithAppliedForward(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
const owningList = tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitUpdateCommandInput
) {
const current = tx
.select()
.from(circuits)
.where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx
.select({ id: circuitLists.id })
.from(circuitLists)
.where(
and(
eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId)
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
)
.get();
if (!owningList) {
throw new Error("Circuit does not belong to project.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId);
this.assertUniqueEquipmentIdentifier(
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId);
if (input.source === "user") {
if (
input.command.payload.changes.every(
(change) => change.field === "voltage"
)
) {
throw new Error("Circuit voltage is derived and cannot be edited.");
}
const devicePhaseTypes = tx
.select({ phaseType: circuitDeviceRows.phaseType })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, current.id))
.all()
.map((row) => row.phaseType);
const derivedVoltage = resolveCircuitVoltage(
tx,
current,
patch.equipmentIdentifier
input.projectId,
patch.sectionId ?? current.sectionId,
devicePhaseTypes
);
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getCircuitFieldValue(current, change.field),
])
) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand(
current.id,
inversePatch
);
const update = tx
.update(circuits)
.set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit changed before command execution.");
if (derivedVoltage === current.voltage) {
delete patch.voltage;
} else {
patch.voltage = derivedVoltage;
}
}
this.assertUniqueEquipmentIdentifier(
tx,
current,
patch.equipmentIdentifier
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
const appliedForward = createCircuitUpdateProjectCommand(
current.id,
patch as CircuitUpdatePatch
);
const inversePatch = Object.fromEntries(
appliedForward.payload.changes.map((change) => [
change.field,
getCircuitFieldValue(current, change.field),
])
) as CircuitUpdatePatch;
const inverse = createCircuitUpdateProjectCommand(
current.id,
inversePatch
);
return { revision, inverse };
});
const update = tx
.update(circuits)
.set(toCircuitPatchValues(patch))
.where(eq(circuits.id, current.id))
.run();
if (update.changes !== 1) {
throw new Error("Circuit changed before command execution.");
}
return { forward: appliedForward, inverse };
}
private assertSectionInCircuitList(
@@ -0,0 +1,17 @@
import { inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
export class CircuitProtectionDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async listByCircuitIds(circuitIds: string[]) {
if (circuitIds.length === 0) {
return [];
}
return this.database
.select()
.from(circuitProtectionDevices)
.where(inArray(circuitProtectionDevices.circuitId, circuitIds));
}
}
@@ -0,0 +1,97 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitProtectionUpdateProjectCommand,
createCircuitProtectionUpdateProjectCommand,
type CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model.js";
import type {
CircuitProtectionProjectCommandStore,
ExecuteCircuitProtectionCommandInput,
} from "../../domain/ports/circuit-protection-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitProtectionProjectCommandRepository
implements CircuitProtectionProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitProtectionCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) => {
assertCircuitProtectionUpdateProjectCommand(input.command);
const { circuitId, expected, target } = input.command.payload;
if (input.source === "user" && target === null) {
throw new Error("User commands cannot remove circuit protection.");
}
this.assertOwnership(tx, input.projectId, circuitId);
const actual = tx
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.get();
if (!sameNullableSnapshot(expected, actual)) {
throw new Error("Circuit protection changed before update.");
}
if (target === null) {
tx.delete(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.run();
} else if (actual) {
tx.update(circuitProtectionDevices)
.set(target)
.where(eq(circuitProtectionDevices.circuitId, circuitId))
.run();
} else {
tx.insert(circuitProtectionDevices).values(target).run();
}
return createCircuitProtectionUpdateProjectCommand(
circuitId,
target,
expected
);
});
}
private assertOwnership(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({ projectId: circuitLists.projectId })
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit protection does not belong to project.");
}
}
}
function sameNullableSnapshot(
expected: CircuitProtectionSnapshot | null,
actual: typeof circuitProtectionDevices.$inferSelect | undefined
) {
if (expected === null) {
return actual === undefined;
}
if (!actual) {
return false;
}
return Object.entries(expected).every(
([key, value]) =>
(actual as Record<string, unknown>)[key] === value
);
}
@@ -12,8 +12,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore
@@ -23,190 +22,184 @@ export class CircuitSectionRenumberProjectCommandRepository
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.database.transaction((tx) => {
const section = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, input.command.payload.sectionId))
.get();
if (!section || section.projectId !== input.projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
const sectionCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionRenumberCommandInput
) {
const section = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, input.command.payload.sectionId))
.get();
if (!section || section.projectId !== input.projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const sectionCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
sectionCircuits.length !== assignments.length ||
sectionCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit renumber must include every circuit in the section."
);
}
const circuitsById = new Map(
sectionCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
sectionCircuits.length !== assignments.length ||
sectionCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
!circuit ||
circuit.equipmentIdentifier !==
assignment.expectedEquipmentIdentifier
) {
throw new Error(
"Circuit renumber must include every circuit in the section."
"Circuit changed before section renumber execution."
);
}
const circuitsById = new Map(
sectionCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.equipmentIdentifier !==
assignment.expectedEquipmentIdentifier
) {
throw new Error(
"Circuit changed before section renumber execution."
);
}
}
}
const listCircuits = tx
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId))
.all();
const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId)
const listCircuits = tx
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId))
.all();
const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId)
);
const targetIdentifiers = new Set(
assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
)
);
const conflictingCircuit = listCircuits.find(
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
throw new Error(
"Target equipment identifier already exists in circuit list."
);
const targetIdentifiers = new Set(
assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
}
const inverse = createCircuitSectionRenumberProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedEquipmentIdentifier:
assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier:
assignment.expectedEquipmentIdentifier,
}))
);
const temporaryIdentifiers = this.createTemporaryIdentifiers(
section.id,
assignments,
listCircuits.map((circuit) => circuit.equipmentIdentifier)
);
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({ equipmentIdentifier: temporaryIdentifier })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
assignment.expectedEquipmentIdentifier
)
)
)
);
const conflictingCircuit = listCircuits.find(
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
.run();
if (updated.changes !== 1) {
throw new Error(
"Target equipment identifier already exists in circuit list."
"Circuit changed during section renumber execution."
);
}
}
const inverse = createCircuitSectionRenumberProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedEquipmentIdentifier:
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier:
assignment.expectedEquipmentIdentifier,
}))
);
const temporaryIdentifiers = this.createTemporaryIdentifiers(
section.id,
assignments,
listCircuits.map((circuit) => circuit.equipmentIdentifier)
);
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({ equipmentIdentifier: temporaryIdentifier })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
assignment.expectedEquipmentIdentifier
)
})
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
temporaryIdentifier
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section renumber execution."
);
}
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during final section renumber execution."
);
}
}
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier,
})
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
temporaryIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during final section renumber execution."
);
}
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return inverse;
}
private createTemporaryIdentifiers(
@@ -21,8 +21,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand
@@ -36,54 +35,48 @@ export class CircuitSectionReorderProjectCommandRepository
execute(input: ExecuteCircuitSectionReorderCommandInput) {
this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => {
const commandSections = this.getCommandSections(input.command);
const inverseSections = commandSections.map((section) => {
this.assertCompleteCurrentSection(
tx,
input.projectId,
section
);
return {
sectionId: section.sectionId,
assignments: section.assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
})),
};
});
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input)
);
}
for (const section of commandSections) {
this.applyAssignments(tx, section);
}
const inverse =
input.command.type === circuitSectionReorderCommandType
? createCircuitSectionReorderProjectCommand(
inverseSections[0].sectionId,
inverseSections[0].assignments
)
: createCircuitSectionsReorderProjectCommand(
inverseSections
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
private applyCommand(
tx: AppDatabase,
input: ExecuteCircuitSectionReorderCommandInput
) {
const commandSections = this.getCommandSections(input.command);
const inverseSections = commandSections.map((section) => {
this.assertCompleteCurrentSection(
tx,
input.projectId,
section
);
return {
sectionId: section.sectionId,
assignments: section.assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
})),
};
});
for (const section of commandSections) {
this.applyAssignments(tx, section);
}
const inverse =
input.command.type === circuitSectionReorderCommandType
? createCircuitSectionReorderProjectCommand(
inverseSections[0].sectionId,
inverseSections[0].assignments
)
: createCircuitSectionsReorderProjectCommand(
inverseSections
);
return inverse;
}
private assertSupportedCommand(
@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { circuitSections } from "../schema/circuit-sections.js";
export function createDefaultCircuitSectionValues(circuitListId: string) {
@@ -13,12 +13,16 @@ export function createDefaultCircuitSectionValues(circuitListId: string) {
}
export class CircuitSectionRepository {
constructor(private readonly database: AppDatabase) {}
async findById(sectionId: string) {
const db = this.database;
const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1);
return row ?? null;
}
async listByCircuitList(circuitListId: string) {
const db = this.database;
return db
.select()
.from(circuitSections)
@@ -27,6 +31,7 @@ export class CircuitSectionRepository {
}
async createDefaults(circuitListId: string) {
const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
const existing = await db
.select({ id: circuitSections.id })
@@ -13,17 +13,20 @@ import type {
CircuitStructureProjectCommandStore,
ExecuteCircuitStructureCommandInput,
} from "../../domain/ports/circuit-structure-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@@ -31,30 +34,17 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.source,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.source,
input.command
)
);
}
private applyCommand(
@@ -91,6 +81,19 @@ export class CircuitStructureProjectCommandRepository
snapshot: CircuitSnapshot
) {
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
const derivedVoltage = resolveCircuitVoltage(
database,
projectId,
snapshot.sectionId,
snapshot.deviceRows.map((row) => row.phaseType)
);
if (snapshot.voltage !== derivedVoltage) {
throw new Error(
"Circuit voltage must match the project phase voltage."
);
}
}
const existingCircuit = database
.select({ id: circuits.id })
@@ -131,10 +134,8 @@ export class CircuitStructureProjectCommandRepository
throw new Error("Circuit device-row id already exists.");
}
for (const row of snapshot.deviceRows) {
if (source === "user" && row.legacyConsumerId !== null) {
throw new Error(
"User commands cannot assign legacy consumer ids."
);
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
}
assertCircuitDeviceRowReferencesInProject(
database,
@@ -153,9 +154,6 @@ export class CircuitStructureProjectCommandRepository
equipmentIdentifier: snapshot.equipmentIdentifier,
displayName: snapshot.displayName,
sortOrder: snapshot.sortOrder,
protectionType: snapshot.protectionType,
protectionRatedCurrent: snapshot.protectionRatedCurrent,
protectionCharacteristic: snapshot.protectionCharacteristic,
cableType: snapshot.cableType,
cableCrossSection: snapshot.cableCrossSection,
cableLength: snapshot.cableLength,
@@ -169,7 +167,16 @@ export class CircuitStructureProjectCommandRepository
})
.run();
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
database
.insert(circuitDeviceRows)
.values(snapshot.deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
}
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
return createCircuitDeleteProjectCommand(
@@ -215,6 +222,11 @@ export class CircuitStructureProjectCommandRepository
asc(circuitDeviceRows.id)
)
.all();
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
@@ -222,9 +234,6 @@ export class CircuitStructureProjectCommandRepository
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType,
protectionRatedCurrent: circuit.protectionRatedCurrent,
protectionCharacteristic: circuit.protectionCharacteristic,
cableType: circuit.cableType,
cableCrossSection: circuit.cableCrossSection,
cableLength: circuit.cableLength,
@@ -236,6 +245,7 @@ export class CircuitStructureProjectCommandRepository
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
});
const result = database
@@ -4,9 +4,6 @@ export interface CircuitCreatePersistenceInput {
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
@@ -24,9 +21,6 @@ export interface CircuitPatchPersistenceInput {
equipmentIdentifier?: string;
displayName?: string | null;
sortOrder?: number;
protectionType?: string | null;
protectionRatedCurrent?: number | null;
protectionCharacteristic?: string | null;
cableType?: string | null;
cableCrossSection?: string | null;
cableLength?: number | null;
@@ -47,9 +41,6 @@ export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenc
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
@@ -74,13 +65,6 @@ export function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
}
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;
+5 -92
View File
@@ -1,26 +1,12 @@
import crypto from "node:crypto";
import { and, asc, eq, ne } from "drizzle-orm";
import { db } from "../client.js";
import { asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
import {
toCircuitCreateValues,
type CircuitCreatePersistenceInput,
} from "./circuit.persistence.js";
export type {
CircuitCreatePersistenceInput,
} from "./circuit.persistence.js";
export {
toCircuitCreateValues,
} from "./circuit.persistence.js";
export class CircuitRepository {
async findById(circuitId: string) {
const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1);
return row ?? null;
}
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitListId: string) {
const db = this.database;
return db
.select()
.from(circuits)
@@ -28,85 +14,12 @@ export class CircuitRepository {
.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;
}
async update(
circuitId: string,
input: {
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
rcdAssignment?: string;
terminalDesignation?: string;
voltage?: number;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
}
) {
await db
.update(circuits)
.set({
sectionId: input.sectionId,
equipmentIdentifier: input.equipmentIdentifier,
displayName: input.displayName ?? null,
sortOrder: input.sortOrder,
protectionType: input.protectionType ?? null,
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
protectionCharacteristic: input.protectionCharacteristic ?? null,
cableType: input.cableType ?? null,
cableCrossSection: input.cableCrossSection ?? null,
cableLength: input.cableLength ?? null,
rcdAssignment: input.rcdAssignment ?? null,
terminalDesignation: input.terminalDesignation ?? null,
voltage: input.voltage ?? null,
controlRequirement: input.controlRequirement ?? null,
status: input.status ?? null,
isReserve: input.isReserve ? 1 : 0,
remark: input.remark ?? null,
})
.where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId));
}
async existsByEquipmentIdentifier(circuitListId: string, equipmentIdentifier: string, excludeCircuitId?: string) {
const rows = await db
.select({ id: circuits.id })
.from(circuits)
.where(
excludeCircuitId
? and(
eq(circuits.circuitListId, circuitListId),
eq(circuits.equipmentIdentifier, equipmentIdentifier),
ne(circuits.id, excludeCircuitId)
)
: and(eq(circuits.circuitListId, circuitListId), eq(circuits.equipmentIdentifier, equipmentIdentifier))
)
.limit(1);
return Boolean(rows.length);
}
async listBySection(sectionId: string) {
const db = this.database;
return db
.select()
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
}
@@ -0,0 +1,263 @@
import { and, eq } from "drizzle-orm";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
assertDistributionBoardComponentUpdateProjectCommand,
createDistributionBoardComponentDeleteProjectCommand,
createDistributionBoardComponentInsertProjectCommand,
createDistributionBoardComponentUpdateProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
distributionBoardComponentUpdateCommandType,
type DistributionBoardComponentSnapshot,
type DistributionBoardComponentStructureProjectCommand,
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
import type {
DistributionBoardComponentStructureProjectCommandStore,
ExecuteDistributionBoardComponentStructureCommandInput,
} from "../../domain/ports/distribution-board-component-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardComponentStructureProjectCommand
): DistributionBoardComponentStructureProjectCommand {
if (command.type === distributionBoardComponentInsertCommandType) {
assertDistributionBoardComponentInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentDeleteCommandType) {
assertDistributionBoardComponentDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentInsertProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentUpdateCommandType) {
assertDistributionBoardComponentUpdateProjectCommand(command);
this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
return createDistributionBoardComponentUpdateProjectCommand(
command.payload.target,
command.payload.expected
);
}
throw new Error(
"Unsupported distribution-board component structure command."
);
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const existing = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
database
.insert(distributionBoardComponents)
.values(snapshot.component)
.run();
if (snapshot.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
snapshot.component.id
)
)
.get();
if (
!component ||
!sameRecord(snapshot.component, component) ||
!sameNullableRecord(snapshot.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before deletion."
);
}
const deleted = database
.delete(distributionBoardComponents)
.where(
and(
eq(distributionBoardComponents.id, component.id),
eq(
distributionBoardComponents.circuitListId,
snapshot.component.circuitListId
)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Distribution-board component could not be deleted."
);
}
}
private update(
database: AppDatabase,
projectId: string,
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, expected);
this.assertOwnership(database, projectId, target);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, expected.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
expected.component.id
)
)
.get();
if (
!component ||
!sameRecord(expected.component, component) ||
!sameNullableRecord(expected.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before update."
);
}
database
.update(distributionBoardComponents)
.set(target.component)
.where(eq(distributionBoardComponents.id, expected.component.id))
.run();
if (
expected.protectionDevice !== null &&
target.protectionDevice !== null
) {
database
.update(distributionBoardComponentProtectionDevices)
.set(target.protectionDevice)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
expected.component.id
)
)
.run();
}
}
private assertOwnership(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, snapshot.component.circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error(
"Distribution-board component circuit list does not belong to project."
);
}
if (snapshot.component.sectionId !== null) {
const section = database
.select({ circuitListId: circuitSections.circuitListId })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.component.sectionId))
.get();
if (
!section ||
section.circuitListId !== snapshot.component.circuitListId
) {
throw new Error(
"Distribution-board component section does not belong to circuit list."
);
}
}
}
}
function sameNullableRecord(
expected: object | null,
actual: object | undefined
) {
return expected === null
? actual === undefined
: actual !== undefined && sameRecord(expected, actual);
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,36 @@
import { asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
export class DistributionBoardComponentRepository {
constructor(private readonly database: AppDatabase) {}
async listByCircuitList(circuitListId: string) {
return this.database
.select()
.from(distributionBoardComponents)
.where(
eq(distributionBoardComponents.circuitListId, circuitListId)
)
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
);
}
async listProtectionByComponentIds(componentIds: string[]) {
if (componentIds.length === 0) {
return [];
}
return this.database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
inArray(
distributionBoardComponentProtectionDevices.componentId,
componentIds
)
);
}
}
@@ -2,13 +2,21 @@ import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
createDistributionBoardDeleteProjectCommand,
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
invertDistributionBoardStructureProjectCommand,
normalizeDistributionBoardStructureProjectCommand,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
type NormalizedDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
createDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateField,
type DistributionBoardUpdatePatch,
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@@ -17,10 +25,11 @@ import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
@@ -28,29 +37,28 @@ export class DistributionBoardStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteDistributionBoardStructureCommandInput) {
return this.database.transaction((tx) => {
const inverse = this.applyCommand(
tx,
input.projectId,
input.command
);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
assertDistributionBoardUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.update(tx, input)
);
}
private applyCommand(
@@ -58,21 +66,15 @@ export class DistributionBoardStructureProjectCommandRepository
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
const structure =
normalizeDistributionBoardStructureProjectCommand(command);
if (command.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(command);
return this.insert(
database,
projectId,
command.payload.structure
);
this.insert(database, projectId, structure);
return invertDistributionBoardStructureProjectCommand(command);
}
if (command.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(command);
return this.delete(
database,
projectId,
command.payload.structure
);
this.delete(database, projectId, structure);
return invertDistributionBoardStructureProjectCommand(command);
}
throw new Error("Unsupported distribution-board structure command.");
}
@@ -80,7 +82,7 @@ export class DistributionBoardStructureProjectCommandRepository
private insert(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
structure: NormalizedDistributionBoardStructureSnapshot
) {
if (
structure.distributionBoard.projectId !== projectId ||
@@ -91,13 +93,33 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
const project = database
.select({ id: projects.id })
.select({
id: projects.id,
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
structure.distributionBoard.supplyType
);
if (structure.distributionBoard.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, structure.distributionBoard.floorId))
.get();
if (!floor || floor.projectId !== projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
const existingBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
@@ -119,7 +141,26 @@ export class DistributionBoardStructureProjectCommandRepository
)
.limit(1)
.get();
if (existingBoard || existingList || existingSection) {
const existingComponent =
structure.components.length === 0
? undefined
: database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(
inArray(
distributionBoardComponents.id,
structure.components.map((component) => component.id)
)
)
.limit(1)
.get();
if (
existingBoard ||
existingList ||
existingSection ||
existingComponent
) {
throw new Error("Distribution-board structure id already exists.");
}
@@ -128,14 +169,22 @@ export class DistributionBoardStructureProjectCommandRepository
.values(structure.distributionBoard)
.run();
database.insert(circuitLists).values(structure.circuitList).run();
database.insert(circuitSections).values(structure.sections).run();
return createDistributionBoardDeleteProjectCommand(structure);
database
.insert(circuitSections)
.values(structure.sections)
.run();
if (structure.components.length > 0) {
database
.insert(distributionBoardComponents)
.values(structure.components)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
structure: DistributionBoardStructureSnapshot
structure: NormalizedDistributionBoardStructureSnapshot
) {
const board = database
.select()
@@ -158,11 +207,30 @@ export class DistributionBoardStructureProjectCommandRepository
asc(circuitSections.id)
)
.all();
const components = database
.select()
.from(distributionBoardComponents)
.where(
eq(
distributionBoardComponents.circuitListId,
structure.circuitList.id
)
)
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all();
if (
!board ||
!list ||
board.projectId !== projectId ||
!structureMatches(structure, { board, list, sections })
!structureMatches(structure, {
board,
list,
sections,
components,
})
) {
throw new Error(
"Distribution-board structure changed before deletion."
@@ -195,22 +263,142 @@ export class DistributionBoardStructureProjectCommandRepository
if (deleted.changes !== 1) {
throw new Error("Distribution board could not be deleted.");
}
return createDistributionBoardInsertProjectCommand(structure);
}
private update(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
const current = database
.select()
.from(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
input.command.payload.distributionBoardId
),
eq(distributionBoards.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error("Distribution board does not belong to project.");
}
const project = database
.select({
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as DistributionBoardUpdatePatch;
if (patch.floorId !== undefined && patch.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, patch.floorId))
.get();
if (!floor || floor.projectId !== input.projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
if (patch.supplyType !== undefined) {
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
patch.supplyType
);
}
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getDistributionBoardFieldValue(current, change.field),
])
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
);
const updated = database
.update(distributionBoards)
.set(patch)
.where(
and(
eq(distributionBoards.id, current.id),
eq(distributionBoards.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Distribution board changed before command execution."
);
}
return inverse;
}
}
function assertSupplyTypeEnabled(
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
) {
if (
supplyType !== null &&
!enabledSupplyTypes.includes(supplyType)
) {
throw new Error(
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
);
}
}
function getDistributionBoardFieldValue<
TField extends DistributionBoardUpdateField,
>(
distributionBoard: typeof distributionBoards.$inferSelect,
field: TField
): DistributionBoardUpdateValues[TField] {
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
}
function structureMatches(
expected: DistributionBoardStructureSnapshot,
expected: NormalizedDistributionBoardStructureSnapshot,
actual: {
board: typeof distributionBoards.$inferSelect;
list: typeof circuitLists.$inferSelect;
sections: Array<typeof circuitSections.$inferSelect>;
components: Array<typeof distributionBoardComponents.$inferSelect>;
}
) {
const {
simultaneityFactor,
...actualDistributionBoard
} = actual.board;
if (
!sameRecord(expected.distributionBoard, actual.board) ||
simultaneityFactor !== 1 ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard
) ||
!sameRecord(expected.circuitList, actual.list) ||
expected.sections.length !== actual.sections.length
expected.sections.length !== actual.sections.length ||
expected.components.length !== actual.components.length
) {
return false;
}
@@ -219,20 +407,32 @@ function structureMatches(
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return expectedSections.every((section, index) =>
const sectionsMatch = expectedSections.every((section, index) =>
sameRecord(section, actual.sections[index])
);
const expectedComponents = [...expected.components].sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return (
sectionsMatch &&
expectedComponents.every((component, index) =>
sameRecord(component, actual.components[index])
)
);
}
function sameRecord(
expected: Record<string, unknown>,
actual: Record<string, unknown>
expected: object,
actual: object
) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actual[key] === value
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,436 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import {
assertDistributionBoardDeleteSubtreeProjectCommand,
assertDistributionBoardInsertSubtreeProjectCommand,
createDistributionBoardDeleteSubtreeProjectCommand,
createDistributionBoardInsertSubtreeProjectCommand,
distributionBoardDeleteSubtreeCommandType,
distributionBoardInsertSubtreeCommandType,
type DistributionBoardSubtreeProjectCommand,
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import type {
DistributionBoardSubtreeProjectCommandStore,
ExecuteDistributionBoardSubtreeCommandInput,
} from "../../domain/ports/distribution-board-subtree-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
capture(projectId: string, distributionBoardId: string) {
return this.captureFrom(
this.database,
projectId,
distributionBoardId
);
}
execute(input: ExecuteDistributionBoardSubtreeCommandInput) {
if (input.command.type === distributionBoardInsertSubtreeCommandType) {
assertDistributionBoardInsertSubtreeProjectCommand(input.command);
} else {
assertDistributionBoardDeleteSubtreeProjectCommand(input.command);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardSubtreeProjectCommand
) {
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.distributionBoard.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
throw new Error(
"Distribution-board subtree changed before deletion."
);
}
const deleted = database
.delete(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
current.distributionBoard.id
),
eq(distributionBoards.projectId, projectId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Distribution-board subtree could not be deleted."
);
}
return createDistributionBoardInsertSubtreeProjectCommand(
command.payload.snapshot
);
}
throw new Error("Unsupported distribution-board subtree command.");
}
private captureFrom(
database: AppDatabase,
projectId: string,
distributionBoardId: string
): DistributionBoardSubtreeSnapshot {
const distributionBoard = database
.select()
.from(distributionBoards)
.where(
and(
eq(distributionBoards.id, distributionBoardId),
eq(distributionBoards.projectId, projectId)
)
)
.get();
if (!distributionBoard) {
throw new Error("Distribution board not found.");
}
const circuitList = database
.select()
.from(circuitLists)
.where(
and(
eq(circuitLists.distributionBoardId, distributionBoard.id),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuitList) {
throw new Error("Distribution-board circuit list not found.");
}
const sections = database
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitList.id))
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id))
.all();
const components = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.circuitListId, circuitList.id))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all()
.map((component) => ({
component,
protectionDevice:
database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
component.id
)
)
.get() ?? null,
}));
const circuitSnapshots = database
.select()
.from(circuits)
.where(eq(circuits.circuitListId, circuitList.id))
.orderBy(asc(circuits.sortOrder), asc(circuits.id))
.all()
.map((circuit) => {
const { isReserve, ...values } = circuit;
return {
...values,
isReserve: Boolean(isReserve),
deviceRows: database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all(),
protectionDevice:
database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get() ?? null,
};
});
const snapshot: DistributionBoardSubtreeSnapshot = {
distributionBoard,
circuitList,
sections,
components,
circuits: circuitSnapshots,
};
assertDistributionBoardSubtreeSnapshot(snapshot);
return snapshot;
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardSubtreeSnapshot
) {
assertDistributionBoardSubtreeSnapshot(snapshot);
if (
snapshot.distributionBoard.projectId !== projectId ||
snapshot.circuitList.projectId !== projectId
) {
throw new Error(
"Distribution-board subtree belongs to another project."
);
}
this.assertProjectReferences(database, projectId, snapshot);
this.assertIdsAvailable(database, snapshot);
database
.insert(distributionBoards)
.values(snapshot.distributionBoard)
.run();
database.insert(circuitLists).values(snapshot.circuitList).run();
if (snapshot.sections.length > 0) {
database.insert(circuitSections).values(snapshot.sections).run();
}
for (const entry of snapshot.components) {
database
.insert(distributionBoardComponents)
.values(entry.component)
.run();
if (entry.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(entry.protectionDevice)
.run();
}
}
for (const circuit of snapshot.circuits) {
const { deviceRows, protectionDevice, isReserve, ...values } =
circuit;
database
.insert(circuits)
.values({ ...values, isReserve: isReserve ? 1 : 0 })
.run();
if (protectionDevice !== null && protectionDevice !== undefined) {
database
.insert(circuitProtectionDevices)
.values(protectionDevice)
.run();
}
if (deviceRows.length > 0) {
database
.insert(circuitDeviceRows)
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
}
}
}
private assertProjectReferences(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardSubtreeSnapshot
) {
const project = database
.select({
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
if (
snapshot.distributionBoard.supplyType !== null &&
!project.enabledSupplyTypes.includes(
snapshot.distributionBoard.supplyType
)
) {
throw new Error(
"Distribution-board subtree uses a disabled supply type."
);
}
if (snapshot.distributionBoard.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, snapshot.distributionBoard.floorId))
.get();
if (!floor || floor.projectId !== projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
const linkedProjectDeviceIds = uniqueStrings(
snapshot.circuits.flatMap((circuit) =>
circuit.deviceRows.flatMap((row) =>
row.linkedProjectDeviceId === null
? []
: [row.linkedProjectDeviceId]
)
)
);
if (linkedProjectDeviceIds.length > 0) {
const references = database
.select({ id: projectDevices.id, projectId: projectDevices.projectId })
.from(projectDevices)
.where(inArray(projectDevices.id, linkedProjectDeviceIds))
.all();
if (
references.length !== linkedProjectDeviceIds.length ||
references.some((reference) => reference.projectId !== projectId)
) {
throw new Error(
"Distribution-board project-device reference is invalid."
);
}
}
const roomIds = uniqueStrings(
snapshot.circuits.flatMap((circuit) =>
circuit.deviceRows.flatMap((row) =>
row.roomId === null ? [] : [row.roomId]
)
)
);
if (roomIds.length > 0) {
const references = database
.select({ id: rooms.id, projectId: rooms.projectId })
.from(rooms)
.where(inArray(rooms.id, roomIds))
.all();
if (
references.length !== roomIds.length ||
references.some((reference) => reference.projectId !== projectId)
) {
throw new Error("Distribution-board room reference is invalid.");
}
}
}
private assertIdsAvailable(
database: AppDatabase,
snapshot: DistributionBoardSubtreeSnapshot
) {
const sectionIds = snapshot.sections.map((section) => section.id);
const componentIds = snapshot.components.map(
({ component }) => component.id
);
const circuitIds = snapshot.circuits.map((circuit) => circuit.id);
const deviceRowIds = snapshot.circuits.flatMap((circuit) =>
circuit.deviceRows.map((row) => row.id)
);
const conflicts = [
database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.id, snapshot.distributionBoard.id))
.get(),
database
.select({ id: circuitLists.id })
.from(circuitLists)
.where(eq(circuitLists.id, snapshot.circuitList.id))
.get(),
sectionIds.length === 0
? undefined
: database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(inArray(circuitSections.id, sectionIds))
.limit(1)
.get(),
componentIds.length === 0
? undefined
: database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(inArray(distributionBoardComponents.id, componentIds))
.limit(1)
.get(),
circuitIds.length === 0
? undefined
: database
.select({ id: circuits.id })
.from(circuits)
.where(inArray(circuits.id, circuitIds))
.limit(1)
.get(),
deviceRowIds.length === 0
? undefined
: database
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, deviceRowIds))
.limit(1)
.get(),
];
if (conflicts.some(Boolean)) {
throw new Error("Distribution-board subtree id already exists.");
}
}
}
function uniqueStrings(values: string[]) {
return [...new Set(values)];
}
function sameSnapshot(
left: DistributionBoardSubtreeSnapshot,
right: DistributionBoardSubtreeSnapshot
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([key, child]) =>
`${JSON.stringify(key)}:${canonicalJson(child)}`
)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
@@ -1,10 +1,6 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
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 { createDefaultCircuitSectionValues } from "./circuit-section.repository.js";
export class DistributionBoardRepository {
constructor(private readonly database: AppDatabase) {}
@@ -16,44 +12,19 @@ export class DistributionBoardRepository {
.where(eq(distributionBoards.projectId, projectId));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const board = { id, projectId, name };
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;
}
async existsInProject(projectId: string, distributionBoardId: string) {
const [row] = await this.database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
eq(distributionBoards.id, distributionBoardId)
async findById(projectId: string, distributionBoardId: string) {
return (
this.database
.select()
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
eq(distributionBoards.id, distributionBoardId)
)
)
)
.limit(1);
return Boolean(row);
.get() ?? null
);
}
}
@@ -0,0 +1,105 @@
import { eq } from "drizzle-orm";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
createExternalCsvConfigurationUpdateProjectCommand,
type ExternalCsvConfigurationSnapshot,
} from "../../domain/models/external-csv-configuration-project-command.model.js";
import type {
ExecuteExternalCsvConfigurationCommandInput,
ExternalCsvConfigurationProjectCommandStore,
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalCsvConfigurationProjectCommandRepository
implements ExternalCsvConfigurationProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const current = tx
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("External CSV configuration belongs to a different project.");
}
if (sameSnapshot(current, target)) {
throw new Error("External CSV configuration did not change.");
}
if (
input.source === "user" &&
target !== null &&
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
) {
throw new Error("External CSV configuration version must advance by one.");
}
if (target === null) {
tx.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.run();
} else if (current === null) {
tx.insert(externalCsvConfigurations).values(target).run();
} else {
const updated = tx
.update(externalCsvConfigurations)
.set({
configurationVersion: target.configurationVersion,
configuration: target.configuration,
})
.where(eq(externalCsvConfigurations.id, current.id))
.run();
if (updated.changes !== 1) {
throw new Error("External CSV configuration changed before update.");
}
}
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalCsvConfigurationSnapshot | null,
expected: ExternalCsvConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("External CSV configuration changed before update.");
}
}
function sameSnapshot(
left: ExternalCsvConfigurationSnapshot | null,
right: ExternalCsvConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
@@ -0,0 +1,28 @@
import { eq } from "drizzle-orm";
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
export class ExternalCsvConfigurationRepository
implements ExternalCsvConfigurationReader
{
constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return { projectExists: false, configuration: null };
}
const configuration = this.database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.get() ?? null;
return { projectExists: true, configuration };
}
}
@@ -0,0 +1,250 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import {
assertExternalInitialImportProjectCommand,
createExternalInitialImportProjectCommand,
isEmptyExternalModelState,
} from "../../domain/models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../../external-model/domain/external-model-matching.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export class ExternalInitialImportProjectCommandRepository
implements ExternalInitialImportProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
assertExternalInitialImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentResult = new ExternalModelStateRepository(tx)
.getByProject(input.projectId);
if (!currentResult.projectExists) throw new Error("Project not found.");
const expected = input.command.payload.expected;
if (!sameState(currentResult.state, expected)) {
throw new Error("External model state changed before initial import.");
}
const target = input.command.payload.target;
if (!isEmptyExternalModelState(target)) {
validatePopulatedTarget(tx, input, target);
}
replaceExternalState(tx, input.projectId, target);
const persisted = new ExternalModelStateRepository(tx)
.getByProject(input.projectId).state;
if (!sameState(persisted, target)) {
throw new Error("Persisted external initial import differs from its target.");
}
return createExternalInitialImportProjectCommand(target, currentResult.state);
});
}
}
function validatePopulatedTarget(
database: AppDatabase,
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
target: ExternalModelStateSnapshot
) {
const source = target.source!;
const batch = target.importBatches[0];
if (source.projectId !== input.projectId) {
throw new Error("External initial import belongs to a different project.");
}
if (
input.source === "user" &&
batch.appliedProjectRevision !== input.expectedRevision + 1
) {
throw new Error("External initial import revision is invalid.");
}
const configuration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get();
if (
!configuration ||
configuration.configurationVersion !== batch.configurationVersion ||
canonicalJson(configuration.configuration) !==
canonicalJson(batch.configurationSnapshot)
) {
throw new Error("External initial import configuration changed before apply.");
}
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
if (sha256 !== batch.sha256) {
throw new Error("External initial import checksum is invalid.");
}
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
throw new Error("External initial import document differs from the original bytes.");
}
const preview = createExternalCsvPreview({
fileName: batch.fileName,
bytes,
configuration: batch.configurationSnapshot,
});
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
if (targetByIfcGuid.size !== preview.objects.length) {
throw new Error("External initial import must contain every CSV object exactly once.");
}
const mappingsById = new Map(
target.roomMappings.map((mapping) => [mapping.id, mapping])
);
const expectedRoomKeys = new Set<string>();
for (const candidate of preview.objects) {
const object = targetByIfcGuid.get(candidate.ifcGuid);
if (!object) {
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
}
const projected = projectInitialExternalObject(
candidate,
batch.configurationSnapshot
);
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
throw new Error("External initial import source values differ from the CSV.");
}
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
if (
!object.overriddenFields.includes(field) &&
canonicalJson(object.planningValues[field]) !==
canonicalJson(projected.planningValues[field])
) {
throw new Error(`External initial planning field ${field} changed without an override.`);
}
}
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
if (roomKey === null) {
if (object.externalRoomMappingId !== null) {
throw new Error("External object without a source room must not use a room mapping.");
}
} else {
expectedRoomKeys.add(roomKey);
const mapping = object.externalRoomMappingId === null
? null
: mappingsById.get(object.externalRoomMappingId);
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
throw new Error("External object source room mapping is incomplete.");
}
}
}
if (
target.roomMappings.length !== expectedRoomKeys.size ||
target.roomMappings.some(
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
)
) {
throw new Error("External initial import room mappings do not match CSV rooms.");
}
validateInternalLinks(database, input.projectId, target);
}
function validateInternalLinks(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
const roomIds = new Set(
database.select({ id: rooms.id }).from(rooms)
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
);
const boardIds = new Set(
database.select({ id: distributionBoards.id }).from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
);
const deviceIds = new Set(
database.select({ id: projectDevices.id }).from(projectDevices)
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
);
for (const mapping of target.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
throw new Error("External room mapping references another project.");
}
if (
mapping.defaultDistributionBoardId !== null &&
!boardIds.has(mapping.defaultDistributionBoardId)
) {
throw new Error("External room mapping distribution board belongs to another project.");
}
}
for (const object of target.objects) {
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
throw new Error("External object distribution board belongs to another project.");
}
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
throw new Error("External object project device belongs to another project.");
}
}
}
function replaceExternalState(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
database.delete(externalModelSources)
.where(eq(externalModelSources.projectId, projectId)).run();
if (target.source === null) return;
database.insert(externalModelSources).values(target.source).run();
database.insert(externalImportBatches).values(
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
}))
).run();
if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run();
}
database.insert(externalModelObjects).values(target.objects).run();
}
function decodeCanonicalBase64(value: string) {
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("External initial import bytes are not canonical base64.");
}
return bytes;
}
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
}
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
const byId = <T extends { id: string }>(entries: readonly T[]) =>
[...entries].sort((left, right) => left.id.localeCompare(right.id));
return {
source: state.source,
importBatches: byId(state.importBatches),
roomMappings: byId(state.roomMappings),
objects: byId(state.objects),
};
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
@@ -0,0 +1,74 @@
import { asc, eq } from "drizzle-orm";
import type { ExternalModelStateReader } from "../../domain/ports/external-model-state.reader.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projects } from "../schema/projects.js";
const emptyState = (): ExternalModelStateSnapshot => ({
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
export class ExternalModelStateRepository implements ExternalModelStateReader {
constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) return { projectExists: false, state: emptyState() };
const source = this.database
.select()
.from(externalModelSources)
.where(eq(externalModelSources.projectId, projectId))
.get() ?? null;
if (!source) return { projectExists: true, state: emptyState() };
const batches = this.database
.select()
.from(externalImportBatches)
.where(eq(externalImportBatches.sourceId, source.id))
.orderBy(
asc(externalImportBatches.appliedProjectRevision),
asc(externalImportBatches.id)
)
.all();
const roomMappings = this.database
.select()
.from(externalRoomMappings)
.where(eq(externalRoomMappings.sourceId, source.id))
.orderBy(
asc(externalRoomMappings.normalizedSourceRoomKey),
asc(externalRoomMappings.id)
)
.all();
const objects = this.database
.select()
.from(externalModelObjects)
.where(eq(externalModelObjects.sourceId, source.id))
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
.all();
return {
projectExists: true,
state: {
source,
importBatches: batches.map(({ originalBytes, ...batch }) => ({
...batch,
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
})),
roomMappings,
objects,
},
};
}
}
@@ -0,0 +1,137 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { AppDatabase } from "../database-context.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
export interface ExternalObjectLinkTransition {
expected: ExternalModelObjectSnapshot;
target: ExternalModelObjectSnapshot;
}
export function loadExpectedExternalObjectTransitions(
database: AppDatabase,
projectId: string,
transitions: ExternalObjectLinkTransition[],
changedMessage: string
) {
const byId = new Map(
transitions.map((transition) => [transition.expected.id, transition])
);
const current = database.select().from(externalModelObjects)
.where(inArray(externalModelObjects.id, [...byId.keys()])).all();
if (current.length !== byId.size) {
throw new Error("One or more external objects no longer exist.");
}
for (const object of current) {
if (
object.projectId !== projectId ||
!snapshotsEqual(toExternalObjectSnapshot(object), byId.get(object.id)!.expected)
) {
throw new Error(changedMessage);
}
}
return transitions;
}
export function assertExternalObjectsCompatibleWithRow(input: {
database: AppDatabase;
projectId: string;
row: CircuitDeviceRowSnapshot;
distributionBoardId: string;
category: CircuitGroupCategory;
assignedObjects: ExternalModelObjectSnapshot[];
allLinkedObjects: ExternalModelObjectSnapshot[];
confirmedConflictObjectIds: ReadonlySet<string>;
}) {
const mappings = input.database.select().from(externalRoomMappings)
.where(eq(externalRoomMappings.projectId, input.projectId)).all();
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
const markers = new Set(
input.allLinkedObjects.map((object) =>
object.acceptedSourceValues.selectionMarker.trim()
)
);
if (markers.size > 1) {
throw new Error("External objects with different selection markers require separate rows.");
}
for (const object of input.assignedObjects) {
if (object.distributionBoardId !== input.distributionBoardId) {
throw new Error("External object distribution does not match target circuit.");
}
if (object.planningValues.category !== input.category) {
throw new Error("External object category does not match target circuit group.");
}
const roomId = object.externalRoomMappingId === null
? null
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
if (roomId !== input.row.roomId) {
throw new Error("External object room does not match target device row.");
}
if (
!matchesRowPlanningValues(input.row, object) &&
!input.confirmedConflictObjectIds.has(object.id)
) {
throw new Error("External object planning values require explicit conflict confirmation.");
}
}
}
export function applyExternalObjectLinks(
database: AppDatabase,
transitions: ExternalObjectLinkTransition[],
changedMessage: string
) {
for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects)
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
.where(and(
eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null
? isNull(externalModelObjects.circuitDeviceRowId)
: eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId)
)).run();
if (updated.changes !== 1) throw new Error(changedMessage);
}
}
export function toExternalObjectSnapshot(
object: typeof externalModelObjects.$inferSelect
): ExternalModelObjectSnapshot {
return { ...object };
}
export function snapshotsEqual(left: unknown, right: unknown) {
return canonicalJson(left) === canonicalJson(right);
}
function matchesRowPlanningValues(
row: CircuitDeviceRowSnapshot,
object: ExternalModelObjectSnapshot
) {
const planning = object.planningValues;
return (
row.displayName === (planning.displayName ?? row.displayName) &&
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
@@ -0,0 +1,239 @@
import { and, asc, eq } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectNewCircuitProjectCommand,
externalObjectAssignToNewCircuitCommandType,
externalObjectDeleteCreatedCircuitCommandType,
invertExternalObjectNewCircuitProjectCommand,
type ExternalObjectNewCircuitProjectCommand,
} from "../../domain/models/external-object-new-circuit-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
import type { ExternalObjectNewCircuitProjectCommandStore } from "../../domain/ports/external-object-new-circuit-project-command.store.js";
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
assertExternalObjectsCompatibleWithRow,
loadExpectedExternalObjectTransitions,
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectNewCircuitProjectCommandStore["execute"]>[0]) {
assertExternalObjectNewCircuitProjectCommand(input.command);
if (
input.source === "user" &&
input.command.type === externalObjectDeleteCreatedCircuitCommandType
) {
throw new Error("Created external circuits may only be removed through project history.");
}
return executeProjectCommandTransaction(this.database, input, (tx) => {
if (input.command.type === externalObjectAssignToNewCircuitCommandType) {
this.insert(tx, input.projectId, input.command);
} else {
this.remove(tx, input.projectId, input.command);
}
return invertExternalObjectNewCircuitProjectCommand(input.command);
});
}
private insert(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewCircuitProjectCommand
) {
const circuit = command.payload.circuit;
const row = circuit.deviceRows[0]!;
const context = this.loadSectionContext(
database,
projectId,
circuit.circuitListId,
circuit.sectionId
);
const parsedIdentifier = parseGroupedEquipmentIdentifier(circuit.equipmentIdentifier);
if (
!parsedIdentifier ||
parsedIdentifier.kind !== "circuit" ||
parsedIdentifier.category !== context.category ||
parsedIdentifier.groupNumber !== context.groupNumber
) {
throw new Error("External circuit equipment identifier does not match target group.");
}
if (row.category !== context.category || !isElectricalPhaseType(row.phaseType)) {
throw new Error("External device row does not match target circuit group.");
}
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
const voltage = resolveCircuitVoltage(database, projectId, circuit.sectionId, [row.phaseType]);
if (circuit.voltage !== voltage) {
throw new Error("External circuit voltage must match the project phase voltage.");
}
if (database.select({ id: circuits.id }).from(circuits)
.where(eq(circuits.id, circuit.id)).get()) {
throw new Error("External circuit id already exists.");
}
if (database.select({ id: circuits.id }).from(circuits).where(and(
eq(circuits.circuitListId, circuit.circuitListId),
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
)).get()) {
throw new Error("Duplicate equipmentIdentifier in circuit list.");
}
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
assertExternalObjectsCompatibleWithRow({
database,
projectId,
row,
distributionBoardId: context.distributionBoardId,
category: context.category,
assignedObjects: objects.map(({ target }) => target),
allLinkedObjects: objects.map(({ target }) => target),
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
});
assertCircuitDeviceRowQuantity({
quantity: row.quantity,
manualQuantity: 0,
externalObjects: objects.map(({ target }) => ({
effectiveQuantity: target.planningValues.effectiveQuantity,
})),
});
database.insert(circuits).values(toCircuitValues(circuit)).run();
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
database.insert(circuitProtectionDevices).values(circuit.protectionDevice!).run();
applyExternalObjectLinks(
database,
objects,
"External object changed during new-circuit assignment."
);
}
private remove(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewCircuitProjectCommand
) {
const expectedCircuit = command.payload.circuit;
this.loadSectionContext(
database,
projectId,
expectedCircuit.circuitListId,
expectedCircuit.sectionId
);
const current = this.loadCircuitSnapshot(database, expectedCircuit.id);
if (!current || !snapshotsEqual(current, expectedCircuit)) {
throw new Error("Created external circuit changed before history removal.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
const rowId = expectedCircuit.deviceRows[0]!.id;
const linkedObjects = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(eq(externalModelObjects.circuitDeviceRowId, rowId)).all();
if (
linkedObjects.length !== objects.length ||
linkedObjects.some(({ id }) => !objects.some(({ expected }) => expected.id === id))
) {
throw new Error("Created external circuit has different object links.");
}
applyExternalObjectLinks(
database,
objects,
"External object changed during new-circuit assignment."
);
const deleted = database.delete(circuits).where(and(
eq(circuits.id, expectedCircuit.id),
eq(circuits.circuitListId, expectedCircuit.circuitListId)
)).run();
if (deleted.changes !== 1) {
throw new Error("Created external circuit changed during history removal.");
}
}
private loadSectionContext(
database: AppDatabase,
projectId: string,
circuitListId: string,
sectionId: string
) {
const context = database.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
category: circuitSections.category,
groupNumber: circuitSections.groupNumber,
}).from(circuitSections)
.innerJoin(circuitLists, eq(circuitLists.id, circuitSections.circuitListId))
.where(and(
eq(circuitSections.id, sectionId),
eq(circuitSections.circuitListId, circuitListId)
)).get();
if (
!context ||
context.projectId !== projectId ||
context.category === null ||
context.groupNumber === null
) {
throw new Error("Target circuit group does not belong to the project.");
}
return {
distributionBoardId: context.distributionBoardId,
category: context.category,
groupNumber: context.groupNumber,
};
}
private loadExpectedObjects(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewCircuitProjectCommand
) {
return loadExpectedExternalObjectTransitions(
database,
projectId,
command.payload.objects,
"External object changed before new-circuit assignment."
);
}
private loadCircuitSnapshot(database: AppDatabase, circuitId: string): CircuitSnapshot | null {
const circuit = database.select().from(circuits).where(eq(circuits.id, circuitId)).get();
if (!circuit) return null;
const rows = database.select().from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.orderBy(asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id)).all();
const protection = database.select().from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuitId)).get();
return {
...circuit,
isReserve: Boolean(circuit.isReserve),
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protection ?? null,
};
}
}
function toCircuitValues(circuit: CircuitSnapshot) {
const { deviceRows: _deviceRows, protectionDevice: _protectionDevice, ...values } = circuit;
return { ...values, isReserve: circuit.isReserve ? 1 : 0 };
}
@@ -0,0 +1,176 @@
import { eq } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
externalObjectDeleteCreatedRowCommandType,
invertExternalObjectNewRowProjectCommand,
type ExternalObjectNewRowProjectCommand,
} from "../../domain/models/external-object-new-row-project-command.model.js";
import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
assertExternalObjectsCompatibleWithRow,
loadExpectedExternalObjectTransitions,
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
export class ExternalObjectNewRowProjectCommandRepository
implements ExternalObjectNewRowProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectNewRowProjectCommandStore["execute"]>[0]) {
assertExternalObjectNewRowProjectCommand(input.command);
if (
input.source === "user" &&
input.command.type === externalObjectDeleteCreatedRowCommandType
) {
throw new Error("Created external rows may only be removed through project history.");
}
return executeProjectCommandTransaction(this.database, input, (tx) => {
if (input.command.type === externalObjectAssignToNewRowCommandType) {
this.insert(tx, input.projectId, input.command);
} else {
this.remove(tx, input.projectId, input.command);
}
return invertExternalObjectNewRowProjectCommand(input.command);
});
}
private insert(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const row = command.payload.row;
const context = this.loadCircuitContext(database, projectId, row.circuitId);
if (!isElectricalPhaseType(row.phaseType)) {
throw new Error("External device-row phase type is invalid.");
}
if (row.category !== context.category) {
throw new Error("External device-row category does not match target circuit group.");
}
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
assertExternalObjectsCompatibleWithRow({
database,
projectId,
row,
distributionBoardId: context.distributionBoardId,
category: context.category,
assignedObjects: objects.map(({ target }) => target),
allLinkedObjects: objects.map(({ target }) => target),
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
});
assertCircuitDeviceRowQuantity({
quantity: row.quantity,
manualQuantity: 0,
externalObjects: objects.map(({ target }) => ({
effectiveQuantity: target.planningValues.effectiveQuantity,
})),
});
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
this.updateCircuitState(database, projectId, row.circuitId);
applyExternalObjectLinks(
database,
objects,
"External object changed during new-row assignment."
);
}
private remove(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const expectedRow = command.payload.row;
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
const currentRow = database.select().from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
if (!currentRow || !snapshotsEqual(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
throw new Error("Created external device row changed before history removal.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
const linkedObjects = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(eq(externalModelObjects.circuitDeviceRowId, expectedRow.id)).all();
if (
linkedObjects.length !== objects.length ||
linkedObjects.some(({ id }) => !objects.some((object) => object.expected.id === id))
) {
throw new Error("Created external device row has different object links.");
}
applyExternalObjectLinks(
database,
objects,
"External object changed during new-row assignment."
);
const deleted = database.delete(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
if (deleted.changes !== 1) {
throw new Error("Created external device row changed during history removal.");
}
this.updateCircuitState(database, projectId, expectedRow.circuitId);
}
private loadCircuitContext(database: AppDatabase, projectId: string, circuitId: string) {
const context = database.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
category: circuitSections.category,
}).from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
.where(eq(circuits.id, circuitId)).get();
if (!context || context.projectId !== projectId || context.category === null) {
throw new Error("Target circuit does not belong to the project.");
}
return {
projectId: context.projectId,
distributionBoardId: context.distributionBoardId,
category: context.category,
};
}
private loadExpectedObjects(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
return loadExpectedExternalObjectTransitions(
database,
projectId,
command.payload.objects,
"External object changed before new-row assignment."
);
}
private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) {
const remaining = database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId)).limit(1).get();
const result = database.update(circuits).set({ isReserve: remaining ? 0 : 1 })
.where(eq(circuits.id, circuitId)).run();
if (result.changes !== 1) throw new Error("Target circuit changed during row assignment.");
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
@@ -0,0 +1,143 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeviceRowQuantity,
} from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
invertExternalObjectRowAssignmentProjectCommand,
} from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../../domain/ports/external-object-row-assignment-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
applyExternalObjectLinks,
assertExternalObjectsCompatibleWithRow,
loadExpectedExternalObjectTransitions,
snapshotsEqual,
toExternalObjectSnapshot,
} from "./external-object-assignment.persistence.js";
export class ExternalObjectRowAssignmentProjectCommandRepository
implements ExternalObjectRowAssignmentProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
assertExternalObjectRowAssignmentProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
this.apply(tx, input.projectId, input.command);
return invertExternalObjectRowAssignmentProjectCommand(input.command);
});
}
private apply(
database: AppDatabase,
projectId: string,
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
) {
const rowTransitions = new Map(
command.payload.rows.map((transition) => [transition.expected.id, transition])
);
const objectTransitions = new Map(
command.payload.objects.map((transition) => [transition.expected.id, transition])
);
const rowIds = [...rowTransitions.keys()];
const currentRows = database.select().from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds)).all();
if (currentRows.length !== rowIds.length) {
throw new Error("External object assignment target no longer exists.");
}
for (const row of currentRows) {
const expected = rowTransitions.get(row.id)!.expected;
if (!snapshotsEqual(toCircuitDeviceRowSnapshot(row), expected)) {
throw new Error("Circuit device row changed before external object assignment.");
}
}
loadExpectedExternalObjectTransitions(
database,
projectId,
command.payload.objects,
"External object changed before row assignment."
);
const rowContexts = database
.select({
rowId: circuitDeviceRows.id,
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
category: circuitSections.category,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (
rowContexts.length !== rowIds.length ||
rowContexts.some((context) =>
context.projectId !== projectId || context.category === null
)
) {
throw new Error("Circuit device row belongs to another project.");
}
const contextByRowId = new Map(rowContexts.map((context) => [context.rowId, context]));
const allProjectObjects = database.select().from(externalModelObjects)
.where(eq(externalModelObjects.projectId, projectId)).all()
.map(toExternalObjectSnapshot);
const targetObjects = allProjectObjects.map((object) =>
objectTransitions.get(object.id)?.target ?? object
);
const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds);
for (const transition of command.payload.rows) {
const linkedObjects = targetObjects.filter(
(object) => object.circuitDeviceRowId === transition.target.id
);
const assignedObjects = command.payload.objects
.map(({ target }) => target)
.filter((object) => object.circuitDeviceRowId === transition.target.id);
const context = contextByRowId.get(transition.target.id)!;
assertExternalObjectsCompatibleWithRow({
database,
projectId,
row: transition.target,
distributionBoardId: context.distributionBoardId,
category: context.category!,
assignedObjects,
allLinkedObjects: linkedObjects,
confirmedConflictObjectIds: confirmedConflicts,
});
assertCircuitDeviceRowQuantity({
quantity: transition.target.quantity,
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
externalObjects: linkedObjects.map((object) => ({
effectiveQuantity: object.planningValues.effectiveQuantity,
})),
});
}
for (const transition of command.payload.rows) {
const result = database.update(circuitDeviceRows)
.set({ quantity: transition.target.quantity })
.where(and(
eq(circuitDeviceRows.id, transition.expected.id),
eq(circuitDeviceRows.quantity, transition.expected.quantity),
eq(circuitDeviceRows.manualQuantity, transition.expected.manualQuantity ?? transition.expected.quantity)
)).run();
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
}
applyExternalObjectLinks(
database,
command.payload.objects,
"External object changed during assignment."
);
}
}
+8 -20
View File
@@ -1,10 +1,12 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { floors } from "../schema/floors.js";
export class FloorRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
const db = this.database;
return db
.select()
.from(floors)
@@ -12,25 +14,11 @@ export class FloorRepository {
.orderBy(asc(floors.sortOrder), asc(floors.name));
}
async create(projectId: string, name: string) {
const id = crypto.randomUUID();
const existing = await this.listByProject(projectId);
const floor = {
id,
projectId,
name,
sortOrder: existing.length,
};
await db.insert(floors).values(floor);
return floor;
}
async existsInProject(projectId: string, floorId: string) {
const [row] = await db
.select({ id: floors.id })
async findById(projectId: string, floorId: string) {
return this.database
.select()
.from(floors)
.where(and(eq(floors.projectId, projectId), eq(floors.id, floorId)))
.limit(1);
return Boolean(row);
.get() ?? null;
}
}
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import type { AppDatabase } from "../database-context.js";
import { globalDevices } from "../schema/global-devices.js";
import type {
CreateGlobalDeviceInput,
@@ -8,11 +8,15 @@ import type {
} from "../../shared/validation/global-device.schemas.js";
export class GlobalDeviceRepository {
constructor(private readonly database: AppDatabase) {}
async list() {
const db = this.database;
return db.select().from(globalDevices);
}
async create(input: CreateGlobalDeviceInput) {
const db = this.database;
const id = crypto.randomUUID();
await db.insert(globalDevices).values({
id,
@@ -22,8 +26,8 @@ export class GlobalDeviceRepository {
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
voltageV: null,
phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
});
@@ -31,6 +35,7 @@ export class GlobalDeviceRepository {
}
async update(globalDeviceId: string, input: UpdateGlobalDeviceInput) {
const db = this.database;
await db
.update(globalDevices)
.set({
@@ -40,8 +45,8 @@ export class GlobalDeviceRepository {
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
voltageV: null,
phaseCount: input.phaseCount,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
})
@@ -49,11 +54,13 @@ export class GlobalDeviceRepository {
}
async findById(globalDeviceId: string) {
const db = this.database;
const [row] = await db.select().from(globalDevices).where(eq(globalDevices.id, globalDeviceId)).limit(1);
return row ?? null;
}
async delete(globalDeviceId: string) {
const db = this.database;
await db.delete(globalDevices).where(eq(globalDevices.id, globalDeviceId));
}
}
@@ -1,163 +0,0 @@
import crypto from "node:crypto";
import { eq, inArray, isNull } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.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.persistence.js";
import {
toCircuitCreateValues,
type CircuitCreatePersistenceInput,
} from "./circuit.persistence.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 {
constructor(private readonly database: AppDatabase) {}
async listSourceConsumersByCircuitList(circuitListId: string) {
return this.database
.select()
.from(consumers)
.where(eq(consumers.circuitListId, circuitListId));
}
async listUnmigratedConsumers() {
return this.database
.select({
id: consumers.id,
projectId: consumers.projectId,
circuitListId: consumers.circuitListId,
})
.from(consumers)
.leftJoin(
legacyConsumerCircuitMigrations,
eq(legacyConsumerCircuitMigrations.consumerId, consumers.id)
)
.where(isNull(legacyConsumerCircuitMigrations.consumerId));
}
async listMigratedConsumerIds(circuitListId: string) {
const rows = await this.database
.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();
this.database.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,96 @@
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export interface ProjectCommandTransactionInput<
Command extends SerializedProjectCommand<unknown>,
> {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: Command;
}
export interface AppliedProjectCommand<
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
> {
forward: Forward;
inverse: Inverse;
}
export function executeProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (transaction: AppDatabase) => Inverse
): { revision: AppendedProjectRevision; inverse: Inverse } {
return executeAppliedProjectCommandTransaction(
database,
input,
(transaction) => ({
forward: input.command,
inverse: applyCommand(transaction),
})
);
}
export function executeProjectCommandTransactionWithAppliedForward<
Command extends SerializedProjectCommand<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } {
return executeAppliedProjectCommandTransaction(
database,
input,
applyCommand
);
}
function executeAppliedProjectCommandTransaction<
Command extends SerializedProjectCommand<unknown>,
Forward extends SerializedProjectCommand<unknown>,
Inverse extends SerializedProjectCommand<unknown>,
>(
database: AppDatabase,
input: ProjectCommandTransactionInput<Command>,
applyCommand: (
transaction: AppDatabase
) => AppliedProjectCommand<Forward, Inverse>
): { revision: AppendedProjectRevision; inverse: Inverse } {
return database.transaction((tx) => {
const { forward, inverse } = applyCommand(tx);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}

Some files were not shown because too many files have changed in this diff Show More