Compare commits
48 commits
archive/dy
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 431f9ff059 | |||
| 4ff01b2ccd | |||
| 2581beee3c | |||
| 1a4c8cad8c | |||
| 77d775fde1 | |||
| 4489cd76f0 | |||
| 4ac32e103d | |||
| bfdd048269 | |||
| c633e78cd4 | |||
| 1cfeb06ec7 | |||
| 52d3b1a270 | |||
| d4803a18d2 | |||
| ae318a51ac | |||
| cf022b250f | |||
| 68d986ff80 | |||
| 3670f02ccd | |||
| 847f4e9493 | |||
| 73579d1e79 | |||
| 0a37783635 | |||
| 9165b8238c | |||
| ade21fe386 | |||
|
|
728132e52a | ||
|
|
95bfba2f67 | ||
|
|
5073d9342a | ||
| 552362bad1 | |||
| b74b495fab | |||
|
|
8d2948cdb1 | ||
|
|
c56cfb30ad | ||
| 042a050876 | |||
| dfeb0e3cb6 | |||
| 855a04e655 | |||
| b893c72534 | |||
| b50c00c0ac | |||
| dce31ee143 | |||
| a6500e2b26 | |||
| 8b1923e8ec | |||
| 5cbbd3a84a | |||
| 598003554a | |||
| 060a203425 | |||
| 573487666c | |||
| 57a62b7e4a | |||
| b9462e61c5 | |||
|
|
7b2419b347 | ||
|
|
72189d23a5 | ||
|
|
e06c7d91e2 | ||
|
|
37a70b6b36 | ||
|
|
80dbc73230 | ||
|
|
49488f4e1b |
285 changed files with 43761 additions and 8696 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Nur beim ersten Start mit leerer Benutzertabelle erforderlich.
|
||||||
|
AUTH_BOOTSTRAP_USERNAME=
|
||||||
|
AUTH_BOOTSTRAP_PASSWORT=
|
||||||
|
AUTH_SESSION_HOURS=12
|
||||||
|
AUTH_SESSION_MAX_DAYS=7
|
||||||
|
# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false.
|
||||||
|
COOKIE_SECURE=false
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,9 +1,13 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
||||||
/drizzle.config.js
|
/drizzle.config.js
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
dynamo/output/
|
/docs/spec/kbp-Stromkreis.xlsx
|
||||||
|
/data/revit-reference/*.xlsx
|
||||||
|
|
|
||||||
345
AGENTS.md
345
AGENTS.md
|
|
@ -36,6 +36,16 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||||
repositories expose only active reads. Runtime writes belong in typed command
|
repositories expose only active reads. Runtime writes belong in typed command
|
||||||
repositories; direct integration fixtures belong under `tests/support`.
|
repositories; direct integration fixtures belong under `tests/support`.
|
||||||
|
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
|
||||||
|
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
|
||||||
|
middleware), `src/domain/services/user.service.ts`,
|
||||||
|
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
|
||||||
|
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
|
||||||
|
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
|
||||||
|
(`src/app/layout.tsx`). Every unsafe API request also requires the explicit
|
||||||
|
`X-Leistungsbilanz-Request: 1` application header, including over plain HTTP.
|
||||||
|
Account settings are available to every signed-in user; user administration
|
||||||
|
remains admin-only. See `docs/authentication.md` for the full picture.
|
||||||
|
|
||||||
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
||||||
tables and upgrade tooling are removed; do not reintroduce them.
|
tables and upgrade tooling are removed; do not reintroduce them.
|
||||||
|
|
@ -52,7 +62,8 @@ See `docs/current-architecture.md` for the complete module and request flow.
|
||||||
- Device rows inside a circuit do not have their own equipment identifiers.
|
- Device rows inside a circuit do not have their own equipment identifiers.
|
||||||
- Protection and cable data belong to the circuit, not to individual devices.
|
- Protection and cable data belong to the circuit, not to individual devices.
|
||||||
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
||||||
- Circuit total power is the sum of all device rows in that circuit.
|
- Circuit total power is the sum of all device rows in that circuit plus the
|
||||||
|
read-only loads of logically linked downstream boards on that feeder circuit.
|
||||||
- Existing equipment identifiers must never be changed automatically.
|
- Existing equipment identifiers must never be changed automatically.
|
||||||
- Renumbering is always an explicit user action.
|
- Renumbering is always an explicit user action.
|
||||||
|
|
||||||
|
|
@ -284,15 +295,23 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 7 additionally stores the project buildings
|
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
||||||
and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6
|
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
||||||
are upgraded with no buildings and `buildingId = null`. Version 6 stores the
|
versions containing legacy CSV configuration or external CSV state are rejected
|
||||||
|
with a German migration error. Version 9 additionally stores
|
||||||
|
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
||||||
|
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
||||||
|
stores the optional net room area `rooms[].areaM2` (see
|
||||||
|
`docs/building-structure.md`); versions 1 to 7 are upgraded with
|
||||||
|
`areaM2 = null`. Version 7 stores the project buildings and
|
||||||
|
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
|
||||||
|
`buildingId = null`. Version 6 stores the
|
||||||
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
||||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||||
with `null` for these fields.
|
with `null` for these fields.
|
||||||
Stored named and automatic snapshots of every supported version (1 to 7)
|
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
|
||||||
remain restorable; they are upgraded while parsing. Version 5 stores
|
they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores
|
||||||
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
external-model source, import batches, room mappings and objects. Version 3
|
external-model source, import batches, room mappings and objects. Version 3
|
||||||
|
|
@ -304,29 +323,45 @@ supported versions contain circuit-group identity, distribution-board components
|
||||||
separate circuit/component protection records. Portable duplicate imports
|
separate circuit/component protection records. Portable duplicate imports
|
||||||
remap component ids and protection-owner references together with the existing
|
remap component ids and protection-owner references together with the existing
|
||||||
project graph.
|
project graph.
|
||||||
Project-wide Revit CSV configuration uses
|
R3 replaced the runtime configuration and persistence foundation with Linear
|
||||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
|
||||||
persisted through the shared project-command transaction, so create, update,
|
the old development-only external CSV data after resetting linked row totals to
|
||||||
delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable
|
their manual share. Snapshot schema 10, restore and project transfer use the
|
||||||
project duplication preserve the configuration; duplication remaps its internal
|
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
|
||||||
id and project link.
|
rejected with a German error. The backend CSV parser and routes are removed.
|
||||||
The project page exposes the Revit CSV modal. It edits the complete
|
R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
|
||||||
transport/column/family-rule configuration through the revision-safe endpoint,
|
server-side sample detection, and removes the old project-page CSV modal. See
|
||||||
requests a stateless preview and then shows the Phase 14.2 initial-import
|
`docs/spec/revit-excel-integration-requirements.md` and
|
||||||
decisions. Preview responses include all mapped objects, while the UI renders
|
`docs/spec/revit-excel-phase-14-plan.md`.
|
||||||
only the first 25. No preview or plan creates external objects, rows, revisions
|
R5 adds stateless XLSX preview/planning and the six-step initial-import wizard.
|
||||||
or server-side drafts.
|
`external-model.apply-initial-import` stores the configuration and complete
|
||||||
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
|
external model state atomically; its inverse powers persistent Undo/Redo
|
||||||
immutable import batches with original bytes plus the classified cell matrix,
|
without parsing workbook bytes. Apply retransmits and reparses the XLSX,
|
||||||
normalized source-room mappings and external objects unique by
|
verifies its preview SHA-256 and creates no circuit, equipment identifier or
|
||||||
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
CircuitDeviceRow.
|
||||||
The general external-model repository is read-only; runtime writes belong only
|
R6 adds one compact project-wide external-object GET without pagination or
|
||||||
to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6
|
batch payloads. The Revit page filters and sorts it in the client and renders a
|
||||||
capture the complete external state and remap every internal UUID and link while
|
virtualized window. `external-object.update-planning` applies multi-object
|
||||||
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
board, ProjectDevice and planning-value changes as one persistent command;
|
||||||
Import batches additionally store the monotonic project CSV configuration
|
linked objects are excluded because they follow their CircuitDeviceRow board.
|
||||||
version introduced by additive migration `0004`; it is distinct from the CSV
|
R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs
|
||||||
configuration JSON schema version.
|
exactly, separates source decisions from return deviations, updates accepted
|
||||||
|
linked quantities atomically, retains missing objects and compacts every older
|
||||||
|
batch to metadata. `external-import.apply-follow-up` stores complete inverse
|
||||||
|
states, so Undo/Redo never parses Excel. A separately confirmed
|
||||||
|
`external-model-object.delete-missing` command removes missing objects and
|
||||||
|
adjusts linked row quantities; Undo restores objects, links and quantities.
|
||||||
|
R10 verifies the complete path through return workbook, follow-up import,
|
||||||
|
snapshot restore, portable duplicate and restart-safe Undo/Redo. A 5,000-object
|
||||||
|
synthetic workbook covers parser, command/snapshot size, transactions and the
|
||||||
|
client-side virtual window. Complete external-state inserts use bounded batches
|
||||||
|
inside their existing transaction for initial/follow-up import, restore and
|
||||||
|
duplicate. See `docs/spec/revit-excel-r10-validation.md`.
|
||||||
|
|
||||||
|
The target configuration has no independent version counter; `expectedRevision`
|
||||||
|
invalidates stale plans, and each import batch stores a configuration copy.
|
||||||
|
Only the newest import batch retains workbook bytes and the full matrix; older
|
||||||
|
batches retain date, filename, SHA-256 and object count.
|
||||||
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
|
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
|
||||||
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
||||||
Normal manual row inserts and quantity edits update both values together.
|
Normal manual row inserts and quantity edits update both values together.
|
||||||
|
|
@ -347,37 +382,12 @@ protection and its external object links. Its history-only inverse requires the
|
||||||
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
||||||
the BMK. All three external assignment stores share
|
the BMK. All three external assignment stores share
|
||||||
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
||||||
The read-only
|
The current read-only
|
||||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||||
projection resolves the board through the owned CircuitList and returns only
|
projection resolves the board through the owned CircuitList and returns compact
|
||||||
that board's compact external objects, unassigned first. It exposes no original
|
legacy external objects. The target Revit page will load one compact project
|
||||||
CSV bytes or command payloads and returns an empty list before optional import.
|
list and filter/sort it client-side in a virtualized table, without server
|
||||||
Confirmed initial state is written only through
|
pagination or batch blobs in the DTO.
|
||||||
`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
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
project. Named snapshots are never removed by this retention policy.
|
project. Named snapshots are never removed by this retention policy.
|
||||||
|
|
@ -461,30 +471,58 @@ snapshot contains the group, optional component protections, complete circuits,
|
||||||
circuit protections and all device-row link/override metadata. Delete
|
circuit protections and all device-row link/override metadata. Delete
|
||||||
re-captures and compares the subtree before cascading; restore preserves every
|
re-captures and compares the subtree before cascading; restore preserves every
|
||||||
UUID in foreign-key-safe order.
|
UUID in foreign-key-safe order.
|
||||||
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||||
`components`, `footerComponents`, optional group category/number and optional
|
`components`, `footerComponents`, optional group category/number and optional
|
||||||
one-to-one protection DTOs for circuits and group components. These are
|
one-to-one protection DTOs for circuits and group components. These are
|
||||||
the only supported circuit-protection source; no flat compatibility fields remain.
|
the only supported circuit-protection source; no flat compatibility fields remain.
|
||||||
|
It resolves logical board feeds once for the complete project and adds each
|
||||||
|
downstream board's demand after its own board simultaneity factor to the chosen
|
||||||
|
feeding circuit as a read-only virtual row. Recursive chains and several boards
|
||||||
|
on one circuit are supported. A board-only link affects hierarchy but adds no
|
||||||
|
load until a feeding circuit is selected.
|
||||||
|
The distribution-board dialog can create one empty feeder circuit and link it
|
||||||
|
in the same `distribution-board.create-feeder-circuit` project command. Its
|
||||||
|
history inverse restores the complete previous board update before removing
|
||||||
|
only the unchanged generated circuit; Redo reuses its UUID and BMK. No
|
||||||
|
CircuitDeviceRow is created. The three-phase group is suggested, the optional
|
||||||
|
circuit display name stays empty by default, and protection remains open for
|
||||||
|
planner sizing. A circuit with exactly one downstream board and no device rows
|
||||||
|
is rendered as one compact row using the live board number/name; several
|
||||||
|
downstream boards or additional devices retain summary and child rows. Editing
|
||||||
|
the name or remark of an empty reserve circuit changes the circuit itself and
|
||||||
|
does not implicitly create a device row.
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
persistent undo step.
|
||||||
The same command (still schema version 1, additively extended) carries the
|
The same command uses schema version 2 for the logical
|
||||||
|
`feedingDistributionBoardId` and optional matching `feedingCircuitId` together
|
||||||
|
with the existing fields. Version-1 history commands remain executable and do
|
||||||
|
not change logical feed references. The command rejects self-links, cycles and
|
||||||
|
circuits from another board. The
|
||||||
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
|
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
|
||||||
(migration `0007`). They are nullable, trimmed planner values; the PUT route
|
(migration `0007`). They are nullable, trimmed planner values; the PUT route
|
||||||
only includes submitted info fields in the command and its inverse. An empty
|
only includes submitted info fields in the command and its inverse. An empty
|
||||||
board with info values is no longer an unchanged setup, so history deletion of
|
board with info values is no longer an unchanged setup, so history deletion of
|
||||||
it is rejected like a changed simultaneity factor. Populated-subtree commands
|
it is rejected like a changed simultaneity factor. Populated-subtree commands
|
||||||
stored before these fields existed are normalized to `null` values
|
stored before these fields existed are normalized to `null` values
|
||||||
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free
|
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` remains free text for
|
||||||
text: the logical link to the feeding board/circuit is an open design decision.
|
external feeds and notes; project-board feeds use the two UUID references.
|
||||||
|
Deleting a referenced board or feeding circuit is blocked in every direct,
|
||||||
|
subtree and history-only removal path. Board copies retain their incoming feed,
|
||||||
|
and project duplication remaps both references.
|
||||||
These fields are intended to be prefillable from Revit parameters later, but
|
These fields are intended to be prefillable from Revit parameters later, but
|
||||||
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
||||||
overwrite).
|
overwrite).
|
||||||
Floor and room setup uses persistent insert, update and delete commands with
|
Floor and room setup uses persistent insert, update and delete commands with
|
||||||
complete stable snapshots. Their inverses remove or restore only unchanged records and
|
complete stable snapshots. Room command schema version 2 stores the optional,
|
||||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
finite, non-negative net room area `areaM2`; persisted version-1 commands are
|
||||||
device rows. All write routes require `expectedRevision` and return the updated
|
upgraded with `areaM2 = null` and exact record comparison prevents them from
|
||||||
history state.
|
silently overwriting a newer area. Room areas are maintained manually, shown
|
||||||
|
with two decimals and summarized per floor and building with a separate missing
|
||||||
|
count. Revit import and W/m² use remain follow-up work. Command 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:
|
Required operations:
|
||||||
|
|
||||||
|
|
@ -514,9 +552,10 @@ Required operations:
|
||||||
`<thead>` so it repeats on printed pages. PDF export uses the browser print
|
`<thead>` so it repeats on printed pages. PDF export uses the browser print
|
||||||
dialog; page size, margins and "Seite x von y" come from injected named
|
dialog; page size, margins and "Seite x von y" come from injected named
|
||||||
`@page` rules (`lb-portrait`, `lb-landscape`).
|
`@page` rules (`lb-portrait`, `lb-landscape`).
|
||||||
- The board hierarchy and "Σ inkl. unterlagert" in the overview are
|
- The board hierarchy follows logical board ids. The overview shows power
|
||||||
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum,
|
before board GZF and demand including linked downstream boards; it does not
|
||||||
no upstream simultaneity factor). Replace this once the feed link is modeled.
|
add a second cumulative column. Board plans render downstream-board loads as
|
||||||
|
read-only rows. `fedFrom` groups only external root feeds.
|
||||||
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
|
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
|
||||||
`print-document.css`). Empty cover values are shown as
|
`print-document.css`). Empty cover values are shown as
|
||||||
"[ergänzen]"; the Planstände editor column shows the project's current
|
"[ergänzen]"; the Planstände editor column shows the project's current
|
||||||
|
|
@ -530,62 +569,86 @@ Required operations:
|
||||||
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
||||||
is served with `nosniff` and a restrictive CSP.
|
is served with `nosniff` and a restrictive CSP.
|
||||||
|
|
||||||
## Revit / CSV External Model Work
|
## Revit / Linear Excel External Model Work
|
||||||
|
|
||||||
When explicitly working on Phase 14, use
|
Phase 14 is active and follows
|
||||||
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
|
`docs/spec/revit-excel-integration-requirements.md` plus
|
||||||
truth together with the current architecture documents.
|
`docs/spec/revit-excel-phase-14-plan.md`. The old CSV requirements and phases
|
||||||
|
14.0–14.6 are superseded. Dynamo, Revit API and JSON transport remain excluded.
|
||||||
|
|
||||||
Critical rules:
|
Critical target rules:
|
||||||
|
|
||||||
- Revit integration is optional. Do not make imported external data mandatory
|
- Exchange uses only `.xlsx` files produced and consumed by the Linear plugin.
|
||||||
for projects, rooms, project devices, circuits or device rows.
|
- Revit integration remains optional. Import never creates a CircuitDeviceRow,
|
||||||
- Parse imports into staging first. Never write CSV rows directly into circuits
|
Circuit or equipment identifier.
|
||||||
or device rows.
|
- Worksharing-ID is the external key and is compared exactly and
|
||||||
- Keep external object identity separate from internal UUIDs and circuit BMKs.
|
case-sensitively. It stays separate from internal UUIDs and circuit BMKs.
|
||||||
`IfcGUID` identifies the external Revit object and must never replace an
|
IfcGUID is optional redundancy, never a key, and duplicate values are only a
|
||||||
internal UUID or `equipmentIdentifier`.
|
warning.
|
||||||
- One imported Revit object may exist without a `CircuitDeviceRow`.
|
- A row without Worksharing-ID is suspicious only when `Familie und Typ` is
|
||||||
Import, room assignment, distribution-board assignment, classification and
|
populated. Show a warning and skip it; do not block. Other ID-less rows,
|
||||||
ProjectDevice linking do not create a row.
|
including the sample summary in column B, are skipped silently.
|
||||||
- Create or change a `CircuitDeviceRow` only after an explicit user action in
|
- Source, planning and override values remain separate. Existing ProjectDevices
|
||||||
the circuit-list editor.
|
remain canonical and are never silently changed.
|
||||||
- Several external objects may link to one `CircuitDeviceRow`, while every
|
- Multi-socket objects remain indivisible. Row quantity stays
|
||||||
external object remains individually traceable for follow-up imports and
|
`manualQuantity + sum(effectiveQuantity)`.
|
||||||
object-specific export.
|
- The project configuration has no independent version counter. Preview and
|
||||||
- Multi-socket Revit objects are indivisible. Their effective quantity may be
|
apply use `expectedRevision`; import batches store a configuration copy.
|
||||||
greater than one, but one external object must never be split across circuits.
|
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
|
||||||
- Existing ProjectDevices are canonical project templates and must not be
|
apply only stored before/after states.
|
||||||
overwritten from Revit values. When creating a new ProjectDevice, mapped
|
- Only the newest import batch retains original bytes and the full matrix;
|
||||||
Revit values may prefill the creation form.
|
older batches retain date, filename, SHA-256 and object count.
|
||||||
- Store imported source values separately from local planning values and local
|
- The Revit object table loads one compact list, filters/sorts in the client and
|
||||||
overrides. Imports never silently overwrite local values.
|
renders it virtually; do not add server pagination.
|
||||||
- Repeated imports match deterministically by project/source and `IfcGUID`.
|
- Linked objects always follow their row's distribution board. Changing a
|
||||||
New, changed, missing and conflicting objects require preview and explicit
|
room's default board affects only unassigned objects and is not blocked.
|
||||||
user decisions.
|
- Deleting a row, circuit or group warns that X Revit objects become unassigned,
|
||||||
- The first implementation assumes one complete Revit model export per project.
|
detaches them in the same command and restores links on Undo. Only deletion
|
||||||
Do not add visible multi-source, linked-model or partial-import management.
|
of referenced rooms and distribution boards is blocked.
|
||||||
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
|
- Missing optional return columns for designation, power, voltage or current
|
||||||
and unknown rows and columns for the return export.
|
produce warnings and are skipped. Only missing circuit columns for the
|
||||||
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
|
selected combined/separate mode block export.
|
||||||
Do not report Revit subtotal rows as missing-identity objects.
|
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||||
- Apply each confirmed import diff through a typed project command with
|
- The corrected R2 return workbook was successfully imported into Linear on
|
||||||
`expectedRevision`, complete inverse data and the shared project command
|
2026-09-30; R3 through R10 and Phase 14 are complete.
|
||||||
transaction boundary.
|
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||||
- External-model state must be added to logical project snapshots and portable
|
complete inverse data and the shared project-command transaction boundary.
|
||||||
project transfer when it becomes part of the supported runtime.
|
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||||
- Keep CSV parsing/serialization in a transport adapter. Domain services must
|
management, partial imports, PostgreSQL, background jobs or new permissions.
|
||||||
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,
|
The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is
|
||||||
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
|
gitignored and must never be embedded in tests or committed. Use synthetic
|
||||||
domain objects and commands before adding migrations.
|
workbooks for automated tests and accept the real file only as an explicit
|
||||||
|
local path in the reference checker.
|
||||||
|
|
||||||
|
## Cable Sizing Module
|
||||||
|
|
||||||
|
`src/cable-sizing` is an agreed, separate calculator module attached to the
|
||||||
|
circuit list (see `docs/cable-sizing-module.md`). Rules:
|
||||||
|
|
||||||
|
- The domain code stays pure: no imports from `db/`, `server/` or `frontend/`.
|
||||||
|
- It never writes project data. Results are applied only by an explicit
|
||||||
|
"Übernehmen", which runs the regular `circuit.update` command.
|
||||||
|
- It is opened from the circuit row action button "Leitungsberechnung", never
|
||||||
|
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
|
||||||
|
- It has no database table and no migration; `/api/cable-sizing/*` is
|
||||||
|
stateless.
|
||||||
|
- Current-carrying capacities and correction factors use
|
||||||
|
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the
|
||||||
|
separate basis of voltage-drop and protection checks are documented in
|
||||||
|
`docs/cable-sizing-module.md`. Inputs outside the supported tables return no
|
||||||
|
result instead of assuming a factor of 1. Direct burial, grouped method E,
|
||||||
|
aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic
|
||||||
|
remain unsupported until their required inputs or normative basis exist.
|
||||||
|
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
|
||||||
|
length, protective device) and remain editable; manual cable entry without
|
||||||
|
a calculation stays possible.
|
||||||
|
|
||||||
## Future Sizing
|
## Future Sizing
|
||||||
|
|
||||||
Do not implement full cable/protection sizing unless explicitly requested.
|
Do not implement full automatic cable/protection sizing of all circuits
|
||||||
|
unless explicitly requested. The cable sizing module above only suggests
|
||||||
|
values for one circuit on demand.
|
||||||
|
|
||||||
Keep the structure ready for it.
|
Keep the structure ready for it.
|
||||||
|
|
||||||
|
|
@ -603,6 +666,37 @@ Future sizing will need:
|
||||||
|
|
||||||
Users must be able to override sizing suggestions.
|
Users must be able to override sizing suggestions.
|
||||||
|
|
||||||
|
## Estimate Balance Module (Grobe Leistungsbilanz)
|
||||||
|
|
||||||
|
`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module
|
||||||
|
for the early-phase power balance by DIN 276 cost group (see
|
||||||
|
`docs/estimate-balance.md`). Rules:
|
||||||
|
|
||||||
|
- It has its own tables (`estimate_*`) and never writes circuit, board or
|
||||||
|
project-device data; it only reads device rows for the comparison.
|
||||||
|
- Writes use the project command mechanism (`estimate-balance.insert/.update/
|
||||||
|
.delete` with complete documents), so revision checks and Undo/Redo apply.
|
||||||
|
- The calculation and comparison live in the import-free
|
||||||
|
`src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI;
|
||||||
|
displayed values use `roundEstimateValue`.
|
||||||
|
- It is deliberately not part of named/automatic project snapshots or snapshot
|
||||||
|
restore; those operations leave estimate balances unchanged. Portable project
|
||||||
|
transfer format version 2 includes every estimate balance and remaps balance,
|
||||||
|
group and row ids when duplicating a project. Version 1 transfers remain
|
||||||
|
importable and contain no estimate balances.
|
||||||
|
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
|
||||||
|
`src/frontend/estimate-balance/`). The editor autosaves the whole document
|
||||||
|
debounced (one Undo step per save), keeps invalid raw input local, and on a
|
||||||
|
409 reloads only if the balance itself changed; otherwise it re-saves with
|
||||||
|
the new revision. `request()` throws `ApiRequestError` (status, code).
|
||||||
|
- A single estimate row can be copied explicitly through the existing
|
||||||
|
ProjectDevice dialog. The dialog is only a prefilled proposal; creation
|
||||||
|
happens after confirmation through `project-device.insert`, with the current
|
||||||
|
project revision and normal Undo/Redo. The copy stores no link back to the
|
||||||
|
estimate row, never places a circuit row automatically, and does not change
|
||||||
|
estimate, snapshot or transfer schemas. Area rows require manual quantity
|
||||||
|
and power because W/m² remains outside the ProjectDevice model.
|
||||||
|
|
||||||
## Persistence and Migration Rules
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
- SQLite is the currently supported database.
|
- SQLite is the currently supported database.
|
||||||
|
|
@ -620,14 +714,17 @@ Users must be able to override sizing suggestions.
|
||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- 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
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- supported production deployment
|
||||||
- logical feed link between distribution boards (`fedFrom` stays free text
|
- second/replacement board feeds, including changeover semantics for SV/SiBe
|
||||||
until the project owner decides whether it references a board, a feeding
|
- Revit prefill proposals for logical board feeds through the Phase 14 preview
|
||||||
circuit or both) and the resulting hierarchical power summation
|
and explicit-confirmation workflow
|
||||||
|
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
|
||||||
|
- permanent account lockout (only temporary per-username login throttling
|
||||||
|
exists)
|
||||||
|
- email/one-time-link password provisioning, OIDC/Entra login
|
||||||
|
- linking user accounts to a personnel/employee master table
|
||||||
|
|
||||||
Do not implement these while working on an unrelated phase.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -7,18 +7,27 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
||||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||||
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
|
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
||||||
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||||
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
||||||
|
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
||||||
|
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R10 sind abgeschlossen;
|
||||||
|
die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport,
|
||||||
|
virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe.
|
||||||
|
Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüft.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
- Projekte, Verteilungen, Etagen und Räume verwalten
|
- Projekte und Verteilungen verwalten; Gebäude, Etagen und Räume auf einer
|
||||||
|
eigenen Seite pflegen, Räume optional mit Fläche und Summen je Etage und
|
||||||
|
Gebäude
|
||||||
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
|
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
|
||||||
3-phasige Stromkreise verwalten
|
3-phasige Stromkreise verwalten
|
||||||
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
|
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
|
||||||
Verteilergeräte in der Stromkreisliste abbilden
|
Verteilergeräte in der Stromkreisliste abbilden
|
||||||
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
|
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
|
||||||
|
- Leitungsquerschnitte für einzelne Stromkreise auf Anforderung nach
|
||||||
|
DIN VDE 0298-4 berechnen und ausdrücklich übernehmen
|
||||||
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
||||||
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
||||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||||
|
|
@ -29,9 +38,12 @@ Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
|
||||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||||
- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen
|
- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen
|
||||||
und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen
|
und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen
|
||||||
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
|
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
||||||
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
|
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
||||||
noch keine Stromkreise oder Gerätezeilen
|
übernehmen
|
||||||
|
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte
|
||||||
|
prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an
|
||||||
|
Linear zurückgeben
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
@ -51,6 +63,8 @@ Voraussetzungen:
|
||||||
```powershell
|
```powershell
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
Set-Location leistungsbilanz-ts
|
Set-Location leistungsbilanz-ts
|
||||||
|
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
||||||
|
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -59,9 +73,15 @@ Danach:
|
||||||
- Frontend: <http://localhost:3001>
|
- Frontend: <http://localhost:3001>
|
||||||
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
||||||
|
|
||||||
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine
|
Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator,
|
||||||
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der
|
solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für
|
||||||
Zugriff läuft über den Rewrite im Web-Dienst.
|
einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte
|
||||||
|
`.env` neben `compose.yaml`; Details stehen unter
|
||||||
|
[Anmeldung und Benutzerverwaltung](docs/authentication.md).
|
||||||
|
|
||||||
|
Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen
|
||||||
|
API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den
|
||||||
|
Rewrite im Web-Dienst.
|
||||||
|
|
||||||
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
||||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||||
|
|
@ -107,6 +127,8 @@ Voraussetzungen:
|
||||||
npm ci
|
npm ci
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:verify:circuit-schema
|
npm run db:verify:circuit-schema
|
||||||
|
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
||||||
|
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
||||||
npm run dev:api
|
npm run dev:api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -150,8 +172,14 @@ vor der Baseline werden nicht unterstützt.
|
||||||
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
||||||
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
|
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
|
||||||
- [Seitenansicht und PDF-Export](docs/print-view.md)
|
- [Seitenansicht und PDF-Export](docs/print-view.md)
|
||||||
|
- [Gebäudestruktur und Raumflächen](docs/building-structure.md)
|
||||||
|
- [Grobe Leistungsbilanz](docs/estimate-balance.md)
|
||||||
|
- [Leitungsberechnung](docs/cable-sizing-module.md)
|
||||||
|
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
|
||||||
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
||||||
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
||||||
|
- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md)
|
||||||
|
- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md)
|
||||||
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
||||||
|
|
||||||
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,12 @@ services:
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
|
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
||||||
# nicht aus dem LAN erreichbar sein.
|
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
||||||
|
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
||||||
|
AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}"
|
||||||
|
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||||
|
# Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein.
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||||
|
|
|
||||||
12
compose.yaml
12
compose.yaml
|
|
@ -45,8 +45,16 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
|
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
|
||||||
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
|
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
|
||||||
|
# .env - siehe docs/authentication.md.
|
||||||
|
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
||||||
|
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
||||||
|
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
||||||
|
AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}"
|
||||||
|
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||||
|
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
|
||||||
|
# Next.js-Rewrite im Web-Dienst erreicht.
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,36 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt,
|
- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt,
|
||||||
Verteilerübersicht, Verteilerpläne, Büro-Logo
|
Verteilerübersicht, Verteilerpläne, Büro-Logo
|
||||||
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
||||||
Befehle, Snapshot-Schema 7, eigene Seite
|
Raumflächen und Summen, Befehle, Snapshot-Schema 8, eigene Seite
|
||||||
|
- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen,
|
||||||
|
Rechnung, Vergleich mit der detaillierten Bilanz, Übernahme als Projektgerät
|
||||||
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||||
|
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
||||||
|
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
||||||
|
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
|
||||||
|
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
|
||||||
|
|
||||||
## Entwicklung und Betrieb
|
## Entwicklung und Betrieb
|
||||||
|
|
||||||
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
||||||
- [Deployment und Betrieb](deployment.md)
|
- [Deployment und Betrieb](deployment.md)
|
||||||
|
|
||||||
## Zukunftsarchitektur
|
## Revit / Linear XLSX
|
||||||
|
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) –
|
||||||
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
|
verbindliche Anforderungen für XLSX und Worksharing-ID
|
||||||
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
|
- [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status,
|
||||||
|
Paketgrenzen und Linear-Freigabepunkt nach R2
|
||||||
|
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
||||||
|
- [R10-Abnahme](spec/revit-excel-r10-validation.md) – Ende-zu-Ende- und
|
||||||
|
5.000-Objekte-Nachweis
|
||||||
|
- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md)
|
||||||
|
|
||||||
|
## Zukunftsarchitektur
|
||||||
|
|
||||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||||
|
|
|
||||||
99
docs/authentication.md
Normal file
99
docs/authentication.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Anmeldung und Benutzerverwaltung
|
||||||
|
|
||||||
|
Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder
|
||||||
|
Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
|
||||||
|
|
||||||
|
- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer**
|
||||||
|
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
|
||||||
|
Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung
|
||||||
|
|
||||||
|
Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
|
||||||
|
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
|
||||||
|
|
||||||
|
## Ablauf beim Anlegen eines Zugangs
|
||||||
|
|
||||||
|
1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit
|
||||||
|
Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst
|
||||||
|
eintippt.
|
||||||
|
2. Der Zugang startet mit `mustChangePassword = true`.
|
||||||
|
3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das
|
||||||
|
ein eigenes Passwort verlangt, bevor es weitergeht.
|
||||||
|
4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues
|
||||||
|
Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn
|
||||||
|
jemand sein Passwort vergessen hat.
|
||||||
|
|
||||||
|
## Technischer Aufbau
|
||||||
|
|
||||||
|
- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) –
|
||||||
|
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
|
||||||
|
`role`, `active`, `mustChangePassword`.
|
||||||
|
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
|
||||||
|
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit
|
||||||
|
absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein
|
||||||
|
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
|
||||||
|
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
|
||||||
|
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
|
||||||
|
(außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und
|
||||||
|
erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist.
|
||||||
|
`/api/users/*` verlangt zusätzlich die Rolle `admin`.
|
||||||
|
- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
|
||||||
|
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
|
||||||
|
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
|
||||||
|
**Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für
|
||||||
|
Administratoren.
|
||||||
|
|
||||||
|
## Schutzmechanismen
|
||||||
|
|
||||||
|
| Angriff | Schutz |
|
||||||
|
|---|---|
|
||||||
|
| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). |
|
||||||
|
| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash |
|
||||||
|
| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung |
|
||||||
|
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. |
|
||||||
|
| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. |
|
||||||
|
| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel |
|
||||||
|
| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) |
|
||||||
|
| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen |
|
||||||
|
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. |
|
||||||
|
| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` |
|
||||||
|
| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt |
|
||||||
|
| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben |
|
||||||
|
| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) |
|
||||||
|
| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt |
|
||||||
|
|
||||||
|
**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im
|
||||||
|
Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen
|
||||||
|
Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen.
|
||||||
|
|
||||||
|
## Bootstrap-Admin
|
||||||
|
|
||||||
|
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
|
||||||
|
`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die
|
||||||
|
`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen
|
||||||
|
ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte
|
||||||
|
gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
|
||||||
|
in `compose.yaml` selbst.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTH_BOOTSTRAP_USERNAME=admin
|
||||||
|
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
|
||||||
|
AUTH_SESSION_HOURS=12
|
||||||
|
AUTH_SESSION_MAX_DAYS=7
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei Zugriff über HTTPS muss `COOKIE_SECURE=true` gesetzt werden. Bei direktem
|
||||||
|
HTTP-Zugriff im LAN bleibt es `false`, da der Browser das Session-Cookie sonst
|
||||||
|
nicht speichert.
|
||||||
|
|
||||||
|
## Bewusst nicht umgesetzt (Stand jetzt)
|
||||||
|
|
||||||
|
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
|
||||||
|
- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich
|
||||||
|
begrenzte Drosselung, siehe oben)
|
||||||
|
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
|
||||||
|
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle
|
||||||
|
|
@ -4,7 +4,8 @@ Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude**
|
||||||
gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen
|
gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen
|
||||||
hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung
|
hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung
|
||||||
ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es
|
ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es
|
||||||
nicht. Abstimmung mit Julian: Issue #6.
|
nicht. Abgestimmt in Issue #6: Das Modell ist so bestätigt, und Gebäude mit
|
||||||
|
zugeordneten Etagen lassen sich nicht löschen.
|
||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ nicht. Abstimmung mit Julian: Issue #6.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts |
|
| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts |
|
||||||
| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude |
|
| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude |
|
||||||
|
| `rooms` | `area_m2` (nullable `REAL`) | optional gepflegte Netto-Raumfläche in m² |
|
||||||
|
|
||||||
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
|
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
|
||||||
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
|
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
|
||||||
|
|
@ -19,6 +21,11 @@ und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration
|
||||||
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
|
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
|
||||||
Datenbanksicherung anzulegen.
|
Datenbanksicherung anzulegen.
|
||||||
|
|
||||||
|
Migration `0011_glossy_norman_osborn` ergänzt ausschließlich das nullable Feld
|
||||||
|
`rooms.area_m2`. Bestehende Räume behalten damit eine unbekannte Fläche. Vor
|
||||||
|
dem Anwenden auf eine bestehende Datenbank ist ebenfalls eine separate
|
||||||
|
Datenbanksicherung erforderlich.
|
||||||
|
|
||||||
## Befehle, Undo/Redo
|
## Befehle, Undo/Redo
|
||||||
|
|
||||||
Alle Änderungen laufen über die bestehende Orts-Struktur
|
Alle Änderungen laufen über die bestehende Orts-Struktur
|
||||||
|
|
@ -40,17 +47,22 @@ persistentem Undo/Redo:
|
||||||
Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie
|
Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie
|
||||||
exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im
|
exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im
|
||||||
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
|
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
|
||||||
- Raumbefehle bleiben unverändert (Version 1).
|
- Raumbefehle `project-room.insert/.update/.delete` haben seit Schema-Version
|
||||||
|
**2** das Feld `areaM2`. Version-1-Befehle werden beim Einlesen mit
|
||||||
|
`areaM2 = null` hochgestuft. Vor einem alten Update oder Delete wird weiterhin
|
||||||
|
der vollständige erwartete Datensatz verglichen; eine inzwischen vorhandene
|
||||||
|
Fläche kann deshalb nicht unbemerkt gelöscht werden.
|
||||||
|
|
||||||
## Snapshots und Transfer
|
## Snapshots und Transfer
|
||||||
|
|
||||||
Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`.
|
Projekt-Snapshot-Schema **8** enthält zusätzlich `rooms[].areaM2`. Version 7
|
||||||
Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer
|
wird mit `areaM2 = null` hochgestuft; die älteren unterstützten Versionen 1–6
|
||||||
Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte
|
werden außerdem mit leerer Gebäudeliste und `buildingId = null` hochgestuft.
|
||||||
bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum
|
Gespeicherte Sicherungspunkte bleiben wiederherstellbar. Die Relationen werden
|
||||||
Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht
|
geprüft (Gebäude gehört zum Projekt, `buildingId` verweist auf ein Gebäude des
|
||||||
und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue
|
Snapshots). Restore und Projekttransfer erhalten die Raumfläche. Das portable
|
||||||
Gebäude-IDs und stellt die Etagenverweise um.
|
Transferformat bleibt Version 2, weil es den versionierten Projektsnapshot
|
||||||
|
bereits einbettet; beim Duplizieren werden nur IDs und Relationen umgesetzt.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
@ -62,6 +74,10 @@ Gebäude-IDs und stellt die Etagenverweise um.
|
||||||
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
|
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
|
||||||
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
|
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
|
||||||
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
|
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
|
||||||
|
- `POST/PUT /api/projects/:projectId/rooms[/:roomId]` akzeptieren zusätzlich
|
||||||
|
optional `areaM2` (`number | null`, endlich und ≥ 0). Beim Anlegen bedeutet
|
||||||
|
ein fehlendes Feld `null`; beim Bearbeiten bleibt die vorhandene Fläche
|
||||||
|
erhalten, wenn das Feld fehlt.
|
||||||
|
|
||||||
## Oberfläche
|
## Oberfläche
|
||||||
|
|
||||||
|
|
@ -73,9 +89,24 @@ nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Fi
|
||||||
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
|
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
|
||||||
Stromkreisliste.
|
Stromkreisliste.
|
||||||
|
|
||||||
|
Der Raumdialog pflegt die optionale **Fläche [m²]** mit deutscher
|
||||||
|
Dezimaltrennzeichen-Eingabe. Gemeint ist die Netto-Raumfläche, wie sie der
|
||||||
|
Revit-Raumparameter „Fläche“ liefert, nicht eine Flächenart nach DIN 277. Die
|
||||||
|
Fläche wird ohne fachliche Rundung gespeichert und mit zwei Nachkommastellen
|
||||||
|
angezeigt. Die Gebäudestruktur zeigt die Fläche je Raum sowie Summen je Etage,
|
||||||
|
je Gebäude, „Ohne Etage“ und „Ohne Gebäude“. Fehlende Flächen werden nicht als
|
||||||
|
Nullfläche ausgegeben, sondern separat gezählt, damit Teilsummen erkennbar
|
||||||
|
bleiben.
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
- Revit: Das Gebäude kann später aus einem Revit-Parameter kommen und über die
|
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
||||||
bestehende CSV-Zuordnung gemappt werden (Frage an Julian in Issue #6).
|
abgeschlossene Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten
|
||||||
|
Raumzuordnung im Importwizard nicht im Weg.
|
||||||
|
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
||||||
|
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
||||||
|
und ausdrücklicher Bestätigung einhalten.
|
||||||
|
- Eine W/m²-Auswertung oder Übernahme der Raumfläche in Flächenlasten ist nicht
|
||||||
|
Teil dieser Funktion; das ist Folgethema von Issue #20.
|
||||||
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
|
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
|
||||||
Gebäude werden hinten angehängt.
|
Gebäude werden hinten angehängt.
|
||||||
|
|
|
||||||
109
docs/cable-sizing-module.md
Normal file
109
docs/cable-sizing-module.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Leitungsberechnung
|
||||||
|
|
||||||
|
Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare
|
||||||
|
Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die
|
||||||
|
ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`,
|
||||||
|
`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`.
|
||||||
|
Revisionen sowie Undo/Redo bleiben dadurch erhalten.
|
||||||
|
|
||||||
|
Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil
|
||||||
|
des Moduls.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen
|
||||||
|
src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung
|
||||||
|
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
|
||||||
|
src/server/routes/cable-sizing.routes.ts Router
|
||||||
|
src/frontend/components/cable-sizing-api.ts Fetch-Client
|
||||||
|
src/frontend/components/cable-sizing-defaults.ts Vorbelegung
|
||||||
|
src/frontend/components/cable-sizing-modal.tsx Dialog
|
||||||
|
tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist
|
||||||
|
zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die
|
||||||
|
Kabelzellen behalten ihr übliches Spreadsheet-Verhalten.
|
||||||
|
|
||||||
|
## Eingaben
|
||||||
|
|
||||||
|
Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes
|
||||||
|
cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der
|
||||||
|
Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung,
|
||||||
|
zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen.
|
||||||
|
|
||||||
|
Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf
|
||||||
|
hin, dass dann kein Spannungsfall angesetzt wird.
|
||||||
|
|
||||||
|
## Normative Grundlage und Grenzen
|
||||||
|
|
||||||
|
Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen
|
||||||
|
**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich
|
||||||
|
**Berichtigung 1:2023-10** geprüft:
|
||||||
|
|
||||||
|
- Strombelastbarkeit: Tabellen 3 bis 6.
|
||||||
|
- Temperaturfaktoren: Tabellen 17 und 20.
|
||||||
|
- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung.
|
||||||
|
- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich.
|
||||||
|
- Oberschwingungen: Anhang B, insbesondere Tabelle B.1.
|
||||||
|
|
||||||
|
Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich
|
||||||
|
mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im
|
||||||
|
Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür
|
||||||
|
auf die Kabel-Produktnorm DIN VDE 0276-603 verweist.
|
||||||
|
|
||||||
|
Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der
|
||||||
|
Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher
|
||||||
|
ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens
|
||||||
|
sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt.
|
||||||
|
|
||||||
|
Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine
|
||||||
|
kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter
|
||||||
|
Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser
|
||||||
|
nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine
|
||||||
|
Empfehlung.
|
||||||
|
|
||||||
|
Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit
|
||||||
|
Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre
|
||||||
|
Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination
|
||||||
|
mit Leitungsschutzschaltern und gG-Sicherungen gehört zur
|
||||||
|
Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu
|
||||||
|
beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig
|
||||||
|
normative Leitungsbemessung.
|
||||||
|
|
||||||
|
## Rechenweg
|
||||||
|
|
||||||
|
1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl.
|
||||||
|
2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen
|
||||||
|
Schutzorgan.
|
||||||
|
3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls
|
||||||
|
Oberschwingungsfaktor.
|
||||||
|
4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt.
|
||||||
|
5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall.
|
||||||
|
6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen
|
||||||
|
Mindestquerschnitt von 2,5 mm².
|
||||||
|
|
||||||
|
Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten
|
||||||
|
ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs
|
||||||
|
wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin
|
||||||
|
manuell eingetragen und bewusst übernommen werden.
|
||||||
|
|
||||||
|
## Nicht enthalten
|
||||||
|
|
||||||
|
- direkte Erdverlegung
|
||||||
|
- detaillierte Anordnung mehrerer Kabel bei Verlegeart E
|
||||||
|
- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer
|
||||||
|
- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität
|
||||||
|
- abweichender Bodenwärmewiderstand sowie Verlegearten F und G
|
||||||
|
- automatische Berechnung oder Änderung mehrerer Stromkreise
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist
|
||||||
|
`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält
|
||||||
|
`supported: false` und einen der Gründe `temperature`, `grouping` oder
|
||||||
|
`harmonics`, wenn keine belastbare Empfehlung möglich ist.
|
||||||
|
|
||||||
|
`GET /api/cable-sizing/laying-methods` und
|
||||||
|
`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten.
|
||||||
|
|
@ -59,6 +59,24 @@ this value for optimistic concurrency checks.
|
||||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||||
the generic command endpoint
|
the generic command endpoint
|
||||||
|
|
||||||
|
Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable
|
||||||
|
Projektexport unter `GET /projects/:projectId/export` verwendet dagegen
|
||||||
|
Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege
|
||||||
|
`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre
|
||||||
|
Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
||||||
|
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
||||||
|
importierbar.
|
||||||
|
|
||||||
|
Das aktuelle Projekt-Snapshot-Schema 10 enthält Linear-XLSX-Zustand und die in
|
||||||
|
Schema 9 eingeführten logischen Einspeisungsverweise der Verteilungen;
|
||||||
|
Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
|
||||||
|
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
|
||||||
|
Snapshot-Versionen 1–7 werden dafür mit
|
||||||
|
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
|
||||||
|
Flächen und Einspeisungsverweise; beim Duplizieren werden Board- und Circuit-IDs
|
||||||
|
neu zugeordnet. Eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
|
||||||
|
versionierten Projektsnapshot einbettet.
|
||||||
|
|
||||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||||
|
|
@ -214,6 +232,12 @@ A stale `expectedRevision` returns HTTP `409` with
|
||||||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||||
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||||
|
|
||||||
|
## Estimate Balance (Grobe Leistungsbilanz)
|
||||||
|
|
||||||
|
Separate module under `/projects/:projectId/estimate-balances`; complete
|
||||||
|
documents are written through `estimate-balance.insert/.update/.delete` project
|
||||||
|
commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
@ -234,23 +258,37 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }`
|
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
|
||||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
||||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
||||||
and an empty string is stored as `null`
|
and an empty string is stored as `null`
|
||||||
- executes `distribution-board.update`; all submitted values, including the
|
- `feedingDistributionBoardId` and `feedingCircuitId` are optional nullable
|
||||||
|
fields. A feeding circuit requires a feeding board and must belong to it;
|
||||||
|
self-links and cycles are rejected. A board-only link creates hierarchy
|
||||||
|
without assigning its load to an upstream circuit.
|
||||||
|
- executes `distribution-board.update` schema version 2; all submitted values, including the
|
||||||
submitted info fields, are restored together by persistent Undo/Redo
|
submitted info fields, are restored together by persistent Undo/Redo
|
||||||
- `fedFrom` is free text until the logical feed link is modeled
|
- `fedFrom` remains free text for external feeds such as HAK, metering,
|
||||||
|
transformer or generator and for supplementary notes
|
||||||
|
- The project-command endpoint additionally accepts
|
||||||
|
`distribution-board.create-feeder-circuit` with an empty complete circuit
|
||||||
|
snapshot and an embedded version-2 `distribution-board.update`. Both writes
|
||||||
|
are one transaction, revision and Undo step. The generated circuit has no
|
||||||
|
device row; Redo keeps its UUID and BMK. The history-only inverse
|
||||||
|
`distribution-board.remove-feeder-circuit` restores the previous board values
|
||||||
|
and removes only the unchanged circuit.
|
||||||
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
||||||
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
||||||
- duplicates the complete distribution-board subtree with new owning UUIDs
|
- duplicates the complete distribution-board subtree with new owning UUIDs
|
||||||
while preserving project-device and room links
|
while preserving project-device, room and incoming feeding links
|
||||||
- response includes the new `distributionBoard`, its `circuitList`, the
|
- response includes the new `distributionBoard`, its `circuitList`, the
|
||||||
revision and current history state
|
revision and current history state
|
||||||
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body: `{ "expectedRevision": 15 }`
|
- body: `{ "expectedRevision": 15 }`
|
||||||
- removes the complete, exactly captured distribution-board subtree in one
|
- removes the complete, exactly captured distribution-board subtree in one
|
||||||
transaction
|
transaction
|
||||||
|
- rejects deletion while another board references this board or one of its
|
||||||
|
circuits as a feeder; circuit and populated-group deletion use the same guard
|
||||||
- response includes `distributionBoardId`, the revision and current history
|
- response includes `distributionBoardId`, the revision and current history
|
||||||
state
|
state
|
||||||
- Copy and deletion use `distribution-board.insert-subtree` and
|
- Copy and deletion use `distribution-board.insert-subtree` and
|
||||||
|
|
@ -283,29 +321,43 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||||
- rejects floors with assigned rooms or distribution boards
|
- rejects floors with assigned rooms or distribution boards
|
||||||
- `POST /projects/:projectId/rooms`
|
- `POST /projects/:projectId/rooms`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }`
|
||||||
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
- executes `project-room.insert` command version 2 with a stable room id;
|
||||||
and must belong to the project when present
|
`floorId` and `areaM2` are optional. An omitted or explicit `null` area
|
||||||
|
creates a room without area; a value must be finite and non-negative.
|
||||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- `PUT /projects/:projectId/rooms/:roomId`
|
- `PUT /projects/:projectId/rooms/:roomId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
|
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }`
|
||||||
- updates number, name and optional floor as one persistent command
|
- updates number, name, optional floor and area as one persistent version-2
|
||||||
|
command. If `areaM2` is omitted, the current area is retained; `null`
|
||||||
|
explicitly clears it.
|
||||||
- `DELETE /projects/:projectId/rooms/:roomId`
|
- `DELETE /projects/:projectId/rooms/:roomId`
|
||||||
- body: `{ "expectedRevision": 17 }`
|
- body: `{ "expectedRevision": 17 }`
|
||||||
- rejects rooms referenced by device rows
|
- rejects rooms referenced by device rows
|
||||||
- Persistent Undo/Redo restores exact previous and target records without
|
- Persistent Undo/Redo restores exact previous and target records without
|
||||||
silently clearing foreign keys.
|
silently clearing foreign keys.
|
||||||
|
- Stored version-1 room commands remain executable and are upgraded with
|
||||||
|
`areaM2 = null`. Exact expected-record checks reject an old update or delete
|
||||||
|
if a newer command has added an area, so legacy history cannot silently
|
||||||
|
erase it.
|
||||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/circuit-trees`
|
||||||
|
- returns all circuit trees for one project from one project-wide read and is
|
||||||
|
used by the print view and feeding-board selection
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
- Purpose: returns fixed header components, grouped sections with optional
|
- Purpose: returns fixed header components, grouped sections with optional
|
||||||
protection components, complete circuit/device-row blocks and auxiliary
|
protection components, complete circuit/device-row blocks and auxiliary
|
||||||
footer components. Circuit and component protection devices use their
|
footer components. Circuit and component protection devices use their
|
||||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||||
remain included.
|
remain included. Circuit totals contain the demand of linked downstream
|
||||||
|
boards. `downstreamBoardLoads` exposes those contributions as read-only
|
||||||
|
values; the grid combines exactly one downstream board without device rows
|
||||||
|
into the circuit row and keeps child rows for mixed or multiple loads.
|
||||||
|
`feedingReference` resolves the current board/circuit labels by id.
|
||||||
|
|
||||||
Response sketch:
|
Response sketch:
|
||||||
|
|
||||||
|
|
@ -411,40 +463,55 @@ 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
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
project device never triggers synchronization implicitly.
|
project device never triggers synchronization implicitly.
|
||||||
|
|
||||||
## Revit CSV Foundation
|
## Cable Sizing (optional module)
|
||||||
|
|
||||||
- `GET /projects/:projectId/external-csv/configuration`
|
Stateless calculator endpoints mounted at `/api/cable-sizing`. They neither
|
||||||
- returns the complete versioned project configuration or `null`
|
read nor write project data; applying a result uses `circuit.update` through
|
||||||
- `PUT /projects/:projectId/external-csv/configuration`
|
`POST /projects/:projectId/commands`.
|
||||||
- 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`,
|
- `POST /cable-sizing/calculate`
|
||||||
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
|
||||||
import uploads and parses the file again and verifies its hash.
|
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` |
|
||||||
|
`E`), conductorMaterial, insulation (`pvc` | `xlpe`),
|
||||||
|
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
|
||||||
|
harmonicNeutralLoad, optional `existingProtection`
|
||||||
|
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
|
||||||
|
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
|
||||||
|
is `false` with `unsupportedReason` when temperature or grouping lie
|
||||||
|
outside the tables or the neutral current needed above 33 % third
|
||||||
|
harmonic is unavailable
|
||||||
|
- unknown fields are rejected (`400`)
|
||||||
|
- `GET /cable-sizing/laying-methods`
|
||||||
|
- `GET /cable-sizing/insulation-materials`
|
||||||
|
|
||||||
|
Full request example and calculation rules: `docs/cable-sizing-module.md`.
|
||||||
|
|
||||||
|
## Revit / Linear XLSX
|
||||||
|
|
||||||
|
- `GET|PUT /projects/:projectId/external-model/configuration` reads or writes
|
||||||
|
the complete XLSX configuration. PUT requires `expectedRevision`.
|
||||||
|
- `POST /projects/:projectId/external-model/configuration/detect` detects the
|
||||||
|
sheet layout and columns from an uploaded `.xlsx` sample without persisting.
|
||||||
|
- `GET /projects/:projectId/external-model/import-status` returns the latest
|
||||||
|
batch metadata, object count and current missing-object count.
|
||||||
|
- `POST .../initial-import/plan|apply` provides the stateless initial plan and
|
||||||
|
its confirmed atomic application. Apply retransmits and reparses the XLSX.
|
||||||
|
- `POST .../follow-up-import/plan|apply` classifies exact Worksharing-ID
|
||||||
|
additions, source changes, unchanged and missing objects. Apply requires the
|
||||||
|
preview hash and source decisions and updates linked row quantities atomically.
|
||||||
|
- `GET|PUT .../external-model/objects` loads one compact project list and
|
||||||
|
applies revision-safe multi-object planning changes.
|
||||||
|
- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row`
|
||||||
|
changes external-object row assignments through persistent commands.
|
||||||
|
- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only
|
||||||
|
objects marked missing and adjusts linked row quantities in the same command.
|
||||||
|
- `POST .../external-model/return-workbook` creates the structure-preserving
|
||||||
|
return XLSX for all, deviating or selected objects.
|
||||||
|
- `GET .../external-model/export-batches` returns append-only export metadata.
|
||||||
|
|
||||||
|
Uploads accept only `.xlsx` up to 10 MiB. Worksharing-IDs are exact and
|
||||||
|
case-sensitive; IfcGUID is optional and non-unique. Undo/Redo uses persisted
|
||||||
|
command states and never reparses workbook bytes.
|
||||||
|
|
||||||
## Document Branding (print view)
|
## Document Branding (print view)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,20 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- The Revit/CSV foundation and confirmed initial import are implemented.
|
- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport,
|
||||||
Existing-row assignment, move and detach plus creation of an external-only
|
Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
|
||||||
row in an existing or newly created circuit have atomic persistent commands.
|
Löschen fehlender Objekte verwenden persistente Commands. Die vollständige
|
||||||
Their API planning and editor UI, follow-up import, conflict review and return
|
Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und
|
||||||
export are pending.
|
Neustarttests geprüft.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- 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
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
- Final electrical sizing logic is not implemented yet.
|
- There is no automatic sizing of all circuits. The optional cable sizing
|
||||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
module suggests a cross-section for one circuit on demand using
|
||||||
|
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
|
||||||
|
burial, grouped method E and neutral-current sizing above 33 % third
|
||||||
|
harmonic, and it does not check short-circuit withstand, disconnection time
|
||||||
|
or selectivity. See `docs/cable-sizing-module.md`.
|
||||||
- Sorting is view-only until users explicitly apply sorted order.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||||
same category. Cross-category moves and multi-circuit moves across group
|
same category. Cross-category moves and multi-circuit moves across group
|
||||||
|
|
@ -27,10 +31,13 @@
|
||||||
- Protection and cable values are planner-owned manual selections. The later
|
- Protection and cable values are planner-owned manual selections. The later
|
||||||
sizing/warning engine does not yet validate them against load, length or
|
sizing/warning engine does not yet validate them against load, length or
|
||||||
voltage-drop rules.
|
voltage-drop rules.
|
||||||
- Distribution-board `fedFrom` is free text. The print overview derives its
|
- One logical incoming feed per distribution board is supported. A second or
|
||||||
supply structure and "Σ inkl. unterlagert" only from exact text matches with
|
replacement feed with changeover semantics (for example SV/SiBe) is still a
|
||||||
board numbers/names and adds values without an upstream simultaneity factor
|
follow-up. `fedFrom` remains the free-text field for external sources and
|
||||||
(see `docs/print-view.md`).
|
supplementary notes. Revit does not yet propose logical feed links.
|
||||||
|
- A logical link without a selected feeding circuit appears in the hierarchy,
|
||||||
|
but deliberately contributes no load to an upstream circuit until the planner
|
||||||
|
selects that circuit (see `docs/print-view.md`).
|
||||||
- The print view uses a kbp corporate design derived from logo and
|
- The print view uses a kbp corporate design derived from logo and
|
||||||
presentation material. Snapshots do not record an editor, so the cover's
|
presentation material. Snapshots do not record an editor, so the cover's
|
||||||
Planstände list shows the project's current editor for every entry. On screen, long
|
Planstände list shows the project's current editor for every entry. On screen, long
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
|
||||||
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
|
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
|
||||||
Datenbank liegt über einen Host-Mount außerhalb des Containers.
|
Datenbank liegt über einen Host-Mount außerhalb des Containers.
|
||||||
|
|
||||||
|
Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus
|
||||||
|
`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar.
|
||||||
|
Schreibzugriffe verlangen zusätzlich den Anwendungsheader
|
||||||
|
`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN
|
||||||
|
ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren
|
||||||
|
vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu
|
||||||
|
Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher
|
||||||
|
des einzelnen API-Prozesses und enden bei dessen Neustart.
|
||||||
|
|
||||||
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
|
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
|
||||||
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
||||||
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
||||||
|
|
@ -43,8 +52,13 @@ vor dem Command und bleiben über Undo/Redo unverändert.
|
||||||
## Wichtige Einstiegspunkte
|
## Wichtige Einstiegspunkte
|
||||||
|
|
||||||
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
|
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
|
||||||
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen,
|
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen
|
||||||
Räume und Projektgeräte
|
und Projektgeräte
|
||||||
|
- `src/app/projects/[projectId]/struktur/page.tsx` – Gebäude, Etagen, Räume
|
||||||
|
und Raumflächen
|
||||||
|
- `src/app/projects/[projectId]/grobbilanz/` – Stände und Editor der groben
|
||||||
|
Leistungsbilanz
|
||||||
|
- `src/app/projects/[projectId]/print/page.tsx` – Seitenansicht und PDF-Export
|
||||||
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
||||||
– unterstützte Editorroute
|
– unterstützte Editorroute
|
||||||
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
||||||
|
|
@ -52,6 +66,11 @@ vor dem Command und bleiben über Undo/Redo unverändert.
|
||||||
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
||||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||||
|
- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung
|
||||||
|
und erzwungener Wechsel eines Startpassworts
|
||||||
|
- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz
|
||||||
|
- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` –
|
||||||
|
kontobezogene Regeln und schmale Persistenzgrenze
|
||||||
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
||||||
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||||
|
|
@ -290,15 +309,20 @@ Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
|
||||||
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
||||||
bestehenden persistenten Projektbefehl.
|
bestehenden persistenten Projektbefehl.
|
||||||
|
|
||||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
`GET /api/projects/:projectId/export` verpackt den vollständigen portablen
|
||||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit
|
||||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen,
|
||||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne
|
||||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der
|
||||||
|
Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den
|
||||||
|
Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar.
|
||||||
|
Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen
|
||||||
|
Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-,
|
||||||
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
|
||||||
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
|
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
|
||||||
|
Fachliche Verknüpfungen
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben
|
innerhalb des unterstützten Laufzeitmodells bleiben
|
||||||
erhalten. Die
|
erhalten. Die
|
||||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
|
|
@ -458,16 +482,50 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
||||||
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
||||||
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
||||||
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
||||||
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
|
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
|
||||||
Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung
|
externe Einspeisungen und Hinweise.
|
||||||
der Etagen; ältere Versionen werden ohne Gebäude hochgestuft.
|
`distribution-board.update` verwendet Schema-Version 2 für die optionalen
|
||||||
|
logischen Verweise `feedingDistributionBoardId` und `feedingCircuitId`.
|
||||||
|
Version-1-Befehle aus der Historie bleiben ausführbar und verändern diese
|
||||||
|
Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören;
|
||||||
|
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
|
||||||
|
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
|
||||||
|
vor der Datenbankänderung fachlich gesperrt.
|
||||||
|
Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein
|
||||||
|
neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige
|
||||||
|
Gruppe und das nächste BMK vor. Der Befehl
|
||||||
|
`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und
|
||||||
|
wendet die vollständige Verteilungsänderung in derselben Transaktion und
|
||||||
|
Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst
|
||||||
|
offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo
|
||||||
|
stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach
|
||||||
|
nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut.
|
||||||
|
Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das
|
||||||
|
Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten
|
||||||
|
Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen
|
||||||
|
Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren
|
||||||
|
Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine
|
||||||
|
Gerätezeile.
|
||||||
|
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
|
||||||
|
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
||||||
|
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
||||||
|
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
||||||
|
Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den
|
||||||
|
externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden
|
||||||
|
hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt.
|
||||||
|
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
||||||
|
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
||||||
|
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
||||||
|
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
|
||||||
|
Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen;
|
||||||
|
ältere Versionen werden ohne Gebäude hochgestuft.
|
||||||
Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
|
Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
|
||||||
Projekts; ältere Versionen werden mit `null`
|
Projekts; ältere Versionen werden mit `null`
|
||||||
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||||
enthält bereits den vollständigen externen Modellzustand
|
enthielt bereits den damaligen externen CSV-Modellzustand
|
||||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
|
||||||
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||||
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||||
1:1-Schutzgerätedaten. Capture,
|
1:1-Schutzgerätedaten. Capture,
|
||||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||||
|
|
@ -478,7 +536,12 @@ Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft;
|
||||||
dieser Baseline werden nicht eingelesen.
|
dieser Baseline werden nicht eingelesen.
|
||||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
||||||
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
||||||
exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
|
exakten Vorher-/Nachher-Snapshots. Raumbefehle verwenden Version 2 und führen
|
||||||
|
die nullable, nicht negative Netto-Raumfläche `areaM2`; Version-1-Befehle
|
||||||
|
werden mit `null` hochgestuft und bleiben durch den vollständigen
|
||||||
|
Snapshotvergleich konfliktfest. Der Raumdialog pflegt die Fläche manuell mit
|
||||||
|
deutscher Dezimaleingabe, die Gebäudestruktur zeigt zwei Nachkommastellen sowie
|
||||||
|
Summen und die Anzahl fehlender Flächen je Etage und Gebäude. Ein Geschoss wird nur
|
||||||
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||||
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
||||||
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
||||||
|
|
@ -499,6 +562,36 @@ werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
|
||||||
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
|
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
|
||||||
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||||
|
|
||||||
|
## Leitungsberechnung
|
||||||
|
|
||||||
|
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
|
||||||
|
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
|
||||||
|
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
|
||||||
|
Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit
|
||||||
|
den in der Moduldokumentation beschriebenen Grenzen geprüft. Die
|
||||||
|
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
|
||||||
|
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
|
||||||
|
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
|
||||||
|
Querschnitt und Länge über den regulären `circuit.update`-Befehl, also mit
|
||||||
|
Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
|
||||||
|
`docs/cable-sizing-module.md`.
|
||||||
|
|
||||||
|
## Grobe Leistungsbilanz
|
||||||
|
|
||||||
|
Das Modul `src/estimate-balance` mit der importfreien Rechnung in
|
||||||
|
`src/shared/estimate-balance` führt Leistungsbilanzen für LPH 2/3 nach
|
||||||
|
DIN-276-Kostengruppen in eigenen Tabellen (`estimate_*`, Migration `0010`).
|
||||||
|
Es ist nicht mit der Stromkreisstruktur verknüpft und liest Gerätezeilen nur
|
||||||
|
für den Vergleich „geschätzt ↔ detailliert“ über das längste passende
|
||||||
|
Kostengruppen-Präfix. Schreibzugriffe laufen über `estimate-balance.insert`,
|
||||||
|
`.update` und `.delete` mit vollständigen Dokumenten, also mit Revision und
|
||||||
|
Undo/Redo. Grobbilanzen sind Teil von Transferformat 2, aber bewusst nicht Teil
|
||||||
|
der logischen Snapshots; ein Restore lässt sie unverändert. Einzelne Zeilen
|
||||||
|
lassen sich über den vorbelegten Projektgeräte-Dialog ausdrücklich als
|
||||||
|
Projektgerät kopieren (`project-device.insert`, ohne gespeicherte Verknüpfung).
|
||||||
|
Oberfläche: `/projects/:projectId/grobbilanz`. Details:
|
||||||
|
`docs/estimate-balance.md`.
|
||||||
|
|
||||||
## Persistenz und Migration
|
## Persistenz und Migration
|
||||||
|
|
||||||
- SQLite ist die aktuell unterstützte Datenbank.
|
- SQLite ist die aktuell unterstützte Datenbank.
|
||||||
|
|
@ -533,7 +626,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||||
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||||
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||||
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
||||||
Transfer-Integration verwenden aktuell Snapshot-Schema 6. Persistente
|
Transfer-Integration verwenden aktuell Snapshot-Schema 10. Persistente
|
||||||
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||||
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||||
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
||||||
|
|
@ -550,141 +643,67 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||||
|
|
||||||
## Revit-CSV-Grundlage
|
## Revit-Anbindung: Linear-XLSX nach R10
|
||||||
|
|
||||||
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
||||||
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
||||||
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
|
||||||
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
|
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
|
||||||
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
|
Änderungen laufen als Projekt-Commands über `expectedRevision`.
|
||||||
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
|
|
||||||
|
|
||||||
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
|
||||||
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
|
||||||
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 6,
|
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
|
||||||
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
|
||||||
werden Konfigurations-UUID und Projektlink remapped.
|
Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und
|
||||||
|
Projekttransfer verwenden diesen Zustand und remappen alle internen
|
||||||
|
Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9
|
||||||
|
werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem
|
||||||
|
CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
|
||||||
|
abgelehnt.
|
||||||
|
|
||||||
Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
|
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
||||||
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
|
seine Endpunkte und Laufzeitverträge sind entfernt. Die Route
|
||||||
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
|
`/projects/:projectId/revit` enthält Einstellungen, Importstatus und den
|
||||||
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
|
sechsstufigen Erstimportwizard.
|
||||||
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
|
`POST /api/projects/:projectId/external-model/initial-import/plan` liest eine
|
||||||
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
|
lokale XLSX zustandslos, gruppiert Quellräume und exakte Familie-und-Typ-Werte
|
||||||
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
|
und liefert die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge.
|
||||||
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
|
Unbekannte Familien bleiben blockierend; fehlender GZF wird sichtbar mit 1
|
||||||
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
|
vorbelegt. `apply` erhält die Datei erneut, parst sie erneut und vergleicht den
|
||||||
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
|
Vorschau-SHA-256. Der Command `external-model.apply-initial-import` speichert
|
||||||
`external-object.update-row-assignment` ist diese atomare Schreibgrenze für
|
die dabei verwendete Konfiguration und den vollständigen externen Zustand in
|
||||||
bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt
|
derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder
|
||||||
sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden
|
BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne
|
||||||
die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit,
|
Workbookbytes erneut einzulesen oder zu parsen.
|
||||||
Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft.
|
|
||||||
Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung;
|
R6 bis R9 ergänzen die einmal geladene, clientseitig gefilterte und
|
||||||
unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht
|
virtualisierte Projektobjektliste, revisionierte Sammelplanung, atomare
|
||||||
still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
|
Zuordnung im Stromkreislisteneditor, strukturtreue Rückgabe und den
|
||||||
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
|
vollständigen Folgeimport. Der Folgeimport trennt fachliche Quelländerungen
|
||||||
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
|
von noch nicht nach Revit übernommenen Rückgabewerten, aktualisiert akzeptierte
|
||||||
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
|
Mengen samt Row-Summe und markiert fehlende Objekte ohne ihren Link zu lösen.
|
||||||
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
|
Eine gesondert bestätigte Aktion löscht fehlende Objekte samt Mengenübergang;
|
||||||
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
|
Undo stellt Objekt, Link und Menge wieder her.
|
||||||
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
|
|
||||||
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
|
R10 weist den Ablauf über Rückgabe, Folgeimport, Snapshot-Restore,
|
||||||
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
|
Projektduplikat und Undo/Redo nach Datenbankneustart nach. Persistenzpfade für
|
||||||
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
|
vollständige externe Zustände schreiben große Mengen in begrenzten
|
||||||
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
|
SQLite-Blöcken. Der synthetische Abnahmetest verarbeitet 5.000 Objekte; Details
|
||||||
bestehen.
|
und Messwerte stehen in
|
||||||
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
|
[R10-Abnahme](spec/revit-excel-r10-validation.md).
|
||||||
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
|
|
||||||
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
|
## Historische CSV-Unterlagen
|
||||||
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
|
|
||||||
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
|
Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige
|
||||||
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
|
Ausgangslage bleibt ausschließlich im [R0-Audit](spec/revit-excel-r0-audit.md)
|
||||||
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
|
und in den als abgelöst markierten CSV-Spezifikationen nachvollziehbar. Alte
|
||||||
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
|
CSV-Snapshots und CSV-History werden weiterhin ausdrücklich abgelehnt.
|
||||||
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
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
|
|
||||||
strukturtreuer Revit-Rückexport
|
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
- Produktionsdeployment
|
- Produktionsdeployment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
||||||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||||
Entwicklungsrechner.
|
Entwicklungsrechner.
|
||||||
|
|
||||||
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
|
||||||
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
|
||||||
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
|
||||||
deshalb nicht öffentlich erreichbar gemacht werden.
|
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
|
||||||
|
|
||||||
## Topologie
|
## Topologie
|
||||||
|
|
||||||
|
|
@ -29,9 +29,10 @@ deshalb nicht öffentlich erreichbar gemacht werden.
|
||||||
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
|
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
|
||||||
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
|
|
||||||
Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht
|
Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht
|
||||||
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
|
(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive
|
||||||
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
|
Session; Schreibzugriffe zusätzlich den Anwendungsheader
|
||||||
|
`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem
|
||||||
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
||||||
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||||
|
|
||||||
|
|
@ -72,6 +73,26 @@ Verwendete Umgebungsvariablen:
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei
|
||||||
|
leerer Benutzertabelle den ersten Administrator; das Startpasswort hat
|
||||||
|
mindestens 12 Zeichen.
|
||||||
|
- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden.
|
||||||
|
- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage.
|
||||||
|
- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN
|
||||||
|
`false`, da Browser das Cookie sonst nicht speichern.
|
||||||
|
|
||||||
|
Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen,
|
||||||
|
nicht versionierten `.env` neben `compose.yaml` gesetzt werden:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTH_BOOTSTRAP_USERNAME=admin
|
||||||
|
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange
|
||||||
|
mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in
|
||||||
|
**Config → Benutzer** an. Details stehen in
|
||||||
|
[Anmeldung und Benutzerverwaltung](authentication.md).
|
||||||
|
|
||||||
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
||||||
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
||||||
|
|
@ -137,7 +158,8 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
|
|
||||||
- TLS-Termination und Reverse Proxy
|
- TLS-Termination und Reverse Proxy
|
||||||
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei
|
||||||
|
festen Rollen später nicht ausreichen
|
||||||
- definierte Secrets- und Konfigurationsverwaltung
|
- definierte Secrets- und Konfigurationsverwaltung
|
||||||
- persistenter, gesicherter Datenbankbetrieb
|
- persistenter, gesicherter Datenbankbetrieb
|
||||||
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
||||||
|
|
|
||||||
151
docs/estimate-balance.md
Normal file
151
docs/estimate-balance.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Grobe Leistungsbilanz (LPH 2/3)
|
||||||
|
|
||||||
|
Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert
|
||||||
|
nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und
|
||||||
|
Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als
|
||||||
|
Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung
|
||||||
|
folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung:
|
||||||
|
Issue #7.
|
||||||
|
|
||||||
|
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
|
||||||
|
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
|
||||||
|
nichts automatisch in Stromkreislisten, Verteilungen oder Projektgeräte. Eine
|
||||||
|
ausdrückliche Einzelaktion kann die Werte einer Grobbilanz-Zeile als Vorschlag
|
||||||
|
in den bestehenden Projektgeräte-Dialog übernehmen; erst dessen Bestätigung
|
||||||
|
legt über `project-device.insert` ein unplatziertes Projektgerät an.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports)
|
||||||
|
src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle
|
||||||
|
src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port
|
||||||
|
src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas
|
||||||
|
src/db/schema/estimate-balances.ts Tabellen (Migration 0010)
|
||||||
|
src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store
|
||||||
|
src/server/controllers/estimate-balance.controller.ts API
|
||||||
|
src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor
|
||||||
|
src/frontend/estimate-balance/ Editor-Komponenten und Helfer
|
||||||
|
```
|
||||||
|
|
||||||
|
Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil
|
||||||
|
das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf).
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung |
|
||||||
|
| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung |
|
||||||
|
| `estimate_rows` | Versorgung (Auswahl ELT/MSR oder Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung |
|
||||||
|
|
||||||
|
Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft,
|
||||||
|
434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch,
|
||||||
|
480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden
|
||||||
|
Stands mit neuen IDs. Stände bleiben bearbeitbar; ein zusätzlicher Status oder
|
||||||
|
eine Bearbeitungssperre ist derzeit nicht vorgesehen.
|
||||||
|
|
||||||
|
## Rechnung
|
||||||
|
|
||||||
|
- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ.
|
||||||
|
- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich
|
||||||
|
die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt
|
||||||
|
werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in
|
||||||
|
`calculateEstimateRow` und lässt sich dort zentral ändern.
|
||||||
|
- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt),
|
||||||
|
Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der
|
||||||
|
Drehstromspannung aus den Projekteinstellungen.
|
||||||
|
- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`,
|
||||||
|
40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das
|
||||||
|
Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 %
|
||||||
|
Reserve 157,56 kW / 163,33 kVA, 235,75 A).
|
||||||
|
|
||||||
|
## Vergleich geschätzt ↔ detailliert
|
||||||
|
|
||||||
|
`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des
|
||||||
|
Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und
|
||||||
|
ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das
|
||||||
|
längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440.
|
||||||
|
Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
|
||||||
|
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
|
||||||
|
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
|
||||||
|
|
||||||
|
## Übernahme als Projektgerät
|
||||||
|
|
||||||
|
Die Zeilenaktion „Als Projektgerät übernehmen“ ist eine einmalige, ausdrücklich
|
||||||
|
bestätigte Kopie. Vor dem Öffnen des Dialogs werden ausstehende Änderungen der
|
||||||
|
Grobbilanz gespeichert. Das Anlegen läuft anschließend mit der aktuellen
|
||||||
|
Projektrevision über den normalen Projektgeräte-Befehl und ist dadurch wie ein
|
||||||
|
manuell angelegtes Projektgerät rückgängig machbar.
|
||||||
|
|
||||||
|
- Name und Anzeigename kommen aus dem Zeilennamen; Kostengruppe, GZF, cos φ und
|
||||||
|
Anmerkung werden vorgeschlagen. `fixed` und `socket` erscheinen als
|
||||||
|
„Festanschluss“ und „Steckdose“.
|
||||||
|
- W werden für Stückzeilen in kW umgerechnet. Die Projektgeräte-Anzahl ist 1,
|
||||||
|
weil das Projektgerät als Vorlage für eine spätere Gerätezeile dient; die
|
||||||
|
ursprüngliche Stückzahl bleibt im Herkunftshinweis sichtbar.
|
||||||
|
- Die Kategorie ist mangels Phaseninformation in der Grobbilanz mit
|
||||||
|
„1-phasig“ vorbelegt und muss im Dialog fachlich geprüft werden.
|
||||||
|
- Flächenzeilen dürfen übernommen werden. Anzahl und Leistung je Stück bleiben
|
||||||
|
leer und müssen manuell angegeben werden, da das Projektgerät noch keine
|
||||||
|
Einheit W/m² kennt (siehe Issue #20).
|
||||||
|
- SV wird nur im Herkunftshinweis gezeigt. Die tatsächliche Versorgungsart
|
||||||
|
gehört zur Verteilung.
|
||||||
|
- Gleichnamige vorhandene Projektgeräte erzeugen eine Warnung, sperren das
|
||||||
|
Anlegen aber nicht.
|
||||||
|
|
||||||
|
Zwischen Grobbilanz-Zeile und Projektgerät wird keine Verbindung gespeichert.
|
||||||
|
Es gibt keine Stapelübernahme und keine automatische Platzierung in einer
|
||||||
|
Verteilung oder einem Stromkreis. Snapshot-, Transfer- und
|
||||||
|
Grobbilanz-Befehlsformate ändern sich dadurch nicht.
|
||||||
|
|
||||||
|
## Befehle, Undo/Redo
|
||||||
|
|
||||||
|
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
|
||||||
|
Revision, Inverse und persistentem Undo/Redo:
|
||||||
|
|
||||||
|
- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen
|
||||||
|
Stand (Gruppen und Zeilen)
|
||||||
|
- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der
|
||||||
|
Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und
|
||||||
|
ersetzt ihn atomar. Die Inverse ist das vertauschte Paar.
|
||||||
|
|
||||||
|
Der Editor speichert das ganze Dokument verzögert (Autosave); jeder
|
||||||
|
Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine
|
||||||
|
Revision (`{ unchanged: true }`).
|
||||||
|
|
||||||
|
## Projekttransfer und bewusste Abgrenzung
|
||||||
|
|
||||||
|
- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen
|
||||||
|
mit allen übrigen Anwendungsdaten.
|
||||||
|
- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das
|
||||||
|
Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen
|
||||||
|
vorhandene Grobbilanzen unverändert.
|
||||||
|
- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2.
|
||||||
|
Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue
|
||||||
|
IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben
|
||||||
|
importierbar und enthalten definitionsgemäß keine Grobbilanzen.
|
||||||
|
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
|
||||||
|
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
|
||||||
|
mit Präfixen.
|
||||||
|
- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in
|
||||||
|
kW. SV- und kVA-Vergleiche können später ergänzt werden.
|
||||||
|
- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe
|
||||||
|
Abschätzung ohne Stromkreisplanung ist.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/projects/:projectId/estimate-balances`
|
||||||
|
- `POST /api/projects/:projectId/estimate-balances` –
|
||||||
|
`{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }`
|
||||||
|
(`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`)
|
||||||
|
- `GET /api/projects/:projectId/estimate-balances/:balanceId`
|
||||||
|
- `PUT /api/projects/:projectId/estimate-balances/:balanceId` –
|
||||||
|
`{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }`
|
||||||
|
- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }`
|
||||||
|
- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison`
|
||||||
|
|
||||||
|
Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und
|
||||||
|
Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum
|
||||||
|
`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden
|
||||||
|
abgelehnt.
|
||||||
|
|
@ -5,8 +5,9 @@ Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
|
||||||
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
|
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
|
||||||
über Projektbefehle mit Undo/Redo gepflegt.
|
über Projektbefehle mit Undo/Redo gepflegt.
|
||||||
|
|
||||||
Beim Laden werden die Projektrevisionen aller Stromkreislisten und ein
|
Beim Laden werden alle Stromkreislisten in einer projektweiten Abfrage gelesen;
|
||||||
abschließend erneut gelesener Projektstand verglichen. Ändert sich das Projekt
|
deren Projektrevision und ein abschließend erneut gelesener Projektstand werden
|
||||||
|
verglichen. Ändert sich das Projekt
|
||||||
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
|
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
|
||||||
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
|
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
|
||||||
automatisch bei späteren Änderungen in einem anderen Tab.
|
automatisch bei späteren Änderungen in einem anderen Tab.
|
||||||
|
|
@ -31,12 +32,15 @@ automatisch bei späteren Änderungen in einem anderen Tab.
|
||||||
erscheinen als orangefarbenes „[ergänzen]“.
|
erscheinen als orangefarbenes „[ergänzen]“.
|
||||||
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
|
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
|
||||||
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
|
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
|
||||||
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
|
Stück, nur lokale Geräte), Leistung vor Verteiler-GZF einschließlich
|
||||||
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“.
|
gespeister Unterverteilungen, Verteiler-GZF, P<sub>gl</sub> einschließlich
|
||||||
|
Unterverteilungen und I<sub>B</sub>. Die Hierarchie folgt den logischen IDs.
|
||||||
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
||||||
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
||||||
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
||||||
(mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen),
|
(reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit
|
||||||
|
Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten
|
||||||
|
Unterverteilungszeilen),
|
||||||
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
|
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
|
||||||
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
|
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
|
||||||
Drucken auf Folgeseiten.
|
Drucken auf Folgeseiten.
|
||||||
|
|
@ -46,19 +50,25 @@ Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
|
||||||
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
|
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
|
||||||
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
|
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
|
||||||
|
|
||||||
## Vorläufige Punkte
|
## Berechnung und offene Punkte
|
||||||
|
|
||||||
- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet
|
- **Einspeisung / Hierarchie:** `feedingDistributionBoardId` bestimmt die
|
||||||
einen Verteiler nur dann unter, wenn der Text eindeutig der Nummer oder
|
Hierarchie. Ist zusätzlich `feedingCircuitId` gesetzt, fließt die Leistung
|
||||||
Bezeichnung eines anderen Verteilers entspricht (ohne Beachtung von
|
der Unterverteilung nach deren eigenem Verteiler-GZF als virtuelle Last in
|
||||||
Groß-/Kleinschreibung und äußeren Leerzeichen). Mehrdeutige Treffer bleiben
|
genau diesen Abgang ein. Ketten werden rekursiv berechnet; mehrere
|
||||||
ohne Zuordnung; „Σ inkl. unterlagert“ ist
|
Unterverteilungen an einem Abgang sind erlaubt. Ohne Abgang bleibt es bei der
|
||||||
eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung
|
Hierarchie ohne Leistungsaufschlag. `fedFrom` bezeichnet nur externe Quellen
|
||||||
(Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind
|
oder ergänzende Hinweise.
|
||||||
offen.
|
- **Keine Doppelzählung:** Die Abgangs- und Verteilersummen enthalten die
|
||||||
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert
|
Unterverteilungsleistung bereits. Deshalb gibt es keine zusätzliche Spalte
|
||||||
S / (√3 · U<sub>3~</sub>) mit S aus P / cos φ je Gerätezeile
|
„Σ inkl. unterlagert“. Die Summenzeile verwendet für hierarchische Leistungen
|
||||||
(fehlendes cos φ = 1) und Verteiler-GZF.
|
nur Wurzelverteilungen; P<sub>inst</sub> summiert weiterhin die lokalen Geräte.
|
||||||
|
- **I<sub>B</sub>:** S entsteht aus Σ(P / cos φ) für Geräte und den wirksamen
|
||||||
|
cos φ der Unterverteilungen, jeweils mit dem fachlich zugehörigen GZF. Bei
|
||||||
|
einem 1-phasigen speisenden Abgang gilt I = S / U<sub>1~</sub>, sonst
|
||||||
|
I = S / (√3 · U<sub>3~</sub>). Für externe oder nur bis zur Verteilung
|
||||||
|
bekannte Einspeisungen bleibt die bisherige Darstellung als symmetrischer
|
||||||
|
Drehstromwert bestehen.
|
||||||
- **Corporate Design:** Farben (`--pd-*` in
|
- **Corporate Design:** Farben (`--pd-*` in
|
||||||
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
|
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
|
||||||
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
|
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
|
||||||
|
|
@ -118,10 +128,12 @@ ihren Wert.
|
||||||
|
|
||||||
## Bestehende Datenbanken
|
## Bestehende Datenbanken
|
||||||
|
|
||||||
Vor dem Anwenden der additiven Migrationen `0007` und `0008` eine verifizierte
|
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
|
||||||
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
||||||
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
||||||
Snapshot-Schema 6 enthält beide Feldgruppen; die unterstützten Versionen 1–5
|
Snapshot-Schema 10 enthält den aktuellen Projektzustand; die in Schema 9
|
||||||
werden beim Lesen hochgestuft. Restore und Projektkopie erhalten die neuen
|
eingeführten Einspeisungsverweise werden in den unterstützten Versionen 1–8
|
||||||
Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
mit leeren Verweisen hochgestuft. Restore und
|
||||||
|
Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
|
||||||
|
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
|
||||||
zum logischen Projektsnapshot oder Projekttransfer.
|
zum logischen Projektsnapshot oder Projekttransfer.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ This document records architectural direction for future work. It is not a final
|
||||||
- persist undo/redo across page reloads and application restarts
|
- persist undo/redo across page reloads and application restarts
|
||||||
- provide a project-wide version history with named restore points
|
- provide a project-wide version history with named restore points
|
||||||
- preserve an auditable sequence of user, import, migration and restore operations
|
- preserve an auditable sequence of user, import, migration and restore operations
|
||||||
- support future Revit/CSV round-trips using stable IFCGUID-based links
|
- support the Revit/Linear-XLSX round-trip using stable, exact
|
||||||
|
Worksharing-ID-based external identity
|
||||||
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
||||||
|
|
||||||
## Separate Concerns
|
## Separate Concerns
|
||||||
|
|
@ -111,7 +112,12 @@ Implemented automatic-snapshot policy:
|
||||||
- automatic capture and retention run inside the same transaction as the
|
- automatic capture and retention run inside the same transaction as the
|
||||||
triggering project revision
|
triggering project revision
|
||||||
|
|
||||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
## Revit / Linear Excel Round-Trip Direction
|
||||||
|
|
||||||
|
Phase 14 is active and follows the
|
||||||
|
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
|
||||||
|
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The CSV/IFCGUID runtime has
|
||||||
|
been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID.
|
||||||
|
|
||||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
|
|
||||||
|
|
@ -120,9 +126,12 @@ Conceptual entities:
|
||||||
- `ExternalModelSource`
|
- `ExternalModelSource`
|
||||||
- identifies a Revit/IFC model or another external source within a project
|
- identifies a Revit/IFC model or another external source within a project
|
||||||
- `ExternalImportBatch`
|
- `ExternalImportBatch`
|
||||||
- file name, checksum, import time, format/version and processing status
|
- file name, checksum, import time, object count and processing status
|
||||||
|
- only the latest batch keeps original workbook bytes, matrix and the
|
||||||
|
configuration copy; older batches keep date, filename, checksum and count
|
||||||
- `ExternalModelObject`
|
- `ExternalModelObject`
|
||||||
- stable IFCGUID and optional Revit element identifiers
|
- exact case-sensitive Worksharing-ID as external identity
|
||||||
|
- optional IfcGUID as non-unique source redundancy
|
||||||
- source attributes and last-seen import batch
|
- source attributes and last-seen import batch
|
||||||
- `ExternalObjectLink`
|
- `ExternalObjectLink`
|
||||||
- explicit relationship to a project device, circuit device row or another future domain entity
|
- explicit relationship to a project device, circuit device row or another future domain entity
|
||||||
|
|
@ -133,13 +142,33 @@ Conceptual entities:
|
||||||
|
|
||||||
Required behavior:
|
Required behavior:
|
||||||
|
|
||||||
- IFCGUID uniqueness is scoped to project and external model source.
|
- Worksharing-ID uniqueness is scoped to the external source and uses exact,
|
||||||
|
case-sensitive comparison. IfcGUID is not a matching key.
|
||||||
- Parsing writes to staging first.
|
- Parsing writes to staging first.
|
||||||
- Users preview additions, changes, missing objects and conflicts before applying them.
|
- Users preview additions, changes, missing objects and conflicts before applying them.
|
||||||
- Applying an import creates one project revision and one auditable change set.
|
- Applying an import creates one project revision and one auditable change set.
|
||||||
- Imported source values and locally assigned planning values remain distinguishable.
|
- Imported source values and locally assigned planning values remain distinguishable.
|
||||||
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
|
- Export retains Worksharing-ID so Linear can match rows deterministically.
|
||||||
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
|
- Missing objects and duplicate/conflicting Worksharing-IDs are reported,
|
||||||
|
never silently reassigned.
|
||||||
|
|
||||||
|
R10 verifies the complete implemented path through initial import, planning,
|
||||||
|
row assignment, return workbook, follow-up import, snapshot restore, portable
|
||||||
|
duplicate and restart-safe Undo/Redo. Large state replacement uses bounded
|
||||||
|
SQLite insert batches so imports, restores and duplicates do not exceed the
|
||||||
|
driver's SQL-variable limit.
|
||||||
|
|
||||||
|
The project configuration has no separate revision counter. Stateless plans
|
||||||
|
and apply requests bind to `expectedRevision`; every applied import batch holds
|
||||||
|
its configuration copy. Apply reparses the retransmitted workbook. Undo and
|
||||||
|
Redo operate only on persisted complete before/after states and never parse an
|
||||||
|
Excel file.
|
||||||
|
|
||||||
|
Deleting a linked row, circuit or group detaches affected Revit objects in the
|
||||||
|
same typed command after an explicit warning; its inverse restores all links.
|
||||||
|
Only deleting referenced rooms and distribution boards is blocked. Linked
|
||||||
|
objects always derive their board from their row, while a room default-board
|
||||||
|
change affects only unassigned objects.
|
||||||
|
|
||||||
## SQLite and PostgreSQL Direction
|
## SQLite and PostgreSQL Direction
|
||||||
|
|
||||||
|
|
@ -288,7 +317,8 @@ Remaining constraints before completing project version history:
|
||||||
boundary; extend it whenever further project mutations are introduced
|
boundary; extend it whenever further project mutations are introduced
|
||||||
- project creation remains initialization at revision zero, while global device
|
- project creation remains initialization at revision zero, while global device
|
||||||
templates remain outside any single project history
|
templates remain outside any single project history
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment
|
||||||
|
identifier or an internal relation id
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
## Deferred Decisions
|
## Deferred Decisions
|
||||||
|
|
@ -296,5 +326,6 @@ Remaining constraints before completing project version history:
|
||||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
- branch visualization after undo followed by new edits
|
- branch visualization after undo followed by new edits
|
||||||
- user/role model and actor attribution
|
- user/role model and actor attribution
|
||||||
- supported Revit CSV dialects and parameter mapping UI
|
- Linear-XLSX compatibility beyond the observed sample and any future
|
||||||
|
project-independent mapping profiles
|
||||||
- PostgreSQL deployment topology and migration date
|
- PostgreSQL deployment topology and migration date
|
||||||
|
|
|
||||||
|
|
@ -1,269 +1,17 @@
|
||||||
# Kontext für die Revit-Anforderungsplanung
|
# LLM-Kontext für die Revit-Integration
|
||||||
|
|
||||||
## Zweck dieser Datei
|
Die frühere Revit-/CSV-Anforderungsplanung ist abgelöst und Phase 14 ist mit
|
||||||
|
R10 abgeschlossen. Für Arbeiten an der Linear-XLSX-Anbindung sind vollständig
|
||||||
|
zu lesen:
|
||||||
|
|
||||||
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
||||||
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
||||||
für einen zukünftigen Revit-Datenaustausch zu klären.
|
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
||||||
|
- [R10-Abnahme](spec/revit-excel-r10-validation.md)
|
||||||
|
- [Aktuelle Systemarchitektur](current-architecture.md)
|
||||||
|
- [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md)
|
||||||
|
|
||||||
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
|
||||||
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad
|
||||||
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen
|
||||||
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
Migrationsmeldung abgelehnt.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
|
||||||
|
|
@ -532,32 +532,37 @@ Acceptance criteria:
|
||||||
|
|
||||||
## Phase 14: External Model Round-Trip
|
## Phase 14: External Model Round-Trip
|
||||||
|
|
||||||
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
|
Status: abgeschlossen am 30.09.2026 mit R10. Siehe
|
||||||
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
|
[R10-Abnahme](revit-excel-r10-validation.md).
|
||||||
[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:
|
Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf
|
||||||
|
Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
|
||||||
|
abgelöst und wird nicht fortgeschrieben.
|
||||||
|
|
||||||
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
|
Verbindliche Quellen:
|
||||||
|
|
||||||
Tasks:
|
- [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md)
|
||||||
|
- [R0–R10-Plan](revit-excel-phase-14-plan.md)
|
||||||
|
- [R0-Bestandsaudit](revit-excel-r0-audit.md)
|
||||||
|
|
||||||
- model external sources, import batches and stable IFCGUID objects
|
Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit
|
||||||
- stage and validate imports before applying them
|
einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie
|
||||||
- show additions, changes, missing objects and conflicts
|
erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird
|
||||||
- link external objects explicitly to project-domain entities
|
zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative
|
||||||
- apply an import as one auditable project revision
|
vorgelegt.
|
||||||
- configure inbound and outbound parameter mappings
|
|
||||||
- export against a known project revision while preserving IFCGUID
|
|
||||||
|
|
||||||
Acceptance criteria:
|
Kernabnahme der Phase:
|
||||||
|
|
||||||
- repeated imports match objects deterministically by source and IFCGUID
|
- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
|
||||||
- imports never silently overwrite local planning values
|
- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
|
||||||
- users approve the diff before it changes the project
|
Rückexport erhalten;
|
||||||
- exported data can be assigned back to the correct Revit objects
|
- Import erzeugt keine Rows und überschreibt keine lokale Planung still;
|
||||||
|
- Zuordnung, Löschung, Folgeimport und Undo/Redo sind atomar und nach Neustart
|
||||||
|
reproduzierbar;
|
||||||
|
- Undo/Redo liest keine Excel-Datei;
|
||||||
|
- nur der letzte Importbatch enthält Workbook und Matrix vollständig;
|
||||||
|
- mehrere Tausend Objekte bleiben über eine clientseitig gefilterte,
|
||||||
|
virtualisierte Liste bedienbar.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,10 @@ requirements and intended sequencing, not proof of implementation.
|
||||||
- [x] Phase E: editor projection and editing.
|
- [x] Phase E: editor projection and editing.
|
||||||
- [x] Phase F: documentation and full GUI verification.
|
- [x] Phase F: documentation and full GUI verification.
|
||||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||||
the planner supplies the calculation requirements.
|
the planner supplies the calculation requirements. The optional per-circuit
|
||||||
|
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
|
||||||
|
tables were confirmed against DIN VDE 0298-4:2023-06 with
|
||||||
|
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
|
||||||
- Detailed specification:
|
- Detailed specification:
|
||||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||||
|
|
||||||
|
|
@ -132,6 +135,26 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
- [x] Keep column visibility and order as a project-specific UI preference so
|
- [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.
|
all distribution boards in the same project open with the same columns.
|
||||||
|
|
||||||
|
## Integrated Contributor Features (September 2026)
|
||||||
|
|
||||||
|
- [x] Client ids without secure-context browser APIs (`createClientId`).
|
||||||
|
- [x] Icon actions, compact column headers and the AMEV picker in the circuit
|
||||||
|
list; icon actions and collapsible filters on the project overview.
|
||||||
|
- [x] Print view with cover, board overview, board plans, board info card and
|
||||||
|
office branding.
|
||||||
|
- [x] Buildings above floors and a dedicated building structure page.
|
||||||
|
- [x] Optional net room area with sums per floor and building.
|
||||||
|
- [x] Optional cable-sizing module for one circuit on demand
|
||||||
|
(DIN VDE 0298-4:2023-06 with Berichtigung 1).
|
||||||
|
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison
|
||||||
|
with the detailed balance and the explicit copy of one row as a
|
||||||
|
ProjectDevice.
|
||||||
|
- [x] Logical distribution-board feed link with an optional feeding circuit,
|
||||||
|
recursive load rollup and deletion guards (#3).
|
||||||
|
|
||||||
|
Open follow-ups (Gitea issues): bill of quantities export (#5), area-based loads in W/m² for
|
||||||
|
ProjectDevices and device rows (#20).
|
||||||
|
|
||||||
## Recommended Sequence
|
## Recommended Sequence
|
||||||
|
|
||||||
1. [x] Distribution-board floor and supply fields.
|
1. [x] Distribution-board floor and supply fields.
|
||||||
|
|
@ -139,6 +162,7 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
4. [x] Distribution-board power summary and project column layout.
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses
|
||||||
foundation as a separate jointly planned phase and must not be inferred from
|
exact Worksharing-ID identity and must not be inferred from these unrelated
|
||||||
these tasks.
|
backlog tasks. See
|
||||||
|
[the R0–R10 plan](revit-excel-phase-14-plan.md).
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,402 +1,8 @@
|
||||||
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung
|
# Abgelöst: alter Phase-14-CSV-Plan
|
||||||
|
|
||||||
## Status
|
Die Phasen 14.0–14.6 und ihre Erledigt-Markierungen sind abgelöst. Für die
|
||||||
|
Umstellung auf Linear-Excel gelten der [R0–R10-Plan](revit-excel-phase-14-plan.md)
|
||||||
|
und die [neuen Anforderungen](revit-excel-integration-requirements.md).
|
||||||
|
|
||||||
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
|
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
|
||||||
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
|
`ae318a5` erhalten und darf nicht fortgeschrieben werden.
|
||||||
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.
|
|
||||||
|
|
|
||||||
501
docs/spec/revit-excel-integration-requirements.md
Normal file
501
docs/spec/revit-excel-integration-requirements.md
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
# Revit-Integration über Linear-Excel – verbindliche Anforderungen
|
||||||
|
|
||||||
|
Stand: 29.09.2026. Dieses Dokument ist die fachliche und technische
|
||||||
|
Arbeitsgrundlage für die neue Phase 14. Es ersetzt die frühere CSV-/IfcGUID-
|
||||||
|
Spezifikation. Der Umsetzungsstand steht im
|
||||||
|
[R0–R10-Plan](revit-excel-phase-14-plan.md); der geprüfte Ausgangszustand im
|
||||||
|
[R0-Audit](revit-excel-r0-audit.md).
|
||||||
|
|
||||||
|
## 1. Ziel und Grenzen
|
||||||
|
|
||||||
|
Der Austausch mit Revit erfolgt ausschließlich über den XLSX-Export/-Import
|
||||||
|
des Linear-Plugins. Eine vollständige Revit-Bauteilliste wird importiert, in
|
||||||
|
Leistungsbilanz geplant und mit gleichem Tabellenaufbau an Linear
|
||||||
|
zurückgegeben. Linear ordnet enthaltene Zeilen anhand der Worksharing-ID zu.
|
||||||
|
|
||||||
|
Nicht Teil dieser Phase sind Dynamo, Revit-API, JSON-Transport, automatische
|
||||||
|
Stromkreisbildung, Übernahme vorhandener Revit-Stromkreiswerte,
|
||||||
|
Teilimporte, mehrere Modellquellen, PostgreSQL und neue Rollen/Rechte.
|
||||||
|
|
||||||
|
Die Integration bleibt optional. Manuelle Projekte, Räume, ProjectDevices,
|
||||||
|
Stromkreise und Gerätezeilen funktionieren ohne externes Modell. Der
|
||||||
|
Circuit-First-Aufbau, stabile interne UUIDs und BMKs, ausdrückliche
|
||||||
|
Neunummerierung sowie die gemeinsame Project-Command-Transaktionsgrenze
|
||||||
|
bleiben unverändert.
|
||||||
|
|
||||||
|
## 2. Verbindliche fachliche Regeln
|
||||||
|
|
||||||
|
- Import erzeugt keine `CircuitDeviceRow`, keinen `Circuit` und kein BMK.
|
||||||
|
- Quellwerte, lokale Planungswerte und Overrides bleiben getrennt.
|
||||||
|
- Ein Folgeimport überschreibt keine lokale Planung still.
|
||||||
|
- Fehlende Objekte werden markiert und erst nach ausdrücklicher Bestätigung
|
||||||
|
entfernt.
|
||||||
|
- Mehrfachsteckdosen sind unteilbare externe Objekte. Ihre wirksame Menge kann
|
||||||
|
größer als eins sein.
|
||||||
|
- Die Row-Menge ist
|
||||||
|
`manualQuantity + Summe(effectiveQuantity der verknüpften Objekte)`.
|
||||||
|
- Nach einer Row-Zuordnung ist die `CircuitDeviceRow` die Planungsebene für
|
||||||
|
alle verknüpften Objekte.
|
||||||
|
- Bestehende ProjectDevices bleiben kanonische Vorlagen und werden durch
|
||||||
|
Importe nie verändert.
|
||||||
|
- Sortieren, Filtern, Importieren, Zuordnen und Verschieben nummeriert keine
|
||||||
|
Stromkreise automatisch neu.
|
||||||
|
|
||||||
|
## 3. Linear-XLSX-Profil
|
||||||
|
|
||||||
|
Die lokale Referenzdatei `docs/spec/kbp-Stromkreis.xlsx` enthält vertrauliche
|
||||||
|
Projektdaten. Sie wird nicht committed und nicht in Tests eingebettet. Tests
|
||||||
|
erzeugen synthetische Workbooks. Der lokale Referenzprüfer erhält den Pfad als
|
||||||
|
Argument.
|
||||||
|
|
||||||
|
Beobachtetes Profil:
|
||||||
|
|
||||||
|
- genau ein Arbeitsblatt; Blattname nach Excel-Regel maximal 31 Zeichen;
|
||||||
|
- Kopfzeile in Zeile 1, Zeile 2 leer, Daten ab Zeile 3, erste Datenspalte A;
|
||||||
|
- Spalte A `Worksharing-ID` im Format `<GUID>-<8 Hex>`;
|
||||||
|
- Parameterbindungen stehen als klassische Excel-Kommentare in den übrigen
|
||||||
|
Kopfzellen;
|
||||||
|
- Zahlen sind typisierte Excel-Zahlen; importierte Werte bleiben unverändert,
|
||||||
|
fachlich verwendete Leistung wird auf 0,01 W projiziert;
|
||||||
|
- Objektzeilen dürfen keinen MEP-Raum besitzen;
|
||||||
|
- eine Schluss-/Summenzeile ohne ID kann vorkommen und wird weder importiert
|
||||||
|
noch zurückgegeben.
|
||||||
|
|
||||||
|
Die Referenzdatei enthält 487 Objektzeilen, eindeutige Worksharing-IDs und
|
||||||
|
keine doppelten nichtleeren IfcGUIDs bei exaktem, case-sensitivem Vergleich.
|
||||||
|
Ihre Summenzeile steht in Spalte B (`CAx_Auswahlkenner`), während
|
||||||
|
`Familie und Typ` leer ist.
|
||||||
|
|
||||||
|
### Zeilen ohne Worksharing-ID
|
||||||
|
|
||||||
|
Nur eine Zeile ohne Worksharing-ID, aber mit befülltem Feld
|
||||||
|
`Familie und Typ`, gilt als verdächtige Objektzeile. Sie erzeugt einen Hinweis
|
||||||
|
und wird übersprungen; sie blockiert den Import nicht. Andere Zeilen ohne ID,
|
||||||
|
einschließlich einer nur in Spalte B befüllten Summenzeile, werden ohne Hinweis
|
||||||
|
übersprungen. Eine Objektzeile ist für den Import eine Zeile mit nichtleerer
|
||||||
|
Worksharing-ID.
|
||||||
|
|
||||||
|
Worksharing-ID und IfcGUID werden stets exakt, ordinal und mit
|
||||||
|
Groß-/Kleinschreibung verglichen. Es findet kein Trimmen, Normalisieren oder
|
||||||
|
Case-Folding für die Identität statt. Eine nichtleere Worksharing-ID mit
|
||||||
|
unerlaubten äußeren Leerzeichen oder falschem Format blockiert. Doppelte
|
||||||
|
Worksharing-IDs blockieren. IfcGUID ist optional, kein Schlüssel und darf
|
||||||
|
doppelt vorkommen; Duplikate erzeugen lediglich einen Hinweis.
|
||||||
|
|
||||||
|
## 4. Upload und Transportadapter
|
||||||
|
|
||||||
|
Der Server akzeptiert ausschließlich Dateien mit Endung `.xlsx` bis 10 MiB.
|
||||||
|
`.xls` und `.xlsm` werden abgelehnt. Parsing findet serverseitig statt. Nach
|
||||||
|
dem Einlesen gelten feste Zeilen-, Spalten- und Zellgrenzen, die in R2 anhand
|
||||||
|
synthetischer Grenztests festgelegt und zentral definiert werden. Es gibt in
|
||||||
|
dieser Phase keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
|
||||||
|
|
||||||
|
Der Adapter unter `src/external-model/xlsx` liest eine transportneutrale
|
||||||
|
Zellmatrix. Eine Zelle ist `string`, `number`, `boolean` oder `empty`.
|
||||||
|
Zusätzlich werden Blattname, Originalzeilennummer, Kopfzeilenkommentare,
|
||||||
|
Spaltenbreiten und die für Hinweise nötigen Formelmetadaten erfasst.
|
||||||
|
Domain-Services kennen weder ExcelJS noch XLSX, React oder SQLite.
|
||||||
|
|
||||||
|
Formeln werden niemals ausgeführt. Ist ein gecachter skalarer Wert vorhanden,
|
||||||
|
wird er verwendet und als Hinweis ausgewiesen. Fehlt er oder ist er ein
|
||||||
|
Fehlerwert, bleibt das Feld leer; bei der Worksharing-ID oder einem anderen
|
||||||
|
Pflichtfeld blockiert dies die betreffende Objektzeile beziehungsweise den
|
||||||
|
Import.
|
||||||
|
|
||||||
|
Genau eine gepflegte Bibliothek wird ergänzt. Vorgesehen ist `exceljs`, sofern
|
||||||
|
R2 die Node-24-Verträglichkeit und den Roundtrip von Kopfkommentaren,
|
||||||
|
Blattname, Spaltenbreiten, Formaten und Zelltypen bestätigt.
|
||||||
|
|
||||||
|
## 5. Projektkonfiguration
|
||||||
|
|
||||||
|
Die Konfiguration ist projektweit, revisionssicher und besitzt keine eigene
|
||||||
|
Versionszählung. Jeder Plan und jedes Apply tragen `expectedRevision`.
|
||||||
|
Da die Projektrevision auch bei Undo/Redo strikt steigt, wird ein alter Plan
|
||||||
|
automatisch ungültig. Jeder Importbatch speichert eine vollständige Kopie der
|
||||||
|
beim Import verwendeten Konfiguration.
|
||||||
|
|
||||||
|
Das Einstellungsmodal auf der Revit-Seite erkennt aus einer zustandslos
|
||||||
|
hochgeladenen Musterdatei Kopfzeile, erste Datenzeile und erste Datenspalte.
|
||||||
|
Diese Werte bleiben editierbar; Standard ist 1 / 3 / A. Es gibt nur ein Feld
|
||||||
|
für die Identitätsspalte: `columns.worksharingId`. Ein separates
|
||||||
|
`layout.idColumn` existiert nicht.
|
||||||
|
|
||||||
|
Zielvertrag:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ExternalModelConfiguration {
|
||||||
|
schemaVersion: 1;
|
||||||
|
layout: {
|
||||||
|
headerRow: number;
|
||||||
|
firstDataRow: number;
|
||||||
|
firstDataColumn: number;
|
||||||
|
};
|
||||||
|
circuitMode: "combined" | "separate";
|
||||||
|
columns: {
|
||||||
|
worksharingId: string;
|
||||||
|
ifcGuid: string | null;
|
||||||
|
roomNumber: string | null;
|
||||||
|
roomName: string | null;
|
||||||
|
familyAndType: string;
|
||||||
|
selectionMarker: string | null;
|
||||||
|
socketCount: string | null;
|
||||||
|
sourceRemark: string | null;
|
||||||
|
circuitCombined: string | null;
|
||||||
|
boardName: string | null;
|
||||||
|
circuitIdentifier: string | null;
|
||||||
|
designation: string | null;
|
||||||
|
electricalPowerW: string | null;
|
||||||
|
voltageV: string | null;
|
||||||
|
currentA: string | null;
|
||||||
|
};
|
||||||
|
deviceTypes: DeviceTypeConfiguration[];
|
||||||
|
familyTypeRules: FamilyTypeRule[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Standardzuordnung:
|
||||||
|
|
||||||
|
| Schlüssel | Bedeutung | Standardspalte |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `worksharingId` | externe Identität, Pflicht | `Worksharing-ID` |
|
||||||
|
| `ifcGuid` | optionale Redundanz | `IfcGUID` |
|
||||||
|
| `roomNumber` | Raumnummer | `MEP-Raum: Nummer` |
|
||||||
|
| `roomName` | Raumname | `MEP-Raum: Name` |
|
||||||
|
| `familyAndType` | Familie und Typ, Pflicht | `Familie und Typ` |
|
||||||
|
| `selectionMarker` | CAx-Auswahlkenner | `CAx_Auswahlkenner` |
|
||||||
|
| `socketCount` | Anzahl Einzelsteckdosen | `CAX_AnzahlObjekte` |
|
||||||
|
| `sourceRemark` | Anmerkung | `CAx_Anmerkung` |
|
||||||
|
| `circuitCombined` | Stromkreis gesamt | `kbp_Stromkreisnummer` |
|
||||||
|
| `boardName` | Verteiler | `kbp-E-Verteiler` |
|
||||||
|
| `circuitIdentifier` | Stromkreis-BMK | `kbp-E-Stromkreis` |
|
||||||
|
| `designation` | Bezeichnung | `kbp-E-Bezeichnung` |
|
||||||
|
| `electricalPowerW` | elektrische Leistung [W] | `kbp-E-Elektrische Leistung` |
|
||||||
|
| `voltageV` | Spannung [V] | `kbp-E-Spannung` |
|
||||||
|
| `currentA` | Stromstärke [A] | `kbp-E-Stromstärke` |
|
||||||
|
|
||||||
|
`worksharingId` und `familyAndType` sind Pflichtmappings. Im Modus
|
||||||
|
`combined` ist `circuitCombined` für den Rückexport Pflicht; im Modus
|
||||||
|
`separate` sind `boardName` und `circuitIdentifier` Pflicht. Fehlende optionale
|
||||||
|
Rückgabespalten `designation`, `electricalPowerW`, `voltageV` und `currentA`
|
||||||
|
werden mit Hinweis übersprungen und blockieren den Export nicht.
|
||||||
|
|
||||||
|
Jedes interne Feld besitzt im Modal ein Dropdown aus den Kopfzellen und eine
|
||||||
|
freie Eingabe. Exakte kbp-Standardnamen werden vorbelegt. Typ und Einheit aus
|
||||||
|
dem Linear-Kommentar werden angezeigt. Nicht gemappte Spalten bleiben beim
|
||||||
|
Rückexport unverändert.
|
||||||
|
|
||||||
|
## 6. Gerätearten und Familie-und-Typ-Regeln
|
||||||
|
|
||||||
|
Eine Geräteart besitzt stabile interne UUID, Name, Kategorie
|
||||||
|
`lighting | single_phase | three_phase` sowie optionale Standardwerte für
|
||||||
|
Leistung je Einheit in W, GZF, cosPhi und Kostengruppe. Jede exakte
|
||||||
|
`Familie und Typ`-Regel verweist auf genau eine Geräteart und verwendet die
|
||||||
|
Mengenregel `1` oder `socketCount`.
|
||||||
|
|
||||||
|
Mehrere Revit-Familien dürfen auf dieselbe Geräteart zeigen. Jedes Revit-
|
||||||
|
Objekt bleibt trotzdem einzeln über seine Worksharing-ID nachverfolgbar.
|
||||||
|
Unbekannte Familien müssen im Importwizard zugeordnet oder über eine neue
|
||||||
|
Geräteart klassifiziert werden; andernfalls blockiert Apply.
|
||||||
|
|
||||||
|
Planungsstartwerte folgen dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. ausdrücklich verknüpftes ProjectDevice;
|
||||||
|
2. Geräteart-Standard;
|
||||||
|
3. leer.
|
||||||
|
|
||||||
|
Der Anzeigename wird aus CAx-Auswahlkenner, dann Geräteartname, dann
|
||||||
|
`Familie und Typ` vorgeschlagen. Ein fehlender GZF wird mit `1` vorbelegt,
|
||||||
|
ohne zusätzliche Bestätigung. Standardwerte sind Startwerte, keine dauerhafte
|
||||||
|
Synchronisierung.
|
||||||
|
|
||||||
|
## 7. Persistenter externer Zustand
|
||||||
|
|
||||||
|
Pro Projekt existiert genau eine implizite Revit-XLSX-Quelle. Ein externes
|
||||||
|
Objekt besitzt eine interne UUID und ist je `(sourceId, worksharingId)`
|
||||||
|
eindeutig. `worksharingId` ist NOT NULL; `ifcGuid` ist nullable und nicht
|
||||||
|
eindeutig. Interne Relationen verwenden ausschließlich interne UUIDs.
|
||||||
|
|
||||||
|
Gespeichert werden:
|
||||||
|
|
||||||
|
- akzeptierte Quellwerte einschließlich aller Rückgabefelder;
|
||||||
|
- lokale Planungswerte und Override-Felder;
|
||||||
|
- Geräteart;
|
||||||
|
- optionales Raum-Mapping, abweichender Verteiler, ProjectDevice und
|
||||||
|
`CircuitDeviceRow`;
|
||||||
|
- Anwesenheitsstatus und Verweis auf den zuletzt relevanten Import;
|
||||||
|
- effektive Menge als fachlicher Wert des unteilbaren Objekts.
|
||||||
|
|
||||||
|
Der effektive Verteiler eines verknüpften Objekts ist immer der Verteiler
|
||||||
|
seiner Row. Für ein unzugeordnetes Objekt gilt zuerst die explizite
|
||||||
|
Objektabweichung, danach der Standardverteiler seines Raum-Mappings. Ändert
|
||||||
|
sich der Standardverteiler eines Raums, betrifft das nur unzugeordnete
|
||||||
|
Objekte; verknüpfte Objekte bleiben über ihre Row unverändert und die Änderung
|
||||||
|
wird deshalb nicht gesperrt.
|
||||||
|
|
||||||
|
## 8. Importbatch-Aufbewahrung, Snapshot und Transfer
|
||||||
|
|
||||||
|
Der aktive Projektzustand enthält den letzten Importbatch vollständig:
|
||||||
|
Originalbytes, klassifizierte Matrix, Dateiname, SHA-256, Zeitpunkt,
|
||||||
|
Objektzahl, Importart, Projektrevision und Konfigurationskopie. Ältere Batches
|
||||||
|
werden zu Metadaten reduziert: Datum, Dateiname, SHA-256 und Objektzahl.
|
||||||
|
Sie enthalten keine Originalbytes, Matrix oder Konfigurationskopie mehr.
|
||||||
|
|
||||||
|
Snapshot-Schema 10 und portabler Projekttransfer übernehmen genau diese
|
||||||
|
Aufteilung. Beim Duplizieren werden alle internen UUIDs und Links remapped;
|
||||||
|
Worksharing-IDs, optionale IfcGUIDs, Quellwerte und das letzte Workbook bleiben
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
Versionen 1–9 ohne externe CSV-Daten werden verlustfrei auf Version 10
|
||||||
|
hochgestuft. Enthält ein alter Stand eine CSV-Konfiguration oder einen nicht
|
||||||
|
leeren CSV-Zustand, wird er mit klarer deutscher Fehlermeldung abgelehnt.
|
||||||
|
Alte CSV-History-Commands werden ebenfalls vor jedem Write abgelehnt und nie
|
||||||
|
stillschweigend übersprungen.
|
||||||
|
|
||||||
|
## 9. Erstimport
|
||||||
|
|
||||||
|
Der Ablauf ist Upload → zustandslose Vorschau/Planung → erneute
|
||||||
|
Dateiübertragung bei Apply. Apply prüft SHA-256, `expectedRevision`,
|
||||||
|
Konfigurationskopie, Matrix und sämtliche Entscheidungen erneut. Nur Apply
|
||||||
|
liest die Excel-Datei erneut ein.
|
||||||
|
|
||||||
|
Undo und Redo lesen keine Excel-Datei und parsen keine gespeicherten Bytes.
|
||||||
|
Sie arbeiten ausschließlich auf den im Command gespeicherten vollständigen
|
||||||
|
Vorher-/Nachher-Zuständen. Dadurch bleiben sie nach Neustart deterministisch
|
||||||
|
und unabhängig vom Parser.
|
||||||
|
|
||||||
|
Der Wizard umfasst:
|
||||||
|
|
||||||
|
1. Vorschau mit Objektzahl, übersprungenen und verdächtigen Zeilen,
|
||||||
|
doppelten Worksharing-IDs, doppelten IfcGUID-Hinweisen, unbekannten
|
||||||
|
Familien, nicht lesbaren Zahlen und Formeln;
|
||||||
|
2. Räume anlegen oder vorhandenen Räumen/Ebenen zuordnen;
|
||||||
|
3. Standardverteiler je Raum, Sammelzuordnung, Objektabweichungen und Objekte
|
||||||
|
ohne Raum;
|
||||||
|
4. Familien exakt Gerätearten und einer Mengenregel zuordnen;
|
||||||
|
5. optional vorhandene oder neu angelegte ProjectDevices verknüpfen;
|
||||||
|
6. Zusammenfassung und ausdrückliche Bestätigung.
|
||||||
|
|
||||||
|
Die Übernahme ist ein atomarer Command und ein Undo-Schritt. Im Import
|
||||||
|
entstehen keine Rows. Neu angelegte Räume und ProjectDevices verwenden ihre
|
||||||
|
vorhandenen Commands und bleiben eigene Undo-Schritte.
|
||||||
|
|
||||||
|
## 10. Objektplanung und Revit-Seite
|
||||||
|
|
||||||
|
Route: `/projects/:projectId/revit`, erreichbar aus der Projektnavigation.
|
||||||
|
Der Revit-Button und das CSV-Modal auf der Projektübersicht entfallen.
|
||||||
|
|
||||||
|
Die Seite besitzt:
|
||||||
|
|
||||||
|
- Statuskarte mit letztem Import, Objektzahl, ohne Verteiler, ohne
|
||||||
|
Stromkreis, fehlend und Abweichung zu Revit;
|
||||||
|
- Aktionen `Import`, `Rückgabe-Excel`, `Einstellungen`;
|
||||||
|
- einklappbare Geräteart-/Familienregel-Übersicht;
|
||||||
|
- kompakte Objekttabelle mit Suche, Spaltenfiltern, Sortierung,
|
||||||
|
Mehrfachauswahl und Sammelzuordnung für Verteiler, ProjectDevice und
|
||||||
|
Planungswerte.
|
||||||
|
|
||||||
|
Die kompakte Liste wird einmal geladen, vollständig im Client gefiltert und
|
||||||
|
sortiert und virtualisiert dargestellt. Es gibt keine Server-Pagination.
|
||||||
|
Originalbytes, Matrix und Command-Payloads gehören nicht in dieses DTO.
|
||||||
|
|
||||||
|
Vor Row-Zuordnung lassen sich Leistung je Einheit, GZF, cosPhi,
|
||||||
|
Kostengruppe, Anzeigename und Bemerkung einzeln oder gesammelt ändern.
|
||||||
|
Geänderte Felder werden Overrides. Nach Row-Zuordnung werden diese Werte im
|
||||||
|
Grid an der Row bearbeitet. Beim ausdrücklichen Lösen erhält das Objekt die
|
||||||
|
aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
|
||||||
|
|
||||||
|
## 11. Editor-Zuordnung und Löschen
|
||||||
|
|
||||||
|
Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste
|
||||||
|
gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen
|
||||||
|
Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als
|
||||||
|
Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die
|
||||||
|
drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen
|
||||||
|
kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und
|
||||||
|
Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und
|
||||||
|
Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen
|
||||||
|
explizit.
|
||||||
|
|
||||||
|
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
|
||||||
|
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
|
||||||
|
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
|
||||||
|
ProjectDevice-Link. Die Anzeige nennt Summe der wirksamen Mengen und Zahl der
|
||||||
|
externen Objekte. Gruppen sind aufklappbar; Einzel- und Mehrfachauswahl sowie
|
||||||
|
Filter bleiben möglich.
|
||||||
|
|
||||||
|
Drag-and-drop:
|
||||||
|
|
||||||
|
- auf vorhandene Row: genau diese Row;
|
||||||
|
- auf Circuit: neue Row mit `manualQuantity = 0`;
|
||||||
|
- auf freien Bereich: neuer Circuit, Schutz und Row in einem Command.
|
||||||
|
|
||||||
|
Neue IDs und BMK werden einmal geplant; Redo rechnet sie nicht neu. Neue Rows
|
||||||
|
übernehmen den bestätigten Gruppenzustand und die Summe der effektiven Mengen.
|
||||||
|
An Rows zeigt ein Hinweis die Zahl verknüpfter Revit-Objekte; einzelne Objekte
|
||||||
|
können ausdrücklich gelöst werden.
|
||||||
|
|
||||||
|
Wird eine Row, ein Circuit oder eine Gruppe mit verknüpften Revit-Objekten
|
||||||
|
gelöscht, zeigt die UI vor Bestätigung:
|
||||||
|
`X Revit-Objekte werden wieder unzugeordnet`. Derselbe Löschcommand löst die
|
||||||
|
Objekte, entfernt die Struktur und speichert alle Links in seiner Inversen.
|
||||||
|
Undo stellt Struktur und Links exakt wieder her. Diese Löschungen werden nicht
|
||||||
|
gesperrt.
|
||||||
|
|
||||||
|
Das Löschen eines Verteilers oder Raums mit referenzierten externen Objekten
|
||||||
|
bleibt gesperrt. Der Nutzer muss die Verknüpfungen beziehungsweise Zuordnungen
|
||||||
|
zuvor ausdrücklich ändern. Die Regel gilt auch für Unterbaum- und History-
|
||||||
|
Pfade.
|
||||||
|
|
||||||
|
## 12. Rückgabe-Excel
|
||||||
|
|
||||||
|
Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
|
||||||
|
an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
|
||||||
|
Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
|
||||||
|
Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
|
||||||
|
Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
|
||||||
|
Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
|
||||||
|
liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
|
||||||
|
|
||||||
|
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
|
||||||
|
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
|
||||||
|
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash
|
||||||
|
und Objektzahl.
|
||||||
|
|
||||||
|
Umfang:
|
||||||
|
|
||||||
|
- alle im letzten Import vorhandenen Objekte (Standard);
|
||||||
|
- nur Objekte mit Abweichung zum zuletzt importierten Revit-Wert;
|
||||||
|
- Auswahl aus der Objekttabelle.
|
||||||
|
|
||||||
|
Die Rückgabe basiert auf einer Kopie des zuletzt importierten Workbooks.
|
||||||
|
Zeilen oberhalb der ersten Datenzeile, Kopfkommentare, Formate,
|
||||||
|
Spaltenbreiten, Blattname und Spaltenreihenfolge bleiben erhalten. Danach
|
||||||
|
folgen nur die gewählten Objektzeilen in ihrer Originalreihenfolge. Alle
|
||||||
|
Spalten bleiben enthalten; Summenzeilen entfallen.
|
||||||
|
|
||||||
|
Stromkreiswerte:
|
||||||
|
|
||||||
|
- `combined`: `DistributionBoard.name + Circuit.equipmentIdentifier`, ohne
|
||||||
|
Trennzeichen;
|
||||||
|
- `separate`: Verteilername und unverändertes BMK in getrennten Spalten;
|
||||||
|
- ohne Circuit: Stromkreisfeld beziehungsweise beide Felder leer.
|
||||||
|
|
||||||
|
Die Wahl des Verteilerwerts liegt in genau einer Funktion und verwendet
|
||||||
|
zunächst `DistributionBoard.name`.
|
||||||
|
|
||||||
|
Weitere Rückgabewerte:
|
||||||
|
|
||||||
|
- Bezeichnung = Row-`displayName`, ohne Row Planungs-Anzeigename;
|
||||||
|
- Leistung [W] = effektive Menge × Einzelleistung, ohne GZF;
|
||||||
|
- Spannung = Circuit-Spannung, sonst Projektstandard der Kategorie;
|
||||||
|
- Strom = `P / (U × cosPhi)` beziehungsweise
|
||||||
|
`P / (sqrt(3) × U × cosPhi)`, auf 0,01 A;
|
||||||
|
- nicht berechenbar = leere Zelle plus Hinweis.
|
||||||
|
|
||||||
|
Zahlen werden als Excel-Zahlen geschrieben. Fehlende optionale Zielspalten
|
||||||
|
werden mit Hinweis ausgelassen. Nur fehlende Stromkreisspalten des gewählten
|
||||||
|
Modus blockieren. Dateiname:
|
||||||
|
`<Projektname>_Revit-Rueckgabe_<YYYY-MM-DD>.xlsx`.
|
||||||
|
|
||||||
|
## 13. Verbindliche R2-Abnahme mit Linear
|
||||||
|
|
||||||
|
R2 endet mit einer Rückgabedatei, die aus der lokalen realen Musterdatei
|
||||||
|
erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese
|
||||||
|
Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach
|
||||||
|
erfolgreichem Linear-Rückimport beginnen.
|
||||||
|
|
||||||
|
Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear
|
||||||
|
eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben.
|
||||||
|
|
||||||
|
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
|
||||||
|
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
|
||||||
|
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
|
||||||
|
Alternative bleibt hinter demselben transportneutralen Adaptervertrag.
|
||||||
|
|
||||||
|
## 14. Folgeimport
|
||||||
|
|
||||||
|
Jeder Import ist eine vollständige Projektbauteilliste. Matching erfolgt
|
||||||
|
ausschließlich über exakt und case-sensitiv verglichene Worksharing-ID.
|
||||||
|
|
||||||
|
- neu: Wizard-Schritte nur für neue Objekte und unbekannte Familien;
|
||||||
|
- geänderte Quellwerte: Einzel- oder Sammelentscheidung;
|
||||||
|
- akzeptierte Mengenänderung: verknüpfte Row-Menge im selben Command;
|
||||||
|
- fehlend: markieren, Link und Menge zunächst beibehalten;
|
||||||
|
- Löschen: erst nach gesonderter Bestätigung;
|
||||||
|
- abweichende Rückgabefelder: Hinweis `in Revit noch nicht aktualisiert`,
|
||||||
|
keine Übernahmeentscheidung.
|
||||||
|
|
||||||
|
`external-import.apply-follow-up` ist atomar und ein Undo-Schritt.
|
||||||
|
Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
|
||||||
|
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
||||||
|
auf Metadaten reduziert.
|
||||||
|
|
||||||
|
Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute
|
||||||
|
Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare
|
||||||
|
`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte
|
||||||
|
Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command.
|
||||||
|
Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen
|
||||||
|
in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte
|
||||||
|
Löschung verwendet `external-model-object.delete-missing`; Undo stellt
|
||||||
|
Objekte, Links und Row-Mengen exakt wieder her.
|
||||||
|
|
||||||
|
## 15. Commands, Persistenz und Sicherheit
|
||||||
|
|
||||||
|
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
|
||||||
|
Gegenrichtung, `expectedRevision`, Anmeldung, CSRF-Header und
|
||||||
|
`project-command-transaction.persistence.ts`. Dazu gehören mindestens:
|
||||||
|
|
||||||
|
- `external-model-configuration.update`;
|
||||||
|
- `external-import.apply-initial`;
|
||||||
|
- `external-import.apply-follow-up`;
|
||||||
|
- `external-model-object.update-planning`;
|
||||||
|
- `external-model-object.update-distribution-board`;
|
||||||
|
- `external-model-room-mapping.update`;
|
||||||
|
- `external-model-object.update-project-device-link`;
|
||||||
|
- `external-object.update-row-assignment`;
|
||||||
|
- `external-object.assign-to-new-row`;
|
||||||
|
- `external-object.assign-to-new-circuit`;
|
||||||
|
- `external-model-object.delete-missing`;
|
||||||
|
- die erweiterten Row-/Circuit-/Gruppen-Löschcommands mit Objekt-Link-
|
||||||
|
Transitionen.
|
||||||
|
|
||||||
|
Die Konfiguration selbst ist Teil der erwarteten/gewünschten Snapshots, aber
|
||||||
|
hat keinen eigenen monotonen Zähler. Importbatches halten eine unveränderliche
|
||||||
|
Konfigurationskopie. Preview/Plan sind zustandslos. Apply überträgt und parst
|
||||||
|
die Datei erneut; Undo/Redo führt nur gespeicherte Zustandsübergänge aus.
|
||||||
|
|
||||||
|
Neue Migrationen sind additiv. Vor Anwendung wird die Datenbank gesichert und
|
||||||
|
das generierte SQL geprüft. Da nur Entwicklungsdaten existieren, darf die
|
||||||
|
Migration alte externe CSV-Daten verwerfen. Vor dem Löschen externer Objekte
|
||||||
|
werden betroffene Row-Mengen auf `manualQuantity` zurückgesetzt. Der Verlust
|
||||||
|
des alten externen Entwicklungszustands wird dokumentiert.
|
||||||
|
|
||||||
|
## 16. Abnahme und Leistung
|
||||||
|
|
||||||
|
Die Oberfläche muss mehrere Tausend Objekte flüssig bedienen. R10 misst
|
||||||
|
Import, Command-/Snapshot-Größe, Speicherbedarf und Client-Interaktion mit
|
||||||
|
synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den
|
||||||
|
sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal
|
||||||
|
geladenen kompakten Liste arbeiten.
|
||||||
|
|
||||||
|
Umsetzungsstand R10: Die vollständige Kette ist mit dateibasierter SQLite-
|
||||||
|
Persistenz und Neustart getestet. Der synthetische Lasttest verarbeitet 5.000
|
||||||
|
Objekte, misst Transport, Planung, Transaktionen, Command-/Snapshotgröße,
|
||||||
|
Heap und Clientprojektion und prüft Restore sowie Projektduplikat. Große
|
||||||
|
Zustandsersetzungen verwenden begrenzte Insertblöcke innerhalb derselben
|
||||||
|
atomaren Transaktion. Die Ergebnisse sind in der
|
||||||
|
[R10-Abnahme](revit-excel-r10-validation.md) dokumentiert.
|
||||||
|
|
||||||
|
Jedes Paket R1–R10 führt fokussierte Tests sowie aus:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm test
|
||||||
|
npm run build:api
|
||||||
|
npm run build:web
|
||||||
|
npm run typecheck:scripts
|
||||||
|
npx tsc --noEmit -p tsconfig.next.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes Paket erhält einen eigenen knappen imperativen Commit. Die konkrete
|
||||||
|
Paketfolge und ihre Freigabepunkte stehen im
|
||||||
|
[R0–R10-Plan](revit-excel-phase-14-plan.md).
|
||||||
237
docs/spec/revit-excel-phase-14-plan.md
Normal file
237
docs/spec/revit-excel-phase-14-plan.md
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
# Phase 14 neu – Plan für die Linear-Excel-Anbindung
|
||||||
|
|
||||||
|
Verbindliche Fachgrundlage:
|
||||||
|
[Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md).
|
||||||
|
Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Paket | Status | Ergebnis / Freigabepunkt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
|
||||||
|
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
|
||||||
|
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
|
||||||
|
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
|
||||||
|
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
|
||||||
|
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
|
||||||
|
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
|
||||||
|
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
|
||||||
|
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
||||||
|
| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
|
||||||
|
| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. |
|
||||||
|
|
||||||
|
## R0 – Audit
|
||||||
|
|
||||||
|
Ergebnis: [R0-Audit](revit-excel-r0-audit.md). Die Freigabe vom 29.09.2026
|
||||||
|
enthält 13 Korrekturen. Bei Abweichungen zwischen dem historischen Audit und
|
||||||
|
der aktuellen Anforderung gilt das neue Anforderungsdokument.
|
||||||
|
|
||||||
|
## R1 – Dokumentation
|
||||||
|
|
||||||
|
Lieferumfang:
|
||||||
|
|
||||||
|
- neues verbindliches Excel-Anforderungsdokument;
|
||||||
|
- dieser R0–R10-Plan;
|
||||||
|
- alte CSV-Anforderungen und alter Plan als kurze Ablösungshinweise;
|
||||||
|
- LLM-Kontext auf die neue Quelle der Wahrheit kürzen;
|
||||||
|
- `AGENTS.md`, aktuelle Architektur, History-/External-Model-Architektur,
|
||||||
|
Roadmap, README, Dokumentationsindex und bekannte Einschränkungen anpassen;
|
||||||
|
- reale Musterdatei per `.gitignore` vom Commit ausschließen;
|
||||||
|
- alle internen Links auf die neuen Dokumente umstellen.
|
||||||
|
|
||||||
|
Keine Laufzeit-, Schema- oder UI-Änderung. Abnahme: keine aktive Dokumentation
|
||||||
|
nennt CSV/IfcGUID als künftigen Transport beziehungsweise Schlüssel; der
|
||||||
|
aktuell noch vorhandene Legacy-Code bleibt als solcher klar benannt.
|
||||||
|
|
||||||
|
## R2 – XLSX-Transportadapter
|
||||||
|
|
||||||
|
Dateien/Module: neues `src/external-model/xlsx`, synthetische Testfixtures und
|
||||||
|
ein auf XLSX umgestellter Referenzprüfer mit Dateipfadargument. Eine neue
|
||||||
|
direkte Bibliothek (`exceljs`, vorbehaltlich der Tests), keine DB-Änderung.
|
||||||
|
|
||||||
|
Abnahme:
|
||||||
|
|
||||||
|
- Kopfzeilenkommentare, Blattname, Spaltenbreiten, Formate und Zelltypen
|
||||||
|
überleben einen strukturellen Roundtrip;
|
||||||
|
- erste Datenzeile/-spalte und Mappings funktionieren ohne separates
|
||||||
|
`layout.idColumn`;
|
||||||
|
- Worksharing-ID/IfcGUID werden exakt und case-sensitiv behandelt;
|
||||||
|
- Zeile ohne ID und ohne `Familie und Typ` wird übersprungen, die Muster-
|
||||||
|
Summenzeile B490 blockiert nicht;
|
||||||
|
- Zeile ohne ID mit `Familie und Typ` erzeugt Hinweis und wird übersprungen;
|
||||||
|
- maximal 10 MiB, nur `.xlsx`, Zeilen-/Spaltengrenzen nach dem Einlesen;
|
||||||
|
- keine Formelausführung;
|
||||||
|
- lokale Referenzprüfung bestätigt 487 Objekte und keine ID-Duplikate;
|
||||||
|
- aus der realen Musterdatei entsteht lokal eine nicht versionierte
|
||||||
|
Rückgabedatei mit einigen geänderten Werten.
|
||||||
|
|
||||||
|
Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein
|
||||||
|
erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter
|
||||||
|
OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert.
|
||||||
|
|
||||||
|
Implementierungsstand vom 30.09.2026: Der Adapter liegt unter
|
||||||
|
`src/external-model/xlsx`, der Referenzprüfer unter
|
||||||
|
`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei
|
||||||
|
die ignorierte Datei
|
||||||
|
`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei
|
||||||
|
enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte
|
||||||
|
Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen,
|
||||||
|
Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte
|
||||||
|
ID-Vergleiche und Grenzen ab.
|
||||||
|
|
||||||
|
Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der
|
||||||
|
Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete
|
||||||
|
Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer;
|
||||||
|
ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten
|
||||||
|
Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete
|
||||||
|
Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im
|
||||||
|
fachlich begrenzten R2-Paket aktualisiert.
|
||||||
|
|
||||||
|
## R3 – Datenmodell und Backend-Cutover
|
||||||
|
|
||||||
|
- Konfiguration ohne eigene Versionszählung;
|
||||||
|
- `worksharing_id NOT NULL`, Unique je Quelle; `ifc_guid` nullable ohne Unique;
|
||||||
|
- additive Migration nach vorherigem DB-Backup; alte externe Entwicklungsdaten
|
||||||
|
verwerfen und Row-Mengen vorher auf `manualQuantity` zurücksetzen;
|
||||||
|
- Batch-Aufbewahrung: nur letzter Batch vollständig, ältere nur Metadaten;
|
||||||
|
- Snapshot 10, Restore und Transfer; alte CSV-Zustände/History klar ablehnen;
|
||||||
|
- Backend-CSV-Parser, Endpunkte und Verträge entfernen;
|
||||||
|
- Apply parst erneut; Undo/Redo verwenden ausschließlich gespeicherte
|
||||||
|
Vorher-/Nachher-Zustände.
|
||||||
|
|
||||||
|
Risiken: Migration mit verknüpften Row-Mengen, alte History-Spitzen und
|
||||||
|
vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
|
||||||
|
|
||||||
|
## R4 – Revit-Seite und Einstellungen
|
||||||
|
|
||||||
|
- Route `/projects/:projectId/revit` und Navigationseintrag;
|
||||||
|
- Status-/Aktionsgrundgerüst;
|
||||||
|
- Einstellungsmodal mit Musterdateierkennung, Layout und Feldzuordnung;
|
||||||
|
- Stromkreismodus `combined | separate`;
|
||||||
|
- Projektseiten-Button und `revit-csv-modal.tsx` entfernen;
|
||||||
|
- nur deutsche UI-Texte und bestehende Icon-/Kartenregeln.
|
||||||
|
|
||||||
|
## R5 – Erstimport
|
||||||
|
|
||||||
|
- Vorschau und Plan mit erneuter Dateiübertragung beim Apply;
|
||||||
|
- Räume, Verteiler, Gerätearten, exakte Familienregeln und optionale
|
||||||
|
ProjectDevices;
|
||||||
|
- fehlender GZF als sichtbarer Startwert `1`, ohne Zusatzbestätigung;
|
||||||
|
- Warn-/Überspringregel für ID-lose Zeilen;
|
||||||
|
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
|
||||||
|
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
|
||||||
|
|
||||||
|
Implementierungsstand vom 30.09.2026: Vorschau und Apply verwenden die
|
||||||
|
projektweite XLSX-Konfiguration; Apply überträgt und parst die Datei erneut und
|
||||||
|
vergleicht den SHA-256 der Vorschau. Der Wizard führt über Vorschau, Räume,
|
||||||
|
Standardverteiler einschließlich Objekten ohne Raum, exakte Familienregeln,
|
||||||
|
optionale ProjectDevices und Bestätigung. Unbekannte Familien können als
|
||||||
|
Geräteart mit stabilen Client-UUIDs und sichtbarem GZF-Startwert 1 klassifiziert
|
||||||
|
werden. `external-model.apply-initial-import` schreibt Konfiguration, Quelle,
|
||||||
|
vollständigen letzten Batch, Raumzuordnungen und Objekte in einer Revision;
|
||||||
|
alle `circuitDeviceRowId` bleiben `null`. Seine Inverse speichert beide
|
||||||
|
Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen.
|
||||||
|
|
||||||
|
## R6 – Objekttabelle und Sammelaktionen
|
||||||
|
|
||||||
|
- kompakte Objektliste einmal laden;
|
||||||
|
- clientseitig suchen, filtern und sortieren;
|
||||||
|
- virtualisierte Darstellung ohne Server-Pagination;
|
||||||
|
- stabile Mehrfachauswahl und Sammelaktionen für Verteiler, ProjectDevice und
|
||||||
|
Objektplanungswerte;
|
||||||
|
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
|
||||||
|
Objekte folgen unverändert ihrer Row.
|
||||||
|
|
||||||
|
Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert
|
||||||
|
eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite
|
||||||
|
sucht, filtert und sortiert vollständig im Client und rendert nur den
|
||||||
|
sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler,
|
||||||
|
ProjectDevice, Leistung und GZF in einem persistenten
|
||||||
|
`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese
|
||||||
|
Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
|
||||||
|
|
||||||
|
## R7 – Stromkreislisteneditor
|
||||||
|
|
||||||
|
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
|
||||||
|
- Drop auf Row, Circuit oder freien Bereich über vorhandene, angepasste
|
||||||
|
Commands;
|
||||||
|
- Row-Indikator, Objektanzeige und Lösen mit Übernahme aktueller Row-Werte;
|
||||||
|
- Row-/Circuit-/Gruppen-Löschcommands lösen verknüpfte Objekte atomar und
|
||||||
|
zeigen `X Revit-Objekte werden wieder unzugeordnet`;
|
||||||
|
- Undo stellt Objektlinks exakt wieder her;
|
||||||
|
- nur Raum-/Verteilerlöschung mit externen Referenzen bleibt gesperrt.
|
||||||
|
|
||||||
|
## R8 – Rückgabe-Excel
|
||||||
|
|
||||||
|
- alle / abweichende / ausgewählte Objekte;
|
||||||
|
- zuletzt importiertes Workbook als strukturelle Grundlage;
|
||||||
|
- Stromkreisfelder des gewählten Modus sind Pflicht;
|
||||||
|
- fehlende optionale Bezeichnung-/Leistung-/Spannung-/Stromspalten nur als
|
||||||
|
Hinweis überspringen;
|
||||||
|
- typisierte Zahlen, gemeinsame Export-/Abweichungsprojektion;
|
||||||
|
- append-only Exportnachweis ohne Projektrevision.
|
||||||
|
|
||||||
|
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
|
||||||
|
|
||||||
|
Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
|
||||||
|
aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
|
||||||
|
Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
|
||||||
|
Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
|
||||||
|
Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
|
||||||
|
Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
|
||||||
|
|
||||||
|
## R9 – Folgeimport
|
||||||
|
|
||||||
|
- exaktes, case-sensitives Matching nach Worksharing-ID;
|
||||||
|
- neu, geändert, unverändert, fehlend und Rückgabefeldabweichungen;
|
||||||
|
- Entscheidungen nur für fachliche Quelländerungen;
|
||||||
|
- akzeptierte Mengenänderungen und Row-Summen atomar;
|
||||||
|
- fehlende Objekte nicht automatisch lösen oder löschen;
|
||||||
|
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
||||||
|
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
|
||||||
|
|
||||||
|
Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige
|
||||||
|
XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend
|
||||||
|
und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert
|
||||||
|
Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst
|
||||||
|
die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe
|
||||||
|
atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre
|
||||||
|
Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine
|
||||||
|
gesondert bestätigte Aktion kann sie anschließend mit passender
|
||||||
|
Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen;
|
||||||
|
Undo/Redo liest keine XLSX-Datei.
|
||||||
|
|
||||||
|
## R10 – Härtung
|
||||||
|
|
||||||
|
Ende-zu-Ende-Test:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Import → Objektplanung → Row-Zuordnung → Rückgabe → Folgeimport
|
||||||
|
→ Snapshot/Restore → Projektduplikat → Undo/Redo nach Neustart
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI,
|
||||||
|
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
|
||||||
|
alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand.
|
||||||
|
|
||||||
|
Umsetzungsstand vom 30.09.2026: Der dateibasierte Integrationstest deckt die
|
||||||
|
vollständige Kette bis zu Undo/Redo nach einem Datenbankneustart ab. Ein
|
||||||
|
synthetisches Workbook mit 5.000 Objekten durchläuft Erst- und Folgeimport,
|
||||||
|
Snapshot-Restore, Projektduplikat sowie die extrahierte Clientfilter- und
|
||||||
|
Virtualisierungslogik. Die dabei gefundene SQLite-Grenze ungebatchter Inserts
|
||||||
|
ist für alle vollständigen External-Model-Zustandspfade behoben. Messwerte und
|
||||||
|
Abnahme stehen in [R10-Abnahme](revit-excel-r10-validation.md). Phase 14 ist
|
||||||
|
damit abgeschlossen.
|
||||||
|
|
||||||
|
## Pflichtprüfungen je Paket R1–R10
|
||||||
|
|
||||||
|
```text
|
||||||
|
fokussierte Tests
|
||||||
|
npm test
|
||||||
|
npm run build:api
|
||||||
|
npm run build:web
|
||||||
|
npm run typecheck:scripts
|
||||||
|
npx tsc --noEmit -p tsconfig.next.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes Paket wird mit einer knappen imperativen Commit-Message abgeschlossen.
|
||||||
528
docs/spec/revit-excel-r0-audit.md
Normal file
528
docs/spec/revit-excel-r0-audit.md
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
# R0 – Audit der Linear-Excel-Anbindung
|
||||||
|
|
||||||
|
Stand: 29.09.2026. Geprüfter Code: `cf022b2`.
|
||||||
|
|
||||||
|
**Status: R0 abgeschlossen und mit Korrekturen für R1 freigegeben.** Dieser Bericht
|
||||||
|
ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten
|
||||||
|
Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und
|
||||||
|
die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0
|
||||||
|
ändert weder Laufzeitcode noch Datenbank noch Oberfläche.
|
||||||
|
|
||||||
|
Die 13 Freigabekorrekturen vom 29.09.2026 sind in den
|
||||||
|
[verbindlichen Anforderungen](revit-excel-integration-requirements.md) und im
|
||||||
|
[R0–R10-Plan](revit-excel-phase-14-plan.md) eingearbeitet. Bei Abweichungen
|
||||||
|
gelten diese beiden R1-Dokumente.
|
||||||
|
|
||||||
|
## 1. Geprüfter Bestand und Keep/Change/Remove
|
||||||
|
|
||||||
|
Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`,
|
||||||
|
`docs/project-history-and-external-model-architecture.md`,
|
||||||
|
`docs/spec/revit-csv-integration-requirements.md` und
|
||||||
|
`docs/spec/revit-csv-phase-14-audit-and-plan.md`. Der alte Plan widerspricht
|
||||||
|
sich bereits intern: Sein Status nennt die Erstübernahme noch nicht vorhanden,
|
||||||
|
während Implementierung und Tests sie nachweisen. Alte Erledigt-Markierungen
|
||||||
|
werden deshalb nicht übernommen.
|
||||||
|
|
||||||
|
| Bereich / Codebeleg | Entscheidung | Begründung und Änderung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/db/repositories/project-command-transaction.persistence.ts` | Keep | Eine Grenze für Fachänderung, Inverse, Revision und History; auch automatische Snapshots bleiben darin. |
|
||||||
|
| `src/db/repositories/external-model-state.repository.ts`, `src/db/schema/external-*.ts` | Change | Quelle, Batches, Raum-Mappings und Objekte wiederverwenden; neue Identität, Konfiguration und typisierte Matrix. Schlanke Objekt-Reads von großen Batch-Blobs trennen. |
|
||||||
|
| `src/domain/models/external-initial-import-project-command.model.ts`, zugehöriger Store | Change | Vollständiger erwarteter/Zielzustand und Row-freier Import bleiben; Worksharing-ID, Gerätearten und atomare Regelübernahme ergänzen. |
|
||||||
|
| `src/external-model/application/external-initial-import-plan.ts`, `external-initial-import-target.ts` | Change | Raumvorschläge, Kataloge, vollständige Entscheidungen und stabile UUIDs behalten. Objektentscheidungen und Gerätearten ergänzen. |
|
||||||
|
| `src/db/repositories/external-object-assignment.persistence.ts` und drei Zuordnungs-Stores | Change | Mengeninvariante und exakte Inversen behalten; Geräteart, effektiven Verteiler, Anwesenheit und Übernahme der Row-Werte beim Lösen ergänzen. |
|
||||||
|
| `CircuitDeviceRow.manualQuantity`, bestehende Mengenprüfungen | Keep | `quantity = manualQuantity + Summe(externer effektiver Mengen)`; Mehrfachsteckdosen bleiben unteilbar. |
|
||||||
|
| `src/external-model/application/external-circuit-list-objects.ts` | Change | Listen-/Projektprüfung behalten; vollständige Planungswerte, Geräteart, effektiver Verteiler, Status und Row-Anzahlen liefern. |
|
||||||
|
| `src/domain/models/project-state-snapshot.model.ts`, Snapshot-Persistenz und Transfer | Change | Schema 10; historische CSV-Zustände ausdrücklich ablehnen; alle neuen internen Referenzen remappen. |
|
||||||
|
| `src/external-model/csv/*`, CSV-Preview, CSV-Validierungen und Controller | Remove/replace | XLSX ersetzt CSV vollständig; kein zweiter aktiver Transportpfad. |
|
||||||
|
| `src/frontend/components/revit-csv-modal.tsx`, Einbindung auf Projektseite | Remove/replace | Eigene Revit-Seite; Einstellungen nur im Modal, Import in eigenem Wizard. |
|
||||||
|
| `src/frontend/components/Sidebar.tsx`, `src/frontend/utils/api.ts`, `src/frontend/types.ts` | Change | Route `revit`, neue DTOs und Endpunkte, CSV-Imports entfernen. |
|
||||||
|
| `src/frontend/components/circuit-tree-editor.tsx`, Grid-Drop-/Projektionsmodule | Change | Separater externer Drag-Payload; Row-, Circuit- und Freiplatz-Ziele bewusst unterscheiden. Bestehende Grid-Bedienung behalten. |
|
||||||
|
| Raum-/ProjectDevice-Formulare und Commands | Keep | Sichtbar vorbelegte, ausdrücklich bestätigte Anlage als eigener Undo-Schritt. |
|
||||||
|
| `scripts/verify-revit-reference-csv.ts`, Transporttests | Replace | Pfadargument für lokalen XLSX-Prüfer; ausschließlich synthetische Workbooks in Tests. |
|
||||||
|
|
||||||
|
### Konkrete Lücken gegenüber dem Auftrag
|
||||||
|
|
||||||
|
- `external-model-contracts.ts` importiert heute CSV-Verträge. Die neutralen
|
||||||
|
Konfigurations-/Matrixverträge gehören künftig in `external-model/domain`;
|
||||||
|
nur `external-model/xlsx` darf ExcelJS kennen.
|
||||||
|
- `ifc_guid` ist derzeit NOT NULL und je Quelle eindeutig; Matching,
|
||||||
|
Snapshots, DTOs und Commands setzen diese Identität voraus.
|
||||||
|
- `internalDeviceType` ist derzeit ein freier String in Planungswerten.
|
||||||
|
Es gibt keinen Geräteart-Katalog mit stabilen IDs und Standardwerten.
|
||||||
|
- Der Erstimport setzt den ProjectDevice-Link, verwendet im Target aber
|
||||||
|
weiterhin `suggestedPlanningValues`. Die neue Priorität ProjectDevice vor
|
||||||
|
Geräteart muss ausdrücklich implementiert werden; ein Link allein genügt
|
||||||
|
nicht. Revit-Leistung wird künftig nicht automatisch zum Planungswert.
|
||||||
|
- Der Import-Target setzt `distributionBoardId = null`; die Raumvorgabe liegt
|
||||||
|
separat im Mapping. Projektion und Zuordnungsprüfung vergleichen heute
|
||||||
|
direkt das Objektfeld. Eine zentrale Auflösung des effektiven Verteilers
|
||||||
|
fehlt und ist vor der Editor-Nutzung erforderlich.
|
||||||
|
- Die bisherige Row-Zuordnung darf laut Validator am Objekt ausschließlich
|
||||||
|
`circuitDeviceRowId` ändern; der Store schreibt auch nur dieses Feld.
|
||||||
|
Lösen mit aktuellen Row-Planungswerten benötigt eine neue Command-Version.
|
||||||
|
- Die Kompatibilitätsprüfung vergleicht Raum, Kategorie, Auswahlkenner und
|
||||||
|
Planungswerte, aber keine stabile Geräteart. Unterschiedliche Gerätearten
|
||||||
|
dürfen nicht versehentlich in dieselbe Row aggregiert werden.
|
||||||
|
- Rows speichern `powerPerUnit` in **kW**, externe Planung
|
||||||
|
`powerPerUnitW` in **W**. Eine zentrale bidirektionale Konvertierung ist
|
||||||
|
nötig; Exportleistung darf nicht um Faktor 1000 falsch sein.
|
||||||
|
- Externe FKs auf Row, ProjectDevice, Raum-Mapping und Verteiler verwenden
|
||||||
|
teilweise `ON DELETE SET NULL`. Bestehende allgemeine Löschpfade sichern
|
||||||
|
externe Objektübergänge nicht durchgängig in ihren Inversen. Auch
|
||||||
|
Unterbaum-/History-Löschungen müssen abgesichert werden.
|
||||||
|
- Ein Editor-Drawer, projektweite Objekttabelle, Exportnachweis und
|
||||||
|
ausführbarer Folgeimport fehlen. `follow_up` als vorhandener Enumwert ist
|
||||||
|
kein implementierter Folgeimport.
|
||||||
|
- Konfigurationsversionen steigen heute nur bei normalen Updates gegenüber
|
||||||
|
dem aktuellen Wert; Undo setzt alte Versionen zurück. Für eine tatsächlich
|
||||||
|
monotone Generation muss diese Semantik korrigiert werden.
|
||||||
|
- XLSX-Parsing ist asynchron. Das heutige erneute synchrone CSV-Parsing
|
||||||
|
innerhalb der SQLite-Transaktion darf nicht einfach durch `await` ersetzt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
## 2. Prüfung der lokalen Musterdatei
|
||||||
|
|
||||||
|
`docs/spec/kbp-Stromkreis.xlsx` wurde ausschließlich lesend auf ZIP-/OOXML-
|
||||||
|
Ebene untersucht; keine Inhalte wurden hochgeladen oder in Fixtures kopiert.
|
||||||
|
Die Datei ist unversioniert und darf nicht committed werden.
|
||||||
|
|
||||||
|
| Befund | Ergebnis |
|
||||||
|
| --- | --- |
|
||||||
|
| Dateigröße / entpackte ZIP-Einträge | 56.765 / 337.740 Bytes |
|
||||||
|
| Arbeitsblätter / Blattnamenlänge | 1 / 31 Zeichen |
|
||||||
|
| Zellbereich | A1:O490 |
|
||||||
|
| Kopfzeile / Leerzeile / Objekte / Abschluss | 1 / 2 / 3–489 / 490 |
|
||||||
|
| Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate |
|
||||||
|
| Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte |
|
||||||
|
| Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname |
|
||||||
|
| IfcGUID-Duplikate | Keine bei exaktem, case-sensitivem Vergleich |
|
||||||
|
| Formeln | Keine; synthetische Tests dafür erforderlich |
|
||||||
|
| Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind |
|
||||||
|
|
||||||
|
Das beobachtete Profil aus dem Auftrag wird damit bestätigt. Zusätzliche
|
||||||
|
Spaltenformatdefinitionen dürfen beim Export nicht auf die gemappten Spalten
|
||||||
|
gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und
|
||||||
|
die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie
|
||||||
|
dürfen nicht ohne Linear-Parameterbindung erfunden werden.
|
||||||
|
|
||||||
|
Die frühere Angabe von zwei IfcGUID-Duplikatgruppen war falsch: PowerShells
|
||||||
|
standardmäßiges Gruppieren verglich ohne Beachtung der Groß-/Kleinschreibung.
|
||||||
|
Ein erneuter ordinaler, case-sensitiver Vergleich ergab keine Duplikate. Die
|
||||||
|
Summenzeile hat nur in B490 einen Wert; `Familie und Typ` ist leer.
|
||||||
|
|
||||||
|
Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue
|
||||||
|
Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 endet
|
||||||
|
deshalb mit einer aus der Musterdatei erzeugten Rückgabedatei und einem
|
||||||
|
verbindlichen Linear-Test durch den Nutzer vor R3.
|
||||||
|
|
||||||
|
## 3. Bibliothek, Adapter und Uploadgrenzen
|
||||||
|
|
||||||
|
**Vorschlag: genau eine neue direkte Abhängigkeit `exceljs`.** Seine
|
||||||
|
öffentliche API unterstützt XLSX, Zellnotizen, Formate, Spaltenbreiten und
|
||||||
|
typisierte Werte. Das passt zum beobachteten klassischen Kommentarformat.
|
||||||
|
Version und transitive Abhängigkeiten werden in R2 festgeschrieben und
|
||||||
|
geprüft. Die veröffentlichte Version ist laut Paketregister 4.4.0; eine
|
||||||
|
aktuelle Releasepflege ist damit nicht bewiesen. Dies ist ein ausdrückliches
|
||||||
|
Wartungsrisiko, kein als erfüllt abgehakter Bibliotheksnachweis.
|
||||||
|
|
||||||
|
Quellen, am 29.09.2026 geprüft:
|
||||||
|
[ExcelJS-Projekt](https://github.com/exceljs/exceljs),
|
||||||
|
[offizielle Paketdokumentation](https://www.npmjs.com/package/exceljs).
|
||||||
|
Keine Bibliothek wird in R0 installiert. R2 muss vor Integration Kommentar-
|
||||||
|
Roundtrip, Node-24-Verträglichkeit und Abhängigkeitsrisiken prüfen; bei einem
|
||||||
|
Blocker wird die Bibliotheksentscheidung neu vorgelegt.
|
||||||
|
|
||||||
|
Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet:
|
||||||
|
|
||||||
|
- ausschließlich `.xlsx`, kein `.xls` oder `.xlsm`;
|
||||||
|
- maximal 10 MiB Upload;
|
||||||
|
- genau ein Arbeitsblatt und keine Formelausführung;
|
||||||
|
- feste Zeilen-, Spalten- und Zellgrenzen nach dem Einlesen.
|
||||||
|
|
||||||
|
Es gibt keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
|
||||||
|
|
||||||
|
Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten
|
||||||
|
(Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete
|
||||||
|
Zeilen mit Originalzeilennummer und Klasse sowie Zellen als diskriminierte
|
||||||
|
Union `string | number | boolean | empty`. Formeltext und Cache-Status sind
|
||||||
|
optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert.
|
||||||
|
Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern
|
||||||
|
einen Blocker. Originalbytes bleiben unverändert gespeichert.
|
||||||
|
|
||||||
|
Eine Zeile ohne Worksharing-ID ist nur verdächtig, wenn `Familie und Typ`
|
||||||
|
gefüllt ist. Sie erzeugt einen Hinweis und wird übersprungen, ohne zu
|
||||||
|
blockieren. Andere ID-lose Zeilen, auch die Summenzeile mit Inhalt in B,
|
||||||
|
werden still übersprungen. Nicht passende nichtleere IDs blockieren.
|
||||||
|
Worksharing-ID und IfcGUID bleiben als Quelltext erhalten und werden exakt,
|
||||||
|
ordinal und case-sensitiv verglichen; ungültige äußere Leerzeichen werden
|
||||||
|
gemeldet statt still korrigiert. IfcGUID wird optional gespeichert, doppelte
|
||||||
|
Werte sind nur Hinweise.
|
||||||
|
Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte
|
||||||
|
Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein.
|
||||||
|
|
||||||
|
Rückgabe lädt eine Kopie der Originalbytes, bewahrt den Bereich oberhalb der
|
||||||
|
ersten Datenzeile samt Kommentaren, Formatierung und Spaltenbreiten und
|
||||||
|
ersetzt den Datenbereich durch gewählte Objektzeilen in Originalreihenfolge.
|
||||||
|
Alle Spalten bleiben enthalten; Summenzeilen entfallen. Unveränderte Zellen
|
||||||
|
behalten ihre importierten Werte und Typen. Formeln in Objektzeilen werden
|
||||||
|
als gecachte Werte ausgegeben, damit Zeilenauswahl keine falschen relativen
|
||||||
|
Bezüge erzeugt; dies wird vor Export als Hinweis ausgewiesen. Keine Formeln
|
||||||
|
werden berechnet. Roundtrip bedeutet strukturelle/semantische Gleichheit,
|
||||||
|
nicht identische ZIP-Bytes.
|
||||||
|
|
||||||
|
## 4. Konfigurationsschema
|
||||||
|
|
||||||
|
Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
layout: {
|
||||||
|
headerRow: 1, firstDataRow: 3, firstDataColumn: 1
|
||||||
|
},
|
||||||
|
circuitMode: "combined" | "separate",
|
||||||
|
columns: {
|
||||||
|
worksharingId: string, familyAndType: string,
|
||||||
|
ifcGuid: string | null, roomNumber: string | null,
|
||||||
|
roomName: string | null, selectionMarker: string | null,
|
||||||
|
socketCount: string | null, sourceRemark: string | null,
|
||||||
|
circuitCombined: string | null, boardName: string | null,
|
||||||
|
circuitIdentifier: string | null, designation: string | null,
|
||||||
|
electricalPowerW: string | null, voltageV: string | null,
|
||||||
|
currentA: string | null
|
||||||
|
},
|
||||||
|
deviceTypes: [{
|
||||||
|
id: UUID, name: string,
|
||||||
|
category: "lighting" | "single_phase" | "three_phase",
|
||||||
|
defaults: {
|
||||||
|
powerPerUnitW: number | null, simultaneityFactor: number | null,
|
||||||
|
cosPhi: number | null, costGroup: string | null
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
familyTypeRules: [{
|
||||||
|
id: UUID, exactFamilyAndType: string, deviceTypeId: UUID,
|
||||||
|
quantityRule: "one" | "socketCount"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar.
|
||||||
|
Es gibt nur `columns.worksharingId` und kein separates `layout.idColumn`.
|
||||||
|
Jede gemappte Kopfzeile muss eindeutig auflösbar sein;
|
||||||
|
doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig,
|
||||||
|
aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten
|
||||||
|
Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem
|
||||||
|
Auftrag. Unbekannte Spalten bleiben ohne Mapping erhalten.
|
||||||
|
|
||||||
|
Geräteart-IDs sind interne UUIDs, keine Namen als Relationsschlüssel. Regeln
|
||||||
|
sind exakt und je Familien-/Typtext eindeutig; jede importierte Familie muss
|
||||||
|
eine Geräteart haben. Gerätearten und Regeln werden als ein versioniertes
|
||||||
|
Konfigurationsaggregat gespeichert. Keine zusätzliche Gerätearttabelle ist
|
||||||
|
für diesen Umfang nötig; Validatoren erzwingen referenzielle Integrität.
|
||||||
|
Referenzierte Gerätearten können nicht kommentarlos entfernt werden.
|
||||||
|
|
||||||
|
Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename:
|
||||||
|
Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt
|
||||||
|
Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung.
|
||||||
|
Fehlender GZF wird sichtbar mit `1` vorbelegt, ohne zusätzliche Bestätigung.
|
||||||
|
Geräteart-
|
||||||
|
Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um.
|
||||||
|
|
||||||
|
## 5. Konkretes Persistenzschema
|
||||||
|
|
||||||
|
Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten.
|
||||||
|
`JSON` bezeichnet validierten JSON-Text in SQLite, nicht ExcelJS-Objekte.
|
||||||
|
|
||||||
|
| Tabelle | Zielspalten / Constraints |
|
||||||
|
| --- | --- |
|
||||||
|
| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat; keine eigene Versionszählung. |
|
||||||
|
| `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. |
|
||||||
|
| `external_import_batches` | Letzter Batch vollständig mit IDs, Importart, Zeit, Dateiname, SHA-256, Objektzahl, `applied_project_revision`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; ältere Batches nur als Metadaten Datum, Dateiname, SHA-256 und Objektzahl. |
|
||||||
|
| `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. |
|
||||||
|
| `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. |
|
||||||
|
| Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. |
|
||||||
|
| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_snapshot/hash`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. |
|
||||||
|
|
||||||
|
`import_batch_id` des Exportnachweises ist absichtlich eine historische UUID
|
||||||
|
ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis
|
||||||
|
nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots
|
||||||
|
und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben.
|
||||||
|
Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1
|
||||||
|
dokumentiert. Nur der letzte Importbatch gehört mit Bytes und Matrix zu
|
||||||
|
Snapshot/Transfer; ältere Batches werden dort nur als Metadaten erhalten.
|
||||||
|
|
||||||
|
Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten;
|
||||||
|
zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`,
|
||||||
|
`linked_project_device_id`, Export `(project_id, exported_at_iso)`.
|
||||||
|
Cross-Project-Links und Geräteart-Verweise prüft jeder Command in der
|
||||||
|
Transaktion; bloße Existenz eines UUID-FK genügt nicht.
|
||||||
|
|
||||||
|
Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften
|
||||||
|
Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die
|
||||||
|
Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden;
|
||||||
|
bei fehlender Vorgabe ist das Objekt ohne Verteiler. Eine Änderung des
|
||||||
|
Raum-Standardverteilers betrifft nur unzugeordnete Objekte; verknüpfte Objekte
|
||||||
|
folgen immer der Row-Verteilung und blockieren diese Änderung nicht.
|
||||||
|
|
||||||
|
Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind
|
||||||
|
Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export
|
||||||
|
verwenden die Row. Beim Lösen werden aktuelle Row-Werte einschließlich
|
||||||
|
Einheitenkonvertierung in die Objektplanung übernommen und als explizite
|
||||||
|
Overrides markiert. Effektive Objektmenge wird nicht durch die Row-Gesamtmenge
|
||||||
|
ersetzt. Die Row und ihr manueller Anteil bleiben erhalten.
|
||||||
|
|
||||||
|
## 6. Commands und Transaktionsgrenze
|
||||||
|
|
||||||
|
Alle öffentlichen Fach-Writes verlangen `expectedRevision`, Anmeldung und
|
||||||
|
`X-Leistungsbilanz-Request: 1`. `expectedRevision` gehört wie bisher zum
|
||||||
|
Ausführungsumschlag. Jede Transition enthält vollständige erwartete/Ziel-
|
||||||
|
Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches.
|
||||||
|
|
||||||
|
| Command | Payload-Skizze und Gegenrichtung |
|
||||||
|
| --- | --- |
|
||||||
|
| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht den vollständigen Fachinhalt; keine eigene Konfigurationsversion. |
|
||||||
|
| `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. |
|
||||||
|
| `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. |
|
||||||
|
| `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. |
|
||||||
|
| `external-model-object.update-distribution-board` v1 | `{ objects: transitions }`; explizite Objektabweichung, Projekt-/Row-Konsistenz. |
|
||||||
|
| `external-model-room-mapping.update` v1 | `{ mappings: transitions }`; interner Raum/Standardverteiler; betroffene Objekt-/Row-Konsistenz mitprüfen. |
|
||||||
|
| `external-model-object.update-project-device-link` v1 | `{ objects: transitions, confirmedPlanningFields }`; keine Vorlagenänderung; neue Startwerte nur für ausdrücklich bestätigte Felder. Gebundene Objekte werden über ihre Row bearbeitet. |
|
||||||
|
| `external-object.update-row-assignment` v2 | `{ rows: transitions, objects: transitions, confirmedConflictObjectIds }`; Linkwechsel, materialisierte Mengen, beim Lösen aktuelle Row-Planungswerte; vollständige Inverse. |
|
||||||
|
| `external-object.assign-to-new-row` v2 | Bestehender vollständiger Row-/Objekt-/Reserveplan mit neuen Objektverträgen; history-only Inverse entfernt nur exakt unveränderte Erzeugnisse. |
|
||||||
|
| `external-object.assign-to-new-circuit` v2 | Bestehender Circuit-/Protection-/Row-/Objektplan; BMK einmal planen und festschreiben, gleiche UUIDs bei Redo. |
|
||||||
|
| `external-model-object.delete-missing` v1 | `{ objects: transitionsToNull, rows: quantityTransitions }`; ausschließlich ausdrücklich bestätigte weiterhin fehlende Objekte, kein Row-/Circuit-Löschen; Inverse stellt IDs, Links und Mengen wieder her. |
|
||||||
|
|
||||||
|
Bestehende `project-device.insert`, Room-/Floor-Commands bleiben eigenständige
|
||||||
|
Undo-Schritte. Familien-/Geräteartentscheidungen im Wizard sind lokale
|
||||||
|
Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater
|
||||||
|
Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu,
|
||||||
|
ohne lokale Entscheidungen zu verlieren.
|
||||||
|
|
||||||
|
Die Konfiguration hat keine eigene Versionszählung. Vorschau und Apply binden
|
||||||
|
sich an `expectedRevision`; weil auch Undo/Redo neue, strikt steigende
|
||||||
|
Projektrevisionen erzeugen, wird ein veralteter Plan eindeutig ungültig.
|
||||||
|
Importbatches speichern eine vollständige Konfigurationskopie. Prüfen:
|
||||||
|
Änderung → Undo → anderer Zweig darf niemals denselben Plan wieder gültig
|
||||||
|
machen.
|
||||||
|
|
||||||
|
XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen
|
||||||
|
Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und
|
||||||
|
Entscheidungen werden erneut validiert. Im Store werden Revision,
|
||||||
|
Konfigurationsinhalt, erwartete Objekte, Links und vollständiger
|
||||||
|
vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client
|
||||||
|
behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht
|
||||||
|
an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo lesen und
|
||||||
|
parsen keine Excel-Datei; sie wenden ausschließlich die gespeicherten
|
||||||
|
Vorher-/Nachher-Zustände an. Keine asynchronen Callbacks in
|
||||||
|
`better-sqlite3`-Transaktionen.
|
||||||
|
|
||||||
|
Row-/Circuit-/Gruppen-Löschcommands erfassen verknüpfte Objekte vollständig.
|
||||||
|
Nach der Warnung `X Revit-Objekte werden wieder unzugeordnet` lösen sie die
|
||||||
|
Links im selben Command; ihre Inverse stellt sie exakt wieder her. Sperren
|
||||||
|
bleiben nur für das Löschen referenzierter Verteiler und Räume und gelten auch
|
||||||
|
in Unterbaum- und History-only-Pfaden.
|
||||||
|
|
||||||
|
## 7. Migration, Snapshot 10, History und Transfer
|
||||||
|
|
||||||
|
Neue additive Migration nach `0013_users` (voraussichtlich `0014`, Nummer
|
||||||
|
beim Beginn von R3 erneut prüfen). Bestehende SQL-/Metadateien unverändert.
|
||||||
|
|
||||||
|
1. Vor R3-Anwendung API-Schreibzugriffe stoppen und `npm run db:backup`
|
||||||
|
ausführen; verifiziertes Backup samt Wiederherstellungsmöglichkeit sichern.
|
||||||
|
R0 verändert keine DB und benötigt daher kein Migrationsbackup.
|
||||||
|
2. Vorhandene externe CSV-Entwicklungsdaten dürfen gemäß Auftrag verworfen
|
||||||
|
werden. Zuerst je verknüpfter Row `quantity = manual_quantity` setzen;
|
||||||
|
sonst bliebe nach Entfernen der externen Objekte eine Phantomlast.
|
||||||
|
Rows, Circuits, manuelle Anteile, UUIDs und BMKs bleiben bestehen.
|
||||||
|
3. Externe Objekte → Mappings → Batches → Quellen → CSV-Konfiguration in
|
||||||
|
FK-sicherer Reihenfolge entfernen. Tabellen gezielt neu aufbauen, wo
|
||||||
|
SQLite NOT-NULL-/Unique-Änderungen dies erfordern. Neue Zieltabellen und
|
||||||
|
Indices erstellen. Keine leeren Worksharing-IDs als Backfill erfinden.
|
||||||
|
4. Generiertes SQL und Drizzle-Metadaten prüfen; saubere und bestehende
|
||||||
|
Test-DBs migrieren; FK-/Integritätsprüfung und Mengenabgleich ausführen.
|
||||||
|
Der Reset ist eine dokumentierte einmalige Entwicklungsmigration,
|
||||||
|
kein stiller Laufzeit-Import.
|
||||||
|
5. Alte Revisionsdaten nicht umschreiben, keine CSV-History heimlich
|
||||||
|
überspringen. An der Ausführungsgrenze alte CSV-Konfigurationscommands,
|
||||||
|
Initial-/Zuordnungscommands v1 einschließlich ihrer History-Inversen und
|
||||||
|
Restores mit CSV-Inhalt vor jedem Write ablehnen.
|
||||||
|
|
||||||
|
Fehlermeldung für History:
|
||||||
|
„Dieser Verlaufsschritt verwendet die frühere CSV-/IfcGUID-Anbindung und
|
||||||
|
kann mit der Excel-Anbindung nicht ausgeführt werden.“
|
||||||
|
Der History-Status sollte den blockierten obersten Schritt samt Grund zeigen;
|
||||||
|
keine automatisch geleerten Stacks und keine Änderung bei Ablehnung. Frühere
|
||||||
|
allgemeine Commands können nach dem Reset zusätzlich an ihren exakten
|
||||||
|
Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung.
|
||||||
|
|
||||||
|
Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle,
|
||||||
|
den letzten Batch mit Originalbytes/Matrix, ältere Batches nur als Metadaten
|
||||||
|
sowie Raum-Mappings, Objekte und Links.
|
||||||
|
Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn
|
||||||
|
`externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer
|
||||||
|
ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration
|
||||||
|
allein zählt als nicht leerer CSV-Zustand und wird nicht still verworfen.
|
||||||
|
Snapshot-Checksumme am gespeicherten Original vor Upgrade prüfen.
|
||||||
|
|
||||||
|
Fehlermeldung:
|
||||||
|
„Dieser Projektstand enthält Daten der früheren CSV-/IfcGUID-Anbindung.
|
||||||
|
Eine automatische Übernahme in die Excel-Anbindung ist nicht möglich.
|
||||||
|
Bitte verwenden Sie einen Projektstand ohne CSV-Daten.“
|
||||||
|
|
||||||
|
Der aktuelle portable Transfer ist bereits **Format 2** (Grobbilanzen),
|
||||||
|
nicht Format 1 wie im alten Audit. Envelope 2 und dessen Format-1-Lesepfad
|
||||||
|
bleiben; enthaltenes Projektzustandsschema wird 10. Duplizieren remappt
|
||||||
|
Projekt-, Konfigurations-, Geräteart-, Regel-, Quellen-, Batch-, Mapping-,
|
||||||
|
Objekt-UUIDs und alle internen Links, auch in Konfigurationssnapshots der
|
||||||
|
Batches. Worksharing-IDs, optionale IfcGUIDs und Originalbytes bleiben
|
||||||
|
unverändert. Grobbilanzen und globale Office-/Auth-Daten behalten ihre
|
||||||
|
bisherige Abgrenzung. Exportnachweise werden nicht als neue Projektexporte
|
||||||
|
dupliziert.
|
||||||
|
|
||||||
|
## 8. Read-Modelle, Oberfläche und Rückgabe
|
||||||
|
|
||||||
|
Vorgeschlagene API-Basis `/api/projects/:projectId/revit`:
|
||||||
|
`configuration` (GET/PUT), `profile/preview` (POST, zustandslos),
|
||||||
|
`initial-import/plan|apply`, `follow-up-import/plan|apply` (POST),
|
||||||
|
`status` und `objects` (GET), `return-workbook` (POST mit Export-Audit).
|
||||||
|
Der bestehende listenbezogene External-Objects-GET bleibt erhalten.
|
||||||
|
Objektmutationen verwenden den vorhandenen Project-Command-Endpunkt mit
|
||||||
|
neuen typisierten Verträgen. CSV-Endpunkte werden entfernt, keine Aliasse.
|
||||||
|
|
||||||
|
Export verwendet `expectedRevision` für einen konsistent gelesenen Stand;
|
||||||
|
die Datei-Erzeugung erfolgt außerhalb des kurzen Read-Snapshots. Danach
|
||||||
|
separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt.
|
||||||
|
Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung,
|
||||||
|
nicht einen tatsächlich abgeschlossenen Browserdownload.
|
||||||
|
|
||||||
|
Die Revit-Seite lädt einmal eine kompakte Objektliste. Filterung und Sortierung
|
||||||
|
laufen im Client; die Tabelle wird virtualisiert und verwendet keine Server-
|
||||||
|
Pagination. Filter-/Sortierzustand, UUID-Auswahl und Sammelaktionen sind
|
||||||
|
unabhängig vom DOM. „Alle gefilterten“ wird vor einer Mutation zu einer
|
||||||
|
expliziten ID-Auswahl an der aktuellen Revision aufgelöst. Große Auswahlmengen
|
||||||
|
werden nicht in getrennte Undo-Schritte aufgeteilt. Kein vollständiges
|
||||||
|
Batch-Blob im DTO.
|
||||||
|
|
||||||
|
Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen,
|
||||||
|
Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln
|
||||||
|
entstehen im Wizard und sind auf der Seite einklappbar nachbearbeitbar.
|
||||||
|
Fehlende Raumangaben bleiben zulässig, Board-Zuordnung ist auch ohne Raum
|
||||||
|
möglich. ProjectDevice-Zuordnung bleibt optional.
|
||||||
|
|
||||||
|
Rechter Editor-Drawer: nur vorhandene, ungebundene konfliktfreie Objekte des
|
||||||
|
effektiven Verteilers. Gruppierung nach internem Raum, Auswahlkenner,
|
||||||
|
Geräteart-ID, Kategorie und identischen Planungswerten; unterschiedliche
|
||||||
|
ProjectDevice-Links bleiben ebenfalls getrennt, da eine Row nur einen Link
|
||||||
|
haben kann. Effektive Einzelmengen sind kein Gleichheitsfeld: Einfach- und
|
||||||
|
Mehrfachsteckdosen sollen gemeinsam gruppierbar sein. Anzeige enthält
|
||||||
|
Mengensumme und Objektanzahl. Fachlich heterogene Auswahl darf nicht
|
||||||
|
willkürlich auf eine einzelne Row zusammenfallen; Konfliktanzeige verlangt
|
||||||
|
eine kompatible Auswahl. Row-Indikator öffnet Objekte und explizites Lösen.
|
||||||
|
|
||||||
|
Eine Funktion `getRevitBoardName(board)` liefert ausschließlich `board.name`.
|
||||||
|
Gemeinsam: Name + unverändertes `equipmentIdentifier`, ohne Separator.
|
||||||
|
Getrennt: Name und BMK in eigene Parameter. Ohne Circuit bleiben beide
|
||||||
|
Stromkreiswerte leer, auch wenn ein Planungsverteiler bekannt ist.
|
||||||
|
|
||||||
|
Exportprojektion und Abweichungsanzeige teilen dieselbe reine Funktion:
|
||||||
|
Row-Anzeigename/-Leistung bei Link, sonst Objektplanung; Objektleistung in W
|
||||||
|
= effektive Menge × Einzelleistung (Row-kW vorher × 1000), ohne GZF.
|
||||||
|
Spannung vom Circuit, sonst Projektstandard passend zur Kategorie.
|
||||||
|
Strom aus Objektleistung, U, cosPhi und Phasenart; Drehstrom mit Faktor √3,
|
||||||
|
auf 0,01 A. Fehlende/ungültige Werte ergeben leer plus Hinweis. Nullleistung
|
||||||
|
bleibt bei gültigem Nenner numerisch 0. Kein still angenommenes cosPhi.
|
||||||
|
Abweichungsvergleich normalisiert Zahlpräzision, Leere und skalare Typen
|
||||||
|
gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand.
|
||||||
|
|
||||||
|
Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite
|
||||||
|
Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden
|
||||||
|
nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene
|
||||||
|
Parameter behalten importierte Werte. Fehlende optionale Spalten für
|
||||||
|
Bezeichnung, Leistung, Spannung und Strom werden mit Hinweis übersprungen.
|
||||||
|
Nur fehlende Stromkreisspalten des gewählten Modus blockieren. Dateiname gemäß
|
||||||
|
Auftrag mit bereinigtem Projektnamen und Tagesdatum.
|
||||||
|
|
||||||
|
Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert
|
||||||
|
immer den vollständigen letzten Quellstand. Unveränderte Objekte werden
|
||||||
|
nicht allein zum Fortschreiben von `lastSeenImportBatchId` neu geschrieben;
|
||||||
|
aktuelle Sichtbarkeit/Quellwerte kommen aus Batch/Matrix. Akzeptierte
|
||||||
|
Planungsgrundlage und letzte importierte Beobachtung sind getrennt.
|
||||||
|
Raum-/Familien-/Marker-/Mengenänderungen brauchen Entscheidungen. Bei
|
||||||
|
unvereinbarer Row-Zuordnung blockiert die Übernahme bis zum ausdrücklichen
|
||||||
|
Lösen/Umordnen, statt Rows automatisch zu erzeugen. Übernommene Mengen
|
||||||
|
ändern alle betroffenen Row-Summen im selben Importcommand. Fehlend setzt
|
||||||
|
nur den Status, erhält vorerst Link und Menge. Rückgabefeld-Abweichungen
|
||||||
|
sind Hinweise, keine Übernahmeentscheidungen. Ein völlig unveränderter
|
||||||
|
Folgeimport erzeugt einen nachvollziehbaren Batch, aber keine unnötigen
|
||||||
|
Objekt-/Row-Updates.
|
||||||
|
|
||||||
|
## 9. Pakete, Verifikation und Risiken
|
||||||
|
|
||||||
|
R0 liefert nur diesen Bericht und einen eigenen Commit. R1–R10 starten erst
|
||||||
|
nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste.
|
||||||
|
|
||||||
|
| Paket | Arbeit und entscheidende Abnahme |
|
||||||
|
| --- | --- |
|
||||||
|
| R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. |
|
||||||
|
| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss. Aus der lokalen Musterdatei entsteht eine Rückgabedatei mit geänderten Werten. Danach Nutzerabnahme in Linear; R3 bleibt bis zum Erfolg gesperrt. Bei Fehlschlag vor R3 OOXML-Direktänderung als Alternative vorlegen. Keine DB-Änderung. |
|
||||||
|
| R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. |
|
||||||
|
| R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. |
|
||||||
|
| R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. |
|
||||||
|
| R6 | Kompakte Objekt-Reads, clientseitige Filter/Sortierung, Virtualisierung, Sammel-Commands und Overrides. |
|
||||||
|
| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele, atomare Objektlösung beim Row-/Circuit-/Gruppen-Löschen und Reload/Undo/Redo. |
|
||||||
|
| R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. |
|
||||||
|
| R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. |
|
||||||
|
| R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. |
|
||||||
|
|
||||||
|
Die temporäre Grenze zwischen R3 und R4 wird ausdrücklich behandelt:
|
||||||
|
Buildfähige, nicht aktive Kompatibilitätstypen für das alte Modal dürfen bis
|
||||||
|
R4 bestehen, aber kein aktiver CSV-Backendpfad. Veraltete Projektaktion bis
|
||||||
|
zur neuen Seite deaktivieren/entfernen, keinen funktionslosen Upload anbieten.
|
||||||
|
Die Dokumentation darf Zwischenstände nicht als fertigen Workflow ausweisen.
|
||||||
|
|
||||||
|
Pflicht je R1–R10: fokussierte Tests, `npm test`, `npm run build:api`,
|
||||||
|
`npm run build:web`, `npm run typecheck:scripts`,
|
||||||
|
`npx tsc --noEmit -p tsconfig.next.json`, je ein knapper imperativer Commit.
|
||||||
|
R0: 31 vorhandene Tests aus sechs Dateien erfolgreich ausgeführt:
|
||||||
|
`external-model-foundation`, `external-initial-import-plan`,
|
||||||
|
`external-initial-import-project-command.repository`,
|
||||||
|
`external-object-row-assignment-project-command.repository`,
|
||||||
|
`external-circuit-list-objects`,
|
||||||
|
`external-csv-configuration-project-command.repository` (je `.test.ts`).
|
||||||
|
Diese decken vorhandene Commit-/Rollback-, Undo-/Redo-, Snapshot-/Transfer-
|
||||||
|
Grundlagen ab, nicht die neue Excel-Funktion. Der erste Start scheiterte an
|
||||||
|
Sandbox-`spawn EPERM`; derselbe Lauf außerhalb der Sandbox bestand.
|
||||||
|
Vollständige Builds/Tests sind für diesen reinen R0-Bericht nicht ausgeführt.
|
||||||
|
|
||||||
|
Zusätzlich erforderliche neue Prüfungen:
|
||||||
|
|
||||||
|
- Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs.
|
||||||
|
- v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen
|
||||||
|
und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen.
|
||||||
|
- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne über `expectedRevision`; Apply mit anderem
|
||||||
|
Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig
|
||||||
|
zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück.
|
||||||
|
- Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte.
|
||||||
|
- Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische
|
||||||
|
Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung.
|
||||||
|
- Row-/Circuit-/Gruppen-Löschen löst externe Links im Command und stellt sie
|
||||||
|
per Undo wieder her. Raum-/Verteilerlöschen mit Referenzen blockiert.
|
||||||
|
- Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt
|
||||||
|
den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise.
|
||||||
|
- 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße,
|
||||||
|
Command-/Snapshot-Dauer und UI-Reaktionszeit messen; nur sichtbare Zeilen im
|
||||||
|
DOM, stabile Auswahl über virtuelle Bereiche.
|
||||||
|
|
||||||
|
Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des
|
||||||
|
Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu
|
||||||
|
belegen. Große Originaldateien vervielfachen sich in History/Snapshots;
|
||||||
|
Limits und Messungen müssen reale Speichergrößen berücksichtigen. Die
|
||||||
|
CSV-Entwicklungsbereinigung ist absichtlich verlustbehaftet, aber auf den
|
||||||
|
externen Zustand begrenzt und nur nach verifiziertem Backup zulässig.
|
||||||
|
Fehlende Zielparameter im Muster verlangen einen neuen vollständigen
|
||||||
|
Linear-Export, keine geratenen GUID-Kommentare.
|
||||||
|
|
||||||
|
**Freigabepunkt:** R0 wurde am 29.09.2026 mit den in R1 dokumentierten
|
||||||
|
Korrekturen freigegeben. R2 besitzt einen neuen verbindlichen Freigabepunkt:
|
||||||
|
Vor Beginn von R3 muss der Nutzer die in R2 erzeugte Rückgabedatei erfolgreich
|
||||||
|
in Linear getestet haben.
|
||||||
60
docs/spec/revit-excel-r10-validation.md
Normal file
60
docs/spec/revit-excel-r10-validation.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# R10-Abnahme der Linear-XLSX-Anbindung
|
||||||
|
|
||||||
|
Stand: 30.09.2026. R10 schließt Phase 14 mit synthetischen, reproduzierbaren
|
||||||
|
Integrations- und Lasttests ab. Die reale Musterdatei wird dafür nicht benötigt
|
||||||
|
und bleibt unversioniert.
|
||||||
|
|
||||||
|
## Ende-zu-Ende-Nachweis
|
||||||
|
|
||||||
|
`tests/revit-xlsx-end-to-end.test.ts` verwendet eine dateibasierte SQLite-
|
||||||
|
Datenbank und prüft in einer Kette:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Erstimport → Objektplanung → Row-Zuordnung → Rückgabe-XLSX
|
||||||
|
→ Folgeimport → Snapshot/Restore → Projektduplikat
|
||||||
|
→ Datenbankneustart → Undo → Redo
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test bestätigt insbesondere die materialisierte Row-Menge, die Reduktion
|
||||||
|
des älteren Importbatches auf Metadaten, das Remapping aller internen UUIDs im
|
||||||
|
Duplikat und parserfreies Undo/Redo nach dem Neustart.
|
||||||
|
|
||||||
|
## Last- und Größenmessung
|
||||||
|
|
||||||
|
`tests/revit-xlsx-scale.test.ts` erzeugt 5.000 synthetische Objekte in einer
|
||||||
|
Linear-kompatiblen XLSX. Der Test misst Erst- und Folgeimport, Command- und
|
||||||
|
Snapshotgröße, Heap-Wachstum sowie clientseitiges Filtern, Sortieren und das
|
||||||
|
virtuelle Fenster. Er führt außerdem Snapshot-Restore und Projektduplikat mit
|
||||||
|
dem vollständigen Zustand aus.
|
||||||
|
|
||||||
|
Messung auf der Entwicklungsumgebung am 30.09.2026:
|
||||||
|
|
||||||
|
| Messwert | Ergebnis |
|
||||||
|
| --- | ---: |
|
||||||
|
| Objekte | 5.000 |
|
||||||
|
| XLSX-Datei | 191.019 Bytes |
|
||||||
|
| Erstimport-Command | 15.598.253 Bytes |
|
||||||
|
| logischer Projektsnapshot | 15.598.071 Bytes |
|
||||||
|
| XLSX-Erstplanung | 6.831 ms |
|
||||||
|
| atomare Erstimporttransaktion | 672 ms |
|
||||||
|
| XLSX-Folgeplanung | 6.303 ms |
|
||||||
|
| atomare Folgeimporttransaktion | 887 ms |
|
||||||
|
| Clientfilter, Sortierung und Fenster | 37 ms |
|
||||||
|
| Heap-Zuwachs des erfolgreichen Gesamtlaufs | 154.125.568 Bytes |
|
||||||
|
|
||||||
|
Der Heap-Wert ist wegen Garbage Collection und der absichtlich verwendeten
|
||||||
|
In-Memory-Datenbank nicht deterministisch. Wiederholte Auditläufe lagen unter
|
||||||
|
der Testobergrenze von 768 MiB. Das virtuelle Fenster enthält bei 520 px Höhe
|
||||||
|
maximal 22 statt 5.000 Zeilen.
|
||||||
|
|
||||||
|
## Gefundene und behobene Grenze
|
||||||
|
|
||||||
|
Der erste 5.000-Objekte-Lauf scheiterte an einem ungebatchten SQLite-
|
||||||
|
Mehrfach-Insert. `insertManyInBatches` begrenzt nun die Insertblöcke für
|
||||||
|
Erstimport, Folgeimport, Snapshot-Restore und Projektduplikat. Alle Blöcke
|
||||||
|
bleiben Teil der jeweiligen bestehenden Transaktion; ein Fehler rollt weiterhin
|
||||||
|
die gesamte Operation zurück.
|
||||||
|
|
||||||
|
Die vorhandenen Tests decken außerdem die deutschen Ablehnungen alter
|
||||||
|
CSV-Snapshots und CSV-History, Löschwarnungen, atomare Link-Inversen und den
|
||||||
|
Schutz referenzierter Räume und Verteiler ab.
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node.
|
|
||||||
|
|
||||||
Optional Dynamo input:
|
|
||||||
IN[0]: output directory or complete .json file path
|
|
||||||
|
|
||||||
The script intentionally performs no Revit transaction and changes no model
|
|
||||||
data. OUT contains a compact summary plus the complete report.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import clr
|
|
||||||
|
|
||||||
clr.AddReference("RevitAPI")
|
|
||||||
clr.AddReference("RevitServices")
|
|
||||||
|
|
||||||
from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402
|
|
||||||
from RevitServices.Persistence import DocumentManager # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId")
|
|
||||||
|
|
||||||
|
|
||||||
def safe_text(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return str(value)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def element_id_text(element_id):
|
|
||||||
if element_id is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return str(element_id.Value)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
return str(element_id.IntegerValue)
|
|
||||||
except Exception:
|
|
||||||
return safe_text(element_id)
|
|
||||||
|
|
||||||
|
|
||||||
def forge_type_id_text(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return value.TypeId
|
|
||||||
except Exception:
|
|
||||||
return safe_text(value)
|
|
||||||
|
|
||||||
|
|
||||||
def parameter_value(parameter):
|
|
||||||
result = {
|
|
||||||
"hasValue": False,
|
|
||||||
"raw": None,
|
|
||||||
"display": None,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result["hasValue"] = bool(parameter.HasValue)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage_type = parameter.StorageType
|
|
||||||
if storage_type == StorageType.String:
|
|
||||||
result["raw"] = parameter.AsString()
|
|
||||||
elif storage_type == StorageType.Integer:
|
|
||||||
result["raw"] = int(parameter.AsInteger())
|
|
||||||
elif storage_type == StorageType.Double:
|
|
||||||
result["raw"] = float(parameter.AsDouble())
|
|
||||||
elif storage_type == StorageType.ElementId:
|
|
||||||
result["raw"] = element_id_text(parameter.AsElementId())
|
|
||||||
except Exception as error:
|
|
||||||
result["readError"] = safe_text(error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result["display"] = parameter.AsValueString()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def describe_parameter(parameter):
|
|
||||||
definition = None
|
|
||||||
try:
|
|
||||||
definition = parameter.Definition
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
name = None
|
|
||||||
if definition is not None:
|
|
||||||
try:
|
|
||||||
name = definition.Name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
is_shared = False
|
|
||||||
try:
|
|
||||||
is_shared = bool(parameter.IsShared)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
shared_guid = None
|
|
||||||
if is_shared:
|
|
||||||
try:
|
|
||||||
shared_guid = str(parameter.GUID)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
data_type = None
|
|
||||||
group_type = None
|
|
||||||
if definition is not None:
|
|
||||||
try:
|
|
||||||
data_type = forge_type_id_text(definition.GetDataType())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
group_type = forge_type_id_text(definition.GetGroupTypeId())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
unit_type = None
|
|
||||||
try:
|
|
||||||
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage_type = str(parameter.StorageType)
|
|
||||||
except Exception:
|
|
||||||
storage_type = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_read_only = bool(parameter.IsReadOnly)
|
|
||||||
except Exception:
|
|
||||||
is_read_only = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_modifiable = bool(parameter.UserModifiable)
|
|
||||||
except Exception:
|
|
||||||
user_modifiable = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"parameterId": element_id_text(getattr(parameter, "Id", None)),
|
|
||||||
"isShared": is_shared,
|
|
||||||
"sharedGuid": shared_guid,
|
|
||||||
"storageType": storage_type,
|
|
||||||
"dataTypeId": data_type,
|
|
||||||
"groupTypeId": group_type,
|
|
||||||
"unitTypeId": unit_type,
|
|
||||||
"isReadOnly": is_read_only,
|
|
||||||
"userModifiable": user_modifiable,
|
|
||||||
"value": parameter_value(parameter),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sorted_parameters(element):
|
|
||||||
parameters = []
|
|
||||||
try:
|
|
||||||
parameters = [describe_parameter(parameter) for parameter in element.Parameters]
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
parameters,
|
|
||||||
key=lambda parameter: (
|
|
||||||
(parameter.get("name") or "").casefold(),
|
|
||||||
parameter.get("parameterId") or "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def named_parameter_occurrences(element, parameter_name):
|
|
||||||
result = []
|
|
||||||
try:
|
|
||||||
parameters = element.GetParameters(parameter_name)
|
|
||||||
if parameters is not None:
|
|
||||||
result = [describe_parameter(parameter) for parameter in parameters]
|
|
||||||
except Exception:
|
|
||||||
parameter = None
|
|
||||||
try:
|
|
||||||
parameter = element.LookupParameter(parameter_name)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if parameter is not None:
|
|
||||||
result = [describe_parameter(parameter)]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def loaded_assembly_versions():
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
from System import AppDomain
|
|
||||||
|
|
||||||
for assembly in AppDomain.CurrentDomain.GetAssemblies():
|
|
||||||
try:
|
|
||||||
name = assembly.GetName()
|
|
||||||
simple_name = str(name.Name)
|
|
||||||
if simple_name in (
|
|
||||||
"DynamoCore",
|
|
||||||
"DynamoCoreWpf",
|
|
||||||
"DynamoRevitDS",
|
|
||||||
"RevitAPI",
|
|
||||||
"RevitServices",
|
|
||||||
):
|
|
||||||
result[simple_name] = str(name.Version)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return dict(sorted(result.items()))
|
|
||||||
|
|
||||||
|
|
||||||
def get_cloud_identity(document):
|
|
||||||
result = {"isModelInCloud": False}
|
|
||||||
try:
|
|
||||||
result["isModelInCloud"] = bool(document.IsModelInCloud)
|
|
||||||
except Exception:
|
|
||||||
return result
|
|
||||||
if not result["isModelInCloud"]:
|
|
||||||
return result
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_path = document.GetCloudModelPath()
|
|
||||||
result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
|
|
||||||
model_path
|
|
||||||
)
|
|
||||||
for property_name, output_name in (
|
|
||||||
("GetProjectGUID", "projectGuid"),
|
|
||||||
("GetModelGUID", "modelGuid"),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
result[output_name] = str(getattr(model_path, property_name)())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as error:
|
|
||||||
result["readError"] = safe_text(error)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_worksharing_identity(document):
|
|
||||||
result = {"isWorkshared": False}
|
|
||||||
try:
|
|
||||||
result["isWorkshared"] = bool(document.IsWorkshared)
|
|
||||||
except Exception:
|
|
||||||
return result
|
|
||||||
if not result["isWorkshared"]:
|
|
||||||
return result
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_path = document.GetWorksharingCentralModelPath()
|
|
||||||
result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
|
|
||||||
model_path
|
|
||||||
)
|
|
||||||
except Exception as error:
|
|
||||||
result["readError"] = safe_text(error)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_output_path(configured_path, report_name):
|
|
||||||
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
|
|
||||||
raw_path = safe_text(configured_path)
|
|
||||||
if raw_path is None or not raw_path.strip():
|
|
||||||
directory = default_directory
|
|
||||||
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
|
|
||||||
else:
|
|
||||||
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
|
|
||||||
if expanded.lower().endswith(".json"):
|
|
||||||
file_path = expanded
|
|
||||||
directory = os.path.dirname(file_path)
|
|
||||||
else:
|
|
||||||
directory = expanded
|
|
||||||
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
|
|
||||||
if not directory:
|
|
||||||
directory = os.getcwd()
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
os.makedirs(directory)
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(file_path, payload):
|
|
||||||
temporary_path = file_path + ".tmp"
|
|
||||||
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
|
|
||||||
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
|
|
||||||
output.write("\n")
|
|
||||||
os.replace(temporary_path, file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def get_input(index, default=None):
|
|
||||||
values = globals().get("IN", [])
|
|
||||||
try:
|
|
||||||
value = values[index]
|
|
||||||
return default if value is None else value
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def build_report():
|
|
||||||
document = DocumentManager.Instance.CurrentDBDocument
|
|
||||||
if document is None:
|
|
||||||
raise RuntimeError("No active Revit document is available.")
|
|
||||||
|
|
||||||
project_information = document.ProjectInformation
|
|
||||||
if project_information is None:
|
|
||||||
raise RuntimeError("The active document has no Project Information element.")
|
|
||||||
|
|
||||||
application = document.Application
|
|
||||||
checked_parameters = {
|
|
||||||
name: named_parameter_occurrences(project_information, name)
|
|
||||||
for name in CHECKED_PARAMETER_NAMES
|
|
||||||
}
|
|
||||||
warnings = []
|
|
||||||
for name in CHECKED_PARAMETER_NAMES:
|
|
||||||
occurrences = checked_parameters[name]
|
|
||||||
populated = [
|
|
||||||
parameter
|
|
||||||
for parameter in occurrences
|
|
||||||
if parameter.get("value", {}).get("raw") not in (None, "")
|
|
||||||
]
|
|
||||||
if not occurrences:
|
|
||||||
warnings.append(name + " is not bound to Project Information.")
|
|
||||||
elif not populated:
|
|
||||||
warnings.append(name + " exists but has no value on Project Information.")
|
|
||||||
elif len(occurrences) > 1:
|
|
||||||
warnings.append(name + " occurs more than once; use a shared-parameter GUID later.")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"reportSchemaVersion": 1,
|
|
||||||
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
||||||
"readOnly": True,
|
|
||||||
"environment": {
|
|
||||||
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
|
|
||||||
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
|
|
||||||
"revitSubVersionNumber": safe_text(
|
|
||||||
getattr(application, "SubVersionNumber", None)
|
|
||||||
),
|
|
||||||
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
|
|
||||||
"pythonVersion": platform.python_version(),
|
|
||||||
"assemblies": loaded_assembly_versions(),
|
|
||||||
},
|
|
||||||
"document": {
|
|
||||||
"title": safe_text(document.Title),
|
|
||||||
"pathName": safe_text(document.PathName),
|
|
||||||
"isFamilyDocument": bool(document.IsFamilyDocument),
|
|
||||||
"cloud": get_cloud_identity(document),
|
|
||||||
"worksharing": get_worksharing_identity(document),
|
|
||||||
},
|
|
||||||
"modelIdentityCandidates": {
|
|
||||||
"projectInformationUniqueId": safe_text(project_information.UniqueId),
|
|
||||||
"projectInformationElementId": element_id_text(project_information.Id),
|
|
||||||
"checkedProjectParameters": checked_parameters,
|
|
||||||
},
|
|
||||||
"projectInformationParameters": sorted_parameters(project_information),
|
|
||||||
"warnings": warnings,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
report = build_report()
|
|
||||||
output_path = resolve_output_path(get_input(0), "model-identity")
|
|
||||||
write_json(output_path, report)
|
|
||||||
OUT = {
|
|
||||||
"ok": True,
|
|
||||||
"filePath": output_path,
|
|
||||||
"projectInformationUniqueId": report["modelIdentityCandidates"][
|
|
||||||
"projectInformationUniqueId"
|
|
||||||
],
|
|
||||||
"warnings": report["warnings"],
|
|
||||||
"report": report,
|
|
||||||
}
|
|
||||||
except Exception as error:
|
|
||||||
OUT = {
|
|
||||||
"ok": False,
|
|
||||||
"error": safe_text(error),
|
|
||||||
"traceback": traceback.format_exc(),
|
|
||||||
}
|
|
||||||
|
|
@ -1,517 +0,0 @@
|
||||||
"""Export all Electrical Fixtures instance/type parameters from Revit 2026.
|
|
||||||
|
|
||||||
Optional Dynamo inputs:
|
|
||||||
IN[0]: output directory or complete .json file path
|
|
||||||
IN[1]: include empty parameters (default True)
|
|
||||||
IN[2]: maximum aggregated sample values (default 5)
|
|
||||||
|
|
||||||
The script is read-only and performs no Revit transaction.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import clr
|
|
||||||
|
|
||||||
clr.AddReference("RevitAPI")
|
|
||||||
clr.AddReference("RevitServices")
|
|
||||||
|
|
||||||
from Autodesk.Revit.DB import ( # noqa: E402
|
|
||||||
BuiltInCategory,
|
|
||||||
FilteredElementCollector,
|
|
||||||
ModelPathUtils,
|
|
||||||
StorageType,
|
|
||||||
)
|
|
||||||
from RevitServices.Persistence import DocumentManager # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def safe_text(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return str(value)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def element_id_text(element_id):
|
|
||||||
if element_id is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return str(element_id.Value)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
return str(element_id.IntegerValue)
|
|
||||||
except Exception:
|
|
||||||
return safe_text(element_id)
|
|
||||||
|
|
||||||
|
|
||||||
def forge_type_id_text(value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return value.TypeId
|
|
||||||
except Exception:
|
|
||||||
return safe_text(value)
|
|
||||||
|
|
||||||
|
|
||||||
def parameter_value(parameter):
|
|
||||||
result = {"hasValue": False, "raw": None, "display": None}
|
|
||||||
try:
|
|
||||||
result["hasValue"] = bool(parameter.HasValue)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage_type = parameter.StorageType
|
|
||||||
if storage_type == StorageType.String:
|
|
||||||
result["raw"] = parameter.AsString()
|
|
||||||
elif storage_type == StorageType.Integer:
|
|
||||||
result["raw"] = int(parameter.AsInteger())
|
|
||||||
elif storage_type == StorageType.Double:
|
|
||||||
result["raw"] = float(parameter.AsDouble())
|
|
||||||
elif storage_type == StorageType.ElementId:
|
|
||||||
result["raw"] = element_id_text(parameter.AsElementId())
|
|
||||||
except Exception as error:
|
|
||||||
result["readError"] = safe_text(error)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result["display"] = parameter.AsValueString()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def describe_parameter(parameter, scope):
|
|
||||||
definition = None
|
|
||||||
try:
|
|
||||||
definition = parameter.Definition
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
name = None
|
|
||||||
data_type = None
|
|
||||||
group_type = None
|
|
||||||
if definition is not None:
|
|
||||||
try:
|
|
||||||
name = definition.Name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
data_type = forge_type_id_text(definition.GetDataType())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
group_type = forge_type_id_text(definition.GetGroupTypeId())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
is_shared = False
|
|
||||||
try:
|
|
||||||
is_shared = bool(parameter.IsShared)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
shared_guid = None
|
|
||||||
if is_shared:
|
|
||||||
try:
|
|
||||||
shared_guid = str(parameter.GUID)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
unit_type = None
|
|
||||||
try:
|
|
||||||
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage_type = str(parameter.StorageType)
|
|
||||||
except Exception:
|
|
||||||
storage_type = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_read_only = bool(parameter.IsReadOnly)
|
|
||||||
except Exception:
|
|
||||||
is_read_only = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_modifiable = bool(parameter.UserModifiable)
|
|
||||||
except Exception:
|
|
||||||
user_modifiable = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scope": scope,
|
|
||||||
"name": name,
|
|
||||||
"parameterId": element_id_text(getattr(parameter, "Id", None)),
|
|
||||||
"isShared": is_shared,
|
|
||||||
"sharedGuid": shared_guid,
|
|
||||||
"storageType": storage_type,
|
|
||||||
"dataTypeId": data_type,
|
|
||||||
"groupTypeId": group_type,
|
|
||||||
"unitTypeId": unit_type,
|
|
||||||
"isReadOnly": is_read_only,
|
|
||||||
"userModifiable": user_modifiable,
|
|
||||||
"value": parameter_value(parameter),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def has_meaningful_value(parameter_description):
|
|
||||||
value = parameter_description.get("value", {})
|
|
||||||
return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get(
|
|
||||||
"display"
|
|
||||||
) not in (None, "")
|
|
||||||
|
|
||||||
|
|
||||||
def read_parameters(element, scope, include_empty):
|
|
||||||
result = []
|
|
||||||
try:
|
|
||||||
for parameter in element.Parameters:
|
|
||||||
description = describe_parameter(parameter, scope)
|
|
||||||
if include_empty or has_meaningful_value(description):
|
|
||||||
result.append(description)
|
|
||||||
except Exception as error:
|
|
||||||
return [], [safe_text(error)]
|
|
||||||
result.sort(
|
|
||||||
key=lambda parameter: (
|
|
||||||
(parameter.get("name") or "").casefold(),
|
|
||||||
parameter.get("parameterId") or "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result, []
|
|
||||||
|
|
||||||
|
|
||||||
def read_space(element, document):
|
|
||||||
try:
|
|
||||||
space = element.Space
|
|
||||||
except Exception as error:
|
|
||||||
return None, safe_text(error)
|
|
||||||
if space is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
level_name = None
|
|
||||||
try:
|
|
||||||
level = document.GetElement(space.LevelId)
|
|
||||||
level_name = None if level is None else safe_text(level.Name)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"uniqueId": safe_text(space.UniqueId),
|
|
||||||
"elementId": element_id_text(space.Id),
|
|
||||||
"number": safe_text(getattr(space, "Number", None)),
|
|
||||||
"name": safe_text(getattr(space, "Name", None)),
|
|
||||||
"levelName": level_name,
|
|
||||||
}, None
|
|
||||||
|
|
||||||
|
|
||||||
def read_family_identity(element, document):
|
|
||||||
symbol = None
|
|
||||||
try:
|
|
||||||
symbol = element.Symbol
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
symbol = document.GetElement(element.GetTypeId())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
family_name = None
|
|
||||||
type_name = None
|
|
||||||
type_unique_id = None
|
|
||||||
type_element_id = None
|
|
||||||
if symbol is not None:
|
|
||||||
try:
|
|
||||||
family_name = safe_text(symbol.Family.Name)
|
|
||||||
except Exception:
|
|
||||||
family_name = safe_text(getattr(symbol, "FamilyName", None))
|
|
||||||
type_name = safe_text(getattr(symbol, "Name", None))
|
|
||||||
type_unique_id = safe_text(getattr(symbol, "UniqueId", None))
|
|
||||||
type_element_id = element_id_text(getattr(symbol, "Id", None))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"familyName": family_name,
|
|
||||||
"typeName": type_name,
|
|
||||||
"typeUniqueId": type_unique_id,
|
|
||||||
"typeElementId": type_element_id,
|
|
||||||
}, symbol
|
|
||||||
|
|
||||||
|
|
||||||
def parameter_inventory_key(parameter):
|
|
||||||
stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or ""
|
|
||||||
return "|".join(
|
|
||||||
(
|
|
||||||
parameter.get("scope") or "",
|
|
||||||
stable_id,
|
|
||||||
parameter.get("name") or "",
|
|
||||||
parameter.get("dataTypeId") or "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sample_value_key(value):
|
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_inventory(inventory, parameter, max_samples):
|
|
||||||
key = parameter_inventory_key(parameter)
|
|
||||||
entry = inventory.get(key)
|
|
||||||
if entry is None:
|
|
||||||
entry = {
|
|
||||||
"scope": parameter.get("scope"),
|
|
||||||
"name": parameter.get("name"),
|
|
||||||
"parameterId": parameter.get("parameterId"),
|
|
||||||
"isShared": parameter.get("isShared"),
|
|
||||||
"sharedGuid": parameter.get("sharedGuid"),
|
|
||||||
"storageType": parameter.get("storageType"),
|
|
||||||
"dataTypeId": parameter.get("dataTypeId"),
|
|
||||||
"groupTypeId": parameter.get("groupTypeId"),
|
|
||||||
"unitTypeId": parameter.get("unitTypeId"),
|
|
||||||
"occurrenceCount": 0,
|
|
||||||
"populatedCount": 0,
|
|
||||||
"sampleValues": [],
|
|
||||||
"_sampleKeys": set(),
|
|
||||||
}
|
|
||||||
inventory[key] = entry
|
|
||||||
entry["occurrenceCount"] += 1
|
|
||||||
if has_meaningful_value(parameter):
|
|
||||||
entry["populatedCount"] += 1
|
|
||||||
value = parameter.get("value")
|
|
||||||
value_key = sample_value_key(value)
|
|
||||||
if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]:
|
|
||||||
entry["_sampleKeys"].add(value_key)
|
|
||||||
entry["sampleValues"].append(value)
|
|
||||||
|
|
||||||
|
|
||||||
def finalize_inventory(inventory):
|
|
||||||
result = []
|
|
||||||
for entry in inventory.values():
|
|
||||||
clean_entry = dict(entry)
|
|
||||||
clean_entry.pop("_sampleKeys", None)
|
|
||||||
result.append(clean_entry)
|
|
||||||
return sorted(
|
|
||||||
result,
|
|
||||||
key=lambda entry: (
|
|
||||||
entry.get("scope") or "",
|
|
||||||
(entry.get("name") or "").casefold(),
|
|
||||||
entry.get("sharedGuid") or entry.get("parameterId") or "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def loaded_assembly_versions():
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
from System import AppDomain
|
|
||||||
|
|
||||||
for assembly in AppDomain.CurrentDomain.GetAssemblies():
|
|
||||||
try:
|
|
||||||
name = assembly.GetName()
|
|
||||||
simple_name = str(name.Name)
|
|
||||||
if simple_name in (
|
|
||||||
"DynamoCore",
|
|
||||||
"DynamoCoreWpf",
|
|
||||||
"DynamoRevitDS",
|
|
||||||
"RevitAPI",
|
|
||||||
"RevitServices",
|
|
||||||
):
|
|
||||||
result[simple_name] = str(name.Version)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return dict(sorted(result.items()))
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_output_path(configured_path):
|
|
||||||
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
||||||
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
|
|
||||||
raw_path = safe_text(configured_path)
|
|
||||||
if raw_path is None or not raw_path.strip():
|
|
||||||
directory = default_directory
|
|
||||||
file_path = os.path.join(
|
|
||||||
directory, "electrical-fixture-parameters-" + timestamp + ".json"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
|
|
||||||
if expanded.lower().endswith(".json"):
|
|
||||||
file_path = expanded
|
|
||||||
directory = os.path.dirname(file_path)
|
|
||||||
else:
|
|
||||||
directory = expanded
|
|
||||||
file_path = os.path.join(
|
|
||||||
directory, "electrical-fixture-parameters-" + timestamp + ".json"
|
|
||||||
)
|
|
||||||
if not directory:
|
|
||||||
directory = os.getcwd()
|
|
||||||
if not os.path.isdir(directory):
|
|
||||||
os.makedirs(directory)
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(file_path, payload):
|
|
||||||
temporary_path = file_path + ".tmp"
|
|
||||||
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
|
|
||||||
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
|
|
||||||
output.write("\n")
|
|
||||||
os.replace(temporary_path, file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def get_input(index, default=None):
|
|
||||||
values = globals().get("IN", [])
|
|
||||||
try:
|
|
||||||
value = values[index]
|
|
||||||
return default if value is None else value
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def build_report(include_empty, max_samples):
|
|
||||||
document = DocumentManager.Instance.CurrentDBDocument
|
|
||||||
if document is None:
|
|
||||||
raise RuntimeError("No active Revit document is available.")
|
|
||||||
if document.IsFamilyDocument:
|
|
||||||
raise RuntimeError("Open a Revit project document, not a family document.")
|
|
||||||
|
|
||||||
application = document.Application
|
|
||||||
collector = (
|
|
||||||
FilteredElementCollector(document)
|
|
||||||
.OfCategory(BuiltInCategory.OST_ElectricalFixtures)
|
|
||||||
.WhereElementIsNotElementType()
|
|
||||||
)
|
|
||||||
source_elements = list(collector)
|
|
||||||
source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "")
|
|
||||||
|
|
||||||
elements = []
|
|
||||||
types_by_unique_id = {}
|
|
||||||
inventory = {}
|
|
||||||
errors = []
|
|
||||||
elements_without_space = 0
|
|
||||||
|
|
||||||
for element in source_elements:
|
|
||||||
element_errors = []
|
|
||||||
try:
|
|
||||||
family_identity, symbol = read_family_identity(element, document)
|
|
||||||
instance_parameters, parameter_errors = read_parameters(
|
|
||||||
element, "instance", include_empty
|
|
||||||
)
|
|
||||||
element_errors.extend(parameter_errors)
|
|
||||||
for parameter in instance_parameters:
|
|
||||||
add_to_inventory(inventory, parameter, max_samples)
|
|
||||||
|
|
||||||
type_unique_id = family_identity.get("typeUniqueId")
|
|
||||||
if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id:
|
|
||||||
type_parameters, type_errors = read_parameters(symbol, "type", include_empty)
|
|
||||||
element_errors.extend(type_errors)
|
|
||||||
for parameter in type_parameters:
|
|
||||||
add_to_inventory(inventory, parameter, max_samples)
|
|
||||||
types_by_unique_id[type_unique_id] = {
|
|
||||||
**family_identity,
|
|
||||||
"parameters": type_parameters,
|
|
||||||
}
|
|
||||||
|
|
||||||
space, space_error = read_space(element, document)
|
|
||||||
if space_error:
|
|
||||||
element_errors.append("MEP Space: " + space_error)
|
|
||||||
if space is None:
|
|
||||||
elements_without_space += 1
|
|
||||||
|
|
||||||
elements.append(
|
|
||||||
{
|
|
||||||
"uniqueId": safe_text(element.UniqueId),
|
|
||||||
"elementId": element_id_text(element.Id),
|
|
||||||
"categoryName": safe_text(
|
|
||||||
None if element.Category is None else element.Category.Name
|
|
||||||
),
|
|
||||||
"family": family_identity,
|
|
||||||
"space": space,
|
|
||||||
"instanceParameters": instance_parameters,
|
|
||||||
"warnings": element_errors,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as error:
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"uniqueId": safe_text(getattr(element, "UniqueId", None)),
|
|
||||||
"elementId": element_id_text(getattr(element, "Id", None)),
|
|
||||||
"error": safe_text(error),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"reportSchemaVersion": 1,
|
|
||||||
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
||||||
"readOnly": True,
|
|
||||||
"complete": len(errors) == 0,
|
|
||||||
"scope": {
|
|
||||||
"builtInCategory": "OST_ElectricalFixtures",
|
|
||||||
"wholeDocument": True,
|
|
||||||
"elementTypesExcluded": True,
|
|
||||||
"includeEmptyParameters": include_empty,
|
|
||||||
},
|
|
||||||
"environment": {
|
|
||||||
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
|
|
||||||
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
|
|
||||||
"revitSubVersionNumber": safe_text(
|
|
||||||
getattr(application, "SubVersionNumber", None)
|
|
||||||
),
|
|
||||||
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
|
|
||||||
"pythonVersion": platform.python_version(),
|
|
||||||
"assemblies": loaded_assembly_versions(),
|
|
||||||
},
|
|
||||||
"document": {
|
|
||||||
"title": safe_text(document.Title),
|
|
||||||
"pathName": safe_text(document.PathName),
|
|
||||||
"projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId),
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"elementCount": len(source_elements),
|
|
||||||
"exportedElementCount": len(elements),
|
|
||||||
"typeCount": len(types_by_unique_id),
|
|
||||||
"parameterDefinitionCount": len(inventory),
|
|
||||||
"elementsWithoutMepSpace": elements_without_space,
|
|
||||||
"elementErrorCount": len(errors),
|
|
||||||
},
|
|
||||||
"parameterInventory": finalize_inventory(inventory),
|
|
||||||
"types": sorted(
|
|
||||||
types_by_unique_id.values(),
|
|
||||||
key=lambda entry: (
|
|
||||||
(entry.get("familyName") or "").casefold(),
|
|
||||||
(entry.get("typeName") or "").casefold(),
|
|
||||||
entry.get("typeUniqueId") or "",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"elements": elements,
|
|
||||||
"errors": errors,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
include_empty_input = get_input(1, True)
|
|
||||||
include_empty = bool(include_empty_input)
|
|
||||||
try:
|
|
||||||
max_samples = int(get_input(2, 5))
|
|
||||||
except Exception:
|
|
||||||
max_samples = 5
|
|
||||||
max_samples = max(0, min(max_samples, 50))
|
|
||||||
|
|
||||||
report = build_report(include_empty, max_samples)
|
|
||||||
output_path = resolve_output_path(get_input(0))
|
|
||||||
write_json(output_path, report)
|
|
||||||
OUT = {
|
|
||||||
"ok": True,
|
|
||||||
"filePath": output_path,
|
|
||||||
"complete": report["complete"],
|
|
||||||
"summary": report["summary"],
|
|
||||||
"errors": report["errors"],
|
|
||||||
}
|
|
||||||
except Exception as error:
|
|
||||||
OUT = {
|
|
||||||
"ok": False,
|
|
||||||
"error": safe_text(error),
|
|
||||||
"traceback": traceback.format_exc(),
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# Revit 2026 / Dynamo diagnostics
|
|
||||||
|
|
||||||
This directory contains self-contained Python scripts for a Dynamo **Python
|
|
||||||
Script** node. They use only Dynamo's built-in Revit integration, the Revit API
|
|
||||||
and the Python standard library. No Dynamo package is required.
|
|
||||||
|
|
||||||
The scripts are read-only. They do not start a Revit transaction and do not
|
|
||||||
change the open model.
|
|
||||||
|
|
||||||
## Python engine
|
|
||||||
|
|
||||||
Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with
|
|
||||||
Revit; optional PythonNet3 packages are not required by these diagnostics.
|
|
||||||
|
|
||||||
## 01 - Check model identity
|
|
||||||
|
|
||||||
File: `01_check_model_identity.py`
|
|
||||||
|
|
||||||
The script reports:
|
|
||||||
|
|
||||||
- Revit, Dynamo and Python versions;
|
|
||||||
- `ProjectInformation.UniqueId` as a native model-identity candidate;
|
|
||||||
- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project
|
|
||||||
Information;
|
|
||||||
- all Project Information parameters;
|
|
||||||
- optional cloud/worksharing identity information when the API exposes it.
|
|
||||||
|
|
||||||
Input `IN[0]` is optional. It may be either an output directory or a complete
|
|
||||||
`.json` file path. With no input, the report is written below the current
|
|
||||||
Windows temporary directory in `leistungsbilanz-dynamo`.
|
|
||||||
|
|
||||||
## 02 - Inventory Electrical Fixtures parameters
|
|
||||||
|
|
||||||
File: `02_export_electrical_fixture_parameter_inventory.py`
|
|
||||||
|
|
||||||
The script reads every instance of
|
|
||||||
`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It
|
|
||||||
exports:
|
|
||||||
|
|
||||||
- element, family, type and MEP Space identities;
|
|
||||||
- every instance parameter and value;
|
|
||||||
- every unique family-type parameter and value;
|
|
||||||
- an aggregated parameter inventory with occurrence counts and sample values;
|
|
||||||
- per-element warnings instead of aborting at the first unreadable element.
|
|
||||||
|
|
||||||
Inputs:
|
|
||||||
|
|
||||||
- `IN[0]` (optional): output directory or complete `.json` path;
|
|
||||||
- `IN[1]` (optional): include empty parameters, default `true`;
|
|
||||||
- `IN[2]` (optional): maximum sample values per aggregated parameter, default
|
|
||||||
`5`.
|
|
||||||
|
|
||||||
The default output location is again the Windows temporary directory. The
|
|
||||||
generated report can contain model paths and project-specific parameter values;
|
|
||||||
review it before sharing or committing it.
|
|
||||||
|
|
||||||
## Running a script
|
|
||||||
|
|
||||||
1. Open the target model in Revit 2026.
|
|
||||||
2. Open Dynamo from **Manage > Visual Programming > Dynamo**.
|
|
||||||
3. Create a graph and add a **Python Script** node.
|
|
||||||
4. Select the `CPython3` engine for the node.
|
|
||||||
5. Copy the complete content of the desired `.py` file into the node.
|
|
||||||
6. Optionally connect a String node containing the output path to `IN[0]`.
|
|
||||||
7. Run the graph and inspect `OUT` for status, counts and the generated path.
|
|
||||||
|
|
||||||
For the first test, run `01_check_model_identity.py` in the Revit main model.
|
|
||||||
Then run the parameter inventory. Keep both generated JSON files so their
|
|
||||||
structure can be checked before the production snapshot DTO is finalized.
|
|
||||||
|
|
@ -11,6 +11,22 @@ const nextConfig = {
|
||||||
typescript: {
|
typescript: {
|
||||||
tsconfigPath: "./tsconfig.next.json",
|
tsconfigPath: "./tsconfig.next.json",
|
||||||
},
|
},
|
||||||
|
poweredByHeader: false,
|
||||||
|
// Clickjacking: the Config pages hold admin actions, so no foreign page
|
||||||
|
// may frame the app. Same-origin framing stays allowed.
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||||
|
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "same-origin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
811
package-lock.json
generated
811
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -24,10 +24,10 @@
|
||||||
"test": "node scripts/run-tests.mjs",
|
"test": "node scripts/run-tests.mjs",
|
||||||
"test:watch": "node scripts/run-tests.mjs --watch",
|
"test:watch": "node scripts/run-tests.mjs --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "node scripts/run-migrations.js",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts"
|
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.4",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ const db = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
const requiredTables = [
|
const requiredTables = [
|
||||||
"buildings",
|
"buildings",
|
||||||
|
"estimate_balances",
|
||||||
|
"estimate_groups",
|
||||||
|
"estimate_rows",
|
||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
"circuits",
|
"circuits",
|
||||||
"circuit_device_rows",
|
"circuit_device_rows",
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const dataDir = path.resolve('data');
|
const dataDir = path.resolve(
|
||||||
|
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
|
||||||
|
);
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
|
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
|
||||||
const db = drizzle(sqlite);
|
|
||||||
|
|
||||||
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
|
async function run() {
|
||||||
|
const sqlite = new Database(databasePath);
|
||||||
|
try {
|
||||||
|
if (requiresLinearXlsxBackup(sqlite)) {
|
||||||
|
const backupDir = path.join(dataDir, 'backups');
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupPath = path.join(
|
||||||
|
backupDir,
|
||||||
|
`leistungsbilanz-before-linear-xlsx-${timestamp}.db`
|
||||||
|
);
|
||||||
|
await sqlite.backup(backupPath);
|
||||||
|
verifyBackup(backupPath);
|
||||||
|
console.log(`Verified pre-migration backup created: ${backupPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate(drizzle(sqlite), {
|
||||||
|
migrationsFolder: path.resolve('src/db/migrations'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
|
}
|
||||||
console.log('Migrations applied');
|
console.log('Migrations applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresLinearXlsxBackup(sqlite) {
|
||||||
|
const tables = new Set(
|
||||||
|
sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||||
|
.all()
|
||||||
|
.map((row) => row.name)
|
||||||
|
);
|
||||||
|
return tables.has('external_csv_configurations') &&
|
||||||
|
!tables.has('external_model_configurations');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyBackup(backupPath) {
|
||||||
|
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const integrity = backup.pragma('integrity_check', { simple: true });
|
||||||
|
const foreignKeyViolations = backup.pragma('foreign_key_check');
|
||||||
|
if (integrity !== 'ok' || foreignKeyViolations.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Pre-migration backup verification failed: integrity=${integrity}, ` +
|
||||||
|
`foreign-key violations=${foreignKeyViolations.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backup.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error('Migration failed:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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`
|
|
||||||
);
|
|
||||||
162
scripts/verify-revit-reference-xlsx.ts
Normal file
162
scripts/verify-revit-reference-xlsx.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import {
|
||||||
|
createDefaultExternalXlsxTransportConfiguration,
|
||||||
|
type ExternalXlsxScalar,
|
||||||
|
} from "../src/external-model/xlsx/external-xlsx-contracts.js";
|
||||||
|
import {
|
||||||
|
createExternalXlsxReturn,
|
||||||
|
parseExternalXlsx,
|
||||||
|
} from "../src/external-model/xlsx/external-xlsx-transport.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const inputPath = resolve(
|
||||||
|
process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx"
|
||||||
|
);
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx"
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
"Eingabe- und Ausgabedatei müssen verschieden sein."
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = readFileSync(inputPath);
|
||||||
|
const configuration = createDefaultExternalXlsxTransportConfiguration();
|
||||||
|
const document = await parseExternalXlsx({
|
||||||
|
fileName: inputPath,
|
||||||
|
bytes: source,
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
const objectRows = document.rows.filter((row) => row.classification === "object");
|
||||||
|
const suspectRows = document.rows.filter(
|
||||||
|
(row) => row.classification === "suspect-object"
|
||||||
|
);
|
||||||
|
const worksharingIdColumn = document.columns.find(
|
||||||
|
(column) => column.header === configuration.columns.worksharingId
|
||||||
|
)?.columnNumber;
|
||||||
|
assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst.");
|
||||||
|
|
||||||
|
assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet.");
|
||||||
|
assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet.");
|
||||||
|
assert.equal(
|
||||||
|
document.columns.filter((column) => column.comment !== null).length,
|
||||||
|
14,
|
||||||
|
"Es werden 14 Kopfzeilenkommentare erwartet."
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length,
|
||||||
|
0,
|
||||||
|
"Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten."
|
||||||
|
);
|
||||||
|
|
||||||
|
const testValues = [
|
||||||
|
{
|
||||||
|
circuitCombined: "UV_R2_TEST-2F1",
|
||||||
|
electricalPowerW: 111,
|
||||||
|
voltageV: 230,
|
||||||
|
currentA: 0.48,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitCombined: "UV_R2_TEST-2F2",
|
||||||
|
electricalPowerW: 222,
|
||||||
|
voltageV: 230,
|
||||||
|
currentA: 0.97,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitCombined: "UV_R2_TEST-3F1",
|
||||||
|
electricalPowerW: 333,
|
||||||
|
voltageV: 400,
|
||||||
|
currentA: 0.48,
|
||||||
|
},
|
||||||
|
] satisfies Record<string, ExternalXlsxScalar>[];
|
||||||
|
|
||||||
|
const updates = testValues.map((values, index) => {
|
||||||
|
const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value;
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new Error("Worksharing-ID muss Text sein.");
|
||||||
|
}
|
||||||
|
return { worksharingId: value, values };
|
||||||
|
});
|
||||||
|
const result = await createExternalXlsxReturn({
|
||||||
|
fileName: inputPath,
|
||||||
|
bytes: source,
|
||||||
|
configuration,
|
||||||
|
updates,
|
||||||
|
});
|
||||||
|
assert.equal(result.exportedObjectCount, 487);
|
||||||
|
mkdirSync(dirname(outputPath), { recursive: true });
|
||||||
|
writeFileSync(outputPath, result.bytes);
|
||||||
|
|
||||||
|
const outputDocument = await parseExternalXlsx({
|
||||||
|
fileName: outputPath,
|
||||||
|
bytes: result.bytes,
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
outputDocument.rows.filter((row) => row.classification === "object").length,
|
||||||
|
487
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
outputDocument.rows.some((row) => row.classification !== "object"),
|
||||||
|
false,
|
||||||
|
"Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten."
|
||||||
|
);
|
||||||
|
assert.equal(outputDocument.sheetName, document.sheetName);
|
||||||
|
assert.deepEqual(
|
||||||
|
outputDocument.columns,
|
||||||
|
document.columns,
|
||||||
|
"Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben."
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
outputDocument.rows.slice(updates.length).map((row) => row.cells),
|
||||||
|
objectRows.slice(updates.length).map((row) => row.cells),
|
||||||
|
"Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten."
|
||||||
|
);
|
||||||
|
const outputWorkbook = new ExcelJS.Workbook();
|
||||||
|
await outputWorkbook.xlsx.load(result.bytes as never);
|
||||||
|
const commentedCells: string[] = [];
|
||||||
|
outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => {
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell) => {
|
||||||
|
if (cell.note !== undefined) commentedCells.push(cell.address);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
commentedCells.length,
|
||||||
|
14,
|
||||||
|
"Nur die 14 Kopfzellen dürfen Kommentare enthalten."
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
commentedCells.every((address) => address.endsWith("1")),
|
||||||
|
true,
|
||||||
|
"Datenzellen dürfen keine Kommentare enthalten."
|
||||||
|
);
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
input: inputPath,
|
||||||
|
output: outputPath,
|
||||||
|
inputBytes: source.length,
|
||||||
|
outputBytes: result.bytes.length,
|
||||||
|
inputSha256: createHash("sha256").update(source).digest("hex"),
|
||||||
|
outputSha256: createHash("sha256").update(result.bytes).digest("hex"),
|
||||||
|
sheetName: document.sheetName,
|
||||||
|
objectCount: objectRows.length,
|
||||||
|
headerCommentCount: document.columns.filter(
|
||||||
|
(column) => column.comment !== null
|
||||||
|
).length,
|
||||||
|
exactDuplicateIfcGuidCount: 0,
|
||||||
|
changedObjectCount: updates.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
325
src/app/config/benutzer/page.tsx
Normal file
325
src/app/config/benutzer/page.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api";
|
||||||
|
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||||
|
import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||||
|
|
||||||
|
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BenutzerPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [users, setUsers] = useState<UserDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||||
|
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await userApi.list();
|
||||||
|
setUsers(rows);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function withBusy(userId: string, action: () => Promise<void>) {
|
||||||
|
setBusyUserId(userId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setBusyUserId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRole(item: UserDto, role: UserRole) {
|
||||||
|
await withBusy(item.id, async () => {
|
||||||
|
const updated = await userApi.update(item.id, { role });
|
||||||
|
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(item: UserDto) {
|
||||||
|
await withBusy(item.id, async () => {
|
||||||
|
const updated = await userApi.update(item.id, { active: !item.active });
|
||||||
|
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: "1.5rem" }}>
|
||||||
|
<div className="page-header" style={{ marginTop: 0 }}>
|
||||||
|
<h2 className="h5" style={{ margin: 0 }}>
|
||||||
|
Benutzer
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
|
||||||
|
Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem
|
||||||
|
Administrator angelegt. Das Startpasswort wird direkt eingetragen; die
|
||||||
|
Person muss es beim ersten Anmelden durch ein eigenes ersetzen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NeuerBenutzerFormular
|
||||||
|
onCreated={(created) => {
|
||||||
|
setUsers((list) => [...list, created]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-danger">{error}</p>}
|
||||||
|
{loading && <p>Lädt…</p>}
|
||||||
|
|
||||||
|
{!loading && users.length > 0 && (
|
||||||
|
<table className="table" style={{ marginTop: "1.5rem" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Anmeldename</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Angelegt</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((item) => {
|
||||||
|
const isSelf = item.id === currentUser?.id;
|
||||||
|
const busy = busyUserId === item.id;
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>{item.username}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={item.role}
|
||||||
|
disabled={busy || isSelf}
|
||||||
|
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||||
|
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
|
||||||
|
>
|
||||||
|
{USER_ROLES.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{USER_ROLE_LABEL[role]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.active}
|
||||||
|
disabled={busy || isSelf}
|
||||||
|
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||||
|
onChange={() => void toggleActive(item)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
|
||||||
|
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setResetPasswordFor(item)}
|
||||||
|
>
|
||||||
|
Passwort zurücksetzen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
|
||||||
|
|
||||||
|
{resetPasswordFor && (
|
||||||
|
<PasswortZuruecksetzenDialog
|
||||||
|
item={resetPasswordFor}
|
||||||
|
onClose={() => setResetPasswordFor(null)}
|
||||||
|
onDone={(updated) => {
|
||||||
|
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||||
|
setResetPasswordFor(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [role, setRole] = useState<UserRole>("user");
|
||||||
|
const [initialPassword, setInitialPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await userApi.create({ username, name, role, initialPassword });
|
||||||
|
onCreated(created);
|
||||||
|
setUsername("");
|
||||||
|
setName("");
|
||||||
|
setRole("user");
|
||||||
|
setInitialPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="row g-2 align-items-end"
|
||||||
|
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
|
||||||
|
>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label" htmlFor="new-user-username">
|
||||||
|
Anmeldename
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-user-username"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label" htmlFor="new-user-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-user-name"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<label className="form-label" htmlFor="new-user-role">
|
||||||
|
Rolle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="new-user-role"
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={role}
|
||||||
|
onChange={(event) => setRole(event.target.value as UserRole)}
|
||||||
|
>
|
||||||
|
{USER_ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{USER_ROLE_LABEL[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<label className="form-label" htmlFor="new-user-password">
|
||||||
|
Startpasswort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-user-password"
|
||||||
|
type="text"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={initialPassword}
|
||||||
|
onChange={(event) => setInitialPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-1">
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
|
||||||
|
{submitting ? "…" : "Anlegen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="col-12">
|
||||||
|
<p className="text-danger mb-0">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswortZuruecksetzenDialog({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
item: UserDto;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (updated: UserDto) => void;
|
||||||
|
}) {
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await userApi.resetPassword(item.id, newPassword);
|
||||||
|
onDone(updated);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop-inline">
|
||||||
|
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
|
||||||
|
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="reset-password">
|
||||||
|
Neues Startpasswort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-danger">{error}</p>}
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Speichert…" : "Zurücksetzen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/app/config/konto/page.tsx
Normal file
103
src/app/config/konto/page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authApi } from "../../../frontend/utils/auth-api";
|
||||||
|
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||||
|
import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||||
|
|
||||||
|
export default function KontoPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (newPassword !== newPasswordRepeat) {
|
||||||
|
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
await authApi.changePassword(currentPassword, newPassword);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setNewPasswordRepeat("");
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
|
||||||
|
<h2 className="h5">Konto</h2>
|
||||||
|
{user && (
|
||||||
|
<p className="text-body-secondary">
|
||||||
|
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
|
||||||
|
{USER_ROLE_LABEL[user.role]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="h6 mt-4">Passwort ändern</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="konto-current-password">
|
||||||
|
Aktuelles Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="konto-current-password"
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="konto-new-password">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="konto-new-password"
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label" htmlFor="konto-new-password-repeat">
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="konto-new-password-repeat"
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
value={newPasswordRepeat}
|
||||||
|
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-danger">{error}</p>}
|
||||||
|
{success && <p className="text-success">Passwort wurde geändert.</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? "Speichert…" : "Passwort ändern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/config/layout.tsx
Normal file
52
src/app/config/layout.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuth } from "../../frontend/components/AuthProvider";
|
||||||
|
import {
|
||||||
|
getConfigRedirect,
|
||||||
|
getConfigTabs,
|
||||||
|
} from "../../frontend/utils/config-access";
|
||||||
|
|
||||||
|
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { status, isAdmin } = useAuth();
|
||||||
|
const tabs = getConfigTabs(isAdmin);
|
||||||
|
const redirectTarget = getConfigRedirect(pathname, isAdmin);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "signed-in") return;
|
||||||
|
if (redirectTarget) router.replace(redirectTarget);
|
||||||
|
}, [status, redirectTarget, router]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
status !== "signed-in" ||
|
||||||
|
redirectTarget !== null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Config</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="config-tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/config/page.tsx
Normal file
3
src/app/config/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function ConfigPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -323,23 +323,24 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Square and icon-only so it fits next to Abmelden in .sidebar-bottom-row. */
|
||||||
.sidebar-theme-toggle {
|
.sidebar-theme-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
padding: 10px 14px;
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #c7d6e0;
|
color: #c7d6e0;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,13 +452,25 @@ a {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header {
|
.filter-panel-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header .filter-panel-toggle {
|
/* Expanded: the fields carry a label above their input, so toggle and
|
||||||
|
count sit on the input line (bottom) instead of the label line. */
|
||||||
|
.filter-panel-row-expanded {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-row-expanded .filter-panel-toggle,
|
||||||
|
.filter-panel-row-expanded .filter-panel-count {
|
||||||
|
margin-bottom: calc((2.1rem - 24px) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-row .filter-panel-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -467,7 +480,7 @@ a {
|
||||||
color: var(--color-ink-soft);
|
color: var(--color-ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header .filter-panel-toggle:hover {
|
.filter-panel-row .filter-panel-toggle:hover {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,10 +515,11 @@ a {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--space-sm) var(--space-md);
|
gap: var(--space-sm) var(--space-md);
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar-field,
|
.filter-bar-field,
|
||||||
|
|
@ -1114,6 +1128,17 @@ a.kpi:hover {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-device-item > summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-item > summary::marker {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-actions {
|
.sidebar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1537,10 +1562,24 @@ a.kpi:hover {
|
||||||
background: var(--panel-bg-subtle);
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .feeder-circuit-row td:nth-child(2) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .device-row td:first-child {
|
.tree-grid .device-row td:first-child {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .downstream-board-load-row td {
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .downstream-board-load-row td:nth-child(2) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .empty-circuit-row td {
|
.tree-grid .empty-circuit-row td {
|
||||||
background: var(--panel-bg-subtle);
|
background: var(--panel-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
@ -2002,3 +2041,842 @@ button.structure-entry-main:focus-visible {
|
||||||
color: var(--color-ink-soft);
|
color: var(--color-ink-soft);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
|
||||||
|
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
|
||||||
|
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
|
||||||
|
|
||||||
|
.estimate-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-saved {
|
||||||
|
color: var(--color-signal-dark);
|
||||||
|
border-color: var(--color-signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-busy {
|
||||||
|
color: var(--color-warn);
|
||||||
|
border-color: var(--color-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-hint {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-field.is-wide {
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-input {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1400px;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison {
|
||||||
|
min-width: 640px;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet th,
|
||||||
|
.estimate-sheet td {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead th {
|
||||||
|
background: var(--surface-header-row);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-th-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-col-kg { width: 3rem; }
|
||||||
|
.estimate-col-supply { width: 4rem; }
|
||||||
|
.estimate-col-name { width: auto; }
|
||||||
|
.estimate-col-quantity { width: 4.8rem; }
|
||||||
|
.estimate-col-unit { width: 3.8rem; }
|
||||||
|
.estimate-col-connection { width: 5rem; }
|
||||||
|
.estimate-col-power { width: 6.4rem; }
|
||||||
|
.estimate-col-gzf { width: 6.8rem; }
|
||||||
|
.estimate-col-cosphi { width: 3.8rem; }
|
||||||
|
.estimate-col-result { width: 4.6rem; }
|
||||||
|
.estimate-col-sv { width: 3.2rem; }
|
||||||
|
.estimate-col-remark { width: 9rem; }
|
||||||
|
.estimate-col-actions { width: 4.5rem; }
|
||||||
|
|
||||||
|
.estimate-cell-input,
|
||||||
|
.estimate-cell-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
padding: 1px 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-select {
|
||||||
|
padding-right: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input:hover:not(:disabled),
|
||||||
|
.estimate-cell-select:hover:not(:disabled) {
|
||||||
|
border-color: var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input:focus,
|
||||||
|
.estimate-cell-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input.is-numeric {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input.is-invalid,
|
||||||
|
.estimate-cell-input.is-invalid:focus,
|
||||||
|
.estimate-cell-input.is-invalid:hover {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-input,
|
||||||
|
.estimate-gzf-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
min-width: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-amev-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--panel-border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 1px 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-amev-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-muted {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-over {
|
||||||
|
color: var(--color-attention-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-under {
|
||||||
|
color: var(--color-signal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cost-group-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row td {
|
||||||
|
background: var(--surface-component-header);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs) var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-editor {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-editor .estimate-cell-input {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-actions-cell {
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-actions-cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-empty-row td {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row td {
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-label {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row td {
|
||||||
|
background: var(--surface-header-row);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong td {
|
||||||
|
font-weight: 800;
|
||||||
|
border-top: 2px solid var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-add-group-row td {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
padding: var(--space-sm) 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unassigned-row td {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-collapse-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-list-table .estimate-number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
||||||
|
.estimate-print-value {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die
|
||||||
|
Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */
|
||||||
|
.estimate-sheet td,
|
||||||
|
.estimate-sheet td.estimate-number,
|
||||||
|
.estimate-sheet .estimate-cell-input,
|
||||||
|
.estimate-sheet .estimate-cell-input.is-numeric {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-cell-select {
|
||||||
|
text-align: center;
|
||||||
|
text-align-last: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-unit-input,
|
||||||
|
.estimate-sheet .estimate-gzf-input {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet td.estimate-name-cell,
|
||||||
|
.estimate-sheet td.estimate-name-cell .estimate-cell-input,
|
||||||
|
.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell),
|
||||||
|
.estimate-comparison td:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet td.estimate-total-label {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css):
|
||||||
|
gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit
|
||||||
|
Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */
|
||||||
|
.estimate-print-titleblock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page estimate-balance {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 10mm 10mm 13mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body:has(.estimate-print-root) .sidebar,
|
||||||
|
.estimate-print-root .estimate-no-print,
|
||||||
|
.estimate-print-root .estimate-page-header,
|
||||||
|
.estimate-print-root .estimate-header-fields,
|
||||||
|
.estimate-print-root .alert,
|
||||||
|
.estimate-print-root input,
|
||||||
|
.estimate-print-root select,
|
||||||
|
.estimate-print-root button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:has(.estimate-print-root),
|
||||||
|
body:has(.estimate-print-root),
|
||||||
|
body:has(.estimate-print-root) .app-shell,
|
||||||
|
body:has(.estimate-print-root) .app-shell-content {
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den
|
||||||
|
Seitenrand dunkel. Im Druck gilt immer das helle Schema. */
|
||||||
|
html:has(.estimate-print-root) {
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-group-editor.is-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */
|
||||||
|
.estimate-sheet .estimate-col-name {
|
||||||
|
width: 52mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-col-remark {
|
||||||
|
width: 24mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-col-supply {
|
||||||
|
width: 13mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.estimate-print-root) .app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root {
|
||||||
|
--ep-blue: #476088;
|
||||||
|
--ep-lilac: #9198c4;
|
||||||
|
--ep-line: #d6dae5;
|
||||||
|
--ep-tint: #f5f6fa;
|
||||||
|
--ep-text: #2b2b2b;
|
||||||
|
--ep-muted: #6b7280;
|
||||||
|
--ep-accent: #c9782a;
|
||||||
|
page: estimate-balance;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none;
|
||||||
|
color: var(--ep-text);
|
||||||
|
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root * {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-print-value {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titelblock wie der Plankopf der Seitenansicht */
|
||||||
|
.estimate-print-titleblock {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30mm 1fr auto auto;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
border: 1.5px solid var(--ep-blue);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-titleblock > * {
|
||||||
|
padding: 2.5mm 3.5mm;
|
||||||
|
border-left: 1px solid var(--ep-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-titleblock > :first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 16mm;
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-logo img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-kicker {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-size: 7pt;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1mm 4mm;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-stand {
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-project {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
gap: 0.8mm 4mm;
|
||||||
|
align-content: center;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta div {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta dt {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta.is-tinted {
|
||||||
|
background: var(--ep-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Karten und Ränder der Bildschirmansicht entfallen */
|
||||||
|
.estimate-print-root .card {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .card-body,
|
||||||
|
.estimate-print-root .card-header {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-comparison-card {
|
||||||
|
margin-top: 6mm;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-comparison-card .card-header {
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet-wrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabelle */
|
||||||
|
.estimate-sheet {
|
||||||
|
min-width: 0;
|
||||||
|
table-layout: auto;
|
||||||
|
font-size: 7.4pt;
|
||||||
|
line-height: 1.2;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ep-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet th,
|
||||||
|
.estimate-sheet td {
|
||||||
|
border: 1px solid var(--ep-line) !important;
|
||||||
|
padding: 0.7mm 1.5mm;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ep-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead th {
|
||||||
|
background: var(--ep-blue) !important;
|
||||||
|
border-color: var(--ep-blue) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: bold;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead .estimate-th-hint {
|
||||||
|
color: #dfe3f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row td {
|
||||||
|
background: var(--ep-tint) !important;
|
||||||
|
border-top: 1.5px solid var(--ep-lilac) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-cost-group-cell {
|
||||||
|
color: var(--ep-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-group-editor {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row td {
|
||||||
|
border-bottom: 1.5px solid var(--ep-line) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row .estimate-total-label {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row td {
|
||||||
|
background: var(--ep-tint) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong td {
|
||||||
|
background: var(--ep-blue) !important;
|
||||||
|
border-color: var(--ep-blue) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row .estimate-total-hint {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong .estimate-total-hint {
|
||||||
|
color: #dfe3f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison .is-over {
|
||||||
|
color: var(--ep-accent) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison .is-under {
|
||||||
|
color: var(--ep-blue) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row,
|
||||||
|
.estimate-subtotal-row,
|
||||||
|
.estimate-total-row,
|
||||||
|
.estimate-row {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row {
|
||||||
|
break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summen einmal am Ende, nicht auf jeder Seite */
|
||||||
|
.estimate-sheet tfoot {
|
||||||
|
display: table-row-group;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-col-actions {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-input,
|
||||||
|
.estimate-gzf-input {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */
|
||||||
|
|
||||||
|
.sidebar-nav-bottom {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 8px 14px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-identity-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(199, 214, 224, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-identity-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle (square) and Abmelden (fills the rest) share one row. */
|
||||||
|
.sidebar-bottom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c7d6e0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-identity {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-bottom-row {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-gate-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
var(--color-primary-deep) 0%,
|
||||||
|
var(--color-primary-dark) 45%,
|
||||||
|
var(--color-primary) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-gate-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 28px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tab:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tab.active {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop-inline {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import Script from "next/script";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "../frontend/components/AppShell";
|
import { AppShell } from "../frontend/components/AppShell";
|
||||||
|
import { AuthProvider } from "../frontend/components/AuthProvider";
|
||||||
|
import { LoginGate } from "../frontend/components/LoginGate";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Leistungsbilanz",
|
title: "Leistungsbilanz",
|
||||||
|
|
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||||
<Script id="theme-init" strategy="beforeInteractive">
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
{THEME_INIT_SCRIPT}
|
{THEME_INIT_SCRIPT}
|
||||||
</Script>
|
</Script>
|
||||||
|
<AuthProvider>
|
||||||
|
<LoginGate>
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
|
</LoginGate>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
896
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
896
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Printer,
|
||||||
|
Redo2,
|
||||||
|
RefreshCw,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
createProjectDevice,
|
||||||
|
getEstimateBalance,
|
||||||
|
getEstimateComparison,
|
||||||
|
getProject,
|
||||||
|
getProjectHistory,
|
||||||
|
isRevisionConflictError,
|
||||||
|
listProjectDevices,
|
||||||
|
redoProjectCommand,
|
||||||
|
undoProjectCommand,
|
||||||
|
updateEstimateBalance,
|
||||||
|
} from "../../../../../frontend/utils/api";
|
||||||
|
import type {
|
||||||
|
EstimateBalanceDto,
|
||||||
|
EstimateComparisonDto,
|
||||||
|
CreateProjectDeviceInput,
|
||||||
|
ProjectDto,
|
||||||
|
ProjectDeviceDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
|
} from "../../../../../frontend/types";
|
||||||
|
import type {
|
||||||
|
EstimateGroupSnapshot,
|
||||||
|
EstimateRowSnapshot,
|
||||||
|
} from "../../../../../shared/estimate-balance/estimate-balance";
|
||||||
|
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||||
|
import { ProjectDeviceModal } from "../../../../../frontend/components/project-device-modal";
|
||||||
|
import {
|
||||||
|
addEstimateGroup,
|
||||||
|
estimateBalanceFingerprint,
|
||||||
|
estimateTextLimits,
|
||||||
|
normalizeEstimateSortOrder,
|
||||||
|
toSaveableEstimateBalance,
|
||||||
|
updateEstimateGroup,
|
||||||
|
updateEstimateRow,
|
||||||
|
validateEstimateBalance,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
||||||
|
import {
|
||||||
|
formatGermanNumber,
|
||||||
|
formatIsoDateGerman,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
||||||
|
import { createEstimateProjectDeviceInitialValues } from "../../../../../frontend/estimate-balance/estimate-project-device-prefill";
|
||||||
|
import {
|
||||||
|
EstimateBalanceSheet,
|
||||||
|
type EstimateBalanceUpdate,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
||||||
|
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
||||||
|
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
||||||
|
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
|
||||||
|
import {
|
||||||
|
EstimateGroupModal,
|
||||||
|
type EstimateGroupFormValues,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 800;
|
||||||
|
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
||||||
|
|
||||||
|
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||||
|
|
||||||
|
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||||
|
type ProjectDeviceDialog = {
|
||||||
|
group: EstimateGroupSnapshot;
|
||||||
|
row: EstimateRowSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFoundError(error: unknown) {
|
||||||
|
return error instanceof ApiRequestError && error.status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EstimateBalanceEditorPage() {
|
||||||
|
const params = useParams<{ projectId: string; balanceId: string }>();
|
||||||
|
const { projectId, balanceId } = params;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
||||||
|
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
||||||
|
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
||||||
|
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
||||||
|
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isMissing, setIsMissing] = useState(false);
|
||||||
|
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
||||||
|
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set()
|
||||||
|
);
|
||||||
|
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
||||||
|
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [projectDeviceDialog, setProjectDeviceDialog] =
|
||||||
|
useState<ProjectDeviceDialog | null>(null);
|
||||||
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
|
const [isPreparingProjectDevice, setIsPreparingProjectDevice] = useState(false);
|
||||||
|
const [isSavingProjectDevice, setIsSavingProjectDevice] = useState(false);
|
||||||
|
|
||||||
|
// Refs carry the latest values into timers, unload handlers and chained
|
||||||
|
// async saves without re-creating them.
|
||||||
|
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
||||||
|
const revisionRef = useRef<number | null>(null);
|
||||||
|
const savedFingerprintRef = useRef<string | null>(null);
|
||||||
|
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
||||||
|
const normalized = normalizeEstimateSortOrder(balance);
|
||||||
|
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
||||||
|
draftRef.current = normalized;
|
||||||
|
setDraft(normalized);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
setSaveError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadComparison = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setComparison(await getEstimateComparison(projectId, balanceId));
|
||||||
|
setComparisonError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setComparisonError(
|
||||||
|
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [balanceId, projectId]);
|
||||||
|
|
||||||
|
/** Reloads project, document and history; replaces the local draft. */
|
||||||
|
const reloadAll = useCallback(async () => {
|
||||||
|
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
||||||
|
// Handled below; avoids an "unhandled rejection" while project/history load.
|
||||||
|
balanceRequest.catch(() => undefined);
|
||||||
|
// Project and history are applied even when the balance is missing, so
|
||||||
|
// Redo stays available after its creation was undone.
|
||||||
|
const [currentProject, currentHistory] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
getProjectHistory(projectId).catch(() => null),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||||
|
setHistory(currentHistory);
|
||||||
|
const balance = await balanceRequest;
|
||||||
|
applyServerBalance(balance);
|
||||||
|
setIsMissing(false);
|
||||||
|
void reloadComparison();
|
||||||
|
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
||||||
|
|
||||||
|
const handleReloadError = useCallback((err: unknown) => {
|
||||||
|
if (isNotFoundError(err)) {
|
||||||
|
setIsMissing(true);
|
||||||
|
setDraft(null);
|
||||||
|
draftRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !balanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
reloadAll()
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch(handleReloadError)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
||||||
|
|
||||||
|
const clearSaveTimer = useCallback(() => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the current draft if it differs from the last saved state. Saves
|
||||||
|
* never overlap: a save requested while another runs waits for it and then
|
||||||
|
* saves whatever changed in the meantime. Resolves to false when the draft
|
||||||
|
* could not be saved.
|
||||||
|
*/
|
||||||
|
const saveNow = useCallback(async (): Promise<boolean> => {
|
||||||
|
clearSaveTimer();
|
||||||
|
while (saveInFlightRef.current) {
|
||||||
|
await saveInFlightRef.current;
|
||||||
|
}
|
||||||
|
const current = draftRef.current;
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (!current || revision === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const fingerprint = estimateBalanceFingerprint(current);
|
||||||
|
if (fingerprint === savedFingerprintRef.current) {
|
||||||
|
setSaveStatus("saved");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const problems = validateEstimateBalance(current);
|
||||||
|
if (problems.length) {
|
||||||
|
setSaveStatus("invalid");
|
||||||
|
setSaveError(problems.join(" "));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = toSaveableEstimateBalance(current);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another command changed the project revision. When this document is
|
||||||
|
* still unchanged on the server (e.g. an edit in a circuit list), the
|
||||||
|
* draft is kept and saved again; otherwise the server state wins.
|
||||||
|
*/
|
||||||
|
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
||||||
|
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
getEstimateBalance(projectId, balanceId),
|
||||||
|
getProjectHistory(projectId).catch(() => null),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
setHistory(currentHistory);
|
||||||
|
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||||
|
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
||||||
|
return "retry";
|
||||||
|
}
|
||||||
|
applyServerBalance(serverBalance);
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
void reloadComparison();
|
||||||
|
return "reloaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (async () => {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const result = await updateEstimateBalance(
|
||||||
|
projectId,
|
||||||
|
body,
|
||||||
|
attempt === 0 ? revision : revisionRef.current ?? revision
|
||||||
|
);
|
||||||
|
if (!("unchanged" in result)) {
|
||||||
|
revisionRef.current = result.history.currentRevision;
|
||||||
|
setHistory(result.history);
|
||||||
|
setProject((currentProject) =>
|
||||||
|
currentProject
|
||||||
|
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||||
|
: currentProject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The sent document is the saved state; the local draft is kept so
|
||||||
|
// typing is never interrupted by the server's copy.
|
||||||
|
savedFingerprintRef.current = fingerprint;
|
||||||
|
setSaveError(null);
|
||||||
|
const latest = draftRef.current;
|
||||||
|
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
||||||
|
setSaveStatus("pending");
|
||||||
|
} else {
|
||||||
|
setSaveStatus("saved");
|
||||||
|
}
|
||||||
|
void reloadComparison();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (isRevisionConflictError(err)) {
|
||||||
|
try {
|
||||||
|
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attempt > 0) {
|
||||||
|
await reloadAll();
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
}
|
||||||
|
} catch (reloadError) {
|
||||||
|
handleReloadError(reloadError);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isNotFoundError(err)) {
|
||||||
|
handleReloadError(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSaveStatus("error");
|
||||||
|
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
saveInFlightRef.current = run;
|
||||||
|
try {
|
||||||
|
return await run;
|
||||||
|
} finally {
|
||||||
|
saveInFlightRef.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
applyServerBalance,
|
||||||
|
balanceId,
|
||||||
|
clearSaveTimer,
|
||||||
|
handleReloadError,
|
||||||
|
projectId,
|
||||||
|
reloadAll,
|
||||||
|
reloadComparison,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Debounced autosave after every change of the draft.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
||||||
|
clearSaveTimer();
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
void saveNow();
|
||||||
|
}, AUTOSAVE_DELAY_MS);
|
||||||
|
return clearSaveTimer;
|
||||||
|
}, [clearSaveTimer, draft, saveNow]);
|
||||||
|
|
||||||
|
// Save immediately when the tab is hidden; on unload/unmount send the last
|
||||||
|
// change with keepalive so it survives the page.
|
||||||
|
useEffect(() => {
|
||||||
|
function hasUnsavedDraft() {
|
||||||
|
const current = draftRef.current;
|
||||||
|
return Boolean(
|
||||||
|
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function sendKeepalive(): boolean {
|
||||||
|
const current = draftRef.current;
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
revision === null ||
|
||||||
|
saveInFlightRef.current ||
|
||||||
|
validateEstimateBalance(current).length
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
||||||
|
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
||||||
|
void saveNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
if (!hasUnsavedDraft() || sendKeepalive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A save is still running or the draft is invalid: let the browser ask.
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
// In-app navigation (e.g. sidebar) unmounts the editor.
|
||||||
|
if (hasUnsavedDraft()) {
|
||||||
|
sendKeepalive();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [projectId, saveNow]);
|
||||||
|
|
||||||
|
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
||||||
|
setNotice(null);
|
||||||
|
setDraft((current) => (current ? update(current) : current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
||||||
|
setInvalidFieldKeys((current) => {
|
||||||
|
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = new Set(current);
|
||||||
|
if (isValid) {
|
||||||
|
next.delete(fieldKey);
|
||||||
|
} else {
|
||||||
|
next.add(fieldKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleHistory(direction: "undo" | "redo") {
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setIsHistoryBusy(true);
|
||||||
|
try {
|
||||||
|
// Pending edits are saved first so they become their own undo step.
|
||||||
|
await saveNow();
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (revision === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result =
|
||||||
|
direction === "undo"
|
||||||
|
? await undoProjectCommand(projectId, revision)
|
||||||
|
: await redoProjectCommand(projectId, revision);
|
||||||
|
revisionRef.current = result.history.currentRevision;
|
||||||
|
setHistory(result.history);
|
||||||
|
await reloadAll();
|
||||||
|
} catch (err) {
|
||||||
|
if (isRevisionConflictError(err)) {
|
||||||
|
await reloadAll().catch(handleReloadError);
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
} else if (isNotFoundError(err)) {
|
||||||
|
handleReloadError(err);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
errorMessage(
|
||||||
|
err,
|
||||||
|
direction === "undo"
|
||||||
|
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||||
|
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsHistoryBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
const current = draftRef.current;
|
||||||
|
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
await saveNow();
|
||||||
|
router.push(`/projects/${projectId}/grobbilanz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveGroup(values: EstimateGroupFormValues) {
|
||||||
|
const editingGroup = groupDialog?.group ?? null;
|
||||||
|
handleChange((current) =>
|
||||||
|
editingGroup
|
||||||
|
? updateEstimateGroup(current, editingGroup.id, values)
|
||||||
|
: addEstimateGroup(current, values).balance
|
||||||
|
);
|
||||||
|
setGroupDialog(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectGzf(value: number) {
|
||||||
|
if (gzfTarget) {
|
||||||
|
handleChange((current) =>
|
||||||
|
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
||||||
|
simultaneityFactor: value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setGzfTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenProjectDevice(groupId: string, rowId: string) {
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setIsPreparingProjectDevice(true);
|
||||||
|
try {
|
||||||
|
if (!(await saveNow())) {
|
||||||
|
setError(
|
||||||
|
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor ein Projektgerät angelegt werden kann."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = draftRef.current;
|
||||||
|
const group = current?.groups.find((entry) => entry.id === groupId);
|
||||||
|
const row = group?.rows.find((entry) => entry.id === rowId);
|
||||||
|
if (!group || !row) {
|
||||||
|
setError("Die gewählte Grobbilanz-Zeile ist nicht mehr vorhanden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingDevices = await listProjectDevices(projectId);
|
||||||
|
setProjectDevices(existingDevices);
|
||||||
|
setProjectDeviceDialog({ group, row });
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
errorMessage(err, "Der Projektgeräte-Dialog konnte nicht vorbereitet werden.")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsPreparingProjectDevice(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProjectDevice(input: CreateProjectDeviceInput) {
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (!projectDeviceDialog || revision === null) return;
|
||||||
|
setError(null);
|
||||||
|
setIsSavingProjectDevice(true);
|
||||||
|
try {
|
||||||
|
if (!(await saveNow())) {
|
||||||
|
setError(
|
||||||
|
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor das Projektgerät angelegt werden kann."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentRevision = revisionRef.current;
|
||||||
|
if (currentRevision === null) return;
|
||||||
|
const result = await createProjectDevice(projectId, input, currentRevision);
|
||||||
|
revisionRef.current = result.history.currentRevision;
|
||||||
|
setHistory(result.history);
|
||||||
|
setProject((currentProject) =>
|
||||||
|
currentProject
|
||||||
|
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||||
|
: currentProject
|
||||||
|
);
|
||||||
|
setProjectDevices((current) => [...current, result.projectDevice]);
|
||||||
|
setProjectDeviceDialog(null);
|
||||||
|
setNotice(`Projektgerät „${result.projectDevice.displayName}“ wurde angelegt.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (isRevisionConflictError(err)) {
|
||||||
|
await reloadAll().catch(handleReloadError);
|
||||||
|
setError(
|
||||||
|
"Der Projektstand hat sich zwischenzeitlich geändert. Der aktuelle Stand wurde geladen; bitte das Projektgerät erneut speichern."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(errorMessage(err, "Das Projektgerät konnte nicht angelegt werden."));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSavingProjectDevice(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEditingDisabled =
|
||||||
|
!draft || isHistoryBusy || isPreparingProjectDevice || isSavingProjectDevice;
|
||||||
|
const invalidCount = invalidFieldKeys.size;
|
||||||
|
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container-fluid py-4 estimate-print-root">
|
||||||
|
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
|
||||||
|
<div className="page-header estimate-page-header">
|
||||||
|
<div className="d-flex align-items-start gap-3">
|
||||||
|
<Link
|
||||||
|
aria-label="Zurück zur Übersicht der Stände"
|
||||||
|
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
||||||
|
href={listHref}
|
||||||
|
onClick={(event) => void handleBackClick(event)}
|
||||||
|
title="Zurück zur Übersicht der Stände"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={18} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||||
|
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
||||||
|
<p className="text-secondary mb-0 estimate-no-print">
|
||||||
|
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="estimate-toolbar estimate-no-print">
|
||||||
|
<SaveStatusBadge
|
||||||
|
error={saveError}
|
||||||
|
invalidCount={invalidCount}
|
||||||
|
onRetry={() => void saveNow()}
|
||||||
|
status={saveStatus}
|
||||||
|
/>
|
||||||
|
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
||||||
|
<button
|
||||||
|
aria-label="Rückgängig"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
||||||
|
onClick={() => void handleHistory("undo")}
|
||||||
|
title="Rückgängig (letzte Änderung im Projekt)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Undo2 aria-hidden="true" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Wiederholen"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
||||||
|
onClick={() => void handleHistory("redo")}
|
||||||
|
title="Wiederholen (im Projekt)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Redo2 aria-hidden="true" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Drucken"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
disabled={!draft}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
title="Drucken"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Printer aria-hidden="true" className="me-1" size={16} />
|
||||||
|
Drucken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className="alert alert-info estimate-no-print" role="status">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning estimate-no-print" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary" role="status">
|
||||||
|
Grobe Leistungsbilanz wird geladen …
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isMissing ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
||||||
|
rückgängig gemacht wurde.{" "}
|
||||||
|
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft && project ? (
|
||||||
|
<>
|
||||||
|
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="estimate-header-fields">
|
||||||
|
<div className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">Projekt</span>
|
||||||
|
<span className="estimate-header-value">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
<label className="estimate-header-field is-wide">
|
||||||
|
<span className="estimate-header-label">Stand</span>
|
||||||
|
<DraftInput
|
||||||
|
ariaLabel="Bezeichnung des Stands"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
format={(value: string) => value}
|
||||||
|
maxLength={estimateTextLimits.balanceName}
|
||||||
|
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
||||||
|
onValidityChange={(isValid) =>
|
||||||
|
handleValidityChange("balance:name", isValid)
|
||||||
|
}
|
||||||
|
parse={(text) =>
|
||||||
|
text.trim()
|
||||||
|
? { ok: true, value: text }
|
||||||
|
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
||||||
|
}
|
||||||
|
value={draft.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">LPH</span>
|
||||||
|
<input
|
||||||
|
aria-label="Leistungsphase"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
maxLength={estimateTextLimits.workStage}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleChange((current) => ({
|
||||||
|
...current,
|
||||||
|
workStage: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="z. B. LPH 3"
|
||||||
|
type="text"
|
||||||
|
value={draft.workStage ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
||||||
|
</label>
|
||||||
|
<label className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">Datum</span>
|
||||||
|
<input
|
||||||
|
aria-label="Datum"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleChange((current) => ({
|
||||||
|
...current,
|
||||||
|
dateIso: event.target.value || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="date"
|
||||||
|
value={draft.dateIso ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">
|
||||||
|
{formatIsoDateGerman(draft.dateIso)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<EstimateBalanceSheet
|
||||||
|
balance={draft}
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
onAddGroup={() => setGroupDialog({ group: null })}
|
||||||
|
onChange={handleChange}
|
||||||
|
onEditGroup={(group) => setGroupDialog({ group })}
|
||||||
|
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||||
|
onTakeOverRow={(groupId, rowId) =>
|
||||||
|
void handleOpenProjectDevice(groupId, rowId)
|
||||||
|
}
|
||||||
|
onValidityChange={handleValidityChange}
|
||||||
|
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={
|
||||||
|
isComparisonOpen
|
||||||
|
? "card shadow-sm estimate-comparison-card"
|
||||||
|
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
aria-controls="estimate-comparison-body"
|
||||||
|
aria-expanded={isComparisonOpen}
|
||||||
|
className="estimate-collapse-toggle"
|
||||||
|
onClick={() => setIsComparisonOpen((open) => !open)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<ChevronDown aria-hidden="true" size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight aria-hidden="true" size={16} />
|
||||||
|
)}
|
||||||
|
Vergleich mit detaillierter Bilanz
|
||||||
|
</button>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<button
|
||||||
|
aria-label="Vergleich neu laden"
|
||||||
|
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
||||||
|
onClick={() => void reloadComparison()}
|
||||||
|
title="Vergleich neu laden"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<div className="card-body" id="estimate-comparison-body">
|
||||||
|
{comparisonError ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
{comparisonError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
||||||
|
<p className="text-secondary small mt-2 mb-0">
|
||||||
|
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
||||||
|
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
||||||
|
Verteiler-GZF.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{groupDialog ? (
|
||||||
|
<EstimateGroupModal
|
||||||
|
group={groupDialog.group}
|
||||||
|
onClose={() => setGroupDialog(null)}
|
||||||
|
onSave={handleSaveGroup}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{gzfTarget ? (
|
||||||
|
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||||
|
) : null}
|
||||||
|
{projectDeviceDialog ? (
|
||||||
|
<ProjectDeviceModal
|
||||||
|
context={
|
||||||
|
<EstimateProjectDeviceContext
|
||||||
|
balanceName={draft?.name ?? ""}
|
||||||
|
group={projectDeviceDialog.group}
|
||||||
|
row={projectDeviceDialog.row}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
existingDevices={projectDevices}
|
||||||
|
globalDevices={[]}
|
||||||
|
initialValues={createEstimateProjectDeviceInitialValues(
|
||||||
|
projectDeviceDialog.group,
|
||||||
|
projectDeviceDialog.row
|
||||||
|
)}
|
||||||
|
isSaving={isSavingProjectDevice}
|
||||||
|
onClose={() => setProjectDeviceDialog(null)}
|
||||||
|
onImportGlobal={async () => undefined}
|
||||||
|
onSave={handleCreateProjectDevice}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EstimateProjectDeviceContext({
|
||||||
|
balanceName,
|
||||||
|
group,
|
||||||
|
row,
|
||||||
|
}: {
|
||||||
|
balanceName: string;
|
||||||
|
group: EstimateGroupSnapshot;
|
||||||
|
row: EstimateRowSnapshot;
|
||||||
|
}) {
|
||||||
|
const originalUnit = row.unit === "area" ? "m²" : "St.";
|
||||||
|
const powerUnit = row.unit === "area" ? "W/m²" : "W";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<strong>Herkunft:</strong> Stand „{balanceName}“, Kostengruppe{" "}
|
||||||
|
{group.costGroup || "ohne Nummer"}.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Originalwerte: {formatGermanNumber(row.quantity, 0, 3)} {originalUnit} ×{" "}
|
||||||
|
{formatGermanNumber(row.powerPerUnitW, 0, 3)} {powerUnit}, GZF{" "}
|
||||||
|
{formatGermanNumber(row.simultaneityFactor, 0, 3)}, cos φ{" "}
|
||||||
|
{formatGermanNumber(row.cosPhi, 0, 3)}.
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{row.unit === "area"
|
||||||
|
? "Das Projektgerät dient als Vorlage. Für Flächenwerte sind Anzahl und Leistung je Stück manuell einzutragen."
|
||||||
|
: "Das Projektgerät ist eine Vorlage mit Anzahl 1."}{" "}
|
||||||
|
Kategorie bitte prüfen.
|
||||||
|
{row.safetySupply
|
||||||
|
? " SV ist nur ein Hinweis; die Versorgungsart wird an der Verteilung festgelegt."
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveStatusBadge({
|
||||||
|
error,
|
||||||
|
invalidCount,
|
||||||
|
onRetry,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
error: string | null;
|
||||||
|
invalidCount: number;
|
||||||
|
onRetry: () => void;
|
||||||
|
status: SaveStatus;
|
||||||
|
}) {
|
||||||
|
const label =
|
||||||
|
status === "saving"
|
||||||
|
? "Wird gespeichert …"
|
||||||
|
: status === "pending"
|
||||||
|
? "Ungespeicherte Änderungen"
|
||||||
|
: status === "error"
|
||||||
|
? "Nicht gespeichert – Fehler"
|
||||||
|
: status === "invalid"
|
||||||
|
? "Nicht gespeichert – ungültige Eingaben"
|
||||||
|
: "Gespeichert";
|
||||||
|
const tone =
|
||||||
|
status === "error" || status === "invalid"
|
||||||
|
? "is-error"
|
||||||
|
: status === "saved"
|
||||||
|
? "is-saved"
|
||||||
|
: "is-busy";
|
||||||
|
return (
|
||||||
|
<div className="estimate-save-status" role="status" aria-live="polite">
|
||||||
|
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{invalidCount ? (
|
||||||
|
<span className="estimate-save-hint">
|
||||||
|
{invalidCount === 1
|
||||||
|
? "1 ungültige Eingabe wird nicht gespeichert"
|
||||||
|
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{status === "error" ? (
|
||||||
|
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createEstimateBalance,
|
||||||
|
deleteEstimateBalance,
|
||||||
|
getProject,
|
||||||
|
listEstimateBalances,
|
||||||
|
} from "../../../../frontend/utils/api";
|
||||||
|
import type {
|
||||||
|
CreateEstimateBalanceInput,
|
||||||
|
EstimateBalanceDto,
|
||||||
|
ProjectCommandResultDto,
|
||||||
|
ProjectDto,
|
||||||
|
} from "../../../../frontend/types";
|
||||||
|
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
|
||||||
|
import {
|
||||||
|
EstimateBalanceCreateModal,
|
||||||
|
type EstimateBalanceCreateDefaults,
|
||||||
|
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
|
||||||
|
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
|
||||||
|
import {
|
||||||
|
formatIsoDateGerman,
|
||||||
|
formatKw,
|
||||||
|
} from "../../../../frontend/estimate-balance/estimate-balance-format";
|
||||||
|
|
||||||
|
function countRows(balance: EstimateBalanceDto) {
|
||||||
|
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EstimateBalanceListPage() {
|
||||||
|
const params = useParams<{ projectId: string }>();
|
||||||
|
const projectId = params.projectId;
|
||||||
|
const router = useRouter();
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
|
||||||
|
const [createDefaults, setCreateDefaults] =
|
||||||
|
useState<EstimateBalanceCreateDefaults | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const [currentProject, loadedBalances] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
listEstimateBalances(projectId),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
reload()
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [projectId, reload]);
|
||||||
|
|
||||||
|
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||||
|
execute: (expectedRevision: number) => Promise<TResult>,
|
||||||
|
fallbackMessage: string
|
||||||
|
): Promise<TResult | null> {
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await execute(project.currentRevision);
|
||||||
|
setProject((current) =>
|
||||||
|
current ? { ...current, currentRevision: result.history.currentRevision } : current
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||||
|
// Refresh revision and list so a stale state does not block the next
|
||||||
|
// attempt; the command error stays visible.
|
||||||
|
await reload().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(input: CreateEstimateBalanceInput) {
|
||||||
|
const result = await runCommand(
|
||||||
|
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
|
||||||
|
"Stand konnte nicht angelegt werden."
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
setCreateDefaults(null);
|
||||||
|
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(balance: EstimateBalanceDto) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runCommand(
|
||||||
|
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
|
||||||
|
"Stand konnte nicht gelöscht werden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = isSaving || !project;
|
||||||
|
const voltage = project?.threePhaseVoltageV ?? 400;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-4">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="d-flex align-items-start gap-3">
|
||||||
|
<Link
|
||||||
|
aria-label="Zurück zur Projektübersicht"
|
||||||
|
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
title="Zurück zur Projektübersicht"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={18} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||||
|
<h1>Grobe Leistungsbilanz</h1>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
|
||||||
|
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setCreateDefaults({})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="me-1" size={16} />
|
||||||
|
Neuer Stand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary" role="status">
|
||||||
|
Grobe Leistungsbilanzen werden geladen …
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 className="structure-column-title" id="estimate-list-heading">
|
||||||
|
Stände
|
||||||
|
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{balances.length ? (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover align-middle mb-0 estimate-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Stand</th>
|
||||||
|
<th>LPH</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th className="text-end">AV kW</th>
|
||||||
|
<th className="text-end">AV kVA</th>
|
||||||
|
<th className="text-end">SV kW</th>
|
||||||
|
<th className="text-end">SV kVA</th>
|
||||||
|
<th className="text-end">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{balances.map((balance) => {
|
||||||
|
const result = calculateEstimateBalance(balance, voltage);
|
||||||
|
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
|
||||||
|
const rowCount = countRows(balance);
|
||||||
|
return (
|
||||||
|
<tr key={balance.id}>
|
||||||
|
<td>
|
||||||
|
<Link className="fw-semibold" href={href}>
|
||||||
|
{balance.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
{balance.groups.length} Kostengruppen ·{" "}
|
||||||
|
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{balance.workStage ?? "–"}</td>
|
||||||
|
<td>{formatIsoDateGerman(balance.dateIso)}</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.av.kw)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.av.kva)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.sv.kw)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.sv.kva)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="d-inline-flex gap-1">
|
||||||
|
<Link
|
||||||
|
aria-label={`Stand „${balance.name}“ öffnen`}
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
href={href}
|
||||||
|
title="Öffnen"
|
||||||
|
>
|
||||||
|
<FolderOpen aria-hidden="true" className="me-1" size={14} />
|
||||||
|
Öffnen
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
|
||||||
|
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() =>
|
||||||
|
setCreateDefaults({
|
||||||
|
name: createCopyName(balance.name),
|
||||||
|
workStage: balance.workStage,
|
||||||
|
template: "copy",
|
||||||
|
copyFromBalanceId: balance.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Als neuen Stand kopieren"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Stand „${balance.name}“ löschen`}
|
||||||
|
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleDelete(balance)}
|
||||||
|
title="Stand löschen"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="text-secondary small px-3 py-2 mb-0">
|
||||||
|
Leistungen jeweils inklusive Reserve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !isLoading ? (
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="structure-empty mb-0">
|
||||||
|
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
|
||||||
|
Basis der Vorlage KG 400 anlegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{createDefaults ? (
|
||||||
|
<EstimateBalanceCreateModal
|
||||||
|
balances={balances}
|
||||||
|
defaults={createDefaults}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setCreateDefaults(null)}
|
||||||
|
onCreate={(input) => void handleCreate(input)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileUp,
|
FileSpreadsheet,
|
||||||
Library,
|
Library,
|
||||||
Link2,
|
Link2,
|
||||||
ListTree,
|
ListTree,
|
||||||
|
|
@ -47,15 +47,16 @@ import {
|
||||||
import {
|
import {
|
||||||
copyGlobalDeviceToProject,
|
copyGlobalDeviceToProject,
|
||||||
copyDistributionBoard,
|
copyDistributionBoard,
|
||||||
|
createFeederCircuitAndUpdateDistributionBoard,
|
||||||
copyProjectDeviceToGlobal,
|
copyProjectDeviceToGlobal,
|
||||||
createDistributionBoard,
|
createDistributionBoard,
|
||||||
createProjectDevice,
|
createProjectDevice,
|
||||||
createRoom,
|
|
||||||
deleteProjectDevice,
|
deleteProjectDevice,
|
||||||
deleteDistributionBoard,
|
deleteDistributionBoard,
|
||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
exportProjectTransfer,
|
exportProjectTransfer,
|
||||||
getProject,
|
getProject,
|
||||||
|
getProjectCircuitTrees,
|
||||||
getProjectDeviceSyncPreview,
|
getProjectDeviceSyncPreview,
|
||||||
listBuildings,
|
listBuildings,
|
||||||
listCircuitLists,
|
listCircuitLists,
|
||||||
|
|
@ -70,10 +71,17 @@ import {
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
} from "../../../frontend/utils/api";
|
} from "../../../frontend/utils/api";
|
||||||
|
import { createClientId } from "../../../frontend/utils/client-id";
|
||||||
|
import { formatValue } from "../../../frontend/utils/circuit-grid-model";
|
||||||
|
import {
|
||||||
|
buildFeederCircuitSnapshot,
|
||||||
|
getDefaultFeederCircuitSection,
|
||||||
|
getNextFeederCircuitIdentifier,
|
||||||
|
} from "../../../frontend/utils/distribution-board-feeder-circuit";
|
||||||
import type {
|
import type {
|
||||||
BuildingDto,
|
BuildingDto,
|
||||||
CircuitListDto,
|
CircuitListDto,
|
||||||
CreateRoomInput,
|
CircuitTreeResponseDto,
|
||||||
CreateProjectDeviceInput,
|
CreateProjectDeviceInput,
|
||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
FloorDto,
|
FloorDto,
|
||||||
|
|
@ -105,7 +113,6 @@ import {
|
||||||
} from "../../../frontend/components/project-settings-modal";
|
} from "../../../frontend/components/project-settings-modal";
|
||||||
import { FormModal } from "../../../frontend/components/form-modal";
|
import { FormModal } from "../../../frontend/components/form-modal";
|
||||||
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
||||||
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
|
|
||||||
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
|
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
|
||||||
|
|
||||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
|
|
@ -144,6 +151,7 @@ const boardFilterCollapsedStorageKey =
|
||||||
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
|
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
|
||||||
const projectDeviceFilterCollapsedStorageKey =
|
const projectDeviceFilterCollapsedStorageKey =
|
||||||
"leistungsbilanz:overview-filter-collapsed:project-devices";
|
"leistungsbilanz:overview-filter-collapsed:project-devices";
|
||||||
|
const createFeederCircuitSelectValue = "__create_feeder_circuit__";
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const params = useParams<{ projectId: string }>();
|
const params = useParams<{ projectId: string }>();
|
||||||
|
|
@ -151,6 +159,7 @@ export default function ProjectDetailPage() {
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
||||||
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
||||||
|
const [circuitTrees, setCircuitTrees] = useState<CircuitTreeResponseDto[]>([]);
|
||||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||||
|
|
@ -170,6 +179,14 @@ export default function ProjectDetailPage() {
|
||||||
setEditingBoardSimultaneityFactor,
|
setEditingBoardSimultaneityFactor,
|
||||||
] = useState("1");
|
] = useState("1");
|
||||||
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
||||||
|
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
|
||||||
|
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
|
||||||
|
const [isCreatingFeederCircuit, setIsCreatingFeederCircuit] =
|
||||||
|
useState(false);
|
||||||
|
const [editingFeederCircuitSectionId, setEditingFeederCircuitSectionId] =
|
||||||
|
useState("");
|
||||||
|
const [editingFeederCircuitDisplayName, setEditingFeederCircuitDisplayName] =
|
||||||
|
useState("");
|
||||||
const [editingBoardInfo, setEditingBoardInfo] =
|
const [editingBoardInfo, setEditingBoardInfo] =
|
||||||
useState<DistributionBoardInfoDraft>(
|
useState<DistributionBoardInfoDraft>(
|
||||||
toDistributionBoardInfoDraft({
|
toDistributionBoardInfoDraft({
|
||||||
|
|
@ -180,7 +197,6 @@ export default function ProjectDetailPage() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||||
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
|
|
||||||
const [structureModal, setStructureModal] = useState<"board" | null>(null);
|
const [structureModal, setStructureModal] = useState<"board" | null>(null);
|
||||||
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
@ -209,6 +225,7 @@ export default function ProjectDetailPage() {
|
||||||
getProject(projectId),
|
getProject(projectId),
|
||||||
listDistributionBoards(projectId),
|
listDistributionBoards(projectId),
|
||||||
listCircuitLists(projectId),
|
listCircuitLists(projectId),
|
||||||
|
getProjectCircuitTrees(projectId),
|
||||||
listBuildings(projectId),
|
listBuildings(projectId),
|
||||||
listFloors(projectId),
|
listFloors(projectId),
|
||||||
listRooms(projectId),
|
listRooms(projectId),
|
||||||
|
|
@ -219,6 +236,7 @@ export default function ProjectDetailPage() {
|
||||||
currentProject,
|
currentProject,
|
||||||
distributionBoards,
|
distributionBoards,
|
||||||
loadedCircuitLists,
|
loadedCircuitLists,
|
||||||
|
loadedCircuitTrees,
|
||||||
loadedBuildings,
|
loadedBuildings,
|
||||||
loadedFloors,
|
loadedFloors,
|
||||||
loadedRooms,
|
loadedRooms,
|
||||||
|
|
@ -228,6 +246,7 @@ export default function ProjectDetailPage() {
|
||||||
setProject(currentProject);
|
setProject(currentProject);
|
||||||
setBoards(distributionBoards);
|
setBoards(distributionBoards);
|
||||||
setCircuitLists(loadedCircuitLists);
|
setCircuitLists(loadedCircuitLists);
|
||||||
|
setCircuitTrees(loadedCircuitTrees.trees);
|
||||||
setBuildings(loadedBuildings);
|
setBuildings(loadedBuildings);
|
||||||
setFloors(loadedFloors);
|
setFloors(loadedFloors);
|
||||||
setRooms(loadedRooms);
|
setRooms(loadedRooms);
|
||||||
|
|
@ -310,6 +329,63 @@ export default function ProjectDetailPage() {
|
||||||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||||||
[circuitLists]
|
[circuitLists]
|
||||||
);
|
);
|
||||||
|
const circuitTreeByBoardId = useMemo(
|
||||||
|
() => new Map(circuitTrees.map((tree) => [tree.distributionBoardId, tree])),
|
||||||
|
[circuitTrees]
|
||||||
|
);
|
||||||
|
const feedingBoardOptions = useMemo(() => {
|
||||||
|
if (!editingBoard) return [];
|
||||||
|
const boardById = new Map(boards.map((board) => [board.id, board]));
|
||||||
|
return boards.filter((candidate) => {
|
||||||
|
if (candidate.id === editingBoard.id) return false;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let current: DistributionBoardDto | undefined = candidate;
|
||||||
|
while (current && current.feedingDistributionBoardId && !visited.has(current.id)) {
|
||||||
|
if (current.feedingDistributionBoardId === editingBoard.id) return false;
|
||||||
|
visited.add(current.id);
|
||||||
|
current = boardById.get(current.feedingDistributionBoardId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [boards, editingBoard]);
|
||||||
|
const selectedFeedingTree = editingFeedingBoardId
|
||||||
|
? circuitTreeByBoardId.get(editingFeedingBoardId)
|
||||||
|
: undefined;
|
||||||
|
const selectedFeedingCircuits = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedFeedingTree?.sections.flatMap((section) =>
|
||||||
|
section.circuits.map((circuit) => ({ circuit, section }))
|
||||||
|
) ?? [],
|
||||||
|
[selectedFeedingTree]
|
||||||
|
);
|
||||||
|
const selectedFeedingCircuit = selectedFeedingCircuits.find(
|
||||||
|
({ circuit }) => circuit.id === editingFeedingCircuitId
|
||||||
|
);
|
||||||
|
const feederCircuitSections =
|
||||||
|
selectedFeedingTree?.sections.filter(
|
||||||
|
(section) => section.category !== undefined
|
||||||
|
) ?? [];
|
||||||
|
const selectedNewFeederCircuitSection = feederCircuitSections.find(
|
||||||
|
(section) => section.id === editingFeederCircuitSectionId
|
||||||
|
);
|
||||||
|
const newFeederCircuitIdentifier = selectedNewFeederCircuitSection
|
||||||
|
? getNextFeederCircuitIdentifier(selectedNewFeederCircuitSection)
|
||||||
|
: null;
|
||||||
|
const selectedFeedingCircuitCategory =
|
||||||
|
selectedFeedingCircuit?.section.category ??
|
||||||
|
(isCreatingFeederCircuit
|
||||||
|
? selectedNewFeederCircuitSection?.category
|
||||||
|
: undefined);
|
||||||
|
const selectedFeedingBoard = boards.find(
|
||||||
|
(board) => board.id === editingFeedingBoardId
|
||||||
|
);
|
||||||
|
const selectedFeedingCircuitUseCount = editingFeedingCircuitId
|
||||||
|
? boards.filter(
|
||||||
|
(board) =>
|
||||||
|
board.id !== editingBoard?.id &&
|
||||||
|
board.feedingCircuitId === editingFeedingCircuitId
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
const enabledBoardSupplyTypes =
|
const enabledBoardSupplyTypes =
|
||||||
project?.enabledDistributionBoardSupplyTypes ?? [];
|
project?.enabledDistributionBoardSupplyTypes ?? [];
|
||||||
const usedBoardSupplyTypes = useMemo(
|
const usedBoardSupplyTypes = useMemo(
|
||||||
|
|
@ -329,6 +405,21 @@ export default function ProjectDetailPage() {
|
||||||
[boards]
|
[boards]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function formatBoardFeedingReference(board: DistributionBoardDto) {
|
||||||
|
if (!board.feedingDistributionBoardId) return board.fedFrom ?? "–";
|
||||||
|
const feedingBoard = boards.find(
|
||||||
|
(candidate) => candidate.id === board.feedingDistributionBoardId
|
||||||
|
);
|
||||||
|
if (!feedingBoard) return "Unbekannte Verteilung";
|
||||||
|
const boardLabel = feedingBoard.boardNumber ?? feedingBoard.name;
|
||||||
|
if (!board.feedingCircuitId) return boardLabel;
|
||||||
|
const circuit = circuitTreeByBoardId
|
||||||
|
.get(feedingBoard.id)
|
||||||
|
?.sections.flatMap((section) => section.circuits)
|
||||||
|
.find((candidate) => candidate.id === board.feedingCircuitId);
|
||||||
|
return `${boardLabel}${circuit?.equipmentIdentifier ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyProjectRevision(currentRevision: number) {
|
function applyProjectRevision(currentRevision: number) {
|
||||||
setProject((current) =>
|
setProject((current) =>
|
||||||
current ? { ...current, currentRevision } : current
|
current ? { ...current, currentRevision } : current
|
||||||
|
|
@ -358,6 +449,7 @@ export default function ProjectDetailPage() {
|
||||||
]);
|
]);
|
||||||
setCircuitLists(await listCircuitLists(projectId));
|
setCircuitLists(await listCircuitLists(projectId));
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setBoardName("");
|
setBoardName("");
|
||||||
setBoardFloorId("");
|
setBoardFloorId("");
|
||||||
setBoardSupplyType(
|
setBoardSupplyType(
|
||||||
|
|
@ -488,6 +580,11 @@ export default function ProjectDetailPage() {
|
||||||
);
|
);
|
||||||
setEditingBoardCopyName(`${board.name} Kopie`);
|
setEditingBoardCopyName(`${board.name} Kopie`);
|
||||||
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
|
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
|
||||||
|
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
|
||||||
|
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
|
||||||
|
setIsCreatingFeederCircuit(false);
|
||||||
|
setEditingFeederCircuitSectionId("");
|
||||||
|
setEditingFeederCircuitDisplayName("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBoardCreator() {
|
function openBoardCreator() {
|
||||||
|
|
@ -516,25 +613,56 @@ export default function ProjectDetailPage() {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await updateDistributionBoard(
|
const boardInput = {
|
||||||
projectId,
|
|
||||||
editingBoard.id,
|
|
||||||
{
|
|
||||||
floorId: editingBoardFloorId || null,
|
floorId: editingBoardFloorId || null,
|
||||||
supplyType: editingBoardSupplyType,
|
supplyType: editingBoardSupplyType,
|
||||||
simultaneityFactor,
|
simultaneityFactor,
|
||||||
|
feedingDistributionBoardId: editingFeedingBoardId || null,
|
||||||
|
feedingCircuitId: editingFeedingCircuitId || null,
|
||||||
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
||||||
|
};
|
||||||
|
let result;
|
||||||
|
if (isCreatingFeederCircuit) {
|
||||||
|
if (!editingFeedingBoardId || !selectedFeedingTree) {
|
||||||
|
throw new Error("Bitte eine speisende Verteilung wählen.");
|
||||||
|
}
|
||||||
|
if (!selectedNewFeederCircuitSection) {
|
||||||
|
throw new Error(
|
||||||
|
"Bitte eine Stromkreisgruppe für den neuen Abgang wählen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const circuit = buildFeederCircuitSnapshot({
|
||||||
|
id: createClientId(),
|
||||||
|
tree: selectedFeedingTree,
|
||||||
|
sectionId: selectedNewFeederCircuitSection.id,
|
||||||
|
displayName: editingFeederCircuitDisplayName,
|
||||||
|
});
|
||||||
|
result = await createFeederCircuitAndUpdateDistributionBoard(
|
||||||
|
projectId,
|
||||||
|
editingBoard.id,
|
||||||
|
{
|
||||||
|
...boardInput,
|
||||||
|
feedingDistributionBoardId: editingFeedingBoardId,
|
||||||
|
feedingCircuitId: circuit.id,
|
||||||
},
|
},
|
||||||
|
circuit,
|
||||||
project.currentRevision
|
project.currentRevision
|
||||||
);
|
);
|
||||||
setBoards((current) =>
|
} else {
|
||||||
current.map((board) =>
|
result = await updateDistributionBoard(
|
||||||
board.id === result.distributionBoard.id
|
projectId,
|
||||||
? result.distributionBoard
|
editingBoard.id,
|
||||||
: board
|
boardInput,
|
||||||
)
|
project.currentRevision
|
||||||
);
|
);
|
||||||
|
}
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
const [updatedBoards, updatedTrees] = await Promise.all([
|
||||||
|
listDistributionBoards(projectId),
|
||||||
|
getProjectCircuitTrees(projectId),
|
||||||
|
]);
|
||||||
|
setBoards(updatedBoards);
|
||||||
|
setCircuitTrees(updatedTrees.trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -547,22 +675,6 @@ export default function ProjectDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateRevitRoom(input: CreateRoomInput) {
|
|
||||||
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
|
|
||||||
const result = await createRoom(projectId, input, project.currentRevision);
|
|
||||||
setRooms((current) => [...current, result.room]);
|
|
||||||
applyProjectRevision(result.history.currentRevision);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) {
|
|
||||||
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
|
|
||||||
const result = await createProjectDevice(projectId, input, project.currentRevision);
|
|
||||||
setProjectDevices((current) => [...current, result.projectDevice]);
|
|
||||||
applyProjectRevision(result.history.currentRevision);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopyBoard() {
|
async function handleCopyBoard() {
|
||||||
if (
|
if (
|
||||||
!projectId ||
|
!projectId ||
|
||||||
|
|
@ -590,6 +702,7 @@ export default function ProjectDetailPage() {
|
||||||
result.circuitList,
|
result.circuitList,
|
||||||
]);
|
]);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -633,6 +746,7 @@ export default function ProjectDetailPage() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
applyProjectRevision(result.history.currentRevision);
|
applyProjectRevision(result.history.currentRevision);
|
||||||
|
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
|
||||||
setEditingBoard(null);
|
setEditingBoard(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
|
@ -863,15 +977,13 @@ export default function ProjectDetailPage() {
|
||||||
<Printer aria-hidden="true" size={16} />
|
<Printer aria-hidden="true" size={16} />
|
||||||
Seitenansicht
|
Seitenansicht
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
className="btn btn-outline-primary btn-with-icon"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
href={`/projects/${projectId}/revit`}
|
||||||
onClick={() => setIsRevitCsvOpen(true)}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
|
<FileSpreadsheet aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Revit-CSV
|
Revit
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-with-icon"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
|
|
@ -989,6 +1101,7 @@ export default function ProjectDetailPage() {
|
||||||
<th>Örtlichkeit</th>
|
<th>Örtlichkeit</th>
|
||||||
<th>Etage</th>
|
<th>Etage</th>
|
||||||
<th>Netzart</th>
|
<th>Netzart</th>
|
||||||
|
<th>Gespeist von</th>
|
||||||
<th>GZF</th>
|
<th>GZF</th>
|
||||||
<th className="text-end">Aktionen</th>
|
<th className="text-end">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1015,6 +1128,7 @@ export default function ProjectDetailPage() {
|
||||||
]
|
]
|
||||||
: "Nicht festgelegt"}
|
: "Nicht festgelegt"}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatBoardFeedingReference(board)}</td>
|
||||||
<td>
|
<td>
|
||||||
{board.simultaneityFactor.toLocaleString(
|
{board.simultaneityFactor.toLocaleString(
|
||||||
"de-DE",
|
"de-DE",
|
||||||
|
|
@ -1159,10 +1273,11 @@ export default function ProjectDetailPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{device.totalPower} kW
|
{formatValue(device.totalPower, "rowTotalPower")} kW
|
||||||
<div className="small text-secondary">
|
<div className="small text-secondary">
|
||||||
{device.quantity} × {device.powerPerUnit} kW · GZF{" "}
|
{formatValue(device.quantity, "quantity")} ×{" "}
|
||||||
{device.simultaneityFactor}
|
{formatValue(device.powerPerUnit, "powerPerUnit")} kW · GZF{" "}
|
||||||
|
{formatValue(device.simultaneityFactor, "simultaneityFactor")}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{device.costGroup ?? "-"}</td>
|
<td>{device.costGroup ?? "-"}</td>
|
||||||
|
|
@ -1392,7 +1507,7 @@ export default function ProjectDetailPage() {
|
||||||
|
|
||||||
{editingBoard ? (
|
{editingBoard ? (
|
||||||
<FormModal
|
<FormModal
|
||||||
description={`${editingBoard.name}: Etagenzuordnung, Netzart und Gleichzeitigkeitsfaktor ändern.`}
|
description={`${editingBoard.name}: Etagenzuordnung, Netzart, Einspeisung und Gleichzeitigkeitsfaktor ändern.`}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onClose={() => setEditingBoard(null)}
|
onClose={() => setEditingBoard(null)}
|
||||||
onSubmit={handleUpdateBoard}
|
onSubmit={handleUpdateBoard}
|
||||||
|
|
@ -1479,6 +1594,175 @@ export default function ProjectDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section className="card mt-4" aria-labelledby="feeding-reference-heading">
|
||||||
|
<div className="card-header" id="feeding-reference-heading">
|
||||||
|
Logische Einspeisung
|
||||||
|
</div>
|
||||||
|
<div className="card-body row g-3">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="feeding-distribution-board">
|
||||||
|
Speisende Verteilung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="feeding-distribution-board"
|
||||||
|
onChange={(event) => {
|
||||||
|
const boardId = event.target.value;
|
||||||
|
setEditingFeedingBoardId(boardId);
|
||||||
|
setEditingFeedingCircuitId("");
|
||||||
|
setIsCreatingFeederCircuit(false);
|
||||||
|
setEditingFeederCircuitSectionId(
|
||||||
|
getDefaultFeederCircuitSection(
|
||||||
|
circuitTreeByBoardId.get(boardId)
|
||||||
|
)?.id ?? ""
|
||||||
|
);
|
||||||
|
setEditingFeederCircuitDisplayName("");
|
||||||
|
}}
|
||||||
|
value={editingFeedingBoardId}
|
||||||
|
>
|
||||||
|
<option value="">Keine Projektverteilung</option>
|
||||||
|
{feedingBoardOptions.map((board) => (
|
||||||
|
<option key={board.id} value={board.id}>
|
||||||
|
{board.boardNumber ?? board.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="feeding-circuit">
|
||||||
|
Speisender Abgang
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
disabled={!editingFeedingBoardId}
|
||||||
|
id="feeding-circuit"
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value === createFeederCircuitSelectValue) {
|
||||||
|
setEditingFeedingCircuitId("");
|
||||||
|
setIsCreatingFeederCircuit(true);
|
||||||
|
setEditingFeederCircuitSectionId(
|
||||||
|
(current) =>
|
||||||
|
current ||
|
||||||
|
getDefaultFeederCircuitSection(selectedFeedingTree)?.id ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreatingFeederCircuit(false);
|
||||||
|
setEditingFeedingCircuitId(event.target.value);
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
isCreatingFeederCircuit
|
||||||
|
? createFeederCircuitSelectValue
|
||||||
|
: editingFeedingCircuitId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Nur Verteilung bekannt</option>
|
||||||
|
<option value={createFeederCircuitSelectValue}>
|
||||||
|
+ Neuen Einspeiseabgang anlegen …
|
||||||
|
</option>
|
||||||
|
{selectedFeedingCircuits.map(({ circuit }) => (
|
||||||
|
<option key={circuit.id} value={circuit.id}>
|
||||||
|
{circuit.equipmentIdentifier}
|
||||||
|
{circuit.displayName ? ` · ${circuit.displayName}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isCreatingFeederCircuit ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="new-feeder-circuit-section">
|
||||||
|
Stromkreisgruppe
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="new-feeder-circuit-section"
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditingFeederCircuitSectionId(event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
value={editingFeederCircuitSectionId}
|
||||||
|
>
|
||||||
|
{feederCircuitSections.map((section) => (
|
||||||
|
<option key={section.id} value={section.id}>
|
||||||
|
{section.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="form-text">
|
||||||
|
Vorgesehenes BMK: {newFeederCircuitIdentifier ?? "–"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="new-feeder-circuit-name">
|
||||||
|
Abgangsbezeichnung (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="new-feeder-circuit-name"
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditingFeederCircuitDisplayName(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="z. B. Steigetrasse Ost"
|
||||||
|
value={editingFeederCircuitDisplayName}
|
||||||
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
Ohne Zusatzbezeichnung wird der Name der gespeisten
|
||||||
|
Verteilung angezeigt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-text mt-2">
|
||||||
|
Der Abgang wird erst beim Speichern angelegt und gemeinsam
|
||||||
|
mit dieser Verteilung verknüpft. Es entsteht keine Gerätezeile.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{editingFeedingBoardId &&
|
||||||
|
!editingFeedingCircuitId &&
|
||||||
|
!isCreatingFeederCircuit ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
|
||||||
|
Leistung dieser Verteilung aber keinem Stromkreis zugerechnet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingBoard?.supplyType &&
|
||||||
|
editingBoardSupplyType !== selectedFeedingBoard.supplyType ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Die Netzarten weichen ab: {selectedFeedingBoard.supplyType}
|
||||||
|
{" → "}{editingBoardSupplyType}. Bitte Umschaltung und
|
||||||
|
Versorgungskonzept prüfen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingCircuitCategory !== undefined &&
|
||||||
|
selectedFeedingCircuitCategory !== "three_phase" ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-warning mb-0 py-2">
|
||||||
|
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese
|
||||||
|
Einspeisung für die Unterverteilung geeignet ist.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedFeedingCircuitUseCount > 0 ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-info mb-0 py-2">
|
||||||
|
Dieser Abgang speist bereits {selectedFeedingCircuitUseCount}
|
||||||
|
{selectedFeedingCircuitUseCount === 1
|
||||||
|
? " weitere Verteilung."
|
||||||
|
: " weitere Verteilungen."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DistributionBoardInfoCard
|
<DistributionBoardInfoCard
|
||||||
draft={editingBoardInfo}
|
draft={editingBoardInfo}
|
||||||
|
|
@ -1591,16 +1875,6 @@ export default function ProjectDetailPage() {
|
||||||
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{project && isRevitCsvOpen ? (
|
|
||||||
<RevitCsvModal
|
|
||||||
currentRevision={project.currentRevision}
|
|
||||||
onClose={() => setIsRevitCsvOpen(false)}
|
|
||||||
onCreateProjectDevice={handleCreateRevitProjectDevice}
|
|
||||||
onCreateRoom={handleCreateRevitRoom}
|
|
||||||
onRevisionChange={applyProjectRevision}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@ import "../../../../frontend/print/print-document.css";
|
||||||
import {
|
import {
|
||||||
deleteDocumentLogo,
|
deleteDocumentLogo,
|
||||||
documentLogoUrl,
|
documentLogoUrl,
|
||||||
getCircuitTree,
|
getProjectCircuitTrees,
|
||||||
getDocumentBranding,
|
getDocumentBranding,
|
||||||
getProject,
|
getProject,
|
||||||
listCircuitLists,
|
|
||||||
listDistributionBoards,
|
listDistributionBoards,
|
||||||
listFloors,
|
listFloors,
|
||||||
listProjectSnapshots,
|
listProjectSnapshots,
|
||||||
|
|
@ -31,7 +30,7 @@ import {
|
||||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
import { PrintDocument } from "../../../../frontend/print/print-document";
|
||||||
import {
|
import {
|
||||||
assertPrintRevisionConsistency,
|
assertPrintRevisionConsistency,
|
||||||
buildPreliminaryBoardHierarchy,
|
buildBoardHierarchy,
|
||||||
summarizePrintBoard,
|
summarizePrintBoard,
|
||||||
type PrintBoardSummary,
|
type PrintBoardSummary,
|
||||||
} from "../../../../frontend/print/print-document-model";
|
} from "../../../../frontend/print/print-document-model";
|
||||||
|
|
@ -89,17 +88,20 @@ export default function ProjectPrintPage() {
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
const [project, boards, circuitLists, floors, snapshots, branding] =
|
const [project, boards, projectTrees, floors, snapshots, branding] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getProject(projectId),
|
getProject(projectId),
|
||||||
listDistributionBoards(projectId),
|
listDistributionBoards(projectId),
|
||||||
listCircuitLists(projectId),
|
getProjectCircuitTrees(projectId),
|
||||||
listFloors(projectId),
|
listFloors(projectId),
|
||||||
listProjectSnapshots(projectId),
|
listProjectSnapshots(projectId),
|
||||||
getDocumentBranding(),
|
getDocumentBranding(),
|
||||||
]);
|
]);
|
||||||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||||||
const summaries = await Promise.all(
|
const treeByBoardId = new Map(
|
||||||
|
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
|
||||||
|
);
|
||||||
|
const summaries =
|
||||||
[...boards]
|
[...boards]
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
|
|
@ -107,19 +109,16 @@ export default function ProjectPrintPage() {
|
||||||
boardSortCollator.compare(left.name, right.name)
|
boardSortCollator.compare(left.name, right.name)
|
||||||
)
|
)
|
||||||
.flatMap((board) => {
|
.flatMap((board) => {
|
||||||
const circuitList = circuitLists.find(
|
const tree = treeByBoardId.get(board.id);
|
||||||
(list) => list.distributionBoardId === board.id
|
return tree ? [{ board, tree }] : [];
|
||||||
);
|
|
||||||
return circuitList ? [{ board, circuitList }] : [];
|
|
||||||
})
|
})
|
||||||
.map(async ({ board, circuitList }) =>
|
.map(({ board, tree }) =>
|
||||||
summarizePrintBoard({
|
summarizePrintBoard({
|
||||||
board,
|
board,
|
||||||
circuitListId: circuitList.id,
|
circuitListId: tree.circuitListId,
|
||||||
tree: await getCircuitTree(projectId, circuitList.id),
|
tree,
|
||||||
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const finalProject = await getProject(projectId);
|
const finalProject = await getProject(projectId);
|
||||||
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
|
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
|
||||||
|
|
@ -148,7 +147,7 @@ export default function ProjectPrintPage() {
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
|
() => (data ? buildBoardHierarchy(data.summaries) : []),
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
const stand = useMemo(
|
const stand = useMemo(
|
||||||
|
|
|
||||||
54
src/app/projects/[projectId]/revit/page.tsx
Normal file
54
src/app/projects/[projectId]/revit/page.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, FileSpreadsheet, Settings, Upload } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { IconLink } from "../../../../frontend/components/icon-link";
|
||||||
|
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
|
||||||
|
import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard";
|
||||||
|
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
|
||||||
|
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
||||||
|
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
||||||
|
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
|
||||||
|
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
|
||||||
|
import type { ExternalInitialImportStatusDto, ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
|
||||||
|
|
||||||
|
export default function RevitPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
|
||||||
|
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [followUpOpen, setFollowUpOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getProject(projectId), getExternalModelConfiguration(projectId), getExternalInitialImportStatus(projectId)])
|
||||||
|
.then(([loadedProject, configuration, status]) => { setProject(loadedProject); setSnapshot(configuration.configuration); setImportStatus(status); })
|
||||||
|
.catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden."));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
async function save(configuration: ExternalModelConfiguration) {
|
||||||
|
if (!project) return;
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
try {
|
||||||
|
const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration);
|
||||||
|
setSnapshot(result.configuration);
|
||||||
|
setProject({ ...project, currentRevision: result.history.currentRevision });
|
||||||
|
setSettingsOpen(false);
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <main className="container py-4">
|
||||||
|
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
|
||||||
|
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
|
||||||
|
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
|
||||||
|
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
|
||||||
|
{followUpOpen && project && snapshot ? <RevitFollowUpImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setFollowUpOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setFollowUpOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
@ -48,13 +48,17 @@ import {
|
||||||
filterRooms,
|
filterRooms,
|
||||||
groupFloorsByBuilding,
|
groupFloorsByBuilding,
|
||||||
hasFloorsWithoutBuilding,
|
hasFloorsWithoutBuilding,
|
||||||
|
hasRoomsWithoutFloor,
|
||||||
normalizeBuildingSelection,
|
normalizeBuildingSelection,
|
||||||
normalizeFloorSelection,
|
normalizeFloorSelection,
|
||||||
showsRoomsWithoutFloorEntry,
|
showsRoomsWithoutFloorEntry,
|
||||||
|
summarizeRoomAreas,
|
||||||
|
type RoomAreaSummary,
|
||||||
type StructureSelection,
|
type StructureSelection,
|
||||||
} from "../../../../frontend/utils/building-structure";
|
} from "../../../../frontend/utils/building-structure";
|
||||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||||
|
import { formatRoomArea } from "../../../../frontend/utils/room-area";
|
||||||
|
|
||||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||||
|
|
@ -68,6 +72,23 @@ function formatCount(count: number, singular: string, plural: string) {
|
||||||
return `${count} ${count === 1 ? singular : plural}`;
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAreaSummary(summary: RoomAreaSummary) {
|
||||||
|
return `${formatRoomArea(summary.areaM2)} m² · ${formatCount(
|
||||||
|
summary.missingAreaCount,
|
||||||
|
"Raum ohne Fläche",
|
||||||
|
"Räume ohne Fläche"
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStructureMeta(
|
||||||
|
count: number,
|
||||||
|
singular: string,
|
||||||
|
plural: string,
|
||||||
|
summary: RoomAreaSummary
|
||||||
|
) {
|
||||||
|
return `${formatCount(count, singular, plural)} · ${formatAreaSummary(summary)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||||
return (
|
return (
|
||||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||||
|
|
@ -307,7 +328,25 @@ export default function BuildingStructurePage() {
|
||||||
() => countRoomsByFloor(rooms, floors),
|
() => countRoomsByFloor(rooms, floors),
|
||||||
[floors, rooms]
|
[floors, rooms]
|
||||||
);
|
);
|
||||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
const areaSummaries = useMemo(
|
||||||
|
() => summarizeRoomAreas(rooms, floors, buildings),
|
||||||
|
[buildings, floors, rooms]
|
||||||
|
);
|
||||||
|
const emptyAreaSummary: RoomAreaSummary = {
|
||||||
|
areaM2: 0,
|
||||||
|
roomCount: 0,
|
||||||
|
missingAreaCount: 0,
|
||||||
|
};
|
||||||
|
const selectedBuildingAreaSummary =
|
||||||
|
effectiveBuildingSelection === ALL_SELECTION
|
||||||
|
? areaSummaries.total
|
||||||
|
: areaSummaries.byBuilding.get(
|
||||||
|
effectiveBuildingSelection === UNASSIGNED_SELECTION
|
||||||
|
? null
|
||||||
|
: effectiveBuildingSelection
|
||||||
|
) ?? emptyAreaSummary;
|
||||||
|
const showUnassignedBuilding =
|
||||||
|
hasFloorsWithoutBuilding(floors, buildings) || hasRoomsWithoutFloor(rooms, floors);
|
||||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||||
rooms,
|
rooms,
|
||||||
floors,
|
floors,
|
||||||
|
|
@ -435,7 +474,8 @@ export default function BuildingStructurePage() {
|
||||||
editingRoom &&
|
editingRoom &&
|
||||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||||
editingRoom.roomName === input.roomName.trim() &&
|
editingRoom.roomName === input.roomName.trim() &&
|
||||||
editingRoom.floorId === (input.floorId || null)
|
editingRoom.floorId === (input.floorId || null) &&
|
||||||
|
editingRoom.areaM2 === input.areaM2
|
||||||
) {
|
) {
|
||||||
setDialog(null);
|
setDialog(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -536,7 +576,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||||
label="Alle"
|
label="Alle"
|
||||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
meta={formatStructureMeta(
|
||||||
|
floors.length,
|
||||||
|
"Etage",
|
||||||
|
"Etagen",
|
||||||
|
areaSummaries.total
|
||||||
|
)}
|
||||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||||
/>
|
/>
|
||||||
{buildings.map((building) => (
|
{buildings.map((building) => (
|
||||||
|
|
@ -553,10 +598,11 @@ export default function BuildingStructurePage() {
|
||||||
isSelected={effectiveBuildingSelection === building.id}
|
isSelected={effectiveBuildingSelection === building.id}
|
||||||
key={building.id}
|
key={building.id}
|
||||||
label={building.name}
|
label={building.name}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
floorCountByBuilding.get(building.id) ?? 0,
|
floorCountByBuilding.get(building.id) ?? 0,
|
||||||
"Etage",
|
"Etage",
|
||||||
"Etagen"
|
"Etagen",
|
||||||
|
areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => selectBuilding(building.id)}
|
onSelect={() => selectBuilding(building.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -565,10 +611,11 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
floorCountByBuilding.get(null) ?? 0,
|
floorCountByBuilding.get(null) ?? 0,
|
||||||
"Etage",
|
"Etage",
|
||||||
"Etagen"
|
"Etagen",
|
||||||
|
areaSummaries.byBuilding.get(null) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -591,7 +638,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||||
label="Alle"
|
label="Alle"
|
||||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
meta={formatStructureMeta(
|
||||||
|
roomsInBuildingSelection,
|
||||||
|
"Raum",
|
||||||
|
"Räume",
|
||||||
|
selectedBuildingAreaSummary
|
||||||
|
)}
|
||||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||||
/>
|
/>
|
||||||
{visibleFloorGroups.map((group) => (
|
{visibleFloorGroups.map((group) => (
|
||||||
|
|
@ -613,10 +665,11 @@ export default function BuildingStructurePage() {
|
||||||
isSelected={effectiveFloorSelection === floor.id}
|
isSelected={effectiveFloorSelection === floor.id}
|
||||||
key={floor.id}
|
key={floor.id}
|
||||||
label={floor.name}
|
label={floor.name}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
roomCountByFloor.get(floor.id) ?? 0,
|
roomCountByFloor.get(floor.id) ?? 0,
|
||||||
"Raum",
|
"Raum",
|
||||||
"Räume"
|
"Räume",
|
||||||
|
areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => setFloorSelection(floor.id)}
|
onSelect={() => setFloorSelection(floor.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -627,7 +680,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
meta={formatStructureMeta(
|
||||||
|
roomCountByFloor.get(null) ?? 0,
|
||||||
|
"Raum",
|
||||||
|
"Räume",
|
||||||
|
areaSummaries.byFloor.get(null) ?? emptyAreaSummary
|
||||||
|
)}
|
||||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -671,6 +729,11 @@ export default function BuildingStructurePage() {
|
||||||
{room.roomName}
|
{room.roomName}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
meta={
|
||||||
|
room.areaM2 === null
|
||||||
|
? "Fläche fehlt"
|
||||||
|
: `${formatRoomArea(room.areaM2)} m²`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!visibleRooms.length && !isLoading ? (
|
{!visibleRooms.length && !isLoading ? (
|
||||||
|
|
|
||||||
705
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
705
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/;
|
||||||
|
// the module is an optional calculator attached to the circuit list and never
|
||||||
|
// writes project data itself (see docs/cable-sizing-module.md).
|
||||||
|
//
|
||||||
|
// Reference data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with
|
||||||
|
// Berichtigung 1:2023-10. The table-by-table scope and the separate sources
|
||||||
|
// required for voltage drop and protection coordination are documented in
|
||||||
|
// docs/cable-sizing-module.md.
|
||||||
|
|
||||||
|
// -- Reference installation methods (DIN VDE 0298-4, Table 2) ---------------
|
||||||
|
|
||||||
|
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const;
|
||||||
|
export type LayingMethod = (typeof LAYING_METHODS)[number];
|
||||||
|
|
||||||
|
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
|
||||||
|
A1: "air",
|
||||||
|
A2: "air",
|
||||||
|
B1: "air",
|
||||||
|
B2: "air",
|
||||||
|
C: "air",
|
||||||
|
E: "air",
|
||||||
|
D: "ground",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
|
||||||
|
A1: "A1 – Einzeladern im Rohr in wärmegedämmter Wand",
|
||||||
|
A2: "A2 – Mehradrige Leitung im Rohr in wärmegedämmter Wand",
|
||||||
|
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
|
||||||
|
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
|
||||||
|
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
|
||||||
|
D: "D – Mehradriges Kabel im Rohr oder Kanal im Erdreich",
|
||||||
|
E: "E – Mehradrige Leitung frei in Luft",
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Insulation / conductor material ----------------------------------------
|
||||||
|
|
||||||
|
export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const;
|
||||||
|
export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number];
|
||||||
|
|
||||||
|
export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
|
||||||
|
pvc: "PVC (70 °C), z. B. NYM, NYY",
|
||||||
|
xlpe: "VPE/XLPE (90 °C), z. B. N2XH, NHXH",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
|
||||||
|
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
|
||||||
|
|
||||||
|
// -- Harmonic neutral loading (DIN VDE 0298-4, Annex B, three-phase only) ---
|
||||||
|
|
||||||
|
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
|
||||||
|
"none",
|
||||||
|
"15to33Percent",
|
||||||
|
"over33Percent",
|
||||||
|
] as const;
|
||||||
|
export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number];
|
||||||
|
|
||||||
|
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
|
||||||
|
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
|
||||||
|
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
|
||||||
|
over33Percent: "3. Harmonische > 33 % – ohne Neutralleiterstrom keine Empfehlung",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HARMONIC_REDUCTION_FACTOR = 0.86;
|
||||||
|
|
||||||
|
// -- Current-carrying capacity tables ----------------------------------------
|
||||||
|
|
||||||
|
export const CROSS_SECTIONS_MM2 = [
|
||||||
|
1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type CoreCount = 2 | 3;
|
||||||
|
type CapacityRow = readonly (number | null)[];
|
||||||
|
type CapacityTable = Record<LayingMethod, Record<CoreCount, CapacityRow>>;
|
||||||
|
|
||||||
|
const n = null;
|
||||||
|
|
||||||
|
/** Iz in A at 30 °C air / 20 °C ground, soil resistivity 2.5 K·m/W. */
|
||||||
|
export const CURRENT_CAPACITY_A: Record<
|
||||||
|
InsulationMaterial,
|
||||||
|
Record<ConductorMaterial, CapacityTable>
|
||||||
|
> = {
|
||||||
|
pvc: {
|
||||||
|
copper: {
|
||||||
|
// DIN VDE 0298-4, tables 3 and 4.
|
||||||
|
A1: {
|
||||||
|
2: [15.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 321],
|
||||||
|
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
|
||||||
|
},
|
||||||
|
A2: {
|
||||||
|
2: [15.5, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
|
||||||
|
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
|
||||||
|
},
|
||||||
|
B1: {
|
||||||
|
2: [17.5, 24, 32, 41, 57, 76, 101, 125, 151, 192, 232, 269, 300, 341, 400],
|
||||||
|
3: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, 262, 296, 346],
|
||||||
|
},
|
||||||
|
B2: {
|
||||||
|
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 133, 168, 201, 232, 258, 294, 344],
|
||||||
|
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, 225, 255, 297],
|
||||||
|
},
|
||||||
|
C: {
|
||||||
|
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
|
||||||
|
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
|
||||||
|
},
|
||||||
|
D: {
|
||||||
|
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
|
||||||
|
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
|
||||||
|
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aluminum: {
|
||||||
|
// DIN VDE 0298-4 aluminium values; the normalization below removes all
|
||||||
|
// entries below the first tabulated aluminium size of 25 mm².
|
||||||
|
A1: {
|
||||||
|
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
|
||||||
|
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
|
||||||
|
},
|
||||||
|
A2: {
|
||||||
|
2: [n, 14.5, 19.5, 25, 33, 44, 58, 71, 86, 108, 130, 150, 172, 195, 229],
|
||||||
|
3: [n, 13.5, 17.5, 23, 31, 41, 53, 65, 78, 98, 118, 135, 155, 176, 207],
|
||||||
|
},
|
||||||
|
B1: {
|
||||||
|
2: [n, 18.5, 25, 32, 44, 60, 79, 97, 118, 150, 181, 210, 234, 266, 312],
|
||||||
|
3: [n, 16.5, 22, 28, 39, 53, 70, 86, 104, 133, 161, 186, 204, 230, 269],
|
||||||
|
},
|
||||||
|
B2: {
|
||||||
|
2: [n, 17.5, 24, 30, 41, 54, 71, 86, 104, 131, 157, 181, 201, 230, 269],
|
||||||
|
3: [n, 15.5, 21, 27, 36, 48, 62, 77, 92, 116, 139, 160, 176, 199, 232],
|
||||||
|
},
|
||||||
|
C: {
|
||||||
|
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
|
||||||
|
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
|
||||||
|
},
|
||||||
|
D: {
|
||||||
|
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
|
||||||
|
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
|
||||||
|
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xlpe: {
|
||||||
|
copper: {
|
||||||
|
// B.52.3 / B.52.5; E: B.52.12 multi-core.
|
||||||
|
A1: {
|
||||||
|
2: [19, 26, 35, 45, 61, 81, 106, 131, 158, 200, 241, 278, 318, 362, 424],
|
||||||
|
3: [17, 23, 31, 40, 54, 73, 95, 117, 141, 179, 216, 249, 285, 324, 380],
|
||||||
|
},
|
||||||
|
A2: {
|
||||||
|
2: [18.5, 25, 33, 42, 57, 76, 99, 121, 145, 183, 220, 253, 290, 329, 386],
|
||||||
|
3: [16.5, 22, 30, 38, 51, 68, 89, 109, 130, 164, 197, 227, 259, 295, 346],
|
||||||
|
},
|
||||||
|
B1: {
|
||||||
|
2: [23, 31, 42, 54, 75, 100, 133, 164, 198, 253, 306, 354, 393, 449, 528],
|
||||||
|
3: [20, 28, 37, 48, 66, 88, 117, 144, 175, 222, 269, 312, 342, 384, 450],
|
||||||
|
},
|
||||||
|
B2: {
|
||||||
|
2: [22, 30, 40, 51, 69, 91, 119, 146, 175, 221, 265, 305, 334, 384, 459],
|
||||||
|
3: [19.5, 26, 35, 44, 60, 80, 105, 128, 154, 194, 233, 268, 300, 340, 398],
|
||||||
|
},
|
||||||
|
C: {
|
||||||
|
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
|
||||||
|
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
|
||||||
|
},
|
||||||
|
D: {
|
||||||
|
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
|
||||||
|
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
|
||||||
|
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aluminum: {
|
||||||
|
// B.52.3 / B.52.5 aluminium; E: B.52.13.
|
||||||
|
A1: {
|
||||||
|
2: [n, 20, 27, 35, 48, 64, 84, 103, 125, 158, 191, 220, 253, 288, 338],
|
||||||
|
3: [n, 19, 25, 32, 44, 58, 76, 94, 113, 142, 171, 197, 226, 256, 300],
|
||||||
|
},
|
||||||
|
A2: {
|
||||||
|
2: [n, 19.5, 26, 33, 45, 60, 78, 96, 115, 145, 175, 201, 230, 262, 307],
|
||||||
|
3: [n, 18, 24, 31, 41, 55, 71, 87, 104, 131, 157, 180, 206, 233, 273],
|
||||||
|
},
|
||||||
|
B1: {
|
||||||
|
2: [n, 25, 33, 43, 59, 79, 105, 130, 157, 200, 242, 281, 307, 351, 412],
|
||||||
|
3: [n, 22, 29, 38, 52, 71, 93, 116, 140, 179, 217, 251, 267, 300, 351],
|
||||||
|
},
|
||||||
|
B2: {
|
||||||
|
2: [n, 23, 31, 40, 54, 72, 94, 115, 138, 175, 210, 242, 261, 300, 358],
|
||||||
|
3: [n, 21, 28, 35, 48, 64, 84, 103, 124, 156, 188, 216, 240, 272, 318],
|
||||||
|
},
|
||||||
|
C: {
|
||||||
|
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
|
||||||
|
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
|
||||||
|
},
|
||||||
|
D: {
|
||||||
|
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
|
||||||
|
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
|
||||||
|
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// DIN VDE 0298-4 only supplies aluminium current-carrying capacities from
|
||||||
|
// 25 mm² onward. Smaller values from the former IEC data set must therefore
|
||||||
|
// never participate in a recommendation.
|
||||||
|
for (const insulation of INSULATION_MATERIALS) {
|
||||||
|
for (const method of LAYING_METHODS) {
|
||||||
|
for (const coreCount of [2, 3] as const) {
|
||||||
|
const row = CURRENT_CAPACITY_A[insulation].aluminum[method][coreCount] as (number | null)[];
|
||||||
|
CROSS_SECTIONS_MM2.forEach((crossSection, index) => {
|
||||||
|
if (crossSection < 25) {
|
||||||
|
row[index] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Correction factors ------------------------------------------------------
|
||||||
|
|
||||||
|
/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */
|
||||||
|
export const TEMPERATURE_FACTORS: Record<
|
||||||
|
"air" | "ground",
|
||||||
|
Record<InsulationMaterial, Record<number, number>>
|
||||||
|
> = {
|
||||||
|
air: {
|
||||||
|
pvc: { 10: 1.22, 15: 1.17, 20: 1.12, 25: 1.06, 30: 1.0, 35: 0.94, 40: 0.87, 45: 0.79, 50: 0.71, 55: 0.61, 60: 0.5 },
|
||||||
|
xlpe: {
|
||||||
|
10: 1.15, 15: 1.12, 20: 1.08, 25: 1.04, 30: 1.0, 35: 0.96, 40: 0.91, 45: 0.87,
|
||||||
|
50: 0.82, 55: 0.76, 60: 0.71, 65: 0.65, 70: 0.58, 75: 0.5, 80: 0.41,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ground: {
|
||||||
|
pvc: { 10: 1.1, 15: 1.05, 20: 1.0, 25: 0.95, 30: 0.89, 35: 0.84, 40: 0.77, 45: 0.71, 50: 0.63, 55: 0.55, 60: 0.45 },
|
||||||
|
xlpe: {
|
||||||
|
10: 1.07, 15: 1.04, 20: 1.0, 25: 0.96, 30: 0.93, 35: 0.89, 40: 0.85, 45: 0.8,
|
||||||
|
50: 0.76, 55: 0.71, 60: 0.65, 65: 0.6, 70: 0.53, 75: 0.46, 80: 0.38,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
|
||||||
|
air: 30,
|
||||||
|
ground: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouping reduction factors according to the corrected references in
|
||||||
|
* DIN VDE 0298-4 Berichtigung 1:2023-10:
|
||||||
|
* A1–C use table 22, row 1 (bundled/touching), D uses table 26 for touching
|
||||||
|
* conduits/ducts in earth. Method E needs details about support, spacing and
|
||||||
|
* number of trays from table 23, which the current input model does not carry;
|
||||||
|
* it is therefore supported only for one circuit.
|
||||||
|
*/
|
||||||
|
export const GROUPING_FACTORS: Record<"A1_TO_C" | "D" | "E", Record<number, number>> = {
|
||||||
|
A1_TO_C: {
|
||||||
|
1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52,
|
||||||
|
9: 0.5, 10: 0.48, 12: 0.45, 14: 0.43, 16: 0.41, 18: 0.39, 20: 0.38,
|
||||||
|
},
|
||||||
|
D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 },
|
||||||
|
E: { 1: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupingTableFor(method: LayingMethod) {
|
||||||
|
return method === "D"
|
||||||
|
? GROUPING_FACTORS.D
|
||||||
|
: method === "E"
|
||||||
|
? GROUPING_FACTORS.E
|
||||||
|
: GROUPING_FACTORS.A1_TO_C;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxGroupingCircuits(method: LayingMethod): number {
|
||||||
|
return Math.max(...Object.keys(groupingTableFor(method)).map(Number));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a tabulated factor. Values between tabulated keys use the next
|
||||||
|
* higher key (the lower, safer factor); values above the table are not
|
||||||
|
* supported (null) and values below the smallest key use the smallest key
|
||||||
|
* (whose factor is lower than the true one, i.e. safe).
|
||||||
|
*/
|
||||||
|
function lookupConservative(table: Record<number, number>, value: number): { key: number; factor: number } | null {
|
||||||
|
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
|
||||||
|
const key = keys.find((candidate) => candidate >= value);
|
||||||
|
if (key === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { key, factor: table[key] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabulated temperatures offered by the UI for a laying method and insulation. */
|
||||||
|
export function temperatureOptions(method: LayingMethod, insulation: InsulationMaterial): number[] {
|
||||||
|
return Object.keys(TEMPERATURE_FACTORS[LAYING_METHOD_GROUP[method]][insulation])
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Conductor resistivity for the voltage drop -------------------------------
|
||||||
|
|
||||||
|
/** Conductivity in m/(Ω·mm²) at the maximum operating temperature (conservative). */
|
||||||
|
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
|
||||||
|
|
||||||
|
// -- Practical minimum cross-sections by circuit category --------------------
|
||||||
|
// A planning convention from docs/spec/06-future-sizing-and-calculations.md
|
||||||
|
// ("Standard Single-Phase Circuits ... cable cross-section: 2.5 mm²"), not a
|
||||||
|
// calculation result. It only ever raises a recommendation.
|
||||||
|
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
|
||||||
|
|
||||||
|
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
|
||||||
|
single_phase: 2.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Protective device --------------------------------------------------------
|
||||||
|
|
||||||
|
export const PROTECTION_KINDS = ["breaker", "fuse"] as const;
|
||||||
|
export type ProtectionKind = (typeof PROTECTION_KINDS)[number];
|
||||||
|
|
||||||
|
export interface ExistingProtection {
|
||||||
|
ratedCurrentA: number;
|
||||||
|
/** breaker: circuit-breaker (I2 ≤ 1.45 In); fuse: gG fuse (I2 up to 2.1 In). */
|
||||||
|
kind: ProtectionKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conventional tripping/fusing current I2 as multiple of In.
|
||||||
|
* Circuit-breakers (IEC 60898-1): 1.45. gG fuses (IEC 60269-2):
|
||||||
|
* In ≤ 4 A: 2.1, 4 A < In < 16 A: 1.9, In ≥ 16 A: 1.6.
|
||||||
|
*/
|
||||||
|
export function conventionalTrippingFactor(protection: ExistingProtection): number {
|
||||||
|
if (protection.kind === "breaker") {
|
||||||
|
return 1.45;
|
||||||
|
}
|
||||||
|
if (protection.ratedCurrentA <= 4) {
|
||||||
|
return 2.1;
|
||||||
|
}
|
||||||
|
return protection.ratedCurrentA < 16 ? 1.9 : 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum Iz required by overload protection (IEC 60364-4-43, 433.1):
|
||||||
|
* In ≤ Iz and I2 ≤ 1.45 · Iz.
|
||||||
|
*/
|
||||||
|
export function minimumIzForProtection(protection: ExistingProtection): number {
|
||||||
|
return Math.max(
|
||||||
|
protection.ratedCurrentA,
|
||||||
|
(conventionalTrippingFactor(protection) * protection.ratedCurrentA) / 1.45
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Input / result -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CableSizingInput {
|
||||||
|
phase: 1 | 3;
|
||||||
|
mode: "power" | "current";
|
||||||
|
/** kW, only used when mode === "power" */
|
||||||
|
powerKw?: number;
|
||||||
|
/** A, only used when mode === "current" */
|
||||||
|
currentA?: number;
|
||||||
|
cosPhi: number;
|
||||||
|
/** Line voltage in V (project single-/three-phase voltage). */
|
||||||
|
voltage: number;
|
||||||
|
/** Single-run cable length in m. */
|
||||||
|
lengthM: number;
|
||||||
|
layingMethod: LayingMethod;
|
||||||
|
conductorMaterial: ConductorMaterial;
|
||||||
|
insulation: InsulationMaterial;
|
||||||
|
/** Ambient air temperature, or ground temperature for D. */
|
||||||
|
ambientTemperatureC: number;
|
||||||
|
/** Number of grouped circuits or multi-core cables, including this one. */
|
||||||
|
groupingCircuits: number;
|
||||||
|
maxVoltageDropPercent: number;
|
||||||
|
harmonicNeutralLoad: HarmonicNeutralLoad;
|
||||||
|
existingProtection?: ExistingProtection;
|
||||||
|
circuitCategory?: CircuitCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrossSectionRow {
|
||||||
|
crossSectionMm2: number;
|
||||||
|
/** Tabulated Iz; null if the table has no value for this size. */
|
||||||
|
ratedCurrentA: number | null;
|
||||||
|
correctedCurrentA: number | null;
|
||||||
|
currentSufficient: boolean | null;
|
||||||
|
voltageDropPercent: number | null;
|
||||||
|
voltageDropSufficient: boolean | null;
|
||||||
|
/** Longest single run meeting maxVoltageDropPercent at this load. */
|
||||||
|
maxLengthForVoltageDropM: number | null;
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectionCoordinationResult {
|
||||||
|
ratedCurrentA: number;
|
||||||
|
kind: ProtectionKind;
|
||||||
|
/** I2 / In used for the check. */
|
||||||
|
conventionalTrippingFactor: number;
|
||||||
|
/** In ≤ Iz and I2 ≤ 1.45 · Iz for the recommended cross-section. */
|
||||||
|
coordinated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnsupportedReason = "temperature" | "grouping" | "harmonics";
|
||||||
|
|
||||||
|
export interface CableSizingResult {
|
||||||
|
/** false: the inputs are outside the tabulated range – no numeric result. */
|
||||||
|
supported: boolean;
|
||||||
|
unsupportedReason: UnsupportedReason | null;
|
||||||
|
operatingCurrentA: number;
|
||||||
|
/**
|
||||||
|
* Current the cross-section must carry after correction:
|
||||||
|
* max(Ib, In, I2/1.45) with an existing protective device, otherwise Ib.
|
||||||
|
*/
|
||||||
|
designCurrentA: number;
|
||||||
|
/** Tabulated temperature/grouping actually used (rounded to the safe side). */
|
||||||
|
temperatureUsedC: number | null;
|
||||||
|
groupingUsed: number | null;
|
||||||
|
temperatureFactor: number | null;
|
||||||
|
groupingFactor: number | null;
|
||||||
|
crossSectionByCapacityMm2: number | null;
|
||||||
|
crossSectionByVoltageDropMm2: number | null;
|
||||||
|
recommendedCrossSectionMm2: number | null;
|
||||||
|
voltageDropAtRecommendedPercent: number | null;
|
||||||
|
combinedDerationFactor: number;
|
||||||
|
harmonicReductionApplied: boolean;
|
||||||
|
practicalMinimumApplied: boolean;
|
||||||
|
rows: CrossSectionRow[];
|
||||||
|
protectionCoordination: ProtectionCoordinationResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatingCurrentA(input: CableSizingInput): number {
|
||||||
|
if (input.mode === "current") {
|
||||||
|
return input.currentA ?? 0;
|
||||||
|
}
|
||||||
|
const powerW = (input.powerKw ?? 0) * 1000;
|
||||||
|
return input.phase === 1
|
||||||
|
? powerW / (input.voltage * input.cosPhi)
|
||||||
|
: powerW / (Math.sqrt(3) * input.voltage * input.cosPhi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
|
||||||
|
const ib = operatingCurrentA(input);
|
||||||
|
const designCurrentA = input.existingProtection
|
||||||
|
? Math.max(ib, minimumIzForProtection(input.existingProtection))
|
||||||
|
: ib;
|
||||||
|
const group = LAYING_METHOD_GROUP[input.layingMethod];
|
||||||
|
const temperature = lookupConservative(
|
||||||
|
TEMPERATURE_FACTORS[group][input.insulation],
|
||||||
|
input.ambientTemperatureC
|
||||||
|
);
|
||||||
|
const grouping =
|
||||||
|
input.groupingCircuits <= maxGroupingCircuits(input.layingMethod)
|
||||||
|
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
|
||||||
|
: null;
|
||||||
|
const harmonicsSupported = !(
|
||||||
|
input.phase === 3 && input.harmonicNeutralLoad === "over33Percent"
|
||||||
|
);
|
||||||
|
const unsupportedReason: UnsupportedReason | null = !temperature
|
||||||
|
? "temperature"
|
||||||
|
: !grouping
|
||||||
|
? "grouping"
|
||||||
|
: !harmonicsSupported
|
||||||
|
? "harmonics"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const empty: CableSizingResult = {
|
||||||
|
supported: false,
|
||||||
|
unsupportedReason,
|
||||||
|
operatingCurrentA: ib,
|
||||||
|
designCurrentA,
|
||||||
|
temperatureUsedC: temperature?.key ?? null,
|
||||||
|
groupingUsed: grouping?.key ?? null,
|
||||||
|
temperatureFactor: temperature?.factor ?? null,
|
||||||
|
groupingFactor: grouping?.factor ?? null,
|
||||||
|
crossSectionByCapacityMm2: null,
|
||||||
|
crossSectionByVoltageDropMm2: null,
|
||||||
|
recommendedCrossSectionMm2: null,
|
||||||
|
voltageDropAtRecommendedPercent: null,
|
||||||
|
combinedDerationFactor: 1,
|
||||||
|
harmonicReductionApplied: false,
|
||||||
|
practicalMinimumApplied: false,
|
||||||
|
rows: [],
|
||||||
|
protectionCoordination: null,
|
||||||
|
};
|
||||||
|
if (unsupportedReason || !temperature || !grouping) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
|
||||||
|
const harmonicReductionApplied =
|
||||||
|
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
|
||||||
|
const combinedDerationFactor =
|
||||||
|
temperature.factor * grouping.factor * (harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1);
|
||||||
|
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
|
||||||
|
const kappa = KAPPA[input.conductorMaterial];
|
||||||
|
|
||||||
|
const ratedCurrents =
|
||||||
|
CURRENT_CAPACITY_A[input.insulation][input.conductorMaterial][input.layingMethod][coreCount];
|
||||||
|
const correctedCurrents = ratedCurrents.map((value) =>
|
||||||
|
value == null ? null : value * combinedDerationFactor
|
||||||
|
);
|
||||||
|
const voltageDrops = CROSS_SECTIONS_MM2.map(
|
||||||
|
(crossSection) =>
|
||||||
|
((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) / (kappa * crossSection) / input.voltage) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexByCapacity = correctedCurrents.findIndex(
|
||||||
|
(value) => value != null && value >= designCurrentA
|
||||||
|
);
|
||||||
|
const indexByVoltageDrop = voltageDrops.findIndex(
|
||||||
|
(value, index) => ratedCurrents[index] != null && value <= input.maxVoltageDropPercent
|
||||||
|
);
|
||||||
|
let indexRecommended =
|
||||||
|
indexByCapacity >= 0 && indexByVoltageDrop >= 0 ? Math.max(indexByCapacity, indexByVoltageDrop) : -1;
|
||||||
|
|
||||||
|
const practicalMinimumMm2 = input.circuitCategory
|
||||||
|
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory]
|
||||||
|
: undefined;
|
||||||
|
let practicalMinimumApplied = false;
|
||||||
|
if (indexRecommended >= 0 && practicalMinimumMm2 != null && CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2) {
|
||||||
|
const minimumIndex = CROSS_SECTIONS_MM2.indexOf(practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number]);
|
||||||
|
if (minimumIndex >= 0 && ratedCurrents[minimumIndex] != null) {
|
||||||
|
indexRecommended = minimumIndex;
|
||||||
|
practicalMinimumApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const voltageDropCurrentBasis = ib * input.cosPhi;
|
||||||
|
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, index) => {
|
||||||
|
const rated = ratedCurrents[index];
|
||||||
|
const corrected = correctedCurrents[index];
|
||||||
|
return {
|
||||||
|
crossSectionMm2: crossSection,
|
||||||
|
ratedCurrentA: rated,
|
||||||
|
correctedCurrentA: corrected,
|
||||||
|
currentSufficient: corrected == null ? null : corrected >= designCurrentA,
|
||||||
|
voltageDropPercent: rated == null ? null : voltageDrops[index],
|
||||||
|
voltageDropSufficient: rated == null ? null : voltageDrops[index] <= input.maxVoltageDropPercent,
|
||||||
|
maxLengthForVoltageDropM:
|
||||||
|
rated == null || voltageDropCurrentBasis <= 0
|
||||||
|
? null
|
||||||
|
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
|
||||||
|
(100 * voltageDropCoefficient * voltageDropCurrentBasis),
|
||||||
|
recommended: index === indexRecommended,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let protectionCoordination: ProtectionCoordinationResult | null = null;
|
||||||
|
if (input.existingProtection && indexRecommended >= 0) {
|
||||||
|
const iz = rows[indexRecommended].correctedCurrentA;
|
||||||
|
const factor = conventionalTrippingFactor(input.existingProtection);
|
||||||
|
const inA = input.existingProtection.ratedCurrentA;
|
||||||
|
protectionCoordination = {
|
||||||
|
ratedCurrentA: inA,
|
||||||
|
kind: input.existingProtection.kind,
|
||||||
|
conventionalTrippingFactor: factor,
|
||||||
|
coordinated: iz != null && inA <= iz && factor * inA <= 1.45 * iz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...empty,
|
||||||
|
supported: true,
|
||||||
|
unsupportedReason: null,
|
||||||
|
crossSectionByCapacityMm2: indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
|
||||||
|
crossSectionByVoltageDropMm2: indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null,
|
||||||
|
recommendedCrossSectionMm2: indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null,
|
||||||
|
voltageDropAtRecommendedPercent: indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
|
||||||
|
combinedDerationFactor,
|
||||||
|
harmonicReductionApplied,
|
||||||
|
practicalMinimumApplied,
|
||||||
|
rows,
|
||||||
|
protectionCoordination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Alerts -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CableSizingAlert {
|
||||||
|
kind: "critical" | "warn" | "info" | "ok";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormat = (value: number, digits = 1) =>
|
||||||
|
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
const crossSectionFormat = (value: number) =>
|
||||||
|
`${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} mm²`;
|
||||||
|
|
||||||
|
export function buildCableSizingAlerts(input: CableSizingInput, result: CableSizingResult): CableSizingAlert[] {
|
||||||
|
const alerts: CableSizingAlert[] = [];
|
||||||
|
const group = LAYING_METHOD_GROUP[input.layingMethod];
|
||||||
|
|
||||||
|
if (!result.supported) {
|
||||||
|
const groupingMaximum = maxGroupingCircuits(input.layingMethod);
|
||||||
|
alerts.push({
|
||||||
|
kind: "critical",
|
||||||
|
text:
|
||||||
|
result.unsupportedReason === "temperature"
|
||||||
|
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
|
||||||
|
: result.unsupportedReason === "harmonics"
|
||||||
|
? "Bei mehr als 33 % dritter Harmonischer muss nach dem Neutralleiterstrom bemessen werden. Ohne diesen Wert wird keine Querschnittsempfehlung ausgegeben."
|
||||||
|
: input.layingMethod === "E"
|
||||||
|
? "Für Verlegeart E benötigt die Norm bei Häufung Angaben zu Trägerart, Abstand und Anzahl der Kabelpritschen. Ohne diese Angaben ist nur ein einzelner Stromkreis berechenbar."
|
||||||
|
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt für Verlegeart ${input.layingMethod} außerhalb der verwendeten Tabelle (max. ${groupingMaximum}) – keine Berechnung möglich.`,
|
||||||
|
});
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.temperatureUsedC !== input.ambientTemperatureC) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: `Temperatur auf den nächsten Tabellenwert ${result.temperatureUsedC} °C gerundet (sichere Seite).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.groupingUsed !== input.groupingCircuits) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: `Häufung auf den nächsten Tabellenwert ${result.groupingUsed} Stromkreise gerundet (sichere Seite).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCapacity = result.crossSectionByCapacityMm2;
|
||||||
|
const byDrop = result.crossSectionByVoltageDropMm2;
|
||||||
|
if (byCapacity == null) {
|
||||||
|
const dueToProtection =
|
||||||
|
input.existingProtection != null && result.designCurrentA > result.operatingCurrentA;
|
||||||
|
alerts.push({
|
||||||
|
kind: "critical",
|
||||||
|
text: dueToProtection
|
||||||
|
? `Das vorhandene Schutzorgan (${input.existingProtection!.ratedCurrentA} A) verlangt Iz ≥ ${numberFormat(result.designCurrentA)} A – das erreicht bei Verlegeart ${input.layingMethod} kein Querschnitt bis 240 mm². Verlegeart oder Schutzorgan prüfen.`
|
||||||
|
: `Der Betriebsstrom ${numberFormat(result.operatingCurrentA)} A übersteigt bei Verlegeart ${input.layingMethod} die Belastbarkeit aller Querschnitte bis 240 mm².`,
|
||||||
|
});
|
||||||
|
} else if (byDrop == null) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "critical",
|
||||||
|
text: `Der Spannungsfall von ${numberFormat(input.maxVoltageDropPercent)} % wird bei ${input.lengthM} m mit keinem Querschnitt bis 240 mm² eingehalten.`,
|
||||||
|
});
|
||||||
|
} else if (byDrop > byCapacity) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "warn",
|
||||||
|
text: `Der Spannungsfall ist maßgeblich: ${crossSectionFormat(byCapacity)} reichen thermisch, für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} % sind ${crossSectionFormat(byDrop)} nötig.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.harmonicReductionApplied) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.lengthM === 0) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (group === "ground") {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: "Erdverlegung: Bodenwärmewiderstand 2,5 K·m/W angenommen (Tabellenreferenz).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.practicalMinimumApplied) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: `Auf ${crossSectionFormat(result.recommendedCrossSectionMm2!)} angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine Rechenanforderung).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.protectionCoordination) {
|
||||||
|
const coordination = result.protectionCoordination;
|
||||||
|
const kindLabel = coordination.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG";
|
||||||
|
alerts.push(
|
||||||
|
coordination.coordinated
|
||||||
|
? {
|
||||||
|
kind: "info",
|
||||||
|
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A berücksichtigt: In ≤ Iz und I₂ = ${numberFormat(coordination.conventionalTrippingFactor, 2)} · In ≤ 1,45 · Iz. Kurzschlussschutz und Abschaltbedingungen sind nicht Teil dieser Berechnung.`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: "warn",
|
||||||
|
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A ist mit dem empfohlenen Querschnitt nicht koordiniert (In ≤ Iz, I₂ ≤ 1,45 · Iz).`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const recommendedRow = result.rows.find((row) => row.recommended);
|
||||||
|
if (recommendedRow && byCapacity != null && byDrop != null) {
|
||||||
|
if (!alerts.some((alert) => alert.kind === "warn" || alert.kind === "critical")) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "ok",
|
||||||
|
text: `${crossSectionFormat(recommendedRow.crossSectionMm2)} ${input.conductorMaterial === "aluminum" ? "Al" : "Cu"} erfüllt bei ${input.lengthM} m Belastbarkeit (${numberFormat(result.designCurrentA)} A) und Spannungsfall (ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (recommendedRow.maxLengthForVoltageDropM != null) {
|
||||||
|
alerts.push({
|
||||||
|
kind: "info",
|
||||||
|
text: `Maximale Länge bei ${crossSectionFormat(recommendedRow.crossSectionMm2)} für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
47
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
47
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Request validation for the cable-sizing HTTP API. Pure - depends only on
|
||||||
|
// zod and the sibling calculation module, never on db/server.
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
CONDUCTOR_MATERIALS,
|
||||||
|
HARMONIC_NEUTRAL_LOAD_OPTIONS,
|
||||||
|
INSULATION_MATERIALS,
|
||||||
|
LAYING_METHODS,
|
||||||
|
PROTECTION_KINDS,
|
||||||
|
type CableSizingInput,
|
||||||
|
} from "./cable-sizing-calculation.js";
|
||||||
|
|
||||||
|
export const cableSizingRequestSchema = z
|
||||||
|
.object({
|
||||||
|
phase: z.union([z.literal(1), z.literal(3)]),
|
||||||
|
mode: z.enum(["power", "current"]),
|
||||||
|
powerKw: z.number().finite().min(0).optional(),
|
||||||
|
currentA: z.number().finite().min(0).optional(),
|
||||||
|
cosPhi: z.number().finite().min(0.1).max(1),
|
||||||
|
voltage: z.number().finite().positive(),
|
||||||
|
lengthM: z.number().finite().min(0).max(10000),
|
||||||
|
layingMethod: z.enum(LAYING_METHODS),
|
||||||
|
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
|
||||||
|
insulation: z.enum(INSULATION_MATERIALS),
|
||||||
|
ambientTemperatureC: z.number().finite().min(-40).max(120),
|
||||||
|
groupingCircuits: z.number().int().min(1).max(100),
|
||||||
|
maxVoltageDropPercent: z.number().finite().min(0.5).max(10),
|
||||||
|
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
|
||||||
|
existingProtection: z
|
||||||
|
.object({
|
||||||
|
ratedCurrentA: z.number().finite().positive(),
|
||||||
|
kind: z.enum(PROTECTION_KINDS),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((value) => (value.mode === "power" ? value.powerKw != null : value.currentA != null), {
|
||||||
|
message: "powerKw is required for mode 'power', currentA for mode 'current'",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
|
||||||
|
|
||||||
|
// The request schema must stay assignable to the domain input shape.
|
||||||
|
type AssertAssignable<T extends CableSizingInput> = T;
|
||||||
|
export type CableSizingRequestIsDomainInput = AssertAssignable<CableSizingRequest>;
|
||||||
42
src/db/migrations/0010_estimate_balances.sql
Normal file
42
src/db/migrations/0010_estimate_balances.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
CREATE TABLE `estimate_balances` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`work_stage` text,
|
||||||
|
`date_iso` text,
|
||||||
|
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
||||||
|
`reserve_sv_percent` real DEFAULT 0 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 INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `estimate_groups` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`balance_id` text NOT NULL,
|
||||||
|
`cost_group` text DEFAULT '' NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`editor` text,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `estimate_rows` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`group_id` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`supply` text,
|
||||||
|
`name` text DEFAULT '' NOT NULL,
|
||||||
|
`quantity` real DEFAULT 0 NOT NULL,
|
||||||
|
`unit` text DEFAULT 'piece' NOT NULL,
|
||||||
|
`connection` text,
|
||||||
|
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
||||||
|
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
||||||
|
`cos_phi` real DEFAULT 1 NOT NULL,
|
||||||
|
`safety_supply` integer DEFAULT false NOT NULL,
|
||||||
|
`remark` text,
|
||||||
|
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
||||||
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `rooms` ADD `area_m2` real;
|
||||||
4
src/db/migrations/0012_good_gateway.sql
Normal file
4
src/db/migrations/0012_good_gateway.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
ALTER TABLE `distribution_boards` ADD `feeding_distribution_board_id` text REFERENCES distribution_boards(id);--> statement-breakpoint
|
||||||
|
ALTER TABLE `distribution_boards` ADD `feeding_circuit_id` text REFERENCES circuits(id);--> statement-breakpoint
|
||||||
|
CREATE INDEX `distribution_boards_feeding_board_idx` ON `distribution_boards` (`feeding_distribution_board_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `distribution_boards_feeding_circuit_idx` ON `distribution_boards` (`feeding_circuit_id`);
|
||||||
13
src/db/migrations/0013_users.sql
Normal file
13
src/db/migrations/0013_users.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`role` text DEFAULT 'user' NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`must_change_password` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
97
src/db/migrations/0014_linear_xlsx_external_model.sql
Normal file
97
src/db/migrations/0014_linear_xlsx_external_model.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
UPDATE `circuit_device_rows`
|
||||||
|
SET `quantity` = `manual_quantity`
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `external_model_objects`
|
||||||
|
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
|
||||||
|
);--> statement-breakpoint
|
||||||
|
DROP TABLE `external_model_objects`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_room_mappings`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_import_batches`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_model_sources`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_model_configurations` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text 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_model_configurations_project_id_unique` ON `external_model_configurations` (`project_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_xlsx' 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_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
|
||||||
|
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,
|
||||||
|
`object_count` integer NOT NULL,
|
||||||
|
`applied_project_revision` integer,
|
||||||
|
`configuration_snapshot` text,
|
||||||
|
`original_bytes` blob,
|
||||||
|
`document` 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
|
||||||
|
);--> 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_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`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_model_objects` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_id` text NOT NULL,
|
||||||
|
`worksharing_id` text NOT NULL,
|
||||||
|
`ifc_guid` text,
|
||||||
|
`device_type_id` 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_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> 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_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
16
src/db/migrations/0015_wide_night_thrasher.sql
Normal file
16
src/db/migrations/0015_wide_night_thrasher.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE `external_export_batches` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`project_revision` integer NOT NULL,
|
||||||
|
`import_batch_id` text NOT NULL,
|
||||||
|
`exported_at_iso` text NOT NULL,
|
||||||
|
`file_name` text NOT NULL,
|
||||||
|
`sha256` text NOT NULL,
|
||||||
|
`configuration_snapshot` text NOT NULL,
|
||||||
|
`configuration_sha256` text NOT NULL,
|
||||||
|
`scope` text NOT NULL,
|
||||||
|
`object_count` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);
|
||||||
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2986
src/db/migrations/meta/0012_snapshot.json
Normal file
2986
src/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3072
src/db/migrations/meta/0013_snapshot.json
Normal file
3072
src/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3100
src/db/migrations/meta/0014_snapshot.json
Normal file
3100
src/db/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3210
src/db/migrations/meta/0015_snapshot.json
Normal file
3210
src/db/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,48 @@
|
||||||
"when": 1790419185882,
|
"when": 1790419185882,
|
||||||
"tag": "0009_buildings",
|
"tag": "0009_buildings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790438092269,
|
||||||
|
"tag": "0010_estimate_balances",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790531351492,
|
||||||
|
"tag": "0011_glossy_norman_osborn",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790540142918,
|
||||||
|
"tag": "0012_good_gateway",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790624623732,
|
||||||
|
"tag": "0013_users",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790748107566,
|
||||||
|
"tag": "0014_linear_xlsx_external_model",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790773066696,
|
||||||
|
"tag": "0015_wide_night_thrasher",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
15
src/db/repositories/batched-insert.persistence.ts
Normal file
15
src/db/repositories/batched-insert.persistence.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
|
||||||
|
const defaultInsertBatchSize = 200;
|
||||||
|
|
||||||
|
export function insertManyInBatches<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
database: AppDatabase,
|
||||||
|
table: TTable,
|
||||||
|
values: readonly unknown[],
|
||||||
|
batchSize = defaultInsertBatchSize
|
||||||
|
) {
|
||||||
|
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("Insert batch size must be a positive integer.");
|
||||||
|
for (let index = 0; index < values.length; index += batchSize) {
|
||||||
|
database.insert(table).values(values.slice(index, index + batchSize) as never).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
interface PersistedMoveRow {
|
interface PersistedMoveRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||||
projectId,
|
projectId,
|
||||||
destinationCircuitIds
|
destinationCircuitIds
|
||||||
);
|
);
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
targetCircuit.id,
|
||||||
|
]);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(circuits)
|
.delete(circuits)
|
||||||
.where(
|
.where(
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,15 @@ import {
|
||||||
toCircuitDeviceRowInsertValues,
|
toCircuitDeviceRowInsertValues,
|
||||||
toCircuitDeviceRowSnapshot,
|
toCircuitDeviceRowSnapshot,
|
||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import {
|
||||||
|
applyExternalObjectLinks,
|
||||||
|
captureExternalObjectDetachTransitions,
|
||||||
|
invertExternalObjectLinkTransitions,
|
||||||
|
loadExpectedExternalObjectTransitions,
|
||||||
|
type ExternalObjectLinkTransition,
|
||||||
|
} from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
implements CircuitDeviceRowStructureProjectCommandStore
|
implements CircuitDeviceRowStructureProjectCommandStore
|
||||||
|
|
@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransactionWithAppliedForward<
|
||||||
|
CircuitDeviceRowStructureProjectCommand,
|
||||||
|
CircuitDeviceRowStructureProjectCommand,
|
||||||
|
CircuitDeviceRowStructureProjectCommand
|
||||||
|
>(
|
||||||
this.database,
|
this.database,
|
||||||
input,
|
input,
|
||||||
(tx) =>
|
(tx) =>
|
||||||
|
|
@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||||
command: CircuitDeviceRowStructureProjectCommand
|
command: CircuitDeviceRowStructureProjectCommand
|
||||||
): CircuitDeviceRowStructureProjectCommand {
|
): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> {
|
||||||
if (command.type === circuitDeviceRowInsertCommandType) {
|
if (command.type === circuitDeviceRowInsertCommandType) {
|
||||||
assertCircuitDeviceRowInsertProjectCommand(command);
|
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||||
return this.insert(
|
return this.insert(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
source,
|
source,
|
||||||
command.payload.row
|
command.payload.row,
|
||||||
|
command.payload.externalObjects ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (command.type === circuitDeviceRowDeleteCommandType) {
|
if (command.type === circuitDeviceRowDeleteCommandType) {
|
||||||
|
|
@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.rowId,
|
command.payload.rowId,
|
||||||
command.payload.expectedCircuitId
|
command.payload.expectedCircuitId,
|
||||||
|
command.payload.externalObjects
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported circuit device-row structure command.");
|
throw new Error("Unsupported circuit device-row structure command.");
|
||||||
|
|
@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||||
row: CircuitDeviceRowSnapshot
|
row: CircuitDeviceRowSnapshot,
|
||||||
|
externalObjects: ExternalObjectLinkTransition[]
|
||||||
) {
|
) {
|
||||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||||
throw new Error("Device-row phase type is invalid.");
|
throw new Error("Device-row phase type is invalid.");
|
||||||
|
|
@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
}
|
}
|
||||||
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
||||||
|
|
||||||
return createCircuitDeviceRowDeleteProjectCommand(
|
if (externalObjects.length > 0) {
|
||||||
row.id,
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
|
||||||
row.circuitId
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
|
||||||
|
inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private delete(
|
private delete(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
rowId: string,
|
rowId: string,
|
||||||
expectedCircuitId: string
|
expectedCircuitId: string,
|
||||||
|
suppliedExternalObjects?: ExternalObjectLinkTransition[]
|
||||||
) {
|
) {
|
||||||
const row = database
|
const row = database
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||||
|
const externalObjects = suppliedExternalObjects ??
|
||||||
|
captureExternalObjectDetachTransitions(database, projectId, [rowId]);
|
||||||
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
|
||||||
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
|
||||||
|
|
||||||
const result = database
|
const result = database
|
||||||
.delete(circuitDeviceRows)
|
.delete(circuitDeviceRows)
|
||||||
|
|
@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
expectedCircuitId
|
expectedCircuitId
|
||||||
);
|
);
|
||||||
|
|
||||||
return createCircuitDeviceRowInsertProjectCommand(
|
return {
|
||||||
toCircuitDeviceRowSnapshot(row)
|
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
|
||||||
);
|
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertCircuitInProject(
|
private assertCircuitInProject(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,14 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
import {
|
||||||
|
applyExternalObjectLinks,
|
||||||
|
captureExternalObjectDetachTransitions,
|
||||||
|
invertExternalObjectLinkTransitions,
|
||||||
|
loadExpectedExternalObjectTransitions,
|
||||||
|
} from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
export class CircuitGroupSubtreeProjectCommandRepository
|
export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
implements CircuitGroupSubtreeProjectCommandStore
|
implements CircuitGroupSubtreeProjectCommandStore
|
||||||
|
|
@ -42,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
} else {
|
} else {
|
||||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||||
}
|
}
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransactionWithAppliedForward<
|
||||||
|
CircuitGroupSubtreeProjectCommand,
|
||||||
|
CircuitGroupSubtreeProjectCommand,
|
||||||
|
CircuitGroupSubtreeProjectCommand
|
||||||
|
>(
|
||||||
this.database,
|
this.database,
|
||||||
input,
|
input,
|
||||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||||
|
|
@ -53,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
command: CircuitGroupSubtreeProjectCommand
|
command: CircuitGroupSubtreeProjectCommand
|
||||||
) {
|
): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> {
|
||||||
if (command.type === circuitGroupDeleteSubtreeCommandType) {
|
if (command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||||
const current = this.captureFrom(
|
const current = this.captureFrom(
|
||||||
database,
|
database,
|
||||||
|
|
@ -65,6 +76,19 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
"Circuit-group subtree changed before deletion."
|
"Circuit-group subtree changed before deletion."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
current.circuits.map((entry) => entry.circuit.id)
|
||||||
|
);
|
||||||
|
const externalObjects = command.payload.externalObjects ??
|
||||||
|
captureExternalObjectDetachTransitions(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id))
|
||||||
|
);
|
||||||
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
|
||||||
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(circuitSections)
|
.delete(circuitSections)
|
||||||
.where(eq(circuitSections.id, current.group.id))
|
.where(eq(circuitSections.id, current.group.id))
|
||||||
|
|
@ -72,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||||
if (deleted.changes !== 1) {
|
if (deleted.changes !== 1) {
|
||||||
throw new Error("Circuit-group subtree could not be deleted.");
|
throw new Error("Circuit-group subtree could not be deleted.");
|
||||||
}
|
}
|
||||||
return createCircuitGroupRestoreSubtreeProjectCommand(
|
return {
|
||||||
command.payload.snapshot
|
forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
|
||||||
);
|
inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (command.type === circuitGroupRestoreSubtreeCommandType) {
|
if (command.type === circuitGroupRestoreSubtreeCommandType) {
|
||||||
this.restore(database, projectId, command.payload.snapshot);
|
this.restore(database, projectId, command.payload.snapshot);
|
||||||
return createCircuitGroupDeleteSubtreeProjectCommand(
|
const externalObjects = command.payload.externalObjects ?? [];
|
||||||
command.payload.snapshot
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
|
||||||
);
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
|
||||||
|
return {
|
||||||
|
forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects),
|
||||||
|
inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported circuit-group subtree command.");
|
throw new Error("Unsupported circuit-group subtree command.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
|
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
|
||||||
.orderBy(asc(circuitSections.sortOrder));
|
.orderBy(asc(circuitSections.sortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(inArray(circuitSections.circuitListId, circuitListIds))
|
||||||
|
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id));
|
||||||
|
}
|
||||||
|
|
||||||
async createDefaults(circuitListId: string) {
|
async createDefaults(circuitListId: string) {
|
||||||
const db = this.database;
|
const db = this.database;
|
||||||
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,17 @@ import {
|
||||||
toCircuitDeviceRowInsertValues,
|
toCircuitDeviceRowInsertValues,
|
||||||
toCircuitDeviceRowSnapshot,
|
toCircuitDeviceRowSnapshot,
|
||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||||
|
import {
|
||||||
|
applyExternalObjectLinks,
|
||||||
|
captureExternalObjectDetachTransitions,
|
||||||
|
invertExternalObjectLinkTransitions,
|
||||||
|
loadExpectedExternalObjectTransitions,
|
||||||
|
type ExternalObjectLinkTransition,
|
||||||
|
} from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
export class CircuitStructureProjectCommandRepository
|
export class CircuitStructureProjectCommandRepository
|
||||||
implements CircuitStructureProjectCommandStore
|
implements CircuitStructureProjectCommandStore
|
||||||
|
|
@ -35,11 +43,15 @@ export class CircuitStructureProjectCommandRepository
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
execute(input: ExecuteCircuitStructureCommandInput) {
|
execute(input: ExecuteCircuitStructureCommandInput) {
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransactionWithAppliedForward<
|
||||||
|
CircuitStructureProjectCommand,
|
||||||
|
CircuitStructureProjectCommand,
|
||||||
|
CircuitStructureProjectCommand
|
||||||
|
>(
|
||||||
this.database,
|
this.database,
|
||||||
input,
|
input,
|
||||||
(tx) =>
|
(tx) =>
|
||||||
this.applyCommand(
|
this.applyWithForward(
|
||||||
tx,
|
tx,
|
||||||
input.projectId,
|
input.projectId,
|
||||||
input.source,
|
input.source,
|
||||||
|
|
@ -48,19 +60,33 @@ export class CircuitStructureProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyCommand(
|
/**
|
||||||
|
* Applies the circuit write inside an already-owned project transaction.
|
||||||
|
* Composite project commands use this without creating a second revision.
|
||||||
|
*/
|
||||||
|
applyInTransaction(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: ExecuteCircuitStructureCommandInput["source"],
|
source: ExecuteCircuitStructureCommandInput["source"],
|
||||||
command: CircuitStructureProjectCommand
|
command: CircuitStructureProjectCommand
|
||||||
): CircuitStructureProjectCommand {
|
): CircuitStructureProjectCommand {
|
||||||
|
return this.applyWithForward(database, projectId, source, command).inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyWithForward(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
source: ExecuteCircuitStructureCommandInput["source"],
|
||||||
|
command: CircuitStructureProjectCommand
|
||||||
|
): AppliedProjectCommand<CircuitStructureProjectCommand, CircuitStructureProjectCommand> {
|
||||||
if (command.type === circuitInsertCommandType) {
|
if (command.type === circuitInsertCommandType) {
|
||||||
assertCircuitInsertProjectCommand(command);
|
assertCircuitInsertProjectCommand(command);
|
||||||
return this.insert(
|
return this.insert(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
source,
|
source,
|
||||||
command.payload.circuit
|
command.payload.circuit,
|
||||||
|
command.payload.externalObjects ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (command.type === circuitDeleteCommandType) {
|
if (command.type === circuitDeleteCommandType) {
|
||||||
|
|
@ -69,7 +95,8 @@ export class CircuitStructureProjectCommandRepository
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.circuitId,
|
command.payload.circuitId,
|
||||||
command.payload.expectedCircuitListId
|
command.payload.expectedCircuitListId,
|
||||||
|
command.payload.externalObjects
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported circuit structure command.");
|
throw new Error("Unsupported circuit structure command.");
|
||||||
|
|
@ -79,7 +106,8 @@ export class CircuitStructureProjectCommandRepository
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
source: ExecuteCircuitStructureCommandInput["source"],
|
source: ExecuteCircuitStructureCommandInput["source"],
|
||||||
snapshot: CircuitSnapshot
|
snapshot: CircuitSnapshot,
|
||||||
|
externalObjects: ExternalObjectLinkTransition[]
|
||||||
) {
|
) {
|
||||||
this.assertCircuitLocation(database, projectId, snapshot);
|
this.assertCircuitLocation(database, projectId, snapshot);
|
||||||
if (source === "user") {
|
if (source === "user") {
|
||||||
|
|
@ -167,17 +195,23 @@ export class CircuitStructureProjectCommandRepository
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
return createCircuitDeleteProjectCommand(
|
if (externalObjects.length > 0) {
|
||||||
snapshot.id,
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
|
||||||
snapshot.circuitListId
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
forward: createCircuitInsertProjectCommand(snapshot, externalObjects),
|
||||||
|
inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private delete(
|
private delete(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
circuitId: string,
|
circuitId: string,
|
||||||
expectedCircuitListId: string
|
expectedCircuitListId: string,
|
||||||
|
suppliedExternalObjects?: ExternalObjectLinkTransition[]
|
||||||
) {
|
) {
|
||||||
const circuit = database
|
const circuit = database
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -215,7 +249,12 @@ export class CircuitStructureProjectCommandRepository
|
||||||
.from(circuitProtectionDevices)
|
.from(circuitProtectionDevices)
|
||||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||||
.get();
|
.get();
|
||||||
const inverse = createCircuitInsertProjectCommand({
|
const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions(
|
||||||
|
database, projectId, rows.map((row) => row.id)
|
||||||
|
);
|
||||||
|
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
|
||||||
|
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
|
||||||
|
const circuitSnapshot: CircuitSnapshot = {
|
||||||
id: circuit.id,
|
id: circuit.id,
|
||||||
circuitListId: circuit.circuitListId,
|
circuitListId: circuit.circuitListId,
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
|
|
@ -234,7 +273,9 @@ export class CircuitStructureProjectCommandRepository
|
||||||
remark: circuit.remark,
|
remark: circuit.remark,
|
||||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||||
protectionDevice: protectionDevice ?? null,
|
protectionDevice: protectionDevice ?? null,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
|
||||||
|
|
||||||
const result = database
|
const result = database
|
||||||
.delete(circuits)
|
.delete(circuits)
|
||||||
|
|
@ -248,7 +289,10 @@ export class CircuitStructureProjectCommandRepository
|
||||||
if (result.changes !== 1) {
|
if (result.changes !== 1) {
|
||||||
throw new Error("Circuit could not be deleted.");
|
throw new Error("Circuit could not be deleted.");
|
||||||
}
|
}
|
||||||
return inverse;
|
return {
|
||||||
|
forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
|
||||||
|
inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertCircuitLocation(
|
private assertCircuitLocation(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
|
||||||
|
|
@ -14,6 +14,15 @@ export class CircuitRepository {
|
||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(inArray(circuits.circuitListId, circuitListIds))
|
||||||
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
async listBySection(sectionId: string) {
|
async listBySection(sectionId: string) {
|
||||||
const db = this.database;
|
const db = this.database;
|
||||||
return db
|
return db
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export class DistributionBoardComponentRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByCircuitLists(circuitListIds: string[]) {
|
||||||
|
if (circuitListIds.length === 0) return [];
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoardComponents)
|
||||||
|
.where(inArray(distributionBoardComponents.circuitListId, circuitListIds))
|
||||||
|
.orderBy(
|
||||||
|
asc(distributionBoardComponents.sortOrder),
|
||||||
|
asc(distributionBoardComponents.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listProtectionByComponentIds(componentIds: string[]) {
|
async listProtectionByComponentIds(componentIds: string[]) {
|
||||||
if (componentIds.length === 0) {
|
if (componentIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
||||||
|
export function assertBoardsAreNotReferencedAsFeeders(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardIds: string[]
|
||||||
|
) {
|
||||||
|
if (distributionBoardIds.length === 0) return;
|
||||||
|
const dependent = database
|
||||||
|
.select({
|
||||||
|
name: distributionBoards.name,
|
||||||
|
boardNumber: distributionBoards.boardNumber,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
[...new Set(distributionBoardIds)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (dependent) throwFeedingReferenceError(dependent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCircuitsAreNotReferencedAsFeeders(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitIds: string[]
|
||||||
|
) {
|
||||||
|
if (circuitIds.length === 0) return;
|
||||||
|
const dependent = database
|
||||||
|
.select({
|
||||||
|
name: distributionBoards.name,
|
||||||
|
boardNumber: distributionBoards.boardNumber,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
distributionBoards.feedingCircuitId,
|
||||||
|
[...new Set(circuitIds)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (dependent) throwFeedingReferenceError(dependent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwFeedingReferenceError(dependent: {
|
||||||
|
name: string;
|
||||||
|
boardNumber: string | null;
|
||||||
|
}): never {
|
||||||
|
const label = dependent.boardNumber ?? dependent.name;
|
||||||
|
throw new Error(`Speist ${label} – erst Einspeisung ändern.`);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import {
|
import {
|
||||||
assertDistributionBoardDeleteProjectCommand,
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
assertDistributionBoardInsertProjectCommand,
|
assertDistributionBoardInsertProjectCommand,
|
||||||
|
|
@ -17,6 +18,20 @@ import {
|
||||||
type DistributionBoardUpdateProjectCommand,
|
type DistributionBoardUpdateProjectCommand,
|
||||||
type DistributionBoardUpdateValues,
|
type DistributionBoardUpdateValues,
|
||||||
} from "../../domain/models/distribution-board-project-command.model.js";
|
} from "../../domain/models/distribution-board-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardFeederCircuitProjectCommand,
|
||||||
|
createDistributionBoardCreateFeederCircuitProjectCommand,
|
||||||
|
createDistributionBoardRemoveFeederCircuitProjectCommand,
|
||||||
|
distributionBoardCreateFeederCircuitCommandType,
|
||||||
|
distributionBoardRemoveFeederCircuitCommandType,
|
||||||
|
type DistributionBoardFeederCircuitProjectCommand,
|
||||||
|
} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
|
||||||
|
import {
|
||||||
|
circuitDeleteCommandType,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
createCircuitDeleteProjectCommand,
|
||||||
|
createCircuitInsertProjectCommand,
|
||||||
|
} from "../../domain/models/circuit-structure-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
DistributionBoardStructureProjectCommandStore,
|
DistributionBoardStructureProjectCommandStore,
|
||||||
ExecuteDistributionBoardStructureCommandInput,
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
|
@ -30,6 +45,9 @@ import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
|
||||||
|
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
|
||||||
|
|
||||||
export class DistributionBoardStructureProjectCommandRepository
|
export class DistributionBoardStructureProjectCommandRepository
|
||||||
implements DistributionBoardStructureProjectCommandStore
|
implements DistributionBoardStructureProjectCommandStore
|
||||||
|
|
@ -61,6 +79,30 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executeFeederCircuit(
|
||||||
|
input: Omit<
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
"command"
|
||||||
|
> & {
|
||||||
|
command: DistributionBoardFeederCircuitProjectCommand;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
assertDistributionBoardFeederCircuitProjectCommand(input.command);
|
||||||
|
if (
|
||||||
|
input.source === "user" &&
|
||||||
|
input.command.type === distributionBoardRemoveFeederCircuitCommandType
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Generated feeder circuits may only be removed through project history."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return executeProjectCommandTransaction(
|
||||||
|
this.database,
|
||||||
|
input,
|
||||||
|
(tx) => this.applyFeederCircuitCommand(tx, input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private applyCommand(
|
private applyCommand(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -248,6 +290,9 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
|
||||||
|
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
|
||||||
|
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -325,6 +370,16 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
patch.supplyType
|
patch.supplyType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.assertFeedingReference(database, input.projectId, current.id, {
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
patch.feedingDistributionBoardId === undefined
|
||||||
|
? current.feedingDistributionBoardId
|
||||||
|
: patch.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId:
|
||||||
|
patch.feedingCircuitId === undefined
|
||||||
|
? current.feedingCircuitId
|
||||||
|
: patch.feedingCircuitId,
|
||||||
|
});
|
||||||
const inversePatch = Object.fromEntries(
|
const inversePatch = Object.fromEntries(
|
||||||
input.command.payload.changes.map((change) => [
|
input.command.payload.changes.map((change) => [
|
||||||
change.field,
|
change.field,
|
||||||
|
|
@ -333,7 +388,8 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
) as DistributionBoardUpdatePatch;
|
) as DistributionBoardUpdatePatch;
|
||||||
const inverse = createDistributionBoardUpdateProjectCommand(
|
const inverse = createDistributionBoardUpdateProjectCommand(
|
||||||
current.id,
|
current.id,
|
||||||
inversePatch
|
inversePatch,
|
||||||
|
input.command.schemaVersion
|
||||||
);
|
);
|
||||||
const updated = database
|
const updated = database
|
||||||
.update(distributionBoards)
|
.update(distributionBoards)
|
||||||
|
|
@ -352,6 +408,135 @@ export class DistributionBoardStructureProjectCommandRepository
|
||||||
}
|
}
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyFeederCircuitCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: Omit<
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
"command"
|
||||||
|
> & {
|
||||||
|
command: DistributionBoardFeederCircuitProjectCommand;
|
||||||
|
}
|
||||||
|
): DistributionBoardFeederCircuitProjectCommand {
|
||||||
|
const circuitRepository =
|
||||||
|
new CircuitStructureProjectCommandRepository(database);
|
||||||
|
const { circuit, boardUpdate } = input.command.payload;
|
||||||
|
if (
|
||||||
|
input.command.type === distributionBoardCreateFeederCircuitCommandType
|
||||||
|
) {
|
||||||
|
const circuitInverse = circuitRepository.applyInTransaction(
|
||||||
|
database,
|
||||||
|
input.projectId,
|
||||||
|
input.source,
|
||||||
|
createCircuitInsertProjectCommand(circuit)
|
||||||
|
);
|
||||||
|
if (circuitInverse.type !== circuitDeleteCommandType) {
|
||||||
|
throw new Error("Created feeder circuit returned an invalid inverse.");
|
||||||
|
}
|
||||||
|
const boardInverse = this.update(database, {
|
||||||
|
...input,
|
||||||
|
command: boardUpdate,
|
||||||
|
});
|
||||||
|
return createDistributionBoardRemoveFeederCircuitProjectCommand(
|
||||||
|
circuit,
|
||||||
|
boardInverse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardInverse = this.update(database, {
|
||||||
|
...input,
|
||||||
|
command: boardUpdate,
|
||||||
|
});
|
||||||
|
const circuitInverse = circuitRepository.applyInTransaction(
|
||||||
|
database,
|
||||||
|
input.projectId,
|
||||||
|
input.source,
|
||||||
|
createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
circuitInverse.type !== circuitInsertCommandType ||
|
||||||
|
!isDeepStrictEqual(circuitInverse.payload.circuit, circuit)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Generated feeder circuit changed before history removal."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createDistributionBoardCreateFeederCircuitProjectCommand(
|
||||||
|
circuit,
|
||||||
|
boardInverse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertFeedingReference(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardId: string,
|
||||||
|
reference: {
|
||||||
|
feedingDistributionBoardId: string | null;
|
||||||
|
feedingCircuitId: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { feedingDistributionBoardId, feedingCircuitId } = reference;
|
||||||
|
if (feedingDistributionBoardId === null) {
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedingDistributionBoardId === distributionBoardId) {
|
||||||
|
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
|
||||||
|
}
|
||||||
|
const projectBoards = database
|
||||||
|
.select({
|
||||||
|
id: distributionBoards.id,
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
|
||||||
|
if (!boardById.has(feedingDistributionBoardId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die speisende Verteilung gehört nicht zum Projekt."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const visited = new Set<string>([distributionBoardId]);
|
||||||
|
let currentId: string | null = feedingDistributionBoardId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen.");
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
||||||
|
}
|
||||||
|
if (feedingCircuitId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const feedingCircuit = database
|
||||||
|
.select({
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
distributionBoardId: circuitLists.distributionBoardId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuits.id, feedingCircuitId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!feedingCircuit ||
|
||||||
|
feedingCircuit.projectId !== projectId ||
|
||||||
|
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSupplyTypeEnabled(
|
function assertSupplyTypeEnabled(
|
||||||
|
|
@ -392,11 +577,20 @@ function structureMatches(
|
||||||
location,
|
location,
|
||||||
fedFrom,
|
fedFrom,
|
||||||
remark,
|
remark,
|
||||||
|
feedingDistributionBoardId,
|
||||||
|
feedingCircuitId,
|
||||||
...actualDistributionBoard
|
...actualDistributionBoard
|
||||||
} = actual.board;
|
} = actual.board;
|
||||||
if (
|
if (
|
||||||
simultaneityFactor !== 1 ||
|
simultaneityFactor !== 1 ||
|
||||||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
|
[
|
||||||
|
boardNumber,
|
||||||
|
location,
|
||||||
|
fedFrom,
|
||||||
|
remark,
|
||||||
|
feedingDistributionBoardId,
|
||||||
|
feedingCircuitId,
|
||||||
|
].some((value) => value !== null) ||
|
||||||
!sameRecord(
|
!sameRecord(
|
||||||
expected.distributionBoard,
|
expected.distributionBoard,
|
||||||
actualDistributionBoard
|
actualDistributionBoard
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class DistributionBoardSubtreeProjectCommandRepository
|
export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
implements DistributionBoardSubtreeProjectCommandStore
|
implements DistributionBoardSubtreeProjectCommandStore
|
||||||
|
|
@ -82,6 +84,10 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
"Distribution-board subtree changed before deletion."
|
"Distribution-board subtree changed before deletion."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
current.distributionBoard.id,
|
||||||
|
]);
|
||||||
|
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -300,6 +306,69 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const feedingDistributionBoardId =
|
||||||
|
snapshot.distributionBoard.feedingDistributionBoardId;
|
||||||
|
const feedingCircuitId = snapshot.distributionBoard.feedingCircuitId;
|
||||||
|
if (feedingDistributionBoardId === null) {
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (feedingDistributionBoardId === snapshot.distributionBoard.id) {
|
||||||
|
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
|
||||||
|
}
|
||||||
|
const projectBoards = database
|
||||||
|
.select({
|
||||||
|
id: distributionBoards.id,
|
||||||
|
feedingDistributionBoardId:
|
||||||
|
distributionBoards.feedingDistributionBoardId,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
|
||||||
|
if (!boardById.has(feedingDistributionBoardId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die speisende Verteilung gehört nicht zum Projekt."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const visited = new Set<string>([snapshot.distributionBoard.id]);
|
||||||
|
let currentId: string | null = feedingDistributionBoardId;
|
||||||
|
while (currentId !== null) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
throw new Error(
|
||||||
|
"Die Einspeisungsverknüpfung würde einen Zyklus erzeugen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
visited.add(currentId);
|
||||||
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
||||||
|
}
|
||||||
|
if (feedingCircuitId !== null) {
|
||||||
|
const feedingCircuit = database
|
||||||
|
.select({
|
||||||
|
projectId: circuitLists.projectId,
|
||||||
|
distributionBoardId: circuitLists.distributionBoardId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuits.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuits.id, feedingCircuitId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!feedingCircuit ||
|
||||||
|
feedingCircuit.projectId !== projectId ||
|
||||||
|
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const linkedProjectDeviceIds = uniqueStrings(
|
const linkedProjectDeviceIds = uniqueStrings(
|
||||||
snapshot.circuits.flatMap((circuit) =>
|
snapshot.circuits.flatMap((circuit) =>
|
||||||
circuit.deviceRows.flatMap((row) =>
|
circuit.deviceRows.flatMap((row) =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertEstimateBalanceDeleteProjectCommand,
|
||||||
|
assertEstimateBalanceInsertProjectCommand,
|
||||||
|
assertEstimateBalanceUpdateProjectCommand,
|
||||||
|
createEstimateBalanceDeleteProjectCommand,
|
||||||
|
createEstimateBalanceInsertProjectCommand,
|
||||||
|
createEstimateBalanceUpdateProjectCommand,
|
||||||
|
estimateBalanceDeleteCommandType,
|
||||||
|
estimateBalanceInsertCommandType,
|
||||||
|
estimateBalanceUpdateCommandType,
|
||||||
|
normalizeEstimateBalanceSnapshot,
|
||||||
|
sameEstimateBalance,
|
||||||
|
type EstimateBalanceProjectCommand,
|
||||||
|
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type {
|
||||||
|
EstimateBalanceProjectCommandStore,
|
||||||
|
ExecuteEstimateBalanceCommandInput,
|
||||||
|
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import {
|
||||||
|
deleteEstimateBalance,
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
|
|
||||||
|
export class EstimateBalanceProjectCommandRepository
|
||||||
|
implements EstimateBalanceProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteEstimateBalanceCommandInput) {
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) =>
|
||||||
|
this.applyCommand(tx, input.projectId, input.command)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
command: EstimateBalanceProjectCommand
|
||||||
|
): EstimateBalanceProjectCommand {
|
||||||
|
switch (command.type) {
|
||||||
|
case estimateBalanceInsertCommandType:
|
||||||
|
assertEstimateBalanceInsertProjectCommand(command);
|
||||||
|
return this.insert(database, projectId, command.payload.balance);
|
||||||
|
case estimateBalanceDeleteCommandType:
|
||||||
|
assertEstimateBalanceDeleteProjectCommand(command);
|
||||||
|
return this.delete(database, projectId, command.payload.balance);
|
||||||
|
case estimateBalanceUpdateCommandType:
|
||||||
|
assertEstimateBalanceUpdateProjectCommand(command);
|
||||||
|
return this.update(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.expected,
|
||||||
|
command.payload.target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
if (balance.projectId !== projectId) {
|
||||||
|
throw new Error("Estimate-balance snapshot does not belong to project.");
|
||||||
|
}
|
||||||
|
const project = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project does not exist.");
|
||||||
|
}
|
||||||
|
const existing = database
|
||||||
|
.select({ id: estimateBalances.id })
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.id, balance.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Estimate-balance id already exists.");
|
||||||
|
}
|
||||||
|
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||||
|
insertEstimateBalance(database, normalized);
|
||||||
|
return createEstimateBalanceDeleteProjectCommand(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
this.assertPersisted(database, projectId, expected, "deletion");
|
||||||
|
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
|
||||||
|
throw new Error("Estimate balance could not be deleted.");
|
||||||
|
}
|
||||||
|
return createEstimateBalanceInsertProjectCommand(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
target: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
this.assertPersisted(database, projectId, expected, "update");
|
||||||
|
// Group and row ids stay stable; replacing the whole document keeps the
|
||||||
|
// command exact and its inverse trivially the swapped pair.
|
||||||
|
deleteEstimateBalance(database, projectId, expected.id);
|
||||||
|
const normalized = normalizeEstimateBalanceSnapshot(target);
|
||||||
|
insertEstimateBalance(database, normalized);
|
||||||
|
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPersisted(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
action: string
|
||||||
|
) {
|
||||||
|
const [persisted] = readEstimateBalances(database, projectId, expected.id);
|
||||||
|
if (!persisted || !sameEstimateBalance(persisted, expected)) {
|
||||||
|
throw new Error(`Estimate balance changed before ${action}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import {
|
||||||
|
estimateBalances,
|
||||||
|
estimateGroups,
|
||||||
|
estimateRows,
|
||||||
|
} from "../schema/estimate-balances.js";
|
||||||
|
|
||||||
|
/** Reads complete balance documents of a project, ordered like the UI shows them. */
|
||||||
|
export function readEstimateBalances(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balanceId?: string
|
||||||
|
): EstimateBalanceSnapshot[] {
|
||||||
|
const balances = database
|
||||||
|
.select()
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(
|
||||||
|
balanceId
|
||||||
|
? and(
|
||||||
|
eq(estimateBalances.projectId, projectId),
|
||||||
|
eq(estimateBalances.id, balanceId)
|
||||||
|
)
|
||||||
|
: eq(estimateBalances.projectId, projectId)
|
||||||
|
)
|
||||||
|
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
|
||||||
|
.all();
|
||||||
|
if (!balances.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const groups = database
|
||||||
|
.select()
|
||||||
|
.from(estimateGroups)
|
||||||
|
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
|
||||||
|
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
|
||||||
|
.all();
|
||||||
|
const rows = groups.length
|
||||||
|
? database
|
||||||
|
.select()
|
||||||
|
.from(estimateRows)
|
||||||
|
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
|
||||||
|
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
|
||||||
|
.all()
|
||||||
|
: [];
|
||||||
|
return balances.map((balance) => ({
|
||||||
|
id: balance.id,
|
||||||
|
projectId: balance.projectId,
|
||||||
|
name: balance.name,
|
||||||
|
workStage: balance.workStage,
|
||||||
|
dateIso: balance.dateIso,
|
||||||
|
reserveAvPercent: balance.reserveAvPercent,
|
||||||
|
reserveSvPercent: balance.reserveSvPercent,
|
||||||
|
sortOrder: balance.sortOrder,
|
||||||
|
groups: groups
|
||||||
|
.filter((group) => group.balanceId === balance.id)
|
||||||
|
.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: group.editor,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
rows: rows
|
||||||
|
.filter((row) => row.groupId === group.id)
|
||||||
|
.map(({ groupId: _groupId, ...row }) => row),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a complete balance document (the caller removed an older version first). */
|
||||||
|
export function insertEstimateBalance(
|
||||||
|
database: AppDatabase,
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.insert(estimateBalances)
|
||||||
|
.values({
|
||||||
|
id: balance.id,
|
||||||
|
projectId: balance.projectId,
|
||||||
|
name: balance.name,
|
||||||
|
workStage: balance.workStage,
|
||||||
|
dateIso: balance.dateIso,
|
||||||
|
reserveAvPercent: balance.reserveAvPercent,
|
||||||
|
reserveSvPercent: balance.reserveSvPercent,
|
||||||
|
sortOrder: balance.sortOrder,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
for (const group of balance.groups) {
|
||||||
|
database
|
||||||
|
.insert(estimateGroups)
|
||||||
|
.values({
|
||||||
|
id: group.id,
|
||||||
|
balanceId: balance.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: group.editor,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (group.rows.length) {
|
||||||
|
database
|
||||||
|
.insert(estimateRows)
|
||||||
|
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a balance with its groups and rows (foreign keys cascade). */
|
||||||
|
export function deleteEstimateBalance(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balanceId: string
|
||||||
|
) {
|
||||||
|
return database
|
||||||
|
.delete(estimateBalances)
|
||||||
|
.where(
|
||||||
|
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
|
||||||
|
)
|
||||||
|
.run().changes;
|
||||||
|
}
|
||||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
DetailedDeviceRowPower,
|
||||||
|
EstimateBalanceSnapshot,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.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 { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import { readEstimateBalances } from "./estimate-balance.persistence.js";
|
||||||
|
|
||||||
|
/** Read side of the estimate-balance module; writes go through commands. */
|
||||||
|
export class EstimateBalanceRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
listByProject(projectId: string): EstimateBalanceSnapshot[] {
|
||||||
|
return readEstimateBalances(this.database, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
|
||||||
|
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSortOrder(projectId: string): number {
|
||||||
|
const rows = this.database
|
||||||
|
.select({ sortOrder: estimateBalances.sortOrder })
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.projectId, projectId))
|
||||||
|
.orderBy(asc(estimateBalances.sortOrder))
|
||||||
|
.all();
|
||||||
|
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device rows of all circuit lists of the project with their total power
|
||||||
|
* (quantity · power per unit · device GZF, like the circuit tree).
|
||||||
|
*/
|
||||||
|
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
|
||||||
|
return this.database
|
||||||
|
.select({
|
||||||
|
costGroup: circuitDeviceRows.costGroup,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||||
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||||
|
cosPhi: circuitDeviceRows.cosPhi,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all()
|
||||||
|
.map((row) => ({
|
||||||
|
costGroup: row.costGroup,
|
||||||
|
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
18
src/db/repositories/external-export-batch.repository.ts
Normal file
18
src/db/repositories/external-export-batch.repository.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { externalExportBatches } from "../schema/external-export-batches.js";
|
||||||
|
|
||||||
|
export class ExternalExportBatchRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
append(batch: typeof externalExportBatches.$inferInsert) {
|
||||||
|
this.database.insert(externalExportBatches).values(batch).run();
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
listByProject(projectId: string) {
|
||||||
|
return this.database.select().from(externalExportBatches)
|
||||||
|
.where(eq(externalExportBatches.projectId, projectId))
|
||||||
|
.orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
|
import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
|
||||||
|
import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.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 { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.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 { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalFollowUpImportProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalFollowUpImportProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
||||||
|
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get();
|
||||||
|
if (!currentState.projectExists || !currentConfiguration || !externalModelStatesEqual(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
const rowIds = input.command.payload.rows.map(({ expected }) => expected.id);
|
||||||
|
const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
|
||||||
|
if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden.");
|
||||||
|
const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
|
||||||
|
validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows);
|
||||||
|
for (const transition of input.command.payload.rows) {
|
||||||
|
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert.");
|
||||||
|
}
|
||||||
|
replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration);
|
||||||
|
replaceState(tx, input.projectId, input.command.payload.targetState);
|
||||||
|
return invertExternalFollowUpImportProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
|
||||||
|
if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt.");
|
||||||
|
const growing = target.importBatches.length > expectedState.importBatches.length;
|
||||||
|
const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!;
|
||||||
|
if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig.");
|
||||||
|
if (growing) {
|
||||||
|
if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig.");
|
||||||
|
const bytes = Buffer.from(latest.originalContentBase64!, "base64");
|
||||||
|
if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports ist ungültig.");
|
||||||
|
}
|
||||||
|
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
|
||||||
|
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
|
||||||
|
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
|
||||||
|
const deviceTypeIds = new Set(configuration.deviceTypes.map((entry) => entry.id));
|
||||||
|
for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); }
|
||||||
|
for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); }
|
||||||
|
assertRows(database, projectId, target, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
|
||||||
|
const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))];
|
||||||
|
const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])];
|
||||||
|
if (!rowsToLoad.length) return;
|
||||||
|
const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all();
|
||||||
|
if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt.");
|
||||||
|
const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
for (const context of contexts) {
|
||||||
|
const current = toCircuitDeviceRowSnapshot(context.row);
|
||||||
|
const effective = transitionById.get(current.id)?.target ?? current;
|
||||||
|
assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); database.insert(externalModelConfigurations).values(target).run(); }
|
||||||
|
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))); insertManyInBatches(database, externalRoomMappings, target.roomMappings); insertManyInBatches(database, externalModelObjects, target.objects); }
|
||||||
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
|
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
|
||||||
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|
||||||
|
|
@ -1,252 +1,89 @@
|
||||||
import crypto from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertExternalInitialImportProjectCommand,
|
assertExternalInitialImportProjectCommand,
|
||||||
createExternalInitialImportProjectCommand,
|
createExternalInitialImportProjectCommand,
|
||||||
isEmptyExternalModelState,
|
isEmptyExternalModelState,
|
||||||
} from "../../domain/models/external-initial-import-project-command.model.js";
|
} from "../../domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
|
||||||
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.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 type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
import {
|
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
|
||||||
createExternalRoomKey,
|
|
||||||
indexExternalObjectsByIfcGuid,
|
|
||||||
projectInitialExternalObject,
|
|
||||||
} from "../../external-model/domain/external-model-matching.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.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 { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
||||||
|
|
||||||
export class ExternalInitialImportProjectCommandRepository
|
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
||||||
implements ExternalInitialImportProjectCommandStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
||||||
assertExternalInitialImportProjectCommand(input.command);
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
const currentResult = new ExternalModelStateRepository(tx)
|
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
||||||
.getByProject(input.projectId);
|
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
|
||||||
if (!currentResult.projectExists) throw new Error("Project not found.");
|
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||||
const expected = input.command.payload.expected;
|
const payload = input.command.payload;
|
||||||
if (!sameState(currentResult.state, expected)) {
|
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
|
||||||
throw new Error("External model state changed before initial import.");
|
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
}
|
}
|
||||||
const target = input.command.payload.target;
|
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
|
||||||
if (!isEmptyExternalModelState(target)) {
|
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
|
||||||
validatePopulatedTarget(tx, input, target);
|
replaceState(tx, input.projectId, payload.targetState);
|
||||||
}
|
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
|
||||||
|
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||||
replaceExternalState(tx, input.projectId, target);
|
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
|
||||||
const persisted = new ExternalModelStateRepository(tx)
|
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
|
||||||
.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(
|
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
|
||||||
database: AppDatabase,
|
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
|
||||||
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
|
const batch = state.importBatches[0];
|
||||||
target: ExternalModelStateSnapshot
|
if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig.");
|
||||||
) {
|
if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig.");
|
||||||
const source = target.source!;
|
const bytes = decodeCanonicalBase64(batch.originalContentBase64!);
|
||||||
const batch = target.importBatches[0];
|
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig.");
|
||||||
if (source.projectId !== input.projectId) {
|
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
|
||||||
throw new Error("External initial import belongs to a different project.");
|
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
|
||||||
}
|
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
|
||||||
if (
|
const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id));
|
||||||
input.source === "user" &&
|
for (const mapping of state.roomMappings) {
|
||||||
batch.appliedProjectRevision !== input.expectedRevision + 1
|
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt.");
|
||||||
) {
|
if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt.");
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
|
for (const object of state.objects) {
|
||||||
|
if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart.");
|
||||||
|
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler.");
|
||||||
|
if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceExternalState(
|
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
|
||||||
database: AppDatabase,
|
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
|
||||||
projectId: string,
|
if (target) database.insert(externalModelConfigurations).values(target).run();
|
||||||
target: ExternalModelStateSnapshot
|
}
|
||||||
) {
|
|
||||||
database.delete(externalModelSources)
|
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) {
|
||||||
.where(eq(externalModelSources.projectId, projectId)).run();
|
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
|
||||||
if (target.source === null) return;
|
if (!target.source) return;
|
||||||
database.insert(externalModelSources).values(target.source).run();
|
database.insert(externalModelSources).values(target.source).run();
|
||||||
database.insert(externalImportBatches).values(
|
insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") })));
|
||||||
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
|
insertManyInBatches(database, externalRoomMappings, target.roomMappings);
|
||||||
...batch,
|
insertManyInBatches(database, externalModelObjects, target.objects);
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
|
||||||
}))
|
|
||||||
).run();
|
|
||||||
if (target.roomMappings.length) {
|
|
||||||
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
|
||||||
}
|
|
||||||
if (target.objects.length) {
|
|
||||||
database.insert(externalModelObjects).values(target.objects).run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCanonicalBase64(value: string) {
|
function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
|
||||||
const bytes = Buffer.from(value, "base64");
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
if (bytes.toString("base64") !== value) {
|
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
|
||||||
throw new Error("External initial import bytes are not canonical base64.");
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|
||||||
}
|
|
||||||
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,79 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertExternalModelConfigurationUpdateProjectCommand,
|
||||||
|
createExternalModelConfigurationUpdateProjectCommand,
|
||||||
|
type ExternalModelConfigurationSnapshot,
|
||||||
|
} from "../../domain/models/external-model-configuration-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteExternalModelConfigurationCommandInput,
|
||||||
|
ExternalModelConfigurationProjectCommandStore,
|
||||||
|
} from "../../domain/ports/external-model-configuration-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalModelConfigurationProjectCommandRepository
|
||||||
|
implements ExternalModelConfigurationProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteExternalModelConfigurationCommandInput) {
|
||||||
|
assertExternalModelConfigurationUpdateProjectCommand(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(externalModelConfigurations)
|
||||||
|
.where(eq(externalModelConfigurations.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("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
|
||||||
|
}
|
||||||
|
if (sameSnapshot(current, target)) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
|
||||||
|
}
|
||||||
|
if (target === null) {
|
||||||
|
tx.delete(externalModelConfigurations)
|
||||||
|
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
|
||||||
|
} else if (current === null) {
|
||||||
|
tx.insert(externalModelConfigurations).values(target).run();
|
||||||
|
} else {
|
||||||
|
const updated = tx.update(externalModelConfigurations)
|
||||||
|
.set({ configuration: target.configuration })
|
||||||
|
.where(eq(externalModelConfigurations.id, current.id)).run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createExternalModelConfigurationUpdateProjectCommand(target, current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSameSnapshot(
|
||||||
|
current: ExternalModelConfigurationSnapshot | null,
|
||||||
|
expected: ExternalModelConfigurationSnapshot | null
|
||||||
|
) {
|
||||||
|
if (!sameSnapshot(current, expected)) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSnapshot(
|
||||||
|
left: ExternalModelConfigurationSnapshot | null,
|
||||||
|
right: ExternalModelConfigurationSnapshot | 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);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
export class ExternalCsvConfigurationRepository
|
export class ExternalModelConfigurationRepository
|
||||||
implements ExternalCsvConfigurationReader
|
implements ExternalModelConfigurationReader {
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
getByProject(projectId: string) {
|
getByProject(projectId: string) {
|
||||||
|
|
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.id, projectId))
|
.where(eq(projects.id, projectId))
|
||||||
.get();
|
.get();
|
||||||
if (!project) {
|
if (!project) return { projectExists: false, configuration: null };
|
||||||
return { projectExists: false, configuration: null };
|
|
||||||
}
|
|
||||||
const configuration = this.database
|
const configuration = this.database
|
||||||
.select()
|
.select()
|
||||||
.from(externalCsvConfigurations)
|
.from(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
.where(eq(externalModelConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
return { projectExists: true, configuration };
|
return { projectExists: true, configuration };
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
.select()
|
.select()
|
||||||
.from(externalModelObjects)
|
.from(externalModelObjects)
|
||||||
.where(eq(externalModelObjects.sourceId, source.id))
|
.where(eq(externalModelObjects.sourceId, source.id))
|
||||||
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
|
.orderBy(
|
||||||
|
asc(externalModelObjects.worksharingId),
|
||||||
|
asc(externalModelObjects.id)
|
||||||
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
source,
|
source,
|
||||||
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
|
originalContentBase64:
|
||||||
|
originalBytes === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalBytes).toString("base64"),
|
||||||
})),
|
})),
|
||||||
roomMappings,
|
roomMappings,
|
||||||
objects,
|
objects,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition {
|
||||||
target: ExternalModelObjectSnapshot;
|
target: ExternalModelObjectSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function captureExternalObjectDetachTransitions(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
rowIds: string[]
|
||||||
|
): ExternalObjectLinkTransition[] {
|
||||||
|
if (rowIds.length === 0) return [];
|
||||||
|
return database.select().from(externalModelObjects)
|
||||||
|
.where(and(
|
||||||
|
eq(externalModelObjects.projectId, projectId),
|
||||||
|
inArray(externalModelObjects.circuitDeviceRowId, rowIds)
|
||||||
|
)).all()
|
||||||
|
.map(toExternalObjectSnapshot)
|
||||||
|
.map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertExternalObjectLinkTransitions(
|
||||||
|
transitions: ExternalObjectLinkTransition[]
|
||||||
|
): ExternalObjectLinkTransition[] {
|
||||||
|
return transitions.map(({ expected, target }) => ({ expected: target, target: expected }));
|
||||||
|
}
|
||||||
|
|
||||||
export function loadExpectedExternalObjectTransitions(
|
export function loadExpectedExternalObjectTransitions(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -51,7 +72,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
|
||||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||||
const markers = new Set(
|
const markers = new Set(
|
||||||
input.allLinkedObjects.map((object) =>
|
input.allLinkedObjects.map((object) =>
|
||||||
object.acceptedSourceValues.selectionMarker.trim()
|
scalarText(object.acceptedSourceValues.selectionMarker).trim()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (markers.size > 1) {
|
if (markers.size > 1) {
|
||||||
|
|
@ -86,7 +107,13 @@ export function applyExternalObjectLinks(
|
||||||
) {
|
) {
|
||||||
for (const { expected, target } of transitions) {
|
for (const { expected, target } of transitions) {
|
||||||
const updated = database.update(externalModelObjects)
|
const updated = database.update(externalModelObjects)
|
||||||
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
|
.set({
|
||||||
|
circuitDeviceRowId: target.circuitDeviceRowId,
|
||||||
|
distributionBoardId: target.distributionBoardId,
|
||||||
|
linkedProjectDeviceId: target.linkedProjectDeviceId,
|
||||||
|
planningValues: target.planningValues,
|
||||||
|
overriddenFields: target.overriddenFields,
|
||||||
|
})
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(externalModelObjects.id, expected.id),
|
eq(externalModelObjects.id, expected.id),
|
||||||
expected.circuitDeviceRowId === null
|
expected.circuitDeviceRowId === null
|
||||||
|
|
@ -118,13 +145,17 @@ function matchesRowPlanningValues(
|
||||||
row.category === planning.category &&
|
row.category === planning.category &&
|
||||||
row.connectionKind === planning.connectionKind &&
|
row.connectionKind === planning.connectionKind &&
|
||||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
|
||||||
row.cosPhi === planning.cosPhi &&
|
row.cosPhi === planning.cosPhi &&
|
||||||
row.costGroup === planning.costGroup &&
|
row.costGroup === planning.costGroup &&
|
||||||
row.remark === planning.remark
|
row.remark === planning.remark
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scalarText(value: string | number | boolean | null) {
|
||||||
|
return value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
function canonicalJson(value: unknown): string {
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
if (value !== null && typeof value === "object") {
|
if (value !== null && typeof value === "object") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
|
import { assertExternalObjectDeleteMissingProjectCommand, invertExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
|
||||||
|
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../../domain/ports/external-object-delete-missing-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 { circuits } from "../schema/circuits.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalObjectDeleteMissingProjectCommandRepository implements ExternalObjectDeleteMissingProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalObjectDeleteMissingProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const rowById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
const rowIds = [...rowById.keys()];
|
||||||
|
const rowContexts = rowIds.length ? tx.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
|
||||||
|
if (rowContexts.length !== rowIds.length || rowContexts.some((entry) => entry.projectId !== input.projectId || !snapshotsEqual(toCircuitDeviceRowSnapshot(entry.row), rowById.get(entry.row.id)!.expected))) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
|
||||||
|
const currentObjects = tx.select().from(externalModelObjects).where(eq(externalModelObjects.projectId, input.projectId)).all().map(toExternalObjectSnapshot);
|
||||||
|
const currentById = new Map(currentObjects.map((object) => [object.id, object]));
|
||||||
|
for (const transition of input.command.payload.objects) {
|
||||||
|
const current = currentById.get((transition.expected ?? transition.target)!.id) ?? null;
|
||||||
|
if (transition.expected === null ? current !== null : current === null || !snapshotsEqual(current, transition.expected)) throw new Error("Ein fehlendes Revit-Objekt wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
const sourceIds = ids(tx.select({ id: externalModelSources.id }).from(externalModelSources).where(eq(externalModelSources.projectId, input.projectId)).all());
|
||||||
|
const batchIds = ids(tx.select({ id: externalImportBatches.id }).from(externalImportBatches).where(eq(externalImportBatches.projectId, input.projectId)).all());
|
||||||
|
const mappingIds = ids(tx.select({ id: externalRoomMappings.id }).from(externalRoomMappings).where(eq(externalRoomMappings.projectId, input.projectId)).all());
|
||||||
|
const boardIds = ids(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all());
|
||||||
|
const deviceIds = ids(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all());
|
||||||
|
for (const { target } of input.command.payload.objects) if (target && (target.projectId !== input.projectId || !sourceIds.has(target.sourceId) || !batchIds.has(target.lastSeenImportBatchId) || !batchIds.has(target.lastAcceptedImportBatchId) || (target.externalRoomMappingId !== null && !mappingIds.has(target.externalRoomMappingId)) || (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) || (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)))) throw new Error("Ein wiederhergestelltes Revit-Objekt verweist auf ein anderes Projekt.");
|
||||||
|
const targetById = new Map(currentObjects.map((object) => [object.id, object]));
|
||||||
|
for (const transition of input.command.payload.objects) { const id = (transition.expected ?? transition.target)!.id; if (transition.target) targetById.set(id, transition.target); else targetById.delete(id); }
|
||||||
|
for (const transition of input.command.payload.rows) assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, externalObjects: [...targetById.values()].filter((object) => object.circuitDeviceRowId === transition.target.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
|
||||||
|
for (const transition of input.command.payload.rows) {
|
||||||
|
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Löschens geändert.");
|
||||||
|
}
|
||||||
|
for (const transition of input.command.payload.objects) {
|
||||||
|
const object = transition.expected ?? transition.target!;
|
||||||
|
if (transition.target === null) {
|
||||||
|
const result = tx.delete(externalModelObjects).where(and(eq(externalModelObjects.id, object.id), eq(externalModelObjects.projectId, input.projectId))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Ein fehlendes Revit-Objekt konnte nicht gelöscht werden.");
|
||||||
|
} else tx.insert(externalModelObjects).values(transition.target).run();
|
||||||
|
}
|
||||||
|
return invertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
|
||||||
|
export function assertDistributionBoardHasNoExternalObjects(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardId: string
|
||||||
|
) {
|
||||||
|
const reference = database.select({ id: externalModelObjects.id })
|
||||||
|
.from(externalModelObjects)
|
||||||
|
.where(and(
|
||||||
|
eq(externalModelObjects.projectId, projectId),
|
||||||
|
eq(externalModelObjects.distributionBoardId, distributionBoardId)
|
||||||
|
)).limit(1).get();
|
||||||
|
if (reference) {
|
||||||
|
throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertRoomHasNoExternalObjects(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
roomId: string
|
||||||
|
) {
|
||||||
|
const reference = database.select({ id: externalModelObjects.id })
|
||||||
|
.from(externalModelObjects)
|
||||||
|
.innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId))
|
||||||
|
.where(and(
|
||||||
|
eq(externalModelObjects.projectId, projectId),
|
||||||
|
eq(externalRoomMappings.roomId, roomId)
|
||||||
|
)).limit(1).get();
|
||||||
|
if (reference) {
|
||||||
|
throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
snapshotsEqual,
|
snapshotsEqual,
|
||||||
} from "./external-object-assignment.persistence.js";
|
} from "./external-object-assignment.persistence.js";
|
||||||
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||||
|
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class ExternalObjectNewCircuitProjectCommandRepository
|
export class ExternalObjectNewCircuitProjectCommandRepository
|
||||||
implements ExternalObjectNewCircuitProjectCommandStore
|
implements ExternalObjectNewCircuitProjectCommandStore
|
||||||
|
|
@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository
|
||||||
objects,
|
objects,
|
||||||
"External object changed during new-circuit assignment."
|
"External object changed during new-circuit assignment."
|
||||||
);
|
);
|
||||||
|
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
|
||||||
|
expectedCircuit.id,
|
||||||
|
]);
|
||||||
const deleted = database.delete(circuits).where(and(
|
const deleted = database.delete(circuits).where(and(
|
||||||
eq(circuits.id, expectedCircuit.id),
|
eq(circuits.id, expectedCircuit.id),
|
||||||
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { assertExternalObjectPlanningUpdateProjectCommand, invertExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
||||||
|
import type { ExternalObjectPlanningProjectCommandStore } from "../../domain/ports/external-object-planning-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalObjectPlanningProjectCommandRepository implements ExternalObjectPlanningProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalObjectPlanningProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const boardIds = new Set(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all().map((row) => row.id));
|
||||||
|
const deviceIds = new Set(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all().map((row) => row.id));
|
||||||
|
for (const { expected, target } of input.command.payload.objects) {
|
||||||
|
const current = tx.select().from(externalModelObjects).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).get();
|
||||||
|
if (!current || !snapshotsEqual(toExternalObjectSnapshot(current), expected)) throw new Error("Ein Revit-Objekt wurde zwischenzeitlich geändert.");
|
||||||
|
if (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) throw new Error("Der Verteiler gehört zu einem anderen Projekt.");
|
||||||
|
if (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)) throw new Error("Das Projektgerät gehört zu einem anderen Projekt.");
|
||||||
|
const updated = tx.update(externalModelObjects).set({ planningValues: target.planningValues, overriddenFields: target.overriddenFields, distributionBoardId: target.distributionBoardId, linkedProjectDeviceId: target.linkedProjectDeviceId }).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).run();
|
||||||
|
if (updated.changes !== 1) throw new Error("Revit-Objekt konnte nicht geändert werden.");
|
||||||
|
}
|
||||||
|
return invertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
projectRoomInsertCommandType,
|
projectRoomInsertCommandType,
|
||||||
projectRoomUpdateCommandType,
|
projectRoomUpdateCommandType,
|
||||||
upgradeProjectFloorCommand,
|
upgradeProjectFloorCommand,
|
||||||
|
upgradeProjectRoomCommand,
|
||||||
type ProjectBuildingSnapshot,
|
type ProjectBuildingSnapshot,
|
||||||
type ProjectFloorSnapshot,
|
type ProjectFloorSnapshot,
|
||||||
type ProjectLocationStructureProjectCommand,
|
type ProjectLocationStructureProjectCommand,
|
||||||
|
|
@ -45,6 +46,7 @@ import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
|
||||||
|
|
||||||
export class ProjectLocationStructureProjectCommandRepository
|
export class ProjectLocationStructureProjectCommandRepository
|
||||||
implements ProjectLocationStructureProjectCommandStore
|
implements ProjectLocationStructureProjectCommandStore
|
||||||
|
|
@ -106,23 +108,29 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
const current = upgradeProjectFloorCommand(command);
|
const current = upgradeProjectFloorCommand(command);
|
||||||
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
||||||
}
|
}
|
||||||
case projectRoomInsertCommandType:
|
case projectRoomInsertCommandType: {
|
||||||
assertProjectRoomInsertProjectCommand(command);
|
assertProjectRoomInsertProjectCommand(command);
|
||||||
|
const current = upgradeProjectRoomCommand(command);
|
||||||
return this.insertRoom(
|
return this.insertRoom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.room
|
current.payload.room
|
||||||
);
|
);
|
||||||
case projectRoomDeleteCommandType:
|
}
|
||||||
|
case projectRoomDeleteCommandType: {
|
||||||
assertProjectRoomDeleteProjectCommand(command);
|
assertProjectRoomDeleteProjectCommand(command);
|
||||||
|
const current = upgradeProjectRoomCommand(command);
|
||||||
return this.deleteRoom(
|
return this.deleteRoom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.room
|
current.payload.room
|
||||||
);
|
);
|
||||||
case projectRoomUpdateCommandType:
|
}
|
||||||
|
case projectRoomUpdateCommandType: {
|
||||||
assertProjectRoomUpdateProjectCommand(command);
|
assertProjectRoomUpdateProjectCommand(command);
|
||||||
return this.updateRoom(database, projectId, command.payload.expected, command.payload.target);
|
const current = upgradeProjectRoomCommand(command);
|
||||||
|
return this.updateRoom(database, projectId, current.payload.expected, current.payload.target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,6 +401,7 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
floorId: target.floorId,
|
floorId: target.floorId,
|
||||||
roomNumber: target.roomNumber,
|
roomNumber: target.roomNumber,
|
||||||
roomName: target.roomName,
|
roomName: target.roomName,
|
||||||
|
areaM2: target.areaM2,
|
||||||
})
|
})
|
||||||
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
|
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
|
||||||
.run();
|
.run();
|
||||||
|
|
@ -431,6 +440,7 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
|
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertRoomHasNoExternalObjects(database, projectId, expected.id);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(rooms)
|
.delete(rooms)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -474,6 +484,15 @@ function toCurrentCommandVersion(
|
||||||
case projectFloorUpdateCommandType:
|
case projectFloorUpdateCommandType:
|
||||||
assertProjectFloorUpdateProjectCommand(command);
|
assertProjectFloorUpdateProjectCommand(command);
|
||||||
return upgradeProjectFloorCommand(command);
|
return upgradeProjectFloorCommand(command);
|
||||||
|
case projectRoomInsertCommandType:
|
||||||
|
assertProjectRoomInsertProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
|
case projectRoomDeleteCommandType:
|
||||||
|
assertProjectRoomDeleteProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
|
case projectRoomUpdateCommandType:
|
||||||
|
assertProjectRoomUpdateProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
default:
|
default:
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
|
||||||
import {
|
import {
|
||||||
assertProjectStateRestoreCommand,
|
assertProjectStateRestoreCommand,
|
||||||
createProjectStateRestoreCommand,
|
createProjectStateRestoreCommand,
|
||||||
|
createProjectTransferRestoreCommand,
|
||||||
|
projectStateRestoreCommandSchemaVersion,
|
||||||
} from "../../domain/models/project-state-restore-command.model.js";
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
parseProjectStateSnapshot,
|
parseProjectStateSnapshot,
|
||||||
|
|
@ -13,6 +15,8 @@ import type {
|
||||||
ProjectStateRestoreCommandStore,
|
ProjectStateRestoreCommandStore,
|
||||||
} from "../../domain/ports/project-state-restore-command.store.js";
|
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
|
|
@ -27,15 +31,21 @@ import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import {
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
||||||
|
|
||||||
export class ProjectStateRestoreCommandRepository
|
export class ProjectStateRestoreCommandRepository
|
||||||
implements ProjectStateRestoreCommandStore
|
implements ProjectStateRestoreCommandStore
|
||||||
|
|
@ -66,10 +76,39 @@ export class ProjectStateRestoreCommandRepository
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
|
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
|
||||||
|
const transferCommand =
|
||||||
|
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
|
||||||
|
? input.command
|
||||||
|
: null;
|
||||||
if (
|
if (
|
||||||
current.payloadSha256 !==
|
current.payloadSha256 !==
|
||||||
input.command.payload.expectedStateSha256 &&
|
input.command.payload.expectedStateSha256 &&
|
||||||
!matchesHistoryRestoreTarget(tx, input, current.state)
|
!matchesHistoryRestoreTarget(
|
||||||
|
tx,
|
||||||
|
input,
|
||||||
|
current.state,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ProjectStateConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.command.payload.expectedStateSha256,
|
||||||
|
current.payloadSha256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
transferCommand &&
|
||||||
|
canonicalEstimateBalances(currentEstimateBalances) !==
|
||||||
|
canonicalEstimateBalances(
|
||||||
|
transferCommand.payload.expectedEstimateBalances
|
||||||
|
) &&
|
||||||
|
!matchesHistoryRestoreTarget(
|
||||||
|
tx,
|
||||||
|
input,
|
||||||
|
current.state,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new ProjectStateConflictError(
|
throw new ProjectStateConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
|
|
@ -82,11 +121,25 @@ export class ProjectStateRestoreCommandRepository
|
||||||
input.command.payload.targetState
|
input.command.payload.targetState
|
||||||
);
|
);
|
||||||
replaceProjectState(tx, targetState);
|
replaceProjectState(tx, targetState);
|
||||||
|
if (transferCommand) {
|
||||||
|
replaceEstimateBalances(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
transferCommand.payload.targetEstimateBalances
|
||||||
|
);
|
||||||
|
}
|
||||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||||
if (!persistedTarget) {
|
if (!persistedTarget) {
|
||||||
throw new Error("Restored project could not be loaded.");
|
throw new Error("Restored project could not be loaded.");
|
||||||
}
|
}
|
||||||
const inverse = createProjectStateRestoreCommand(
|
const inverse = transferCommand
|
||||||
|
? createProjectTransferRestoreCommand(
|
||||||
|
persistedTarget.payloadSha256,
|
||||||
|
current.state,
|
||||||
|
transferCommand.payload.targetEstimateBalances,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
|
: createProjectStateRestoreCommand(
|
||||||
persistedTarget.payloadSha256,
|
persistedTarget.payloadSha256,
|
||||||
current.state
|
current.state
|
||||||
);
|
);
|
||||||
|
|
@ -98,7 +151,8 @@ export class ProjectStateRestoreCommandRepository
|
||||||
function matchesHistoryRestoreTarget(
|
function matchesHistoryRestoreTarget(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
input: ExecuteProjectStateRestoreCommandInput,
|
input: ExecuteProjectStateRestoreCommandInput,
|
||||||
currentState: ProjectStateSnapshot
|
currentState: ProjectStateSnapshot,
|
||||||
|
currentEstimateBalances: EstimateBalanceSnapshot[]
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
(input.source !== "undo" && input.source !== "redo") ||
|
(input.source !== "undo" && input.source !== "redo") ||
|
||||||
|
|
@ -122,25 +176,45 @@ function matchesHistoryRestoreTarget(
|
||||||
input.source === "undo"
|
input.source === "undo"
|
||||||
? changeSet.forwardPayloadJson
|
? changeSet.forwardPayloadJson
|
||||||
: changeSet.inversePayloadJson;
|
: changeSet.inversePayloadJson;
|
||||||
const command = JSON.parse(serialized) as {
|
const command = JSON.parse(serialized) as unknown;
|
||||||
type?: unknown;
|
assertProjectStateRestoreCommand(command);
|
||||||
payload?: { targetState?: unknown };
|
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
|
||||||
};
|
const stateMatches =
|
||||||
if (command.type !== "project.restore-state") {
|
canonicalProjectState(expectedState) ===
|
||||||
|
canonicalProjectState(currentState);
|
||||||
|
if (!stateMatches) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const expectedState = parseProjectStateSnapshot(
|
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
|
||||||
command.payload?.targetState
|
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
|
||||||
);
|
canonicalEstimateBalances(currentEstimateBalances);
|
||||||
return (
|
|
||||||
canonicalProjectState(expectedState) ===
|
|
||||||
canonicalProjectState(currentState)
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
|
||||||
|
return JSON.stringify(
|
||||||
|
[...balances]
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id))
|
||||||
|
.map((balance) => canonicalEstimateBalance(balance))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceEstimateBalances(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balances: EstimateBalanceSnapshot[]
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.delete(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.projectId, projectId))
|
||||||
|
.run();
|
||||||
|
for (const balance of balances) {
|
||||||
|
insertEstimateBalance(database, balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -168,7 +242,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
buildings: byId(state.buildings),
|
buildings: byId(state.buildings),
|
||||||
floors: byId(state.floors),
|
floors: byId(state.floors),
|
||||||
rooms: byId(state.rooms),
|
rooms: byId(state.rooms),
|
||||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
externalModelConfiguration: state.externalModelConfiguration,
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: state.externalModel.source,
|
source: state.externalModel.source,
|
||||||
importBatches: byId(state.externalModel.importBatches),
|
importBatches: byId(state.externalModel.importBatches),
|
||||||
|
|
@ -197,13 +271,21 @@ function replaceProjectState(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
) {
|
) {
|
||||||
|
database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalModelSources)
|
.delete(externalModelSources)
|
||||||
.where(eq(externalModelSources.projectId, state.project.id))
|
.where(eq(externalModelSources.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalCsvConfigurations)
|
.delete(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
.where(eq(externalModelConfigurations.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
|
|
@ -249,15 +331,23 @@ function replaceProjectState(
|
||||||
|
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
if (state.externalCsvConfiguration !== null) {
|
if (state.externalModelConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalCsvConfigurations)
|
.insert(externalModelConfigurations)
|
||||||
.values(state.externalCsvConfiguration)
|
.values(state.externalModelConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
insertMany(database, projectDevices, state.projectDevices);
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
insertMany(database, distributionBoards, state.distributionBoards);
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoards,
|
||||||
|
state.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
insertMany(database, circuitLists, state.circuitLists);
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
insertMany(database, circuitSections, state.circuitSections);
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
insertMany(
|
insertMany(
|
||||||
|
|
@ -268,6 +358,7 @@ function replaceProjectState(
|
||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
restoreDistributionBoardFeedingReferences(database, state);
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
distributionBoardComponents,
|
distributionBoardComponents,
|
||||||
|
|
@ -297,7 +388,10 @@ function replaceProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
originalBytes:
|
||||||
|
originalContentBase64 === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalContentBase64, "base64"),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -305,16 +399,35 @@ function replaceProjectState(
|
||||||
insertMany(database, externalModelObjects, state.externalModel.objects);
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreDistributionBoardFeedingReferences(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
for (const board of state.distributionBoards) {
|
||||||
|
if (
|
||||||
|
board.feedingDistributionBoardId === null &&
|
||||||
|
board.feedingCircuitId === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: board.feedingCircuitId,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, board.id))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Distribution-board feeding reference could not be restored.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
table: TTable,
|
table: TTable,
|
||||||
values: unknown[]
|
values: unknown[]
|
||||||
) {
|
) {
|
||||||
if (values.length === 0) {
|
insertManyInBatches(database, table, values);
|
||||||
return;
|
|
||||||
}
|
|
||||||
database
|
|
||||||
.insert(table)
|
|
||||||
.values(values as never)
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
export interface PersistedProjectStateSnapshot {
|
export interface PersistedProjectStateSnapshot {
|
||||||
|
|
@ -66,10 +66,10 @@ export function readProjectStateSnapshot(
|
||||||
.where(eq(distributionBoards.projectId, projectId))
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||||
.all();
|
.all();
|
||||||
const externalCsvConfiguration = database
|
const externalModelConfiguration = database
|
||||||
.select()
|
.select()
|
||||||
.from(externalCsvConfigurations)
|
.from(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
.where(eq(externalModelConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
const externalModel = new ExternalModelStateRepository(database)
|
const externalModel = new ExternalModelStateRepository(database)
|
||||||
.getByProject(projectId).state;
|
.getByProject(projectId).state;
|
||||||
|
|
@ -266,7 +266,7 @@ export function readProjectStateSnapshot(
|
||||||
projectEditor: project.projectEditor,
|
projectEditor: project.projectEditor,
|
||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
externalCsvConfiguration,
|
externalModelConfiguration,
|
||||||
externalModel,
|
externalModel,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
circuitSections: sectionRows,
|
circuitSections: sectionRows,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createProjectTransferEnvelope,
|
createProjectTransferEnvelope,
|
||||||
parseProjectTransferEnvelope,
|
parseProjectTransferEnvelope,
|
||||||
|
remapEstimateBalances,
|
||||||
remapProjectState,
|
remapProjectState,
|
||||||
|
serializeProjectTransferPayload,
|
||||||
} from "../../domain/models/project-transfer.model.js";
|
} from "../../domain/models/project-transfer.model.js";
|
||||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
@ -21,16 +23,21 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import {
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
import {
|
import {
|
||||||
hashProjectStatePayload,
|
hashProjectStatePayload,
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
||||||
|
|
||||||
export class ProjectTransferRepository implements ProjectTransferStore {
|
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
@ -40,10 +47,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const estimateBalances = readEstimateBalances(this.database, projectId);
|
||||||
|
const payloadSha256 = hashProjectStatePayload(
|
||||||
|
serializeProjectTransferPayload(snapshot.state, estimateBalances)
|
||||||
|
);
|
||||||
return createProjectTransferEnvelope(
|
return createProjectTransferEnvelope(
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
snapshot.payloadSha256,
|
payloadSha256,
|
||||||
snapshot.state
|
snapshot.state,
|
||||||
|
estimateBalances
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,10 +73,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
projectId,
|
projectId,
|
||||||
() => crypto.randomUUID()
|
() => crypto.randomUUID()
|
||||||
);
|
);
|
||||||
|
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
|
||||||
|
const targetEstimateBalances =
|
||||||
|
transfer.projectState.project.id === projectId
|
||||||
|
? transfer.estimateBalances
|
||||||
|
: remapEstimateBalances(
|
||||||
|
transfer.estimateBalances,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
command: createProjectStateRestoreCommand(
|
command: createProjectTransferRestoreCommand(
|
||||||
current.payloadSha256,
|
current.payloadSha256,
|
||||||
targetState
|
targetState,
|
||||||
|
currentEstimateBalances,
|
||||||
|
targetEstimateBalances
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -78,19 +101,38 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
() => crypto.randomUUID(),
|
() => crypto.randomUUID(),
|
||||||
`${transfer.projectState.project.name} (Kopie)`
|
`${transfer.projectState.project.name} (Kopie)`
|
||||||
);
|
);
|
||||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
const estimateBalances = remapEstimateBalances(
|
||||||
|
transfer.estimateBalances,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
insertProjectState(tx, state);
|
||||||
|
for (const balance of estimateBalances) {
|
||||||
|
insertEstimateBalance(tx, balance);
|
||||||
|
}
|
||||||
|
});
|
||||||
return { projectId, name: state.project.name };
|
return { projectId, name: state.project.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseVerified(value: unknown) {
|
private parseVerified(value: unknown) {
|
||||||
const rawPayloadSha256 =
|
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
|
||||||
isPlainObject(value) &&
|
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
|
||||||
isPlainObject(value.projectState)
|
? hashProjectStatePayload(
|
||||||
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
isLegacy
|
||||||
|
? JSON.stringify(value.projectState)
|
||||||
|
: JSON.stringify({
|
||||||
|
projectState: value.projectState,
|
||||||
|
estimateBalances: value.estimateBalances,
|
||||||
|
})
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const transfer = parseProjectTransferEnvelope(value);
|
const transfer = parseProjectTransferEnvelope(value);
|
||||||
const payloadJson = serializeProjectStateSnapshot(
|
const payloadJson = isLegacy
|
||||||
transfer.projectState
|
? serializeProjectStateSnapshot(transfer.projectState)
|
||||||
|
: serializeProjectTransferPayload(
|
||||||
|
transfer.projectState,
|
||||||
|
transfer.estimateBalances
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||||
|
|
@ -122,17 +164,25 @@ function insertProjectState(
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({ ...state.project, currentRevision: 0 })
|
.values({ ...state.project, currentRevision: 0 })
|
||||||
.run();
|
.run();
|
||||||
if (state.externalCsvConfiguration !== null) {
|
if (state.externalModelConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalCsvConfigurations)
|
.insert(externalModelConfigurations)
|
||||||
.values(state.externalCsvConfiguration)
|
.values(state.externalModelConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
insertMany(database, projectDevices, state.projectDevices);
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
insertMany(database, distributionBoards, state.distributionBoards);
|
insertMany(
|
||||||
|
database,
|
||||||
|
distributionBoards,
|
||||||
|
state.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
feedingDistributionBoardId: null,
|
||||||
|
feedingCircuitId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
insertMany(database, circuitLists, state.circuitLists);
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
insertMany(database, circuitSections, state.circuitSections);
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
insertMany(
|
insertMany(
|
||||||
|
|
@ -143,6 +193,25 @@ function insertProjectState(
|
||||||
isReserve: circuit.isReserve ? 1 : 0,
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
for (const board of state.distributionBoards) {
|
||||||
|
if (
|
||||||
|
board.feedingDistributionBoardId === null &&
|
||||||
|
board.feedingCircuitId === null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set({
|
||||||
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
||||||
|
feedingCircuitId: board.feedingCircuitId,
|
||||||
|
})
|
||||||
|
.where(eq(distributionBoards.id, board.id))
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Distribution-board feeding reference could not be imported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
insertMany(
|
insertMany(
|
||||||
database,
|
database,
|
||||||
distributionBoardComponents,
|
distributionBoardComponents,
|
||||||
|
|
@ -172,7 +241,10 @@ function insertProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
originalBytes:
|
||||||
|
originalContentBase64 === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalContentBase64, "base64"),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -185,7 +257,5 @@ function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
table: TTable,
|
table: TTable,
|
||||||
values: unknown[]
|
values: unknown[]
|
||||||
) {
|
) {
|
||||||
if (values.length > 0) {
|
insertManyInBatches(database, table, values);
|
||||||
database.insert(table).values(values as never).run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
src/db/repositories/user.repository.ts
Normal file
117
src/db/repositories/user.repository.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { users } from "../schema/users.js";
|
||||||
|
import {
|
||||||
|
LastActiveAdminStoreError,
|
||||||
|
UsernameAlreadyExistsStoreError,
|
||||||
|
type CreateUserAccount,
|
||||||
|
type UserStore,
|
||||||
|
} from "../../domain/ports/user.store.js";
|
||||||
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserRepository implements UserStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
const db = this.database;
|
||||||
|
return db.select().from(users).orderBy(asc(users.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async count() {
|
||||||
|
const db = this.database;
|
||||||
|
const rows = await db.select({ id: users.id }).from(users).limit(1);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(userId: string) {
|
||||||
|
const db = this.database;
|
||||||
|
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUsername(username: string) {
|
||||||
|
const db = this.database;
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username.trim().toLowerCase()))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateUserAccount) {
|
||||||
|
const db = this.database;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
username: input.username.trim().toLowerCase(),
|
||||||
|
name: input.name,
|
||||||
|
role: input.role,
|
||||||
|
passwordHash: input.passwordHash,
|
||||||
|
active: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRoleAndActive(
|
||||||
|
userId: string,
|
||||||
|
patch: { role?: UserRole; active?: boolean }
|
||||||
|
) {
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all();
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
const removesActiveAdmin =
|
||||||
|
target.role === "admin" &&
|
||||||
|
target.active &&
|
||||||
|
(patch.role === "user" || patch.active === false);
|
||||||
|
if (removesActiveAdmin) {
|
||||||
|
const activeAdmins = tx
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.role, "admin"), eq(users.active, true)))
|
||||||
|
.limit(2)
|
||||||
|
.all();
|
||||||
|
if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(users)
|
||||||
|
.set({
|
||||||
|
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||||
|
...(patch.active !== undefined ? { active: patch.active } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.run();
|
||||||
|
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(
|
||||||
|
userId: string,
|
||||||
|
passwordHash: string,
|
||||||
|
mustChangePassword: boolean
|
||||||
|
) {
|
||||||
|
const db = this.database;
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ passwordHash, mustChangePassword, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
return this.findById(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { distributionBoards } from "./distribution-boards.js";
|
import { distributionBoards } from "./distribution-boards.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const circuitLists = sqliteTable(
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
distributionBoardId: text("distribution_board_id")
|
distributionBoardId: text("distribution_board_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => distributionBoards.id, { onDelete: "cascade" }),
|
.references((): AnySQLiteColumn => distributionBoards.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
},
|
},
|
||||||
(table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)]
|
(table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { index, integer, real, sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { circuitLists } from "./circuit-lists.js";
|
import { circuitLists } from "./circuit-lists.js";
|
||||||
import { circuitSections } from "./circuit-sections.js";
|
import { circuitSections } from "./circuit-sections.js";
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const circuits = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
circuitListId: text("circuit_list_id")
|
circuitListId: text("circuit_list_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => circuitLists.id, { onDelete: "cascade" }),
|
.references((): AnySQLiteColumn => circuitLists.id, { onDelete: "cascade" }),
|
||||||
sectionId: text("section_id")
|
sectionId: text("section_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => circuitSections.id, { onDelete: "cascade" }),
|
.references(() => circuitSections.id, { onDelete: "cascade" }),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { index, real, sqliteTable, text, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
|
import { circuits } from "./circuits.js";
|
||||||
import { floors } from "./floors.js";
|
import { floors } from "./floors.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export const distributionBoards = sqliteTable("distribution_boards", {
|
export const distributionBoards = sqliteTable(
|
||||||
|
"distribution_boards",
|
||||||
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
projectId: text("project_id")
|
projectId: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
@ -20,5 +23,22 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
fedFrom: text("fed_from"),
|
fedFrom: text("fed_from"),
|
||||||
remark: text("remark"),
|
remark: text("remark"),
|
||||||
});
|
feedingDistributionBoardId: text("feeding_distribution_board_id").references(
|
||||||
|
(): AnySQLiteColumn => distributionBoards.id,
|
||||||
|
{ onDelete: "restrict" }
|
||||||
|
),
|
||||||
|
feedingCircuitId: text("feeding_circuit_id").references(
|
||||||
|
(): AnySQLiteColumn => circuits.id,
|
||||||
|
{ onDelete: "restrict" }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("distribution_boards_feeding_board_idx").on(
|
||||||
|
table.feedingDistributionBoardId
|
||||||
|
),
|
||||||
|
index("distribution_boards_feeding_circuit_idx").on(
|
||||||
|
table.feedingCircuitId
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
|
||||||
60
src/db/schema/estimate-balances.ts
Normal file
60
src/db/schema/estimate-balances.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
||||||
|
// circuit structure. See docs/estimate-balance.md.
|
||||||
|
export const estimateBalances = sqliteTable(
|
||||||
|
"estimate_balances",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
workStage: text("work_stage"),
|
||||||
|
dateIso: text("date_iso"),
|
||||||
|
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
||||||
|
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const estimateGroups = sqliteTable(
|
||||||
|
"estimate_groups",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
balanceId: text("balance_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
||||||
|
costGroup: text("cost_group").notNull().default(""),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
editor: text("editor"),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const estimateRows = sqliteTable(
|
||||||
|
"estimate_rows",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
groupId: text("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
supply: text("supply"),
|
||||||
|
name: text("name").notNull().default(""),
|
||||||
|
quantity: real("quantity").notNull().default(0),
|
||||||
|
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
||||||
|
connection: text("connection", { enum: ["fixed", "socket"] }),
|
||||||
|
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
||||||
|
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||||
|
cosPhi: real("cos_phi").notNull().default(1),
|
||||||
|
safetySupply: integer("safety_supply", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
remark: text("remark"),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
||||||
|
);
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
export const externalCsvConfigurations = sqliteTable(
|
|
||||||
"external_csv_configurations",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
configurationVersion: integer("configuration_version").notNull(),
|
|
||||||
configuration: text("configuration", { mode: "json" })
|
|
||||||
.$type<ExternalCsvConfiguration>()
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("external_csv_configurations_project_id_unique").on(
|
|
||||||
table.projectId
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
24
src/db/schema/external-export-batches.ts
Normal file
24
src/db/schema/external-export-batches.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
export const externalExportScopes = ["all", "deviating", "selected"] as const;
|
||||||
|
export type ExternalExportScope = (typeof externalExportScopes)[number];
|
||||||
|
|
||||||
|
export const externalExportBatches = sqliteTable(
|
||||||
|
"external_export_batches",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
projectRevision: integer("project_revision").notNull(),
|
||||||
|
importBatchId: text("import_batch_id").notNull(),
|
||||||
|
exportedAtIso: text("exported_at_iso").notNull(),
|
||||||
|
fileName: text("file_name").notNull(),
|
||||||
|
sha256: text("sha256").notNull(),
|
||||||
|
configurationSnapshot: text("configuration_snapshot", { mode: "json" }).$type<ExternalModelConfiguration>().notNull(),
|
||||||
|
configurationSha256: text("configuration_sha256").notNull(),
|
||||||
|
scope: text("scope", { enum: externalExportScopes }).notNull(),
|
||||||
|
objectCount: integer("object_count").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("external_export_batches_project_exported_idx").on(table.projectId, table.exportedAtIso)]
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue