forked from jappel/leistungsbilanz-ts
Compare commits
76 commits
| 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 | |||
| 92da72bf14 | |||
| 868f48e7ac | |||
| 4643c8d11a | |||
| 3ffa50be4c | |||
| 2e223be8b4 | |||
| 641513c2ef | |||
| 203757b92c | |||
| 0a17da2b8e | |||
|
|
7b2419b347 | ||
|
|
b7811085a3 | ||
|
|
72189d23a5 | ||
|
|
e06c7d91e2 | ||
|
|
37a70b6b36 | ||
|
|
80dbc73230 | ||
|
|
68b9ee850b | ||
|
|
acff38afb8 | ||
|
|
837b29c7c5 | ||
|
|
5f4d9bf789 | ||
|
|
ead01fe5bd | ||
|
|
cd89c4a499 | ||
|
|
51cbab1750 | ||
|
|
79fcb75ffb | ||
|
|
ced4917a86 | ||
|
|
db4a6c3a2e | ||
|
|
49488f4e1b | ||
|
|
7692699ee2 | ||
|
|
4779e4a1a7 | ||
|
|
d95f0bf5c0 | ||
|
|
e394ff93f4 | ||
| 2e3e85a8e5 | |||
| 204499d7e4 | |||
| 906aa751c7 | |||
| c6cdfc42d5 | |||
| 9504905c8f |
339 changed files with 62249 additions and 8408 deletions
|
|
@ -6,3 +6,6 @@ data
|
|||
*.db
|
||||
.env
|
||||
.env.*
|
||||
# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image.
|
||||
.codex
|
||||
.agents
|
||||
|
|
|
|||
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,6 +1,13 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
||||
/drizzle.config.js
|
||||
data/*.db
|
||||
data/backups/*.db
|
||||
.codex/*.log
|
||||
/docs/spec/kbp-Stromkreis.xlsx
|
||||
/data/revit-reference/*.xlsx
|
||||
|
|
|
|||
411
AGENTS.md
411
AGENTS.md
|
|
@ -36,6 +36,16 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
|||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||
repositories expose only active reads. Runtime writes belong in typed command
|
||||
repositories; direct integration fixtures belong under `tests/support`.
|
||||
- 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,
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Renumbering is always an explicit user action.
|
||||
|
||||
|
|
@ -164,19 +175,53 @@ Keyboard behavior:
|
|||
- Escape cancels
|
||||
- Tab / Shift+Tab navigates editable cells
|
||||
- Arrow keys navigate cells when not editing
|
||||
- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the
|
||||
selected GZF cell also shows a small table-icon button. The picked value is
|
||||
committed like a typed edit.
|
||||
|
||||
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
||||
|
||||
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
|
||||
`lucide-react` icons). The German action text is always the accessible name and
|
||||
tooltip; the toolbar shows icon plus text, group headers and rows show icons
|
||||
only, separated into logical blocks with `IconButtonSeparator`. Destructive
|
||||
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
|
||||
removing a device row).
|
||||
Column headers show the label (click to sort) with a funnel icon for the
|
||||
value filter; an active filter is highlighted and shows its value count.
|
||||
Header labels break only between words and put a trailing unit such as
|
||||
`[kW]` on a second line.
|
||||
The project overview uses the same rule: row actions are icon-only
|
||||
`IconButton`s, card-header and panel actions show icon plus text, navigation
|
||||
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
|
||||
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
|
||||
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
|
||||
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
|
||||
the bar collapses, its state is kept per card in `localStorage`, a collapsed
|
||||
active filter shows "Filter aktiv".
|
||||
|
||||
## Bootstrap / Styling Rule
|
||||
|
||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
||||
|
||||
Cards follow the PM app look: 1px border all around, 16px radius and the
|
||||
soft shadow defined on `.card` in `globals.css` (own dark-mode value). The
|
||||
`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other
|
||||
`shadow-*` classes or a colored top border to cards.
|
||||
|
||||
Do not use Bootstrap as the core table/grid framework for the circuit list editor.
|
||||
|
||||
The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior.
|
||||
|
||||
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
||||
|
||||
## Browser Runtime
|
||||
|
||||
The web UI is also opened over plain HTTP from other machines in the LAN, which
|
||||
is not a secure context. Do not call secure-context-only browser APIs such as
|
||||
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
|
||||
from `src/frontend/utils/client-id.ts`.
|
||||
|
||||
## Drag-and-Drop Rules
|
||||
|
||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||
|
|
@ -250,8 +295,24 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
|||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
project data atomically and records a new `restore` revision with a complete
|
||||
inverse command. Restore can therefore be undone and redone after a restart.
|
||||
Current snapshot schema version 5 additionally stores the manual quantity share
|
||||
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
||||
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
||||
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
|
||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||
with `null` for these fields.
|
||||
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
|
||||
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
|
||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||
external-model source, import batches, room mappings and objects. Version 3
|
||||
snapshots are upgraded with an empty external-model state; version 2 snapshots
|
||||
|
|
@ -262,29 +323,45 @@ supported versions contain circuit-group identity, distribution-board components
|
|||
separate circuit/component protection records. Portable duplicate imports
|
||||
remap component ids and protection-owner references together with the existing
|
||||
project graph.
|
||||
Project-wide Revit CSV configuration uses
|
||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||
persisted through the shared project-command transaction, so create, update,
|
||||
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
|
||||
project duplication preserve the configuration; duplication remaps its internal
|
||||
id and project link.
|
||||
The project page exposes the Revit CSV modal. It edits the complete
|
||||
transport/column/family-rule configuration through the revision-safe endpoint,
|
||||
requests a stateless preview and then shows the Phase 14.2 initial-import
|
||||
decisions. Preview responses include all mapped objects, while the UI renders
|
||||
only the first 25. No preview or plan creates external objects, rows, revisions
|
||||
or server-side drafts.
|
||||
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
|
||||
immutable import batches with original bytes plus the classified cell matrix,
|
||||
normalized source-room mappings and external objects unique by
|
||||
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
||||
The general external-model repository is read-only; runtime writes belong only
|
||||
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
|
||||
capture the complete external state and remap every internal UUID and link while
|
||||
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
||||
Import batches additionally store the monotonic project CSV configuration
|
||||
version introduced by additive migration `0004`; it is distinct from the CSV
|
||||
configuration JSON schema version.
|
||||
R3 replaced the runtime configuration and persistence foundation with Linear
|
||||
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
|
||||
the old development-only external CSV data after resetting linked row totals to
|
||||
their manual share. Snapshot schema 10, restore and project transfer use the
|
||||
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
|
||||
rejected with a German error. The backend CSV parser and routes are removed.
|
||||
R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
|
||||
server-side sample detection, and removes the old project-page CSV modal. See
|
||||
`docs/spec/revit-excel-integration-requirements.md` and
|
||||
`docs/spec/revit-excel-phase-14-plan.md`.
|
||||
R5 adds stateless XLSX preview/planning and the six-step initial-import wizard.
|
||||
`external-model.apply-initial-import` stores the configuration and complete
|
||||
external model state atomically; its inverse powers persistent Undo/Redo
|
||||
without parsing workbook bytes. Apply retransmits and reparses the XLSX,
|
||||
verifies its preview SHA-256 and creates no circuit, equipment identifier or
|
||||
CircuitDeviceRow.
|
||||
R6 adds one compact project-wide external-object GET without pagination or
|
||||
batch payloads. The Revit page filters and sorts it in the client and renders a
|
||||
virtualized window. `external-object.update-planning` applies multi-object
|
||||
board, ProjectDevice and planning-value changes as one persistent command;
|
||||
linked objects are excluded because they follow their CircuitDeviceRow board.
|
||||
R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs
|
||||
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
|
||||
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
||||
Normal manual row inserts and quantity edits update both values together.
|
||||
|
|
@ -305,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
|
||||
the BMK. All three external assignment stores share
|
||||
`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`
|
||||
projection resolves the board through the owned CircuitList and returns only
|
||||
that board's compact external objects, unassigned first. It exposes no original
|
||||
CSV bytes or command payloads and returns an empty list before optional import.
|
||||
Confirmed initial state is written only through
|
||||
`external-import.apply-initial`. The command rechecks configuration version,
|
||||
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
|
||||
planning overrides and internal project links in the shared transaction. It
|
||||
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
|
||||
unchanged external state, and Redo restores the same UUIDs and bytes.
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless
|
||||
read path for the first wizard stage. It rejects projects with an existing
|
||||
external source and returns source-room groups, exact room-number suggestions,
|
||||
exact family/type groups, projected object values, issue counts and the current
|
||||
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
|
||||
domain row. The wide modal renders these groups and catalogs as explicit
|
||||
decisions and identifies exact, ambiguous and new room matches. Missing rooms
|
||||
and ProjectDevices are created through the reused project forms with mapped
|
||||
Revit values as visible defaults. Each catalog creation remains its own project
|
||||
command; the wizard adds and selects the result without discarding its plan.
|
||||
The dedicated
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
|
||||
retransmits and replans the file against the expected hash, configuration
|
||||
version and project revision. It requires one explicit decision per source-room
|
||||
and exact family/type group, blocks unclassified families, creates all stable
|
||||
external UUIDs server-side and invokes `external-import.apply-initial`.
|
||||
Room/default-board and optional ProjectDevice links are supported; CSV circuit
|
||||
values remain source-only and every `circuitDeviceRowId` remains null. The UI
|
||||
blocks unclassified families, requires a final confirmation and applies the
|
||||
whole import as one persistent Undo/Redo step.
|
||||
projection resolves the board through the owned CircuitList and returns compact
|
||||
legacy external objects. The target Revit page will load one compact project
|
||||
list and filter/sort it client-side in a virtualized table, without server
|
||||
pagination or batch blobs in the DTO.
|
||||
The central revision boundary creates an automatic logical snapshot after each
|
||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||
project. Named snapshots are never removed by this retention policy.
|
||||
|
|
@ -368,6 +420,10 @@ Project settings use the persistent `project.update-settings` command. Project
|
|||
metadata, the public-building classification, both voltage defaults and the
|
||||
enabled distribution-board supply types change in one revision and Undo/Redo
|
||||
restores them together; the project PUT route requires `expectedRevision`.
|
||||
Schema version 3 additionally carries the print cover fields `siteAddress`,
|
||||
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
|
||||
(without them) and baseline version 1 stay executable and keep the current
|
||||
cover values. The PUT route accepts the cover fields optionally.
|
||||
The public-building flag is the future sizing input for requiring halogen-free
|
||||
cables and lines. The system catalog is `AV`, `SV`, `EV`,
|
||||
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||
|
|
@ -415,18 +471,58 @@ snapshot contains the group, optional component protections, complete circuits,
|
|||
circuit protections and all device-row link/override metadata. Delete
|
||||
re-captures and compares the subtree before cascading; restore preserves every
|
||||
UUID in foreign-key-safe order.
|
||||
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||
The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||
`components`, `footerComponents`, optional group category/number and optional
|
||||
one-to-one protection DTOs for circuits and group components. These are
|
||||
the only supported circuit-protection source; no flat compatibility fields remain.
|
||||
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.update`; both values are snapshot/export fields and one
|
||||
persistent undo step.
|
||||
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`
|
||||
(migration `0007`). They are nullable, trimmed planner values; the PUT route
|
||||
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
|
||||
it is rejected like a changed simultaneity factor. Populated-subtree commands
|
||||
stored before these fields existed are normalized to `null` values
|
||||
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` remains free text for
|
||||
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
|
||||
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
||||
overwrite).
|
||||
Floor and room setup uses persistent insert, update and delete commands with
|
||||
complete stable snapshots. Their inverses remove or restore only unchanged records and
|
||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||
device rows. All write routes require `expectedRevision` and return the updated
|
||||
history state.
|
||||
complete stable snapshots. Room command schema version 2 stores the optional,
|
||||
finite, non-negative net room area `areaM2`; persisted version-1 commands are
|
||||
upgraded with `areaM2 = null` and exact record comparison prevents them from
|
||||
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:
|
||||
|
||||
|
|
@ -443,62 +539,116 @@ Required operations:
|
|||
- synchronization changes
|
||||
|
||||
|
||||
## Revit / CSV External Model Work
|
||||
## Print View (Seitenansicht)
|
||||
|
||||
When explicitly working on Phase 14, use
|
||||
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
|
||||
truth together with the current architecture documents.
|
||||
- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project
|
||||
page button "Seitenansicht". Read-only: it only uses existing GET endpoints.
|
||||
- Pure read model `src/frontend/print/print-document-model.ts` (tested in
|
||||
`tests/print-document-model.test.ts`); React sheets in
|
||||
`src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in
|
||||
`src/frontend/print/print-document.css`.
|
||||
- Pages: cover (A4 portrait, tabular), board overview with structure and
|
||||
power sums, one plan per board (A4/A3 landscape) whose title block sits in
|
||||
`<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
|
||||
`@page` rules (`lb-portrait`, `lb-landscape`).
|
||||
- The board hierarchy follows logical board ids. The overview shows power
|
||||
before board GZF and demand including linked downstream boards; it does not
|
||||
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
|
||||
`print-document.css`). Empty cover values are shown as
|
||||
"[ergänzen]"; the Planstände editor column shows the project's current
|
||||
`projectEditor` because snapshots do not record an editor.
|
||||
- The document logo and the office profile (office name/address on the
|
||||
cover) are global office branding: tables `document_branding_assets`
|
||||
(migration `0007`) and `document_branding_settings` (migration `0008`),
|
||||
endpoints `GET|PUT|DELETE /api/document-branding/logo`,
|
||||
`PUT /api/document-branding/office` and `GET /api/document-branding`.
|
||||
It is intentionally outside project revisions, snapshots and transfer.
|
||||
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
||||
is served with `nosniff` and a restrictive CSP.
|
||||
|
||||
Critical rules:
|
||||
## Revit / Linear Excel External Model Work
|
||||
|
||||
- Revit integration is optional. Do not make imported external data mandatory
|
||||
for projects, rooms, project devices, circuits or device rows.
|
||||
- Parse imports into staging first. Never write CSV rows directly into circuits
|
||||
or device rows.
|
||||
- Keep external object identity separate from internal UUIDs and circuit BMKs.
|
||||
`IfcGUID` identifies the external Revit object and must never replace an
|
||||
internal UUID or `equipmentIdentifier`.
|
||||
- One imported Revit object may exist without a `CircuitDeviceRow`.
|
||||
Import, room assignment, distribution-board assignment, classification and
|
||||
ProjectDevice linking do not create a row.
|
||||
- Create or change a `CircuitDeviceRow` only after an explicit user action in
|
||||
the circuit-list editor.
|
||||
- Several external objects may link to one `CircuitDeviceRow`, while every
|
||||
external object remains individually traceable for follow-up imports and
|
||||
object-specific export.
|
||||
- Multi-socket Revit objects are indivisible. Their effective quantity may be
|
||||
greater than one, but one external object must never be split across circuits.
|
||||
- Existing ProjectDevices are canonical project templates and must not be
|
||||
overwritten from Revit values. When creating a new ProjectDevice, mapped
|
||||
Revit values may prefill the creation form.
|
||||
- Store imported source values separately from local planning values and local
|
||||
overrides. Imports never silently overwrite local values.
|
||||
- Repeated imports match deterministically by project/source and `IfcGUID`.
|
||||
New, changed, missing and conflicting objects require preview and explicit
|
||||
user decisions.
|
||||
- The first implementation assumes one complete Revit model export per project.
|
||||
Do not add visible multi-source, linked-model or partial-import management.
|
||||
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
|
||||
and unknown rows and columns for the return export.
|
||||
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
|
||||
Do not report Revit subtotal rows as missing-identity objects.
|
||||
- Apply each confirmed import diff through a typed project command with
|
||||
`expectedRevision`, complete inverse data and the shared project command
|
||||
transaction boundary.
|
||||
- External-model state must be added to logical project snapshots and portable
|
||||
project transfer when it becomes part of the supported runtime.
|
||||
- Keep CSV parsing/serialization in a transport adapter. Domain services must
|
||||
not depend on CSV syntax, React, `better-sqlite3` or concrete repositories.
|
||||
- Do not implement automatic circuit planning, Dynamo/API exchange,
|
||||
PostgreSQL, background jobs or permissions as part of the initial CSV phase.
|
||||
Phase 14 is active and follows
|
||||
`docs/spec/revit-excel-integration-requirements.md` plus
|
||||
`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.
|
||||
|
||||
Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
|
||||
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
|
||||
domain objects and commands before adding migrations.
|
||||
Critical target rules:
|
||||
|
||||
- Exchange uses only `.xlsx` files produced and consumed by the Linear plugin.
|
||||
- Revit integration remains optional. Import never creates a CircuitDeviceRow,
|
||||
Circuit or equipment identifier.
|
||||
- Worksharing-ID is the external key and is compared exactly and
|
||||
case-sensitively. It stays separate from internal UUIDs and circuit BMKs.
|
||||
IfcGUID is optional redundancy, never a key, and duplicate values are only a
|
||||
warning.
|
||||
- A row without Worksharing-ID is suspicious only when `Familie und Typ` is
|
||||
populated. Show a warning and skip it; do not block. Other ID-less rows,
|
||||
including the sample summary in column B, are skipped silently.
|
||||
- Source, planning and override values remain separate. Existing ProjectDevices
|
||||
remain canonical and are never silently changed.
|
||||
- Multi-socket objects remain indivisible. Row quantity stays
|
||||
`manualQuantity + sum(effectiveQuantity)`.
|
||||
- The project configuration has no independent version counter. Preview and
|
||||
apply use `expectedRevision`; import batches store a configuration copy.
|
||||
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
|
||||
apply only stored before/after states.
|
||||
- Only the newest import batch retains original bytes and the full matrix;
|
||||
older batches retain date, filename, SHA-256 and object count.
|
||||
- The Revit object table loads one compact list, filters/sorts in the client and
|
||||
renders it virtually; do not add server pagination.
|
||||
- Linked objects always follow their row's distribution board. Changing a
|
||||
room's default board affects only unassigned objects and is not blocked.
|
||||
- Deleting a row, circuit or group warns that X Revit objects become unassigned,
|
||||
detaches them in the same command and restores links on Undo. Only deletion
|
||||
of referenced rooms and distribution boards is blocked.
|
||||
- Missing optional return columns for designation, power, voltage or current
|
||||
produce warnings and are skipped. Only missing circuit columns for the
|
||||
selected combined/separate mode block export.
|
||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||
- The corrected R2 return workbook was successfully imported into Linear on
|
||||
2026-09-30; R3 through R10 and Phase 14 are complete.
|
||||
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||
complete inverse data and the shared project-command transaction boundary.
|
||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||
management, partial imports, PostgreSQL, background jobs or new permissions.
|
||||
|
||||
The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is
|
||||
gitignored and must never be embedded in tests or committed. Use synthetic
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -516,6 +666,37 @@ Future sizing will need:
|
|||
|
||||
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
|
||||
|
||||
- SQLite is the currently supported database.
|
||||
|
|
@ -533,11 +714,17 @@ Users must be able to override sizing suggestions.
|
|||
|
||||
## 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
|
||||
- multi-user/PostgreSQL operation
|
||||
- supported production deployment
|
||||
- second/replacement board feeds, including changeover semantics for SV/SiBe
|
||||
- Revit prefill proposals for logical board feeds through the Phase 14 preview
|
||||
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.
|
||||
|
||||
|
|
|
|||
56
Dockerfile
56
Dockerfile
|
|
@ -1,14 +1,56 @@
|
|||
FROM node:22
|
||||
WORKDIR /app
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deps: vollstaendiger Abhaengigkeitsbaum, gemeinsam genutzt von "dev" und
|
||||
# "build". Bleibt auf dem vollen node-Image, weil better-sqlite3 beim
|
||||
# Installieren eine Toolchain braucht.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:24 AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dev: Ziel fuer compose.dev.yaml. Behaelt devDependencies, damit tsx,
|
||||
# drizzle-kit und "next dev" verfuegbar sind. Die Quellen kommen zur Laufzeit
|
||||
# ueber Bind-Mounts, das COPY hier ist nur der Ausgangsstand.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM deps AS dev
|
||||
ENV NODE_ENV=development
|
||||
COPY . .
|
||||
RUN mkdir -p data
|
||||
EXPOSE 3000 3001
|
||||
CMD ["npm", "run", "dev:api"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build: API nach dist/ und Frontend nach .next/ uebersetzen, danach die
|
||||
# devDependencies entfernen, damit das Laufzeit-Image nur noch traegt, was es
|
||||
# auch ausfuehrt. Die Migrationsskripte kommen mit drizzle-orm und
|
||||
# better-sqlite3 aus, beides sind regulaere dependencies.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM deps AS build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# next build schreibt die Rewrite-Ziele aus next.config.mjs fest in
|
||||
# .next/routes-manifest.json, "next start" kann eine andere API-Adresse
|
||||
# spaeter nicht mehr uebernehmen. Der Wert muss hier bekannt sein, nicht erst
|
||||
# zur Laufzeit. Server-Komponenten lesen die Variable zur Laufzeit.
|
||||
ARG API_INTERNAL_URL=http://localhost:3000
|
||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
||||
COPY . .
|
||||
RUN npm run build:api && npm run build:web
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["sh", "scripts/docker-start.sh"]
|
||||
# ---------------------------------------------------------------------------
|
||||
# prod: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der
|
||||
# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt.
|
||||
# Laeuft als "node" (UID 1000) statt als root.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:24-slim AS prod
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=node:node /app ./
|
||||
RUN mkdir -p data && chown node:node data
|
||||
USER node
|
||||
EXPOSE 3000 3001
|
||||
CMD ["node", "dist/server/index.js"]
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -4,19 +4,30 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
|||
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||
|
||||
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||
implementiert.
|
||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||
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
|
||||
|
||||
- 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
|
||||
3-phasige Stromkreise verwalten
|
||||
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
|
||||
Verteilergeräte in der Stromkreisliste abbilden
|
||||
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
|
||||
- Leitungsquerschnitte für einzelne Stromkreise auf Anforderung nach
|
||||
DIN VDE 0298-4 berechnen und ausdrücklich übernehmen
|
||||
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
||||
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||
|
|
@ -25,6 +36,14 @@ implementiert.
|
|||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
||||
- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen
|
||||
und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen
|
||||
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
||||
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
||||
ü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
|
||||
|
||||
|
|
@ -44,39 +63,72 @@ Voraussetzungen:
|
|||
```powershell
|
||||
git clone <repository-url>
|
||||
Set-Location leistungsbilanz-ts
|
||||
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
||||
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
||||
docker compose up --build --detach
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
- Frontend: <http://localhost:3001>
|
||||
- API-Healthcheck: <http://localhost:3000/health>
|
||||
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
||||
|
||||
Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator,
|
||||
solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für
|
||||
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
|
||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
||||
|
||||
Das Laufzeit-Image läuft als Benutzer `node` (UID 1000), nicht als root. Ein
|
||||
`data/`-Verzeichnis, das ein früherer root-Container angelegt hat, braucht
|
||||
deshalb einmalig `sudo chown -R 1000:1000 data` auf dem Host.
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
docker compose logs --follow
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
||||
Produktionsdeployment. Details stehen in
|
||||
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
||||
ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne
|
||||
root-Rechte im Container. Details stehen in
|
||||
[Deployment und Betrieb](docs/deployment.md).
|
||||
|
||||
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
|
||||
Quellcode-Mounts und Watchern:
|
||||
|
||||
```powershell
|
||||
docker compose -f compose.dev.yaml up --build --detach
|
||||
docker compose -f compose.dev.yaml logs --follow
|
||||
docker compose -f compose.dev.yaml down
|
||||
```
|
||||
|
||||
Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und
|
||||
macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn
|
||||
niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen
|
||||
Server.
|
||||
|
||||
## Direkte lokale Entwicklung
|
||||
|
||||
Voraussetzungen:
|
||||
|
||||
- Node.js 22
|
||||
- Node.js 24
|
||||
- npm
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run db:migrate
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -119,8 +171,15 @@ vor der Baseline werden nicht unterstützt.
|
|||
- [Aktuelle Architektur](docs/current-architecture.md)
|
||||
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
||||
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.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)
|
||||
- [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)
|
||||
|
||||
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
||||
|
|
|
|||
105
compose.dev.yaml
Normal file
105
compose.dev.yaml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Entwicklungs-Stack: Quellcode-Mounts, laufende Dev-Server, Hot Reload.
|
||||
# docker compose -f compose.dev.yaml up --build
|
||||
#
|
||||
# Die Polling-Watcher unten sind fuer Bind-Mounts unter Windows und macOS
|
||||
# noetig, weil inotify-Events die VM-Grenze nicht passieren. Sie kosten
|
||||
# dauerhaft CPU; genau deshalb laufen im Server-Stack (compose.yaml) gar
|
||||
# keine Watcher.
|
||||
#
|
||||
# Der Projektname muss sich von dem des Server-Stacks unterscheiden, damit
|
||||
# "docker compose down" in diesem Verzeichnis niemals einen laufenden
|
||||
# Server-Stack abraeumen kann.
|
||||
name: leistungsbilanz-dev
|
||||
|
||||
x-service-defaults: &service-defaults
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "10"
|
||||
|
||||
services:
|
||||
api:
|
||||
<<: *service-defaults
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- npm run db:migrate && npm run db:verify:circuit-schema && exec npm run dev:api
|
||||
environment:
|
||||
PORT: "3000"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||
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}"
|
||||
# Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein.
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||
# Puffer erwuenscht, die Grenze soll nur den Host vor Dauerlast schuetzen.
|
||||
mem_limit: 1g
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
web:
|
||||
<<: *service-defaults
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
command:
|
||||
- npm
|
||||
- run
|
||||
- dev:web
|
||||
- --
|
||||
- --hostname
|
||||
- 0.0.0.0
|
||||
environment:
|
||||
API_INTERNAL_URL: http://api:3000
|
||||
WATCHPACK_POLLING: "true"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
# "next dev" haelt Kompilierzustand und waechst im Dauerbetrieb stetig;
|
||||
# die Grenze macht daraus einen Container-Neustart statt Host-Swapping.
|
||||
mem_limit: 2g
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
114
compose.yaml
114
compose.yaml
|
|
@ -1,82 +1,112 @@
|
|||
# Server-Stack: gebauter Stand, keine Quellcode-Mounts, keine Datei-Watcher.
|
||||
# docker compose up --build --detach
|
||||
#
|
||||
# Fuer die Entwicklung mit Hot Reload compose.dev.yaml verwenden.
|
||||
name: leistungsbilanz
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
x-build: &build
|
||||
context: .
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
||||
environment:
|
||||
PORT: "3000"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
target: prod
|
||||
args:
|
||||
# Wird von next build in .next/routes-manifest.json eingebacken, siehe
|
||||
# Dockerfile. Muss der API-Adresse in diesem Netz entsprechen.
|
||||
API_INTERNAL_URL: http://api:3000
|
||||
|
||||
x-service-defaults: &service-defaults
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
# Der API-Prozess beendet HTTP-Server und SQLite-Handle auf SIGTERM; die
|
||||
# Frist gibt laufenden Requests Zeit, statt sie nach 10s in ein SIGKILL
|
||||
# laufen zu lassen.
|
||||
stop_grace_period: 20s
|
||||
# Das Image laeuft als "node" (UID 1000); zusaetzliche Rechte braucht
|
||||
# weder der API- noch der Web-Prozess.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "10"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
services:
|
||||
api:
|
||||
<<: *service-defaults
|
||||
build: *build
|
||||
# "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und
|
||||
# SIGTERM erreicht node nie.
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
|
||||
# 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:
|
||||
- "3000"
|
||||
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||
# schaltet Swap fuer den Container ab, damit ein Leck den Container trifft
|
||||
# und nicht den Host.
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 5s
|
||||
- fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
<<: *service-defaults
|
||||
build: *build
|
||||
command:
|
||||
- npm
|
||||
- run
|
||||
- dev:web
|
||||
- --
|
||||
- node_modules/.bin/next
|
||||
- start
|
||||
- -p
|
||||
- "3001"
|
||||
- --hostname
|
||||
- 0.0.0.0
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
API_INTERNAL_URL: http://api:3000
|
||||
WATCHPACK_POLLING: "true"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "10"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
healthcheck:
|
||||
# /web-health statt /health: next.config.mjs leitet /health an die API
|
||||
# weiter, und "/" wuerde wegen des Redirects auf /projects bei jeder
|
||||
# Probe zwei Seiten rendern.
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 5s
|
||||
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
|
|
|||
|
|
@ -12,21 +12,39 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
|||
fachliches Modell und Grid-Aufteilung
|
||||
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
|
||||
Drag-and-drop, Sortierung, Filterung und Undo/Redo
|
||||
- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt,
|
||||
Verteilerübersicht, Verteilerpläne, Büro-Logo
|
||||
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
||||
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
|
||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||
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
|
||||
|
||||
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
||||
- [Deployment und Betrieb](deployment.md)
|
||||
|
||||
## Zukunftsarchitektur
|
||||
## Revit / Linear XLSX
|
||||
|
||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
|
||||
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
|
||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) –
|
||||
verbindliche Anforderungen für XLSX und Worksharing-ID
|
||||
- [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)
|
||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||
|
|
@ -48,3 +66,6 @@ Limitierungen und Roadmap.
|
|||
|
||||
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
|
||||
Arbeitsstand, keine aktuelle Implementierungsanweisung
|
||||
- [Revit-/Dynamo-Anbindung](archive/revit-dynamo-leistungsbilanz-integration-konzept.md) –
|
||||
nicht weiterverfolgte Vorüberlegung zur direkten Dynamo-/API-Anbindung;
|
||||
Diagnoseskripte über den Git-Tag `archive/dynamo-vorueberlegungen`
|
||||
|
|
|
|||
1152
docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md
Normal file
1152
docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md
Normal file
File diff suppressed because it is too large
Load diff
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
|
||||
112
docs/building-structure.md
Normal file
112
docs/building-structure.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Gebäudestruktur (Gebäude → Etagen → Räume)
|
||||
|
||||
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
|
||||
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
|
||||
nicht. Abgestimmt in Issue #6: Das Modell ist so bestätigt, und Gebäude mit
|
||||
zugeordneten Etagen lassen sich nicht löschen.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
| Tabelle | Feld | Bedeutung |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `rooms` | `area_m2` (nullable `REAL`) | optional gepflegte Netto-Raumfläche in m² |
|
||||
|
||||
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
|
||||
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
|
||||
und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration
|
||||
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
|
||||
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
|
||||
|
||||
Alle Änderungen laufen über die bestehende Orts-Struktur
|
||||
(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und
|
||||
persistentem Undo/Redo:
|
||||
|
||||
- `project-building.insert` / `project-building.delete` mit vollständigem
|
||||
Gebäude-Snapshot (Schema-Version 1)
|
||||
- `project-building.update`: ändert nur den Namen
|
||||
- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind
|
||||
(„Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“)
|
||||
- Etagenbefehle `project-floor.insert/.update/.delete` haben seit
|
||||
Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name
|
||||
und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude
|
||||
muss zum Projekt gehören.
|
||||
- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie
|
||||
stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit
|
||||
`buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten
|
||||
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
|
||||
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
|
||||
- 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
|
||||
|
||||
Projekt-Snapshot-Schema **8** enthält zusätzlich `rooms[].areaM2`. Version 7
|
||||
wird mit `areaM2 = null` hochgestuft; die älteren unterstützten Versionen 1–6
|
||||
werden außerdem mit leerer Gebäudeliste und `buildingId = null` hochgestuft.
|
||||
Gespeicherte Sicherungspunkte bleiben wiederherstellbar. Die Relationen werden
|
||||
geprüft (Gebäude gehört zum Projekt, `buildingId` verweist auf ein Gebäude des
|
||||
Snapshots). Restore und Projekttransfer erhalten die Raumfläche. Das portable
|
||||
Transferformat bleibt Version 2, weil es den versionierten Projektsnapshot
|
||||
bereits einbettet; beim Duplizieren werden nur IDs und Relationen umgesetzt.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach
|
||||
`sortOrder`, Name
|
||||
- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }`
|
||||
- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }`
|
||||
- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }`
|
||||
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
|
||||
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
|
||||
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
|
||||
|
||||
Gebäude, Etagen und Räume werden auf der eigenen Seite
|
||||
`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über
|
||||
die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten
|
||||
Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt
|
||||
nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter
|
||||
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
|
||||
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
|
||||
|
||||
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
||||
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
|
||||
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
|
||||
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
|
||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||
|
|
@ -74,8 +92,9 @@ Protected-board structure uses
|
|||
persisted inverse of a delete. The dispatcher also supports the documented
|
||||
project-device, project settings, distribution-board and project-location
|
||||
commands. The generic editor commands listed here use schema version `1`.
|
||||
`project.update-settings` uses version `2` and still accepts supported baseline
|
||||
version `1` commands without changing the newer building classification.
|
||||
`project.update-settings` uses version `3` (including the print cover fields)
|
||||
and still accepts version `2` and supported baseline version `1` commands
|
||||
without changing the newer cover fields or building classification.
|
||||
Pre-baseline formats remain unsupported. Unsupported types or schema versions
|
||||
are rejected. Insert commands contain the complete entity or circuit block
|
||||
with stable ids; delete commands include the expected parent identity. Circuit
|
||||
|
|
@ -173,6 +192,9 @@ synchronizes linked circuit rows implicitly.
|
|||
- `PUT /projects/:projectId`
|
||||
- request:
|
||||
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
||||
- optional print cover fields `siteAddress` (max. 500), `workStage` (max.
|
||||
100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep
|
||||
their value, empty strings are stored as `null`
|
||||
- executes `project.update-settings` as one atomic revision
|
||||
- response:
|
||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
|
|
@ -210,6 +232,12 @@ A stale `expectedRevision` returns HTTP `409` with
|
|||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||
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
|
||||
|
||||
### Distribution Board Setup
|
||||
|
|
@ -230,62 +258,106 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||
- body:
|
||||
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||
- executes `distribution-board.update`; floor and supply type are restored
|
||||
together by persistent Undo/Redo
|
||||
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
|
||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
||||
and an empty string is stored as `null`
|
||||
- `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
|
||||
- `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`
|
||||
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
||||
- 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
|
||||
revision and current history state
|
||||
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||
- body: `{ "expectedRevision": 15 }`
|
||||
- removes the complete, exactly captured distribution-board subtree in one
|
||||
transaction
|
||||
- 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
|
||||
state
|
||||
- Copy and deletion use `distribution-board.insert-subtree` and
|
||||
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
|
||||
removes the same complete subtree, including protection devices.
|
||||
|
||||
### Project Floors and Rooms
|
||||
### Project Buildings, Floors and Rooms
|
||||
|
||||
- `GET /projects/:projectId/buildings`
|
||||
- `POST /projects/:projectId/buildings`
|
||||
- body: `{ "name": "Haus A", "expectedRevision": 12 }`
|
||||
- executes `project-building.insert` with a stable building id
|
||||
- response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- `PUT /projects/:projectId/buildings/:buildingId`
|
||||
- body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only)
|
||||
- `DELETE /projects/:projectId/buildings/:buildingId`
|
||||
- body: `{ "expectedRevision": 14 }`
|
||||
- rejects buildings with assigned floors
|
||||
- `POST /projects/:projectId/floors`
|
||||
- body: `{ "name": "EG", "expectedRevision": 13 }`
|
||||
- executes `project-floor.insert` with a stable floor id
|
||||
- body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
|
||||
(`buildingId` optional, `null` = no building)
|
||||
- executes `project-floor.insert` (command version 2) with a stable floor id
|
||||
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
||||
- `PUT /projects/:projectId/floors/:floorId`
|
||||
- body: `{ "name": "1. OG", "expectedRevision": 14 }`
|
||||
- executes an exact persistent floor update
|
||||
- body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }`
|
||||
- executes an exact persistent floor update of name and/or building; a
|
||||
missing `buildingId` keeps the current building
|
||||
- `DELETE /projects/:projectId/floors/:floorId`
|
||||
- body: `{ "expectedRevision": 15 }`
|
||||
- rejects floors with assigned rooms or distribution boards
|
||||
- `POST /projects/:projectId/rooms`
|
||||
- body:
|
||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
||||
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
||||
and must belong to the project when present
|
||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }`
|
||||
- executes `project-room.insert` command version 2 with a stable room id;
|
||||
`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": { ... } }`
|
||||
- `PUT /projects/:projectId/rooms/:roomId`
|
||||
- body:
|
||||
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
|
||||
- updates number, name and optional floor as one persistent command
|
||||
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }`
|
||||
- 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`
|
||||
- body: `{ "expectedRevision": 17 }`
|
||||
- rejects rooms referenced by device rows
|
||||
- Persistent Undo/Redo restores exact previous and target records without
|
||||
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`.
|
||||
|
||||
### 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`
|
||||
- Purpose: returns fixed header components, grouped sections with optional
|
||||
protection components, complete circuit/device-row blocks and auxiliary
|
||||
footer components. Circuit and component protection devices use their
|
||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||
remain included.
|
||||
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:
|
||||
|
||||
|
|
@ -391,37 +463,76 @@ inverse command. The former direct restore/reconnect endpoints are removed.
|
|||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||
project device never triggers synchronization implicitly.
|
||||
|
||||
## Revit CSV Foundation
|
||||
## Cable Sizing (optional module)
|
||||
|
||||
- `GET /projects/:projectId/external-csv/configuration`
|
||||
- returns the complete versioned project configuration or `null`
|
||||
- `PUT /projects/:projectId/external-csv/configuration`
|
||||
- request: `expectedRevision` and the complete validated configuration
|
||||
- executes `external-csv-configuration.update`
|
||||
- returns configuration, revision and project history state
|
||||
- `POST /projects/:projectId/external-csv/preview`
|
||||
- request: file name and Base64-encoded CSV content up to 18 MB
|
||||
- requires a stored project configuration
|
||||
- returns transport metadata, SHA-256, row classifications, suspect row
|
||||
numbers and mapped source values for every recognized IFC object
|
||||
- does not persist a draft, mutate project state or create a revision
|
||||
- `POST /projects/:projectId/external-csv/initial-import/plan`
|
||||
- reparses the file and returns grouped room/family decisions plus current
|
||||
room, distribution-board and ProjectDevice catalogs
|
||||
- is stateless and rejects an already imported external source
|
||||
- `POST /projects/:projectId/external-csv/initial-import/apply`
|
||||
- requires the expected revision, configuration version, file hash and one
|
||||
decision for every room and exact family/type group
|
||||
- reparses the file, blocks unknown family/type values and executes
|
||||
`external-import.apply-initial` as one undoable project revision
|
||||
- creates no Circuit or CircuitDeviceRow
|
||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||
- resolves the circuit list's distribution board server-side
|
||||
- returns only external objects assigned to that board, with unassigned
|
||||
objects first, their source room/marker values and current row-link status
|
||||
- returns an empty list when the optional Revit import does not exist
|
||||
- is read-only and exposes no stored command payloads or import bytes
|
||||
Stateless calculator endpoints mounted at `/api/cable-sizing`. They neither
|
||||
read nor write project data; applying a result uses `circuit.update` through
|
||||
`POST /projects/:projectId/commands`.
|
||||
|
||||
Parser errors expose stable codes such as `header-not-found`,
|
||||
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
||||
import uploads and parses the file again and verifies its hash.
|
||||
- `POST /cable-sizing/calculate`
|
||||
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
|
||||
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)
|
||||
|
||||
Office-wide branding for the print view. These endpoints are mounted at
|
||||
`/api/document-branding`, outside `/api/projects`, because the data is global
|
||||
and intentionally not part of project revisions, snapshots or transfer.
|
||||
|
||||
- `GET /document-branding`
|
||||
- returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null, "office": { "officeName", "officeAddress" } }`
|
||||
- `GET /document-branding/logo`
|
||||
- returns the stored image bytes with its media type, or `404` without logo
|
||||
- responses carry `X-Content-Type-Options: nosniff` and a restrictive
|
||||
Content-Security-Policy so an SVG logo cannot execute scripts
|
||||
- `PUT /document-branding/logo`
|
||||
- request: `{ "fileName": "logo.png", "mimeType": "image/png", "contentBase64": "..." }`
|
||||
- accepted types: `image/png`, `image/jpeg`, `image/svg+xml`; at most 2 MB
|
||||
after decoding; the content signature must match the declared type
|
||||
- replaces an existing logo; returns the logo metadata
|
||||
- `DELETE /document-branding/logo`
|
||||
- removes the logo; returns `204`
|
||||
- `PUT /document-branding/office`
|
||||
- request: `{ "officeName": "…" | null, "officeAddress": "…" | null }`
|
||||
(at most 200 / 500 characters; empty text is stored as not set)
|
||||
- returns `{ "office": { "officeName", "officeAddress" } }`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
# Circuit List Editor Interactions
|
||||
|
||||
The back-arrow link beside the page title returns to the project's distribution
|
||||
board overview without changing circuit-list data.
|
||||
|
||||
## Editing Model
|
||||
|
||||
Inline cells are static text by default. A cell enters edit mode by:
|
||||
|
|
@ -44,6 +47,7 @@ Selection, keyboard movement, and editability checks run against this normalized
|
|||
- arrow keys: move selected cell in grid when not editing
|
||||
- type-to-edit: printable keys open edit mode and replace current display value with typed character
|
||||
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
|
||||
- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table
|
||||
- `Delete`: delete the selected circuit or device context after confirmation
|
||||
|
||||
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
|
||||
|
|
@ -52,6 +56,23 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
|
|||
|
||||
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
|
||||
|
||||
## AMEV Simultaneity Factor
|
||||
|
||||
The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open
|
||||
the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project
|
||||
device dialog uses as well. "Übernehmen" commits the mean value of the chosen
|
||||
consumer group through the same path as a typed edit (`commitEdit` with an
|
||||
explicit target cell), so it is one undoable command and works on device rows,
|
||||
compact circuits, reserve circuits and the `-frei-` row. A running inline edit
|
||||
of the cell is discarded when the picker opens. The button only appears on the
|
||||
selected cell; the grid keeps its static-text cells.
|
||||
|
||||
The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and
|
||||
closes on Escape without writing a value. Closing returns focus to the grid;
|
||||
only "Übernehmen" saves the selected mean value. Opening the picker is disabled
|
||||
while a write or history operation is running. Its cell button stays outside
|
||||
the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell.
|
||||
|
||||
## Equipment Identifier Validation
|
||||
|
||||
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
||||
|
|
@ -127,11 +148,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
|||
|
||||
## Filtering and Sorting
|
||||
|
||||
- Clicking a column label changes sorting; the funnel icon next to it opens
|
||||
the value filter. An active filter is highlighted and shows the number of
|
||||
selected values. Both controls retain German accessible names and tooltips.
|
||||
- Header labels wrap between words; trailing units such as `[kW]` appear on
|
||||
a separate line.
|
||||
- Per-column filtering works on normalized displayed values.
|
||||
- Sorting is view-level first and treats circuits as blocks.
|
||||
- Multi-device circuits are not split during sort.
|
||||
- Sorting alone does not persist order.
|
||||
|
||||
## Action Buttons
|
||||
|
||||
The editor toolbar uses icons with visible German labels. Group headers and
|
||||
circuit/device rows use compact icon buttons with German accessible names and
|
||||
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
|
||||
their roles. Dividers separate related actions; destructive actions use the
|
||||
danger style. Circuit deletion uses a trash icon, while device-row deletion
|
||||
uses a crossed-out list icon.
|
||||
|
||||
The shared `src/frontend/components/icon-button.tsx` component preserves
|
||||
disabled states and expanded states for toggles. Row and group action buttons
|
||||
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
|
||||
unchanged. The existing command handlers and deletion confirmations are reused.
|
||||
|
||||
## Apply Sorted Order
|
||||
|
||||
`Apply sorted order` persists current sorted block order to backend by section.
|
||||
|
|
@ -161,6 +201,11 @@ across multiple sections is atomic and occupies one history step. Explicit
|
|||
renumbering uses a collision-safe persistent project command and one
|
||||
project-history step.
|
||||
|
||||
The editor creates client-side UUIDs through `createClientId` in
|
||||
`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available
|
||||
and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion
|
||||
also works over plain HTTP from another machine in the LAN.
|
||||
|
||||
Covered operations include:
|
||||
|
||||
- insert/delete circuit
|
||||
|
|
|
|||
|
|
@ -9,16 +9,20 @@
|
|||
- Named and automatic logical project snapshots can be listed and restored
|
||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||
created every 25 revisions and only their newest 12 entries are retained.
|
||||
- The Revit/CSV foundation and confirmed initial import are implemented.
|
||||
Existing-row assignment, move and detach plus creation of an external-only
|
||||
row in an existing or newly created circuit have atomic persistent commands.
|
||||
Their API planning and editor UI, follow-up import, conflict review and return
|
||||
export are pending.
|
||||
- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport,
|
||||
Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
|
||||
Löschen fehlender Objekte verwenden persistente Commands. Die vollständige
|
||||
Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und
|
||||
Neustarttests geprüft.
|
||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||
- The global device library supports basic CRUD and copy operations, but has no
|
||||
versioning, permissions or controlled synchronization model.
|
||||
- Final electrical sizing logic is not implemented yet.
|
||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||
- There is no automatic sizing of all circuits. The optional cable sizing
|
||||
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.
|
||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||
same category. Cross-category moves and multi-circuit moves across group
|
||||
|
|
@ -27,3 +31,15 @@
|
|||
- Protection and cable values are planner-owned manual selections. The later
|
||||
sizing/warning engine does not yet validate them against load, length or
|
||||
voltage-drop rules.
|
||||
- One logical incoming feed per distribution board is supported. A second or
|
||||
replacement feed with changeover semantics (for example SV/SiBe) is still a
|
||||
follow-up. `fedFrom` remains the free-text field for external sources and
|
||||
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
|
||||
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
|
||||
board plans are not split into pages; pagination happens in the browser print
|
||||
dialog.
|
||||
|
|
|
|||
|
|
@ -27,15 +27,38 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
|||
data/leistungsbilanz.db (SQLite)
|
||||
```
|
||||
|
||||
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
||||
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
||||
liegt über einen Host-Mount außerhalb des Containers.
|
||||
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
||||
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
||||
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
|
||||
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
|
||||
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
|
||||
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
|
||||
`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen
|
||||
auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen
|
||||
vor dem Command und bleiben über Undo/Redo unverändert.
|
||||
|
||||
## Wichtige Einstiegspunkte
|
||||
|
||||
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
|
||||
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen,
|
||||
Räume und Projektgeräte
|
||||
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen
|
||||
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`
|
||||
– unterstützte Editorroute
|
||||
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
||||
|
|
@ -43,6 +66,11 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||
- `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/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||
|
|
@ -232,8 +260,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
|||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||
`project.update-settings` versioniert Projektname, interne und externe
|
||||
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
|
||||
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
|
||||
Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
|
||||
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
|
||||
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
|
||||
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
|
||||
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
|
||||
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||
|
|
@ -245,22 +274,55 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
|||
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
|
||||
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||
Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
|
||||
versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
|
||||
Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite
|
||||
„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können
|
||||
einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare,
|
||||
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
|
||||
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
|
||||
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
|
||||
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
|
||||
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
|
||||
`IconButton`/`IconLink`.
|
||||
Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
|
||||
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
|
||||
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
|
||||
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
|
||||
Die Verteilersuche berücksichtigt Name, Verteilernummer, Örtlichkeit und
|
||||
Etagenname; die Gerätesuche
|
||||
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
|
||||
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
|
||||
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
|
||||
und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende
|
||||
Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise
|
||||
„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei
|
||||
der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte.
|
||||
Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar.
|
||||
Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe
|
||||
und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine
|
||||
Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
|
||||
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
||||
bestehenden persistenten Projektbefehl.
|
||||
|
||||
`GET /api/projects/:projectId/export` verpackt den vollständigen portablen
|
||||
Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit
|
||||
SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen,
|
||||
ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne
|
||||
Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format,
|
||||
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
|
||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
||||
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
|
||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
|
||||
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
|
||||
Fachliche Verknüpfungen
|
||||
innerhalb des unterstützten Laufzeitmodells bleiben
|
||||
erhalten. Die
|
||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||
|
|
@ -416,11 +478,54 @@ wieder her.
|
|||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
||||
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
||||
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
||||
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
||||
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
|
||||
externe Einspeisungen und Hinweise.
|
||||
`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
|
||||
Projekts; ältere Versionen werden mit `null`
|
||||
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||
enthält bereits den vollständigen externen Modellzustand
|
||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
||||
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||
enthielt bereits den damaligen externen CSV-Modellzustand
|
||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
|
||||
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||
1:1-Schutzgerätedaten. Capture,
|
||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||
|
|
@ -431,7 +536,12 @@ Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft;
|
|||
dieser Baseline werden nicht eingelesen.
|
||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
||||
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
||||
exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
|
||||
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
|
||||
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
||||
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
||||
|
|
@ -452,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.
|
||||
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
|
||||
|
||||
- SQLite ist die aktuell unterstützte Datenbank.
|
||||
|
|
@ -486,7 +626,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
|||
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
||||
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
|
||||
Transfer-Integration verwenden aktuell Snapshot-Schema 10. Persistente
|
||||
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
||||
|
|
@ -503,141 +643,67 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
|||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||
|
||||
## Revit-CSV-Grundlage
|
||||
## Revit-Anbindung: Linear-XLSX nach R10
|
||||
|
||||
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
||||
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
||||
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
||||
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
|
||||
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
|
||||
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
|
||||
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
||||
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
||||
Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
|
||||
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
|
||||
Änderungen laufen als Projekt-Commands über `expectedRevision`.
|
||||
|
||||
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
||||
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
||||
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
|
||||
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
||||
werden Konfigurations-UUID und Projektlink remapped.
|
||||
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
|
||||
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
|
||||
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
|
||||
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
|
||||
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
|
||||
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
|
||||
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
|
||||
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
|
||||
Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite
|
||||
Konfigurationsversion am Batch, getrennt von der Schema-Version des
|
||||
Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten
|
||||
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
|
||||
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
|
||||
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
|
||||
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
|
||||
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
|
||||
Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit
|
||||
dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes,
|
||||
Matrix und alle internen Links werden weiterhin vollständig erfasst,
|
||||
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
|
||||
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
|
||||
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
|
||||
einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen
|
||||
Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes
|
||||
erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und
|
||||
validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
|
||||
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
|
||||
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
|
||||
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
|
||||
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
|
||||
erzeugen weiterhin keine dieser Zeilen.
|
||||
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
||||
seine Endpunkte und Laufzeitverträge sind entfernt. Die Route
|
||||
`/projects/:projectId/revit` enthält Einstellungen, Importstatus und den
|
||||
sechsstufigen Erstimportwizard.
|
||||
|
||||
Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom
|
||||
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
|
||||
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
|
||||
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
|
||||
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
|
||||
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
|
||||
`external-object.update-row-assignment` ist diese atomare Schreibgrenze für
|
||||
bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt
|
||||
sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden
|
||||
die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit,
|
||||
Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft.
|
||||
Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung;
|
||||
unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht
|
||||
still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
|
||||
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
|
||||
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
|
||||
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
|
||||
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
|
||||
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
|
||||
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
|
||||
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
|
||||
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
|
||||
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
|
||||
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
|
||||
bestehen.
|
||||
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
|
||||
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
|
||||
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
|
||||
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
|
||||
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
|
||||
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
|
||||
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
|
||||
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
|
||||
Die drei Zuordnungsadapter teilen sich
|
||||
`external-object-assignment.persistence.ts` für Snapshotvergleich,
|
||||
Kompatibilitätsregeln und link-sichere Updates.
|
||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||
liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer.
|
||||
Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur
|
||||
dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte
|
||||
Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige
|
||||
Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen
|
||||
Revit-Import ist die Projektion leer.
|
||||
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
||||
ändern die Konfiguration; der PUT plant Identität und nächsten
|
||||
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
|
||||
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
|
||||
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
|
||||
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet
|
||||
denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen
|
||||
noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei.
|
||||
Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen
|
||||
Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte,
|
||||
liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die
|
||||
vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge.
|
||||
Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben
|
||||
sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt.
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei,
|
||||
erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen.
|
||||
Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen
|
||||
Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional
|
||||
einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende
|
||||
Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt
|
||||
stabile interne UUIDs und übergibt den vollständigen Zustand an
|
||||
`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte
|
||||
und alle `circuitDeviceRowId` bleiben `null`.
|
||||
Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
|
||||
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
|
||||
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
|
||||
zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
|
||||
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der
|
||||
Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende
|
||||
Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er
|
||||
zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume,
|
||||
Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die
|
||||
Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog
|
||||
wird der vollständige Import als eine persistente, rückgängig machbare
|
||||
Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen.
|
||||
Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über
|
||||
die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese
|
||||
Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt.
|
||||
Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
|
||||
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
|
||||
ihn für die betroffene Gruppe.
|
||||
`POST /api/projects/:projectId/external-model/initial-import/plan` liest eine
|
||||
lokale XLSX zustandslos, gruppiert Quellräume und exakte Familie-und-Typ-Werte
|
||||
und liefert die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge.
|
||||
Unbekannte Familien bleiben blockierend; fehlender GZF wird sichtbar mit 1
|
||||
vorbelegt. `apply` erhält die Datei erneut, parst sie erneut und vergleicht den
|
||||
Vorschau-SHA-256. Der Command `external-model.apply-initial-import` speichert
|
||||
die dabei verwendete Konfiguration und den vollständigen externen Zustand in
|
||||
derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder
|
||||
BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne
|
||||
Workbookbytes erneut einzulesen oder zu parsen.
|
||||
|
||||
R6 bis R9 ergänzen die einmal geladene, clientseitig gefilterte und
|
||||
virtualisierte Projektobjektliste, revisionierte Sammelplanung, atomare
|
||||
Zuordnung im Stromkreislisteneditor, strukturtreue Rückgabe und den
|
||||
vollständigen Folgeimport. Der Folgeimport trennt fachliche Quelländerungen
|
||||
von noch nicht nach Revit übernommenen Rückgabewerten, aktualisiert akzeptierte
|
||||
Mengen samt Row-Summe und markiert fehlende Objekte ohne ihren Link zu lösen.
|
||||
Eine gesondert bestätigte Aktion löscht fehlende Objekte samt Mengenübergang;
|
||||
Undo stellt Objekt, Link und Menge wieder her.
|
||||
|
||||
R10 weist den Ablauf über Rückgabe, Folgeimport, Snapshot-Restore,
|
||||
Projektduplikat und Undo/Redo nach Datenbankneustart nach. Persistenzpfade für
|
||||
vollständige externe Zustände schreiben große Mengen in begrenzten
|
||||
SQLite-Blöcken. Der synthetische Abnahmetest verarbeitet 5.000 Objekte; Details
|
||||
und Messwerte stehen in
|
||||
[R10-Abnahme](spec/revit-excel-r10-validation.md).
|
||||
|
||||
## Historische CSV-Unterlagen
|
||||
|
||||
Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige
|
||||
Ausgangslage bleibt ausschließlich im [R0-Audit](spec/revit-excel-r0-audit.md)
|
||||
und in den als abgelöst markierten CSV-Spezifikationen nachvollziehbar. Alte
|
||||
CSV-Snapshots und CSV-History werden weiterhin ausdrücklich abgelehnt.
|
||||
|
||||
## Noch nicht unterstützt
|
||||
|
||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
|
||||
strukturtreuer Revit-Rückexport
|
||||
- vollständige elektrische Dimensionierung
|
||||
- Produktionsdeployment
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,64 @@
|
|||
|
||||
## Aktueller Status
|
||||
|
||||
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
||||
Es gibt zwei Compose-Stacks.
|
||||
|
||||
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
||||
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
||||
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
||||
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
||||
öffentlich erreichbar gemacht werden.
|
||||
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
||||
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
||||
für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles,
|
||||
also als Benutzer `node` statt als root, ohne devDependencies, mit
|
||||
`cap_drop: ALL`, `no-new-privileges` und harten Speichergrenzen.
|
||||
|
||||
## Entwicklungs-Topologie
|
||||
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
||||
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
|
||||
und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
||||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||
Entwicklungsrechner.
|
||||
|
||||
| Komponente | Port | Healthcheck | Persistenz |
|
||||
| --- | ---: | --- | --- |
|
||||
| Next.js Web | 3001 | `GET /` | keine |
|
||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
|
||||
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
|
||||
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
|
||||
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
|
||||
|
||||
## Topologie
|
||||
|
||||
| Komponente | Port | Auf dem Host | Healthcheck | Persistenz |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| Next.js Web | 3001 | `3001` (Server) / `127.0.0.1:3001` (Dev) | `GET /web-health` | keine |
|
||||
| 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` |
|
||||
|
||||
Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht
|
||||
(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive
|
||||
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
|
||||
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||
|
||||
Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue
|
||||
UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das
|
||||
dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese
|
||||
ID-Erzeugung nicht erforderlich.
|
||||
|
||||
### Speichergrenzen
|
||||
|
||||
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
||||
`memswap_limit` auf denselben Wert gesetzt, womit Swap für den Container
|
||||
abgeschaltet ist: ein Speicherleck lässt dann den Container am OOM-Killer
|
||||
sterben und `restart: unless-stopped` greift, statt dass der Host in den Swap
|
||||
läuft. Aktuell 512 MB für die API und 1 GB für das Frontend. Der
|
||||
Entwicklungs-Stack bekommt 1 GB bzw. 2 GB und behält Swap, weil `next dev`
|
||||
Kompilierzustand hält und im Dauerbetrieb stetig wächst.
|
||||
|
||||
### Rechte im Container
|
||||
|
||||
Das Laufzeit-Image läuft als `node` (UID 1000). Ein `data/`-Verzeichnis, das
|
||||
ein früherer root-Container angelegt hat, ist dann nicht beschreibbar und
|
||||
braucht einmalig auf dem Host:
|
||||
|
||||
```bash
|
||||
sudo chown -R 1000:1000 data
|
||||
```
|
||||
|
||||
Verwendete Umgebungsvariablen:
|
||||
|
||||
|
|
@ -24,15 +67,42 @@ Verwendete Umgebungsvariablen:
|
|||
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||
`http://api:3000`
|
||||
- `NEXT_TELEMETRY_DISABLED=1`
|
||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
||||
Dateibeobachtung in Docker
|
||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
||||
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
||||
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||
`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.
|
||||
|
||||
Beim API-Start laufen zuerst `npm run db:migrate` und
|
||||
`npm run db:verify:circuit-schema`.
|
||||
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
|
||||
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
||||
Entwicklungsstack über `npm run db:migrate` und
|
||||
`npm run db:verify:circuit-schema`).
|
||||
|
||||
`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build`
|
||||
schreibt die Rewrite-Ziele aus `next.config.mjs` fest in
|
||||
`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als
|
||||
Build-Argument an das Image durch, nicht nur als Laufzeit-Variable.
|
||||
|
||||
## Logging
|
||||
|
||||
|
|
@ -68,13 +138,28 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
|
|||
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
||||
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
||||
|
||||
## Herunterfahren
|
||||
|
||||
`SIGTERM` und `SIGINT` schließen in der API den HTTP-Server und anschließend
|
||||
das SQLite-Handle und beenden den Prozess. Offene Keep-Alive-Verbindungen –
|
||||
vor allem die des Next.js-Servers – werden dabei über
|
||||
`server.closeIdleConnections()` freigegeben, laufende Requests dürfen zu Ende
|
||||
laufen. Vorher wurde das Signal nur geloggt, der Prozess lief weiter und
|
||||
Docker beendete ihn nach zehn Sekunden mit `SIGKILL`, mitten in einem
|
||||
möglichen Schreibvorgang. Greift der Abbau nicht innerhalb von 15 Sekunden,
|
||||
beendet sich der Prozess selbst mit Exit-Code 1; `stop_grace_period: 20s` in
|
||||
beiden Compose-Dateien liegt bewusst darüber.
|
||||
|
||||
Die Startbefehle in `compose.yaml` enden mit `exec node …`. Ohne das `exec`
|
||||
bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
|
||||
|
||||
## Voraussetzungen für ein späteres Produktionssetup
|
||||
|
||||
Vor einer produktiven Installation werden mindestens benötigt:
|
||||
|
||||
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
|
||||
- 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
|
||||
- persistenter, gesicherter Datenbankbetrieb
|
||||
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
||||
|
|
@ -83,14 +168,17 @@ Vor einer produktiven Installation werden mindestens benötigt:
|
|||
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||
Mehrbenutzerbetrieb erforderlich ist
|
||||
|
||||
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
||||
lokalen Entwicklungsstart in der README.
|
||||
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
||||
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
|
||||
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
|
||||
|
||||
## Backup und Wiederherstellung
|
||||
|
||||
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
|
||||
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
|
||||
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
|
||||
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
|
||||
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
|
||||
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
|
||||
|
||||
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
|
||||
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
|
|||
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
||||
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
|
||||
|
||||
Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung
|
||||
mit Quellcode-Mounts und Hot Reload stattdessen
|
||||
`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste
|
||||
sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar.
|
||||
|
||||
Für direkte Node.js-Entwicklung:
|
||||
|
||||
```powershell
|
||||
|
|
@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
|
|||
```powershell
|
||||
docker compose up --build --detach
|
||||
docker compose ps
|
||||
Invoke-WebRequest http://localhost:3000/health
|
||||
Invoke-WebRequest http://localhost:3001/health
|
||||
Invoke-WebRequest http://localhost:3001/web-health
|
||||
```
|
||||
|
||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||
|
|
|
|||
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.
|
||||
139
docs/print-view.md
Normal file
139
docs/print-view.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Seitenansicht und PDF-Export
|
||||
|
||||
Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
|
||||
Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
|
||||
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
|
||||
über Projektbefehle mit Undo/Redo gepflegt.
|
||||
|
||||
Beim Laden werden alle Stromkreislisten in einer projektweiten Abfrage gelesen;
|
||||
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
|
||||
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
|
||||
automatisch bei späteren Änderungen in einem anderen Tab.
|
||||
|
||||
## Aufruf
|
||||
|
||||
- Projektseite → Button **Seitenansicht** öffnet
|
||||
`/projects/:projectId/print` in einem neuen Tab.
|
||||
- **Als PDF speichern / Drucken** nutzt den Druckdialog des Browsers
|
||||
(Ziel „Als PDF speichern“). Der Dokumenttitel `Leistungsbilanz – <Projekt>`
|
||||
wird als Dateiname vorgeschlagen.
|
||||
- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um.
|
||||
Deckblatt bleibt A4 hoch.
|
||||
- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle
|
||||
Projekte).
|
||||
- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo.
|
||||
|
||||
## Seiten
|
||||
|
||||
1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument,
|
||||
Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben
|
||||
erscheinen als orangefarbenes „[ergänzen]“.
|
||||
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
|
||||
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
|
||||
Stück, nur lokale Geräte), Leistung vor Verteiler-GZF einschließlich
|
||||
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
|
||||
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
||||
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
||||
(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“
|
||||
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
|
||||
Drucken auf Folgeseiten.
|
||||
|
||||
Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
|
||||
`@page`-Regeln (`lb-portrait`, `lb-landscape`), die die Seite zur Laufzeit
|
||||
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
|
||||
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
|
||||
|
||||
## Berechnung und offene Punkte
|
||||
|
||||
- **Einspeisung / Hierarchie:** `feedingDistributionBoardId` bestimmt die
|
||||
Hierarchie. Ist zusätzlich `feedingCircuitId` gesetzt, fließt die Leistung
|
||||
der Unterverteilung nach deren eigenem Verteiler-GZF als virtuelle Last in
|
||||
genau diesen Abgang ein. Ketten werden rekursiv berechnet; mehrere
|
||||
Unterverteilungen an einem Abgang sind erlaubt. Ohne Abgang bleibt es bei der
|
||||
Hierarchie ohne Leistungsaufschlag. `fedFrom` bezeichnet nur externe Quellen
|
||||
oder ergänzende Hinweise.
|
||||
- **Keine Doppelzählung:** Die Abgangs- und Verteilersummen enthalten die
|
||||
Unterverteilungsleistung bereits. Deshalb gibt es keine zusätzliche Spalte
|
||||
„Σ inkl. unterlagert“. Die Summenzeile verwendet für hierarchische Leistungen
|
||||
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
|
||||
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
|
||||
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
|
||||
CD-Vorgabe ersetzt, falls diese abweicht.
|
||||
- **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die
|
||||
aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte
|
||||
(noch) keinen Bearbeiter speichern.
|
||||
|
||||
## Datenquellen des Deckblatts
|
||||
|
||||
| Feld | Quelle |
|
||||
|---|---|
|
||||
| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen |
|
||||
| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) |
|
||||
| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) |
|
||||
| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch |
|
||||
| Planstände | benannte Sicherungspunkte |
|
||||
|
||||
Die vier Projektfelder laufen über `project.update-settings` (Schema-Version
|
||||
3, Migration `0008`) und sind Teil von Snapshot-Schema 6 und Projektexport.
|
||||
Im `PUT /api/projects/:projectId` sind sie optional; fehlende Felder behalten
|
||||
ihren Wert.
|
||||
|
||||
## Büroangaben
|
||||
|
||||
- Tabelle `document_branding_settings` (Schlüssel/Wert, Migration `0008`),
|
||||
global wie das Logo.
|
||||
- `GET /api/document-branding` liefert zusätzlich
|
||||
`office: { officeName, officeAddress }`.
|
||||
- `PUT /api/document-branding/office` mit `{ "officeName", "officeAddress" }`
|
||||
(max. 200 / 500 Zeichen, leerer Text = nicht gesetzt).
|
||||
|
||||
## Logo
|
||||
|
||||
- Globale Büro-Einstellung, nicht Teil von Projekten, Revisionen, Snapshots
|
||||
oder Projektexport.
|
||||
- Tabelle `document_branding_assets` (Migration `0007`).
|
||||
- `GET /api/document-branding` → `{ logo: { mimeType, fileName, sizeBytes, updatedAtIso } | null }`
|
||||
- `GET /api/document-branding/logo` → Bilddaten (`404` ohne Logo), mit
|
||||
`X-Content-Type-Options: nosniff` und restriktiver CSP.
|
||||
- `PUT /api/document-branding/logo` mit
|
||||
`{ "fileName", "mimeType": "image/png" | "image/jpeg" | "image/svg+xml", "contentBase64" }`,
|
||||
maximal 2 MB; der Dateiinhalt wird gegen das Format geprüft.
|
||||
- `DELETE /api/document-branding/logo` → `204`.
|
||||
|
||||
## Code
|
||||
|
||||
- `src/app/projects/[projectId]/print/page.tsx` – Datenladen, Werkzeugleiste,
|
||||
`@page`-Regeln
|
||||
- `src/frontend/print/print-document-model.ts` – reine Berechnungen und
|
||||
Formatierung (Tests: `tests/print-document-model.test.ts`)
|
||||
- `src/frontend/print/print-document.tsx` – Blätter und Tabellen
|
||||
- `src/frontend/print/print-document.css` – Layout, auf `.print-view`
|
||||
begrenzt
|
||||
- `src/frontend/components/distribution-board-info-card.tsx` – Infokarte im
|
||||
Bearbeiten-Dialog der Verteilung
|
||||
|
||||
## Bestehende Datenbanken
|
||||
|
||||
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
|
||||
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
|
||||
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
|
||||
Snapshot-Schema 10 enthält den aktuellen Projektzustand; die in Schema 9
|
||||
eingeführten Einspeisungsverweise werden in den unterstützten Versionen 1–8
|
||||
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.
|
||||
|
|
@ -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
|
||||
- provide a project-wide version history with named restore points
|
||||
- preserve an auditable sequence of user, import, migration and restore operations
|
||||
- support future Revit/CSV round-trips using stable IFCGUID-based links
|
||||
- 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
|
||||
|
||||
## Separate Concerns
|
||||
|
|
@ -111,7 +112,12 @@ Implemented automatic-snapshot policy:
|
|||
- automatic capture and retention run inside the same transaction as the
|
||||
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.
|
||||
|
||||
|
|
@ -120,9 +126,12 @@ Conceptual entities:
|
|||
- `ExternalModelSource`
|
||||
- identifies a Revit/IFC model or another external source within a project
|
||||
- `ExternalImportBatch`
|
||||
- file name, checksum, import time, format/version and processing status
|
||||
- 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`
|
||||
- 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
|
||||
- `ExternalObjectLink`
|
||||
- explicit relationship to a project device, circuit device row or another future domain entity
|
||||
|
|
@ -133,13 +142,33 @@ Conceptual entities:
|
|||
|
||||
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.
|
||||
- Users preview additions, changes, missing objects and conflicts before applying them.
|
||||
- Applying an import creates one project revision and one auditable change set.
|
||||
- Imported source values and locally assigned planning values remain distinguishable.
|
||||
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
|
||||
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
|
||||
- Export retains Worksharing-ID so Linear can match rows deterministically.
|
||||
- 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
|
||||
|
||||
|
|
@ -275,6 +304,10 @@ Completed foundation:
|
|||
- `project-floor.insert` and `project-room.insert` persist complete location
|
||||
records with stable ids; their inverses reject changed or referenced records
|
||||
so Undo never clears room assignments or device-row links implicitly
|
||||
- `project-building.insert/.update/.delete` persist complete building records;
|
||||
deleting a building with assigned floors is rejected. Floor commands carry
|
||||
`buildingId` since command version 2; version 1 floor commands from older
|
||||
history are upgraded with `buildingId = null` before execution
|
||||
- the floor and room POST routes require the current expected revision and the
|
||||
project page tracks the returned history state
|
||||
|
||||
|
|
@ -284,7 +317,8 @@ Remaining constraints before completing project version history:
|
|||
boundary; extend it whenever further project mutations are introduced
|
||||
- project creation remains initialization at revision zero, while global device
|
||||
templates remain outside any single project history
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
- 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
|
||||
|
||||
## Deferred Decisions
|
||||
|
|
@ -292,5 +326,6 @@ Remaining constraints before completing project version history:
|
|||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||
- branch visualization after undo followed by new edits
|
||||
- user/role model and actor attribution
|
||||
- supported Revit CSV dialects and parameter mapping UI
|
||||
- Linear-XLSX compatibility beyond the observed sample and any future
|
||||
project-independent mapping profiles
|
||||
- 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
|
||||
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
||||
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
||||
- [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
|
||||
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
||||
|
||||
## Produkt und fachliches Modell
|
||||
|
||||
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
|
||||
|
||||
Das unterstützte Laufzeitmodell ist:
|
||||
|
||||
```text
|
||||
Project
|
||||
├── Floor
|
||||
│ └── Room
|
||||
├── ProjectDevice
|
||||
└── DistributionBoard
|
||||
└── CircuitList
|
||||
└── CircuitSection
|
||||
└── Circuit
|
||||
└── CircuitDeviceRow
|
||||
```
|
||||
|
||||
Wichtige fachliche Grenzen:
|
||||
|
||||
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
|
||||
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
|
||||
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
|
||||
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
|
||||
und Kategorie gehören zur Gerätezeile.
|
||||
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
|
||||
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
|
||||
lokale Werte und ausdrücklich erfasste Abweichungen.
|
||||
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
|
||||
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
|
||||
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
|
||||
erlaubt.
|
||||
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
|
||||
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
|
||||
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
|
||||
werden.
|
||||
|
||||
Für einen späteren Revit-Austausch besonders relevante Daten sind:
|
||||
|
||||
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
|
||||
Kennzeichnung als öffentliches Gebäude und Projektspannungen.
|
||||
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
|
||||
Raumname.
|
||||
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
|
||||
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
|
||||
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
|
||||
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
|
||||
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
|
||||
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
|
||||
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
|
||||
|
||||
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
|
||||
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
|
||||
aktuellen Produkt nicht vorgesehen.
|
||||
|
||||
## Aktuelle technische Architektur
|
||||
|
||||
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
|
||||
`src/frontend`.
|
||||
- API: Express und Zod unter `src/server`.
|
||||
- Fachregeln und typisierte Commands: `src/domain`.
|
||||
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
|
||||
- Laufzeitkomposition konkreter Repositories:
|
||||
`src/server/composition`.
|
||||
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
|
||||
SQLite-Datei unter `data/leistungsbilanz.db`.
|
||||
- Docker Compose wird für die lokale Entwicklung unterstützt.
|
||||
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
|
||||
|
||||
Der zentrale Editor liegt unter:
|
||||
|
||||
```text
|
||||
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
|
||||
src/frontend/components/circuit-tree-editor.tsx
|
||||
```
|
||||
|
||||
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
|
||||
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
|
||||
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
|
||||
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
|
||||
|
||||
## Revisionen, Undo/Redo und Sicherungspunkte
|
||||
|
||||
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
|
||||
Command- und Transaktionsgrenze:
|
||||
|
||||
```text
|
||||
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
|
||||
```
|
||||
|
||||
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
|
||||
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
|
||||
geben die erwartete Revision mit, damit veraltete Clients keine neueren
|
||||
Änderungen überschreiben.
|
||||
|
||||
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
|
||||
Anwendungsneustart. Zusätzlich gibt es:
|
||||
|
||||
- eine unveränderliche Revisions-Timeline;
|
||||
- benannte und automatische logische Projektsnapshots;
|
||||
- wiederherstellbare Snapshots mit Prüfsumme;
|
||||
- einen portablen, versionierten JSON-Projekttransfer.
|
||||
|
||||
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
|
||||
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
|
||||
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
|
||||
revisioniert und rückgängig machbar sein.
|
||||
|
||||
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
|
||||
|
||||
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
|
||||
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
|
||||
Projekts innerhalb von Leistungsbilanz.
|
||||
|
||||
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
|
||||
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
|
||||
für CSV-Dateien oder Revit-Parameter verwendet werden.
|
||||
|
||||
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
|
||||
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
|
||||
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
|
||||
zuweisen kann.
|
||||
|
||||
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
|
||||
|
||||
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
|
||||
Implementierungsdesign:
|
||||
|
||||
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
|
||||
Gerätezeilen.
|
||||
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
|
||||
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
|
||||
widersprüchliche Objekte angezeigt.
|
||||
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
|
||||
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
|
||||
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
|
||||
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
|
||||
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
|
||||
unterscheidbar bleiben.
|
||||
- Lokale Planungswerte dürfen niemals still überschrieben werden.
|
||||
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
|
||||
ausdrücklich modelliert werden.
|
||||
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
|
||||
automatisch einem anderen Objekt zugeordnet.
|
||||
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
|
||||
die Werte deterministisch zurückschreiben kann.
|
||||
- Ein Export bezieht sich auf eine bekannte Projektrevision.
|
||||
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
|
||||
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
|
||||
Serverbetrieb oder unabhängigen Hintergrundjobs.
|
||||
|
||||
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
|
||||
|
||||
- `ExternalModelSource`
|
||||
- `ExternalImportBatch`
|
||||
- `ExternalModelObject`
|
||||
- `ExternalObjectLink`
|
||||
- `ExternalParameterMapping`
|
||||
- `ExternalExportBatch`
|
||||
|
||||
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
|
||||
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
|
||||
Tabellen behandeln.
|
||||
|
||||
## Auftrag an das andere LLM
|
||||
|
||||
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
|
||||
realen Ablauf in Revit und kläre danach schrittweise mindestens:
|
||||
|
||||
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
|
||||
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
|
||||
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
|
||||
Revit-Versionen)?
|
||||
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
|
||||
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
|
||||
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
|
||||
aktualisierte Revit-Dateien unterschieden?
|
||||
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
|
||||
Leistungsbilanz geplant?
|
||||
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
|
||||
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
|
||||
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
|
||||
Projektgerät, Raum oder einer Verteilung zugeordnet?
|
||||
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
|
||||
Einheiten und unter welchen Revit-Parameternamen?
|
||||
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
|
||||
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
|
||||
Rückgabedatei erzeugt werden?
|
||||
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
|
||||
wiederverwendet und zwischen Projekten geteilt werden?
|
||||
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
|
||||
doppelte oder nicht mehr sichtbare Revit-Objekte?
|
||||
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
|
||||
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
|
||||
eine Auswahl?
|
||||
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
|
||||
Anwender vor der Übernahme?
|
||||
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
|
||||
verhindern oder nur eine Warnung erzeugen?
|
||||
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
|
||||
16. Müssen Import und Export synchron im Browser laufen oder werden später
|
||||
Hintergrundjobs benötigt?
|
||||
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
|
||||
Versionshistorie und Support gespeichert werden?
|
||||
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
|
||||
Abnahmekriterien?
|
||||
|
||||
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
|
||||
Notizen ausdrücklich:
|
||||
|
||||
- vom Anwender bestätigte Anforderungen;
|
||||
- sinnvolle Vorschläge;
|
||||
- offene Entscheidungen;
|
||||
- technische Folgerungen;
|
||||
- bewusst nicht betrachtete spätere Ausbaustufen.
|
||||
|
||||
## Erwartetes Ergebnis der Anforderungsrunde
|
||||
|
||||
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
|
||||
soll mindestens enthalten:
|
||||
|
||||
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
|
||||
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
|
||||
Rückexport;
|
||||
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
|
||||
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
|
||||
- Identitäts-, Matching- und Quellmodell;
|
||||
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
|
||||
- Konflikt-, Validierungs- und Freigaberegeln;
|
||||
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
|
||||
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
|
||||
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
|
||||
Architekturregeln zu umgehen;
|
||||
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
|
||||
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
|
||||
müssen.
|
||||
|
||||
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
|
||||
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
|
||||
nur Architekturvorschläge des LLM sind.
|
||||
|
||||
## Weiterführende Projektdokumente
|
||||
|
||||
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
|
||||
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
|
||||
Command-Pfad
|
||||
- `docs/project-history-and-external-model-architecture.md` – bisherige
|
||||
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
|
||||
- `docs/spec/01-domain-context.md` – fachlicher Kontext
|
||||
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||
kontrollierte Synchronisierung
|
||||
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
|
||||
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||
Funktionen
|
||||
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
|
||||
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad
|
||||
ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen
|
||||
Migrationsmeldung abgelehnt.
|
||||
|
|
|
|||
|
|
@ -532,32 +532,37 @@ Acceptance criteria:
|
|||
|
||||
## Phase 14: External Model Round-Trip
|
||||
|
||||
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
|
||||
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
|
||||
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
|
||||
und die verbindlichen
|
||||
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
|
||||
Status: abgeschlossen am 30.09.2026 mit R10. Siehe
|
||||
[R10-Abnahme](revit-excel-r10-validation.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
|
||||
- stage and validate imports before applying them
|
||||
- show additions, changes, missing objects and conflicts
|
||||
- link external objects explicitly to project-domain entities
|
||||
- apply an import as one auditable project revision
|
||||
- configure inbound and outbound parameter mappings
|
||||
- export against a known project revision while preserving IFCGUID
|
||||
Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit
|
||||
einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie
|
||||
erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird
|
||||
zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative
|
||||
vorgelegt.
|
||||
|
||||
Acceptance criteria:
|
||||
Kernabnahme der Phase:
|
||||
|
||||
- repeated imports match objects deterministically by source and IFCGUID
|
||||
- imports never silently overwrite local planning values
|
||||
- users approve the diff before it changes the project
|
||||
- exported data can be assigned back to the correct Revit objects
|
||||
- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
|
||||
- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
|
||||
Rückexport erhalten;
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ requirements and intended sequencing, not proof of implementation.
|
|||
- [x] Phase E: editor projection and editing.
|
||||
- [x] Phase F: documentation and full GUI verification.
|
||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||
the planner supplies the calculation requirements.
|
||||
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:
|
||||
`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
|
||||
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
|
||||
|
||||
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.
|
||||
4. [x] Distribution-board power summary and project column layout.
|
||||
|
||||
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||
foundation as a separate jointly planned phase and must not be inferred from
|
||||
these tasks.
|
||||
The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses
|
||||
exact Worksharing-ID identity and must not be inferred from these unrelated
|
||||
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
|
||||
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
|
||||
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
|
||||
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
|
||||
noch nicht.
|
||||
|
||||
Fachliche Quelle bleibt
|
||||
[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
|
||||
Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die
|
||||
Reihenfolge kleiner Arbeitspakete.
|
||||
|
||||
## Ergebnis des Bestandsaudits
|
||||
|
||||
### Beibehalten
|
||||
|
||||
- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis
|
||||
viele `CircuitDeviceRow`-Datensätze.
|
||||
- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf
|
||||
diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht
|
||||
ausdrücklich bestätigt.
|
||||
- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung
|
||||
zwischen einer Gerätezeile und genau einer Projektgerätevorlage.
|
||||
- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte,
|
||||
Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen
|
||||
vierten, fachlich eigenen Pfad.
|
||||
- Alle bestätigten Änderungen verwenden typisierte Commands mit
|
||||
`expectedRevision`, vollständiger Gegenrichtung und
|
||||
`project-command-transaction.persistence.ts` als gemeinsamer
|
||||
Transaktionsgrenze.
|
||||
- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit
|
||||
fester Fremdschlüsselreihenfolge und UUID-Remapping.
|
||||
- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte
|
||||
Laufzeitadapter.
|
||||
|
||||
### Erweitern oder gezielt refaktorieren
|
||||
|
||||
- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den
|
||||
bestätigten externen Projektzustand erweitert. Bereits veröffentlichte
|
||||
Snapshot-Versionen werden nicht verändert.
|
||||
- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration,
|
||||
Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine
|
||||
konkreten SQLite-Abhängigkeiten.
|
||||
- Der Stromkreislisteneditor erhält später einen separaten Drawer für
|
||||
unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht
|
||||
dafür zweckentfremdet.
|
||||
- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer
|
||||
`CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“.
|
||||
|
||||
### Hinzufügen
|
||||
|
||||
- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain;
|
||||
- projektweite, versionierte Revit-CSV-Konfiguration;
|
||||
- eine zunächst implizite externe Modellquelle je Projekt;
|
||||
- Importbatches einschließlich Originaltransport und akzeptiertem
|
||||
Konfigurationsstand;
|
||||
- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und
|
||||
Planungswerten und einzelnem Lebenszyklus;
|
||||
- Quellraum-Mappings auf interne Räume und Standardverteilungen;
|
||||
- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler
|
||||
abweichender Verteilung;
|
||||
- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices;
|
||||
- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und
|
||||
End-to-End-Tests.
|
||||
|
||||
### Entfernen
|
||||
|
||||
Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14
|
||||
kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt.
|
||||
|
||||
## Vorgeschlagenes Domänenmodell
|
||||
|
||||
Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die
|
||||
jeweilige englische Pluralform in `snake_case`.
|
||||
|
||||
### `ExternalModelSource`
|
||||
|
||||
- stabile interne UUID;
|
||||
- `projectId`;
|
||||
- technischer Name und Quelltyp `revit_csv`;
|
||||
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
|
||||
abgeleitet und nicht als redundanter Zeiger gespeichert.
|
||||
|
||||
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
|
||||
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
|
||||
Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an
|
||||
`(sourceId, ifcGuid)`.
|
||||
|
||||
### `ExternalCsvConfiguration`
|
||||
|
||||
- stabile UUID und `projectId` mit genau einer aktiven Konfiguration;
|
||||
- Konfigurationsschema-Version;
|
||||
- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen,
|
||||
Dezimalformat und Kopfzeilenerkennung;
|
||||
- exakte Spaltenzuordnung;
|
||||
- geordnete, exakte Familie-und-Typ-Regeln;
|
||||
- monotoner Konfigurationsstand.
|
||||
|
||||
Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in
|
||||
einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei
|
||||
verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar,
|
||||
ohne eine zweite veränderbare Historientabelle einzuführen.
|
||||
|
||||
### `ExternalImportBatch`
|
||||
|
||||
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
|
||||
- Dateiname, SHA-256 und zugehörige Projektrevision;
|
||||
- verwendete monotone Konfigurationsversion und vollständiger
|
||||
Konfigurationssnapshot;
|
||||
- Originaldatei als Bytes;
|
||||
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
|
||||
|
||||
Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes
|
||||
belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten
|
||||
Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten,
|
||||
Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren.
|
||||
|
||||
### `ExternalRoomMapping`
|
||||
|
||||
- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel;
|
||||
- Quellgeschoss-, Raumnummer- und Raumname-Snapshot;
|
||||
- optionales `roomId`;
|
||||
- optionale `defaultDistributionBoardId`.
|
||||
|
||||
Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält
|
||||
das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung.
|
||||
|
||||
### `ExternalModelObject`
|
||||
|
||||
- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`;
|
||||
- letzter gesehener und letzter akzeptierter Importbatch;
|
||||
- akzeptierte Quellwerte getrennt als strukturierter Snapshot;
|
||||
- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame
|
||||
Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe
|
||||
und Bemerkung;
|
||||
- explizite Override-Metadaten;
|
||||
- optionales `externalRoomMappingId`;
|
||||
- optionale abweichende `distributionBoardId`;
|
||||
- optionales `linkedProjectDeviceId`;
|
||||
- optionales `circuitDeviceRowId`;
|
||||
- Anwesenheitsstatus für bestätigte fehlende Objekte.
|
||||
|
||||
Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele
|
||||
einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet
|
||||
sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre
|
||||
wirksame Menge größer eins.
|
||||
|
||||
Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem
|
||||
Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter
|
||||
Anwesenheits- oder Planungszustand wird persistiert.
|
||||
|
||||
## Mengenmodell der Gerätezeile
|
||||
|
||||
Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller
|
||||
Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines
|
||||
Objekts manuelle Mengen löschen oder doppelt zählen.
|
||||
|
||||
Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt:
|
||||
|
||||
```text
|
||||
quantity = manualQuantity
|
||||
+ Summe(effectiveQuantity aller verknüpften ExternalModelObjects)
|
||||
```
|
||||
|
||||
- Bei der Migration erhält jede bestehende Row
|
||||
`manualQuantity = quantity`; ihr Ergebnis bleibt unverändert.
|
||||
- Eine ausschließlich aus externen Objekten erstellte Row startet mit
|
||||
`manualQuantity = 0`.
|
||||
- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende
|
||||
Berechnungen, Filter und Exporte und wird im selben Command aktualisiert.
|
||||
- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet
|
||||
sein.
|
||||
- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte
|
||||
übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht
|
||||
automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine
|
||||
ausdrückliche Nutzerentscheidung.
|
||||
- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim
|
||||
Lösen bleibt der manuelle Anteil erhalten.
|
||||
|
||||
Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere
|
||||
Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller
|
||||
Row-Lebenszyklus ist ausgeschlossen.
|
||||
|
||||
## Command-Schnittstellen
|
||||
|
||||
Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte
|
||||
Snapshots und werden mit der aktuellen Projektrevision geplant. Große
|
||||
Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch
|
||||
bleiben und dürfen spätere Quellstände nicht erneut auswerten.
|
||||
|
||||
- `external-csv-configuration.update`: vollständige projektweite
|
||||
Konfiguration ändern.
|
||||
- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe
|
||||
Objekte und bestätigte Links atomar einfügen.
|
||||
- `external-import.apply-follow-up`: neuen Batch und die vollständigen
|
||||
erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte
|
||||
atomar übernehmen.
|
||||
- `external-model-object.update-planning`: lokale Werte und Overrides eines
|
||||
oder mehrerer Objekte ändern.
|
||||
- `external-model-object.update-project-device-link`: ProjectDevice-Links
|
||||
explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
|
||||
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
|
||||
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
|
||||
und alle betroffenen materialisierten Mengen atomar aktualisieren.
|
||||
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
|
||||
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
|
||||
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
|
||||
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
|
||||
Command gespeichert und bei Redo nicht neu berechnet.
|
||||
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
|
||||
weiterhin unveränderte fehlende Objekte löschen.
|
||||
|
||||
Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen
|
||||
`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare
|
||||
Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und
|
||||
Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern
|
||||
keine Projektrevision und laufen über einen eigenen Audit-Store.
|
||||
|
||||
## Snapshot-, Transfer- und Migrationsfolgen
|
||||
|
||||
- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das
|
||||
Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2
|
||||
ergänzt `externalCsvConfiguration = null`.
|
||||
- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und
|
||||
Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt
|
||||
leere externe Collections.
|
||||
- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende
|
||||
Werte werden einmalig aus `quantity` übernommen.
|
||||
- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte
|
||||
Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge.
|
||||
- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links
|
||||
remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte
|
||||
bleiben unverändert.
|
||||
- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene
|
||||
Project-State-Schema-Version wird additiv erhöht.
|
||||
- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert.
|
||||
|
||||
## Implementierungsfolge
|
||||
|
||||
### 14.1 – CSV-Konfiguration und reine Vorschau
|
||||
|
||||
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine
|
||||
synthetische Fixtures ergänzen. **Erledigt.**
|
||||
2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
|
||||
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
|
||||
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
|
||||
synthetische Testdaten; die Referenzdatei steht noch aus.**
|
||||
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
|
||||
`external-csv-configuration.update` persistieren. **Erledigt.**
|
||||
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
|
||||
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
|
||||
deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
|
||||
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
|
||||
gegen die Vorschau geprüft.
|
||||
|
||||
**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme
|
||||
erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2.
|
||||
|
||||
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
|
||||
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
|
||||
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
|
||||
Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
|
||||
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
|
||||
einen Commit aufgenommen.
|
||||
|
||||
### 14.2 – Persistente Objekte und Erstimport
|
||||
|
||||
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
|
||||
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
|
||||
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
|
||||
weiterhin keinen Import-Write außerhalb eines Commands.
|
||||
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
|
||||
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
|
||||
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
|
||||
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
|
||||
lassen IFCGUID und Quelltransport jedoch unverändert.
|
||||
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
|
||||
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
|
||||
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
|
||||
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
|
||||
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
|
||||
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
|
||||
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
|
||||
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
|
||||
bleibt unverändert.
|
||||
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
|
||||
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
|
||||
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
|
||||
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
|
||||
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
|
||||
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
|
||||
schreibt weder Entwurf noch Projektzustand. Der dedizierte
|
||||
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
|
||||
Planung, vollständige Entscheidungen für alle Quellräume und exakten
|
||||
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
|
||||
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
|
||||
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
|
||||
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
|
||||
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
|
||||
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
|
||||
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
|
||||
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
|
||||
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
|
||||
|
||||
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
|
||||
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
|
||||
Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
|
||||
|
||||
### 14.3 – Editor und explizite Zuordnung
|
||||
|
||||
1. `manualQuantity` und Mengeninvarianten migrieren und testen.
|
||||
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
|
||||
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
|
||||
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
|
||||
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
|
||||
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
|
||||
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
|
||||
**Teilweise erledigt:** `external-object.update-row-assignment` deckt
|
||||
Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und
|
||||
Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum,
|
||||
Selektionskennzeichen und Planungswerte, verlangt für bestätigbare
|
||||
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
|
||||
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
|
||||
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
|
||||
Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
|
||||
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
|
||||
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
|
||||
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
|
||||
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
|
||||
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
|
||||
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
|
||||
Standardschutz und genau einer externen Row wird gemeinsam mit den
|
||||
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
|
||||
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
|
||||
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
|
||||
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
|
||||
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
|
||||
**Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene
|
||||
GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers,
|
||||
unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker,
|
||||
Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden;
|
||||
Drawer, Filter und Mengenwirkung folgen.
|
||||
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
|
||||
testen.
|
||||
|
||||
Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl,
|
||||
Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt.
|
||||
|
||||
### 14.4 – Strukturtreuer Rückexport
|
||||
|
||||
1. Exportprojektion gegen eine bekannte Projektrevision ergänzen.
|
||||
2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen.
|
||||
3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten.
|
||||
4. Download und append-only Exportnachweis ergänzen.
|
||||
|
||||
Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich
|
||||
festgelegt.
|
||||
|
||||
### 14.5 – Folgeimport und Konflikte
|
||||
|
||||
1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen.
|
||||
2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren.
|
||||
3. Einzel- und Sammelentscheidungen im Wizard ergänzen.
|
||||
4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo
|
||||
übernehmen.
|
||||
|
||||
### 14.6 – Härtung und End-to-End-Abnahme
|
||||
|
||||
1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren.
|
||||
2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport,
|
||||
Snapshot/Restore und Projektduplikat prüfen.
|
||||
3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen.
|
||||
4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich
|
||||
ausgelieferten Stand nachziehen.
|
||||
|
||||
## Risiken und Schutzmaßnahmen
|
||||
|
||||
- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile;
|
||||
Commands ändern Links und materialisierte Menge gemeinsam.
|
||||
- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind
|
||||
getrennt; jede Übernahme benötigt eine Nutzerentscheidung.
|
||||
- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage;
|
||||
deren bestehende Commands bleiben alleinige Schreibgrenze.
|
||||
- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix,
|
||||
Dialekt, Konfiguration, Hash und Projektrevision.
|
||||
- **Sehr große Undo-Payloads:** Importcommands speichern vollständige
|
||||
erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen,
|
||||
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
|
||||
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
|
||||
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
|
||||
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
|
||||
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
|
||||
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
|
||||
|
||||
## Freigabepunkt
|
||||
|
||||
Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine
|
||||
Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket
|
||||
ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus.
|
||||
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
|
||||
`ae318a5` erhalten und darf nicht fortgeschrieben werden.
|
||||
|
|
|
|||
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,11 +0,0 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const drizzle_kit_1 = require("drizzle-kit");
|
||||
exports.default = (0, drizzle_kit_1.defineConfig)({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema/*.ts",
|
||||
out: "./src/db/migrations",
|
||||
dbCredentials: {
|
||||
url: "./data/leistungsbilanz.db",
|
||||
},
|
||||
});
|
||||
17
index.html
17
index.html
|
|
@ -1,17 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leistungsbilanz API</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="info">
|
||||
<h1>Leistungsbilanz Backend</h1>
|
||||
<p>Der aktuelle Stand laeuft als TypeScript Node/SQLite API.</p>
|
||||
<p>Serverstart: <code>npm run dev</code></p>
|
||||
<p>Healthcheck: <code>GET /health</code></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -11,6 +11,22 @@ const nextConfig = {
|
|||
typescript: {
|
||||
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() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
834
package-lock.json
generated
834
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -3,6 +3,9 @@
|
|||
"version": "1.0.0",
|
||||
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
||||
"main": "dist/server/index.js",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run dev:api",
|
||||
"dev:api": "tsx watch src/server/index.ts",
|
||||
|
|
@ -10,6 +13,9 @@
|
|||
"docker:up": "docker compose up --build --detach",
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs --follow",
|
||||
"docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach",
|
||||
"docker:dev:down": "docker compose -f compose.dev.yaml down",
|
||||
"docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow",
|
||||
"build": "npm run build:api",
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
|
|
@ -18,10 +24,10 @@
|
|||
"test": "node scripts/run-tests.mjs",
|
||||
"test:watch": "node scripts/run-tests.mjs --watch",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate": "node scripts/run-migrations.js",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
|
@ -30,6 +36,7 @@
|
|||
"better-sqlite3": "^12.9.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^5.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^16.2.4",
|
||||
|
|
@ -40,7 +47,7 @@
|
|||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ const dbPath = path.resolve("data", "leistungsbilanz.db");
|
|||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
const requiredTables = [
|
||||
"buildings",
|
||||
"estimate_balances",
|
||||
"estimate_groups",
|
||||
"estimate_rows",
|
||||
"circuit_sections",
|
||||
"circuits",
|
||||
"circuit_device_rows",
|
||||
|
|
@ -12,6 +16,8 @@ const requiredTables = [
|
|||
"circuit_protection_devices",
|
||||
"distribution_board_components",
|
||||
"distribution_board_component_protection_devices",
|
||||
"document_branding_assets",
|
||||
"document_branding_settings",
|
||||
];
|
||||
|
||||
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||
|
|
@ -87,6 +93,10 @@ const requiredProjectColumns = [
|
|||
"description",
|
||||
"is_public_building",
|
||||
"enabled_distribution_board_supply_types",
|
||||
"site_address",
|
||||
"work_stage",
|
||||
"project_lead",
|
||||
"project_editor",
|
||||
];
|
||||
const projectColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||
|
|
@ -94,10 +104,21 @@ const projectColumns = new Set(
|
|||
const missingProjectColumns = requiredProjectColumns.filter(
|
||||
(name) => !projectColumns.has(name)
|
||||
);
|
||||
const requiredFloorColumns = ["building_id"];
|
||||
const floorColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name)
|
||||
);
|
||||
const missingFloorColumns = requiredFloorColumns.filter(
|
||||
(name) => !floorColumns.has(name)
|
||||
);
|
||||
const requiredDistributionBoardColumns = [
|
||||
"floor_id",
|
||||
"supply_type",
|
||||
"simultaneity_factor",
|
||||
"board_number",
|
||||
"location",
|
||||
"fed_from",
|
||||
"remark",
|
||||
];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
|
|
@ -145,6 +166,7 @@ console.log(
|
|||
requiredCircuitSectionColumns.join(", ")
|
||||
);
|
||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||
console.log("Required floor columns:", requiredFloorColumns.join(", "));
|
||||
console.log(
|
||||
"Required distribution-board columns:",
|
||||
requiredDistributionBoardColumns.join(", ")
|
||||
|
|
@ -161,6 +183,11 @@ if (missing.length > 0) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingFloorColumns.length > 0) {
|
||||
console.error("Missing floor columns:", missingFloorColumns.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingProjectDeviceColumns.length > 0) {
|
||||
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running migrations..."
|
||||
node scripts/run-migrations.js
|
||||
|
||||
echo "Starting API server on :3000..."
|
||||
node dist/server/index.js &
|
||||
|
||||
echo "Waiting for API..."
|
||||
until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Starting Next.js on :3001..."
|
||||
exec node_modules/.bin/next start -p 3001
|
||||
|
|
@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
|
|||
const fs = require('node:fs');
|
||||
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 });
|
||||
|
||||
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
|
||||
const db = drizzle(sqlite);
|
||||
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
|
||||
|
||||
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
|
||||
sqlite.close();
|
||||
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}`);
|
||||
}
|
||||
|
||||
console.log('Migrations applied');
|
||||
migrate(drizzle(sqlite), {
|
||||
migrationsFolder: path.resolve('src/db/migrations'),
|
||||
});
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
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;
|
||||
}
|
||||
1467
src/app/globals.css
1467
src/app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,8 @@ import Script from "next/script";
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "../frontend/components/AppShell";
|
||||
import { AuthProvider } from "../frontend/components/AuthProvider";
|
||||
import { LoginGate } from "../frontend/components/LoginGate";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leistungsbilanz",
|
||||
|
|
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
<AuthProvider>
|
||||
<LoginGate>
|
||||
<AppShell>{children}</AppShell>
|
||||
</LoginGate>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||
|
|
@ -10,10 +11,20 @@ export default function CircuitTreeEditPage() {
|
|||
return (
|
||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Link
|
||||
aria-label="Zurück zur Verteilerübersicht"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
href={`/projects/${params.projectId}`}
|
||||
title="Zurück zur Verteilerübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||
Zum Projekt
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
359
src/app/projects/[projectId]/print/page.tsx
Normal file
359
src/app/projects/[projectId]/print/page.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import "../../../../frontend/print/print-document.css";
|
||||
import {
|
||||
deleteDocumentLogo,
|
||||
documentLogoUrl,
|
||||
getProjectCircuitTrees,
|
||||
getDocumentBranding,
|
||||
getProject,
|
||||
listDistributionBoards,
|
||||
listFloors,
|
||||
listProjectSnapshots,
|
||||
updateOfficeProfile,
|
||||
uploadDocumentLogo,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
DocumentLogoMetadataDto,
|
||||
OfficeProfileDto,
|
||||
ProjectDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../../../../frontend/types";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import {
|
||||
officeProfileFieldLabels,
|
||||
officeProfileFieldMaxLengths,
|
||||
} from "../../../../shared/constants/project-cover";
|
||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
||||
import {
|
||||
assertPrintRevisionConsistency,
|
||||
buildBoardHierarchy,
|
||||
summarizePrintBoard,
|
||||
type PrintBoardSummary,
|
||||
} from "../../../../frontend/print/print-document-model";
|
||||
import {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
} from "../../../../shared/constants/document-branding";
|
||||
|
||||
type PlanFormat = "a4" | "a3";
|
||||
|
||||
interface PrintData {
|
||||
project: ProjectDto;
|
||||
summaries: PrintBoardSummary[];
|
||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
||||
}
|
||||
|
||||
const boardSortCollator = new Intl.Collator("de", { numeric: true });
|
||||
|
||||
function cssString(value: string) {
|
||||
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
|
||||
}
|
||||
|
||||
function pageRules(project: ProjectDto, stand: string, format: PlanFormat) {
|
||||
const marginBoxes = `
|
||||
@bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }`;
|
||||
return `
|
||||
@page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} }
|
||||
@page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`;
|
||||
}
|
||||
|
||||
export default function ProjectPrintPage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const [data, setData] = useState<PrintData | null>(null);
|
||||
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
|
||||
const [office, setOffice] = useState<OfficeProfileDto>({
|
||||
officeName: null,
|
||||
officeAddress: null,
|
||||
});
|
||||
const [officeDraft, setOfficeDraft] = useState<{
|
||||
officeName: string;
|
||||
officeAddress: string;
|
||||
} | null>(null);
|
||||
const [format, setFormat] = useState<PlanFormat>("a4");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [printedAt] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
const [project, boards, projectTrees, floors, snapshots, branding] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listDistributionBoards(projectId),
|
||||
getProjectCircuitTrees(projectId),
|
||||
listFloors(projectId),
|
||||
listProjectSnapshots(projectId),
|
||||
getDocumentBranding(),
|
||||
]);
|
||||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||||
const treeByBoardId = new Map(
|
||||
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
|
||||
);
|
||||
const summaries =
|
||||
[...boards]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
|
||||
boardSortCollator.compare(left.name, right.name)
|
||||
)
|
||||
.flatMap((board) => {
|
||||
const tree = treeByBoardId.get(board.id);
|
||||
return tree ? [{ board, tree }] : [];
|
||||
})
|
||||
.map(({ board, tree }) =>
|
||||
summarizePrintBoard({
|
||||
board,
|
||||
circuitListId: tree.circuitListId,
|
||||
tree,
|
||||
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
||||
})
|
||||
);
|
||||
const finalProject = await getProject(projectId);
|
||||
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setData({
|
||||
project,
|
||||
summaries,
|
||||
namedSnapshots: snapshots
|
||||
.filter((snapshot) => snapshot.kind === "named")
|
||||
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
|
||||
});
|
||||
setLogo(branding.logo);
|
||||
setOffice(branding.office);
|
||||
document.title = `Leistungsbilanz – ${project.name}`;
|
||||
}
|
||||
load().catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden.");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const entries = useMemo(
|
||||
() => (data ? buildBoardHierarchy(data.summaries) : []),
|
||||
[data]
|
||||
);
|
||||
const stand = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(printedAt),
|
||||
[printedAt]
|
||||
);
|
||||
const logoSrc = logo
|
||||
? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}`
|
||||
: null;
|
||||
|
||||
async function handleLogoUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) {
|
||||
setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen.");
|
||||
return;
|
||||
}
|
||||
if (file.size > maxDocumentLogoBytes) {
|
||||
setError("Das Logo darf höchstens 2 MB groß sein.");
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadDocumentLogo(file);
|
||||
setLogo(result.logo);
|
||||
setMessage("Logo hinterlegt. Es gilt für alle Projekte.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!officeDraft) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateOfficeProfile({
|
||||
officeName: officeDraft.officeName.trim() || null,
|
||||
officeAddress: officeDraft.officeAddress.trim() || null,
|
||||
});
|
||||
setOffice(result.office);
|
||||
setOfficeDraft(null);
|
||||
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoDelete() {
|
||||
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteDocumentLogo();
|
||||
setLogo(null);
|
||||
setMessage("Logo entfernt.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// Paper output: always light, independent of the app's dark mode.
|
||||
<div
|
||||
className={format === "a3" ? "print-view pd-a3" : "print-view"}
|
||||
data-bs-theme="light"
|
||||
>
|
||||
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
|
||||
<div className="pd-toolbar">
|
||||
<span className="pd-toolbar-title">
|
||||
Seitenansicht{data ? ` · ${data.project.name}` : ""}
|
||||
</span>
|
||||
{message ? <span className="pd-toolbar-message">{message}</span> : null}
|
||||
{error ? <span className="pd-toolbar-error">{error}</span> : null}
|
||||
<label className="d-flex align-items-center gap-2">
|
||||
Planformat
|
||||
<select
|
||||
className="form-select form-select-sm w-auto"
|
||||
onChange={(event) => setFormat(event.target.value as PlanFormat)}
|
||||
value={format}
|
||||
>
|
||||
<option value="a4">A4 quer</option>
|
||||
<option value="a3">A3 quer</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setOfficeDraft({
|
||||
officeName: office.officeName ?? "",
|
||||
officeAddress: office.officeAddress ?? "",
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Büroangaben
|
||||
</button>
|
||||
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
|
||||
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
|
||||
<input
|
||||
accept={documentLogoMimeTypes.join(",")}
|
||||
disabled={isBusy}
|
||||
hidden
|
||||
onChange={(event) => void handleLogoUpload(event)}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
{logo ? (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void handleLogoDelete()}
|
||||
type="button"
|
||||
>
|
||||
Logo entfernen
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={!data}
|
||||
onClick={() => window.print()}
|
||||
type="button"
|
||||
>
|
||||
Als PDF speichern / Drucken
|
||||
</button>
|
||||
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
|
||||
Zum Projekt
|
||||
</Link>
|
||||
</div>
|
||||
{officeDraft ? (
|
||||
<FormModal
|
||||
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
|
||||
isSaving={isBusy}
|
||||
onClose={() => setOfficeDraft(null)}
|
||||
onSubmit={handleOfficeSave}
|
||||
submitLabel="Büroangaben speichern"
|
||||
title="Büroangaben"
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="office-name">
|
||||
{officeProfileFieldLabels.officeName}
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="office-name"
|
||||
maxLength={officeProfileFieldMaxLengths.officeName}
|
||||
onChange={(event) =>
|
||||
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
|
||||
}
|
||||
value={officeDraft.officeName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="office-address">
|
||||
{officeProfileFieldLabels.officeAddress}
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="office-address"
|
||||
maxLength={officeProfileFieldMaxLengths.officeAddress}
|
||||
onChange={(event) =>
|
||||
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
|
||||
}
|
||||
placeholder={"Straße Nr.\nPLZ Ort"}
|
||||
rows={3}
|
||||
value={officeDraft.officeAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
{data ? (
|
||||
<PrintDocument
|
||||
entries={entries}
|
||||
logoSrc={logoSrc}
|
||||
namedSnapshots={data.namedSnapshots}
|
||||
office={office}
|
||||
printedAt={printedAt}
|
||||
project={data.project}
|
||||
/>
|
||||
) : error ? null : (
|
||||
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>;
|
||||
}
|
||||
855
src/app/projects/[projectId]/struktur/page.tsx
Normal file
855
src/app/projects/[projectId]/struktur/page.tsx
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createBuilding,
|
||||
createFloor,
|
||||
createRoom,
|
||||
deleteBuilding,
|
||||
deleteFloor,
|
||||
deleteRoom,
|
||||
getProject,
|
||||
listBuildings,
|
||||
listFloors,
|
||||
listRooms,
|
||||
updateBuilding,
|
||||
updateFloor,
|
||||
updateRoom,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
BuildingDto,
|
||||
CreateRoomInput,
|
||||
FloorDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
} from "../../../../frontend/types";
|
||||
import {
|
||||
ALL_SELECTION,
|
||||
UNASSIGNED_BUILDING_LABEL,
|
||||
UNASSIGNED_FLOOR_LABEL,
|
||||
UNASSIGNED_SELECTION,
|
||||
countFloorsByBuilding,
|
||||
countRoomsByFloor,
|
||||
defaultBuildingIdForNewFloor,
|
||||
defaultFloorIdForNewRoom,
|
||||
describeRoomLocation,
|
||||
filterFloorsByBuilding,
|
||||
filterRooms,
|
||||
groupFloorsByBuilding,
|
||||
hasFloorsWithoutBuilding,
|
||||
hasRoomsWithoutFloor,
|
||||
normalizeBuildingSelection,
|
||||
normalizeFloorSelection,
|
||||
showsRoomsWithoutFloorEntry,
|
||||
summarizeRoomAreas,
|
||||
type RoomAreaSummary,
|
||||
type StructureSelection,
|
||||
} from "../../../../frontend/utils/building-structure";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||
import { formatRoomArea } from "../../../../frontend/utils/room-area";
|
||||
|
||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||
|
||||
type StructureDialog =
|
||||
| { kind: "building"; building: BuildingDto | null }
|
||||
| { kind: "floor"; floor: FloorDto | null }
|
||||
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
|
||||
|
||||
function formatCount(count: number, singular: string, plural: string) {
|
||||
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) {
|
||||
return (
|
||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||
left.roomName.localeCompare(right.roomName, "de")
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureColumnProps {
|
||||
addLabel: string;
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
headingId: string;
|
||||
isAddDisabled: boolean;
|
||||
onAdd: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function StructureColumn({
|
||||
addLabel,
|
||||
children,
|
||||
count,
|
||||
headingId,
|
||||
isAddDisabled,
|
||||
onAdd,
|
||||
title,
|
||||
}: StructureColumnProps) {
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<h2 className="structure-column-title" id={headingId}>
|
||||
{title}
|
||||
<span className="badge text-bg-secondary ms-2">{count}</span>
|
||||
</h2>
|
||||
<button
|
||||
aria-label={addLabel}
|
||||
className="btn btn-sm btn-outline-primary structure-icon-button"
|
||||
disabled={isAddDisabled}
|
||||
onClick={onAdd}
|
||||
title={addLabel}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="list-group list-group-flush structure-list">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureEntryProps {
|
||||
actions?: ReactNode;
|
||||
hint?: string;
|
||||
isSelected?: boolean;
|
||||
label: ReactNode;
|
||||
meta?: string;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
function StructureEntry({
|
||||
actions,
|
||||
hint,
|
||||
isSelected = false,
|
||||
label,
|
||||
meta,
|
||||
onSelect,
|
||||
}: StructureEntryProps) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="structure-entry-text">
|
||||
<span className="structure-entry-label">{label}</span>
|
||||
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
|
||||
</span>
|
||||
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
isSelected
|
||||
? "list-group-item structure-entry is-selected"
|
||||
: "list-group-item structure-entry"
|
||||
}
|
||||
>
|
||||
{onSelect ? (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className="structure-entry-main"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="structure-entry-main">{content}</div>
|
||||
)}
|
||||
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntryActionsProps {
|
||||
deleteLabel: string;
|
||||
editLabel: string;
|
||||
isDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function EntryActions({
|
||||
deleteLabel,
|
||||
editLabel,
|
||||
isDisabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: EntryActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={editLabel}
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onEdit}
|
||||
title={editLabel}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={deleteLabel}
|
||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onDelete}
|
||||
title={deleteLabel}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildingStructurePage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||
const [buildingSelection, setBuildingSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [floorSelection, setFloorSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [dialog, setDialog] = useState<StructureDialog | null>(null);
|
||||
const [dialogName, setDialogName] = useState("");
|
||||
const [dialogBuildingId, setDialogBuildingId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reloadStructure = useCallback(async () => {
|
||||
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listBuildings(projectId),
|
||||
listFloors(projectId),
|
||||
listRooms(projectId),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setBuildings(loadedBuildings);
|
||||
setFloors(loadedFloors);
|
||||
setRooms(loadedRooms);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadStructure()
|
||||
.then(() => setError(null))
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Gebäudestruktur konnte nicht geladen werden."
|
||||
)
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId, reloadStructure]);
|
||||
|
||||
const effectiveBuildingSelection = normalizeBuildingSelection(
|
||||
buildingSelection,
|
||||
floors,
|
||||
buildings
|
||||
);
|
||||
const effectiveFloorSelection = normalizeFloorSelection(
|
||||
floorSelection,
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
|
||||
const visibleFloors = useMemo(
|
||||
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
|
||||
[buildings, effectiveBuildingSelection, floors]
|
||||
);
|
||||
const visibleFloorGroups = useMemo(
|
||||
() =>
|
||||
effectiveBuildingSelection === ALL_SELECTION && buildings.length
|
||||
? groupFloorsByBuilding(visibleFloors, buildings)
|
||||
: [{ buildingId: null, label: "", floors: visibleFloors }],
|
||||
[buildings, effectiveBuildingSelection, visibleFloors]
|
||||
);
|
||||
const visibleRooms = useMemo(
|
||||
() =>
|
||||
filterRooms(
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection,
|
||||
effectiveFloorSelection
|
||||
).sort(compareRooms),
|
||||
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
|
||||
);
|
||||
const roomsInBuildingSelection = useMemo(
|
||||
() =>
|
||||
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
|
||||
.length,
|
||||
[buildings, effectiveBuildingSelection, floors, rooms]
|
||||
);
|
||||
const floorCountByBuilding = useMemo(
|
||||
() => countFloorsByBuilding(floors, buildings),
|
||||
[buildings, floors]
|
||||
);
|
||||
const roomCountByFloor = useMemo(
|
||||
() => countRoomsByFloor(rooms, floors),
|
||||
[floors, rooms]
|
||||
);
|
||||
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(
|
||||
rooms,
|
||||
floors,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
const isBusy = isSaving || !project;
|
||||
|
||||
function selectBuilding(selection: StructureSelection) {
|
||||
setBuildingSelection(selection);
|
||||
setFloorSelection(ALL_SELECTION);
|
||||
}
|
||||
|
||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||
execute: (expectedRevision: number) => Promise<TResult>,
|
||||
fallbackMessage: string
|
||||
): Promise<boolean> {
|
||||
if (!project) {
|
||||
return false;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execute(project.currentRevision);
|
||||
setProject((current) =>
|
||||
current
|
||||
? { ...current, currentRevision: result.history.currentRevision }
|
||||
: current
|
||||
);
|
||||
await reloadStructure();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||
// Refresh the revision and lists so a stale state does not block the
|
||||
// next attempt; the command error stays visible.
|
||||
await reloadStructure().catch(() => undefined);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBuildingDialog(building: BuildingDto | null) {
|
||||
setDialogName(building?.name ?? "");
|
||||
setDialog({ kind: "building", building });
|
||||
}
|
||||
|
||||
function openFloorDialog(floor: FloorDto | null) {
|
||||
setDialogName(floor?.name ?? "");
|
||||
setDialogBuildingId(
|
||||
floor
|
||||
? floor.buildingId ?? ""
|
||||
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
|
||||
);
|
||||
setDialog({ kind: "floor", floor });
|
||||
}
|
||||
|
||||
function openRoomDialog(room: RoomDto | null) {
|
||||
setDialog({
|
||||
kind: "room",
|
||||
room,
|
||||
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "building" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingBuilding = dialog.building;
|
||||
const name = dialogName.trim();
|
||||
// An unchanged dialog is not a command; the backend rejects no-op updates.
|
||||
if (editingBuilding && editingBuilding.name === name) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingBuilding
|
||||
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
|
||||
: createBuilding(projectId, { name }, expectedRevision),
|
||||
"Gebäude konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "floor" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingFloor = dialog.floor;
|
||||
const input = {
|
||||
name: dialogName.trim(),
|
||||
buildingId: dialogBuildingId || null,
|
||||
};
|
||||
if (
|
||||
editingFloor &&
|
||||
editingFloor.name === input.name &&
|
||||
editingFloor.buildingId === input.buildingId
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingFloor
|
||||
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
|
||||
: createFloor(projectId, input, expectedRevision),
|
||||
"Etage konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRoom(input: CreateRoomInput) {
|
||||
if (dialog?.kind !== "room") {
|
||||
return;
|
||||
}
|
||||
const editingRoom = dialog.room;
|
||||
if (
|
||||
editingRoom &&
|
||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||
editingRoom.roomName === input.roomName.trim() &&
|
||||
editingRoom.floorId === (input.floorId || null) &&
|
||||
editingRoom.areaM2 === input.areaM2
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingRoom
|
||||
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
|
||||
: createRoom(projectId, input, expectedRevision),
|
||||
"Raum konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBuilding(building: BuildingDto) {
|
||||
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
|
||||
setError(BUILDING_HAS_FLOORS_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) =>
|
||||
deleteBuilding(projectId, building.id, expectedRevision),
|
||||
"Gebäude konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteFloor(floor: FloorDto) {
|
||||
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
|
||||
"Etage konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRoom(room: RoomDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
|
||||
"Raum konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
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>Gebäudestruktur</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Gebäudestruktur wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="structure-columns">
|
||||
<StructureColumn
|
||||
addLabel="Gebäude hinzufügen"
|
||||
count={buildings.length}
|
||||
headingId="structure-buildings-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openBuildingDialog(null)}
|
||||
title="Gebäude"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatStructureMeta(
|
||||
floors.length,
|
||||
"Etage",
|
||||
"Etagen",
|
||||
areaSummaries.total
|
||||
)}
|
||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||
/>
|
||||
{buildings.map((building) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Gebäude „${building.name}“ löschen`}
|
||||
editLabel={`Gebäude „${building.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteBuilding(building)}
|
||||
onEdit={() => openBuildingDialog(building)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveBuildingSelection === building.id}
|
||||
key={building.id}
|
||||
label={building.name}
|
||||
meta={formatStructureMeta(
|
||||
floorCountByBuilding.get(building.id) ?? 0,
|
||||
"Etage",
|
||||
"Etagen",
|
||||
areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => selectBuilding(building.id)}
|
||||
/>
|
||||
))}
|
||||
{showUnassignedBuilding ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||
meta={formatStructureMeta(
|
||||
floorCountByBuilding.get(null) ?? 0,
|
||||
"Etage",
|
||||
"Etagen",
|
||||
areaSummaries.byBuilding.get(null) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!buildings.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
Noch keine Gebäude vorhanden.
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Etage hinzufügen"
|
||||
count={visibleFloors.length}
|
||||
headingId="structure-floors-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openFloorDialog(null)}
|
||||
title="Etagen"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatStructureMeta(
|
||||
roomsInBuildingSelection,
|
||||
"Raum",
|
||||
"Räume",
|
||||
selectedBuildingAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||
/>
|
||||
{visibleFloorGroups.map((group) => (
|
||||
<FloorGroupEntries
|
||||
key={group.buildingId ?? "unassigned"}
|
||||
label={group.label}
|
||||
>
|
||||
{group.floors.map((floor) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Etage „${floor.name}“ löschen`}
|
||||
editLabel={`Etage „${floor.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteFloor(floor)}
|
||||
onEdit={() => openFloorDialog(floor)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveFloorSelection === floor.id}
|
||||
key={floor.id}
|
||||
label={floor.name}
|
||||
meta={formatStructureMeta(
|
||||
roomCountByFloor.get(floor.id) ?? 0,
|
||||
"Raum",
|
||||
"Räume",
|
||||
areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(floor.id)}
|
||||
/>
|
||||
))}
|
||||
</FloorGroupEntries>
|
||||
))}
|
||||
{showUnassignedFloor ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||
meta={formatStructureMeta(
|
||||
roomCountByFloor.get(null) ?? 0,
|
||||
"Raum",
|
||||
"Räume",
|
||||
areaSummaries.byFloor.get(null) ?? emptyAreaSummary
|
||||
)}
|
||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!visibleFloors.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{floors.length
|
||||
? "Keine Etagen in dieser Auswahl."
|
||||
: "Noch keine Etagen vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Raum hinzufügen"
|
||||
count={visibleRooms.length}
|
||||
headingId="structure-rooms-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openRoomDialog(null)}
|
||||
title="Räume"
|
||||
>
|
||||
{visibleRooms.map((room) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
|
||||
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteRoom(room)}
|
||||
onEdit={() => openRoomDialog(room)}
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
effectiveFloorSelection === ALL_SELECTION
|
||||
? describeRoomLocation(room, floors, buildings)
|
||||
: undefined
|
||||
}
|
||||
key={room.id}
|
||||
label={
|
||||
<>
|
||||
<span className="structure-room-number">{room.roomNumber}</span>{" "}
|
||||
{room.roomName}
|
||||
</>
|
||||
}
|
||||
meta={
|
||||
room.areaM2 === null
|
||||
? "Fläche fehlt"
|
||||
: `${formatRoomArea(room.areaM2)} m²`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!visibleRooms.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{rooms.length
|
||||
? "Keine Räume in dieser Auswahl."
|
||||
: "Noch keine Räume vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
</div>
|
||||
|
||||
{dialog?.kind === "building" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveBuilding}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
|
||||
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
|
||||
>
|
||||
<label className="form-label" htmlFor="building-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="building-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. Haus A"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "floor" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveFloor}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||||
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="floor-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. EG"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-building">
|
||||
Gebäude
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="floor-building"
|
||||
onChange={(event) => setDialogBuildingId(event.target.value)}
|
||||
value={dialogBuildingId}
|
||||
>
|
||||
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
|
||||
{buildings.map((building) => (
|
||||
<option key={building.id} value={building.id}>
|
||||
{building.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "room" ? (
|
||||
<RoomModal
|
||||
buildings={buildings}
|
||||
floors={floors}
|
||||
initialRoom={dialog.room ?? undefined}
|
||||
initialValues={
|
||||
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorGroupEntries({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<li className="list-group-item structure-group-label">
|
||||
{label}
|
||||
</li>
|
||||
) : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/app/web-health/route.ts
Normal file
10
src/app/web-health/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
// Liveness probe for the web container itself. The "/health" path is
|
||||
// rewritten to the API in next.config.mjs, so it cannot answer for this
|
||||
// process. Kept as a route handler so a probe does not render a page.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
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>;
|
||||
|
|
@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext(
|
|||
|
||||
export const db = defaultDatabaseContext.db;
|
||||
|
||||
/**
|
||||
* Schliesst das SQLite-Handle des Prozesses. Nur beim Herunterfahren
|
||||
* aufrufen: danach schlaegt jede Abfrage ueber {@link db} fehl.
|
||||
*/
|
||||
export function closeDefaultDatabase(): void {
|
||||
defaultDatabaseContext.close();
|
||||
}
|
||||
|
|
|
|||
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal file
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE `document_branding_assets` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`file_name` text,
|
||||
`data` blob NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `remark` text;
|
||||
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `document_branding_settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_editor` text;
|
||||
9
src/db/migrations/0009_buildings.sql
Normal file
9
src/db/migrations/0009_buildings.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE `buildings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;
|
||||
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`);
|
||||
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
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
|
|
@ -50,6 +50,69 @@
|
|||
"when": 1786043080323,
|
||||
"tag": "0006_damp_skrulls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1790411540872,
|
||||
"tag": "0007_board_info_and_document_logo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1790411560953,
|
||||
"tag": "0008_project_cover_and_office_profile",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1790419185882,
|
||||
"tag": "0009_buildings",
|
||||
"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();
|
||||
}
|
||||
}
|
||||
27
src/db/repositories/building.repository.ts
Normal file
27
src/db/repositories/building.repository.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
|
||||
export class BuildingRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(eq(buildings.projectId, projectId))
|
||||
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
|
||||
}
|
||||
|
||||
async findById(projectId: string, buildingId: string) {
|
||||
return (
|
||||
this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(
|
||||
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
|
||||
)
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||
|
||||
interface PersistedMoveRow {
|
||||
id: string;
|
||||
|
|
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||
projectId,
|
||||
destinationCircuitIds
|
||||
);
|
||||
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
|
||||
targetCircuit.id,
|
||||
]);
|
||||
const deleted = database
|
||||
.delete(circuits)
|
||||
.where(
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ import {
|
|||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} 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 {
|
||||
applyExternalObjectLinks,
|
||||
captureExternalObjectDetachTransitions,
|
||||
invertExternalObjectLinkTransitions,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
type ExternalObjectLinkTransition,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
implements CircuitDeviceRowStructureProjectCommandStore
|
||||
|
|
@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
return executeProjectCommandTransactionWithAppliedForward<
|
||||
CircuitDeviceRowStructureProjectCommand,
|
||||
CircuitDeviceRowStructureProjectCommand,
|
||||
CircuitDeviceRowStructureProjectCommand
|
||||
>(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
|
|
@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
projectId: string,
|
||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||
command: CircuitDeviceRowStructureProjectCommand
|
||||
): CircuitDeviceRowStructureProjectCommand {
|
||||
): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> {
|
||||
if (command.type === circuitDeviceRowInsertCommandType) {
|
||||
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||
return this.insert(
|
||||
database,
|
||||
projectId,
|
||||
source,
|
||||
command.payload.row
|
||||
command.payload.row,
|
||||
command.payload.externalObjects ?? []
|
||||
);
|
||||
}
|
||||
if (command.type === circuitDeviceRowDeleteCommandType) {
|
||||
|
|
@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
database,
|
||||
projectId,
|
||||
command.payload.rowId,
|
||||
command.payload.expectedCircuitId
|
||||
command.payload.expectedCircuitId,
|
||||
command.payload.externalObjects
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit device-row structure command.");
|
||||
|
|
@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
database: AppDatabase,
|
||||
projectId: string,
|
||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||
row: CircuitDeviceRowSnapshot
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
externalObjects: ExternalObjectLinkTransition[]
|
||||
) {
|
||||
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("Device-row phase type is invalid.");
|
||||
|
|
@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
}
|
||||
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
||||
|
||||
return createCircuitDeviceRowDeleteProjectCommand(
|
||||
row.id,
|
||||
row.circuitId
|
||||
);
|
||||
if (externalObjects.length > 0) {
|
||||
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
|
||||
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(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
rowId: string,
|
||||
expectedCircuitId: string
|
||||
expectedCircuitId: string,
|
||||
suppliedExternalObjects?: ExternalObjectLinkTransition[]
|
||||
) {
|
||||
const row = database
|
||||
.select()
|
||||
|
|
@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
);
|
||||
}
|
||||
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
|
||||
.delete(circuitDeviceRows)
|
||||
|
|
@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||
expectedCircuitId
|
||||
);
|
||||
|
||||
return createCircuitDeviceRowInsertProjectCommand(
|
||||
toCircuitDeviceRowSnapshot(row)
|
||||
);
|
||||
return {
|
||||
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
|
||||
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
|
||||
};
|
||||
}
|
||||
|
||||
private assertCircuitInProject(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
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
|
||||
implements CircuitGroupSubtreeProjectCommandStore
|
||||
|
|
@ -42,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
|||
} else {
|
||||
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
|
||||
}
|
||||
return executeProjectCommandTransaction(
|
||||
return executeProjectCommandTransactionWithAppliedForward<
|
||||
CircuitGroupSubtreeProjectCommand,
|
||||
CircuitGroupSubtreeProjectCommand,
|
||||
CircuitGroupSubtreeProjectCommand
|
||||
>(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
|
|
@ -53,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
|||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupSubtreeProjectCommand
|
||||
) {
|
||||
): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> {
|
||||
if (command.type === circuitGroupDeleteSubtreeCommandType) {
|
||||
const current = this.captureFrom(
|
||||
database,
|
||||
|
|
@ -65,6 +76,19 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
|||
"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
|
||||
.delete(circuitSections)
|
||||
.where(eq(circuitSections.id, current.group.id))
|
||||
|
|
@ -72,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
|||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit-group subtree could not be deleted.");
|
||||
}
|
||||
return createCircuitGroupRestoreSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
return {
|
||||
forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
|
||||
inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
|
||||
};
|
||||
}
|
||||
if (command.type === circuitGroupRestoreSubtreeCommandType) {
|
||||
this.restore(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
const externalObjects = command.payload.externalObjects ?? [];
|
||||
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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 type { AppDatabase } from "../database-context.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
|
|
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
|
|||
.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) {
|
||||
const db = this.database;
|
||||
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {
|
||||
|
|
|
|||
|
|
@ -25,9 +25,17 @@ import {
|
|||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} 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 { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
captureExternalObjectDetachTransitions,
|
||||
invertExternalObjectLinkTransitions,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
type ExternalObjectLinkTransition,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class CircuitStructureProjectCommandRepository
|
||||
implements CircuitStructureProjectCommandStore
|
||||
|
|
@ -35,11 +43,15 @@ export class CircuitStructureProjectCommandRepository
|
|||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
return executeProjectCommandTransactionWithAppliedForward<
|
||||
CircuitStructureProjectCommand,
|
||||
CircuitStructureProjectCommand,
|
||||
CircuitStructureProjectCommand
|
||||
>(
|
||||
this.database,
|
||||
input,
|
||||
(tx) =>
|
||||
this.applyCommand(
|
||||
this.applyWithForward(
|
||||
tx,
|
||||
input.projectId,
|
||||
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,
|
||||
projectId: string,
|
||||
source: ExecuteCircuitStructureCommandInput["source"],
|
||||
command: 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) {
|
||||
assertCircuitInsertProjectCommand(command);
|
||||
return this.insert(
|
||||
database,
|
||||
projectId,
|
||||
source,
|
||||
command.payload.circuit
|
||||
command.payload.circuit,
|
||||
command.payload.externalObjects ?? []
|
||||
);
|
||||
}
|
||||
if (command.type === circuitDeleteCommandType) {
|
||||
|
|
@ -69,7 +95,8 @@ export class CircuitStructureProjectCommandRepository
|
|||
database,
|
||||
projectId,
|
||||
command.payload.circuitId,
|
||||
command.payload.expectedCircuitListId
|
||||
command.payload.expectedCircuitListId,
|
||||
command.payload.externalObjects
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit structure command.");
|
||||
|
|
@ -79,7 +106,8 @@ export class CircuitStructureProjectCommandRepository
|
|||
database: AppDatabase,
|
||||
projectId: string,
|
||||
source: ExecuteCircuitStructureCommandInput["source"],
|
||||
snapshot: CircuitSnapshot
|
||||
snapshot: CircuitSnapshot,
|
||||
externalObjects: ExternalObjectLinkTransition[]
|
||||
) {
|
||||
this.assertCircuitLocation(database, projectId, snapshot);
|
||||
if (source === "user") {
|
||||
|
|
@ -167,17 +195,23 @@ export class CircuitStructureProjectCommandRepository
|
|||
.run();
|
||||
}
|
||||
|
||||
return createCircuitDeleteProjectCommand(
|
||||
snapshot.id,
|
||||
snapshot.circuitListId
|
||||
);
|
||||
if (externalObjects.length > 0) {
|
||||
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
|
||||
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(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string,
|
||||
expectedCircuitListId: string
|
||||
expectedCircuitListId: string,
|
||||
suppliedExternalObjects?: ExternalObjectLinkTransition[]
|
||||
) {
|
||||
const circuit = database
|
||||
.select()
|
||||
|
|
@ -215,7 +249,12 @@ export class CircuitStructureProjectCommandRepository
|
|||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||
.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,
|
||||
circuitListId: circuit.circuitListId,
|
||||
sectionId: circuit.sectionId,
|
||||
|
|
@ -234,7 +273,9 @@ export class CircuitStructureProjectCommandRepository
|
|||
remark: circuit.remark,
|
||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||
protectionDevice: protectionDevice ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
|
||||
|
||||
const result = database
|
||||
.delete(circuits)
|
||||
|
|
@ -248,7 +289,10 @@ export class CircuitStructureProjectCommandRepository
|
|||
if (result.changes !== 1) {
|
||||
throw new Error("Circuit could not be deleted.");
|
||||
}
|
||||
return inverse;
|
||||
return {
|
||||
forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
|
||||
inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
|
||||
};
|
||||
}
|
||||
|
||||
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 { circuits } from "../schema/circuits.js";
|
||||
|
||||
|
|
@ -14,6 +14,15 @@ export class CircuitRepository {
|
|||
.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) {
|
||||
const db = this.database;
|
||||
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[]) {
|
||||
if (componentIds.length === 0) {
|
||||
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 { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
|
|
@ -17,6 +18,20 @@ import {
|
|||
type DistributionBoardUpdateProjectCommand,
|
||||
type DistributionBoardUpdateValues,
|
||||
} 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 {
|
||||
DistributionBoardStructureProjectCommandStore,
|
||||
ExecuteDistributionBoardStructureCommandInput,
|
||||
|
|
@ -30,6 +45,9 @@ import { distributionBoards } from "../schema/distribution-boards.js";
|
|||
import { floors } from "../schema/floors.js";
|
||||
import { projects } from "../schema/projects.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
|
||||
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(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
|
|
@ -248,6 +290,9 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||
);
|
||||
}
|
||||
|
||||
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
|
||||
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
|
||||
|
||||
const deleted = database
|
||||
.delete(distributionBoards)
|
||||
.where(
|
||||
|
|
@ -325,6 +370,16 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||
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(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
|
|
@ -333,7 +388,8 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||
) as DistributionBoardUpdatePatch;
|
||||
const inverse = createDistributionBoardUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
inversePatch,
|
||||
input.command.schemaVersion
|
||||
);
|
||||
const updated = database
|
||||
.update(distributionBoards)
|
||||
|
|
@ -352,6 +408,135 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||
}
|
||||
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(
|
||||
|
|
@ -388,10 +573,24 @@ function structureMatches(
|
|||
) {
|
||||
const {
|
||||
simultaneityFactor,
|
||||
boardNumber,
|
||||
location,
|
||||
fedFrom,
|
||||
remark,
|
||||
feedingDistributionBoardId,
|
||||
feedingCircuitId,
|
||||
...actualDistributionBoard
|
||||
} = actual.board;
|
||||
if (
|
||||
simultaneityFactor !== 1 ||
|
||||
[
|
||||
boardNumber,
|
||||
location,
|
||||
fedFrom,
|
||||
remark,
|
||||
feedingDistributionBoardId,
|
||||
feedingCircuitId,
|
||||
].some((value) => value !== null) ||
|
||||
!sameRecord(
|
||||
expected.distributionBoard,
|
||||
actualDistributionBoard
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardSubtreeSnapshot,
|
||||
normalizeDistributionBoardSubtreeSnapshot,
|
||||
type DistributionBoardSubtreeSnapshot,
|
||||
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
|
||||
import type {
|
||||
|
|
@ -31,6 +32,8 @@ import { projects } from "../schema/projects.js";
|
|||
import { rooms } from "../schema/rooms.js";
|
||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
||||
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
|
||||
|
||||
export class DistributionBoardSubtreeProjectCommandRepository
|
||||
implements DistributionBoardSubtreeProjectCommandStore
|
||||
|
|
@ -63,23 +66,28 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
|||
projectId: string,
|
||||
command: DistributionBoardSubtreeProjectCommand
|
||||
) {
|
||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardDeleteSubtreeProjectCommand(
|
||||
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
|
||||
command.payload.snapshot
|
||||
);
|
||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||
this.insert(database, projectId, snapshot);
|
||||
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
|
||||
}
|
||||
if (command.type === distributionBoardDeleteSubtreeCommandType) {
|
||||
const current = this.captureFrom(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.snapshot.distributionBoard.id
|
||||
snapshot.distributionBoard.id
|
||||
);
|
||||
if (!sameSnapshot(current, command.payload.snapshot)) {
|
||||
if (!sameSnapshot(current, snapshot)) {
|
||||
throw new Error(
|
||||
"Distribution-board subtree changed before deletion."
|
||||
);
|
||||
}
|
||||
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
|
||||
current.distributionBoard.id,
|
||||
]);
|
||||
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
|
||||
const deleted = database
|
||||
.delete(distributionBoards)
|
||||
.where(
|
||||
|
|
@ -97,9 +105,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
|||
"Distribution-board subtree could not be deleted."
|
||||
);
|
||||
}
|
||||
return createDistributionBoardInsertSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
|
||||
}
|
||||
throw new Error("Unsupported distribution-board subtree command.");
|
||||
}
|
||||
|
|
@ -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(
|
||||
snapshot.circuits.flatMap((circuit) =>
|
||||
circuit.deviceRows.flatMap((row) =>
|
||||
|
|
|
|||
86
src/db/repositories/document-branding.repository.ts
Normal file
86
src/db/repositories/document-branding.repository.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import {
|
||||
documentBrandingAssets,
|
||||
type DocumentBrandingAsset,
|
||||
} from "../schema/document-branding-assets.js";
|
||||
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
|
||||
|
||||
// Plain repository without revision semantics: branding is global office
|
||||
// configuration, not a project mutation.
|
||||
export class DocumentBrandingRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
get(key: string): DocumentBrandingAsset | undefined {
|
||||
return this.db
|
||||
.select()
|
||||
.from(documentBrandingAssets)
|
||||
.where(eq(documentBrandingAssets.key, key))
|
||||
.get();
|
||||
}
|
||||
|
||||
upsert(asset: {
|
||||
key: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
data: Buffer;
|
||||
}): DocumentBrandingAsset {
|
||||
const updatedAt = new Date();
|
||||
return this.db
|
||||
.insert(documentBrandingAssets)
|
||||
.values({ ...asset, updatedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: documentBrandingAssets.key,
|
||||
set: {
|
||||
mimeType: asset.mimeType,
|
||||
fileName: asset.fileName,
|
||||
data: asset.data,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
return (
|
||||
this.db
|
||||
.delete(documentBrandingAssets)
|
||||
.where(eq(documentBrandingAssets.key, key))
|
||||
.run().changes > 0
|
||||
);
|
||||
}
|
||||
|
||||
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
|
||||
const rows = this.db
|
||||
.select()
|
||||
.from(documentBrandingSettings)
|
||||
.where(inArray(documentBrandingSettings.key, [...keys]))
|
||||
.all();
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
|
||||
) as Record<TKey, string | null>;
|
||||
}
|
||||
|
||||
/** Stores the given values atomically; `null` removes a setting. */
|
||||
setSettings(values: Record<string, string | null>) {
|
||||
const updatedAt = new Date();
|
||||
this.db.transaction((tx) => {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) {
|
||||
tx.delete(documentBrandingSettings)
|
||||
.where(eq(documentBrandingSettings.key, key))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(documentBrandingSettings)
|
||||
.values({ key, value, updatedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: documentBrandingSettings.key,
|
||||
set: { value, updatedAt },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
assertExternalInitialImportProjectCommand,
|
||||
createExternalInitialImportProjectCommand,
|
||||
isEmptyExternalModelState,
|
||||
} 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 { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
||||
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
|
||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
createExternalRoomKey,
|
||||
indexExternalObjectsByIfcGuid,
|
||||
projectInitialExternalObject,
|
||||
} from "../../external-model/domain/external-model-matching.js";
|
||||
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||
import { 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 { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||
import { insertManyInBatches } from "./batched-insert.persistence.js";
|
||||
|
||||
export class ExternalInitialImportProjectCommandRepository
|
||||
implements ExternalInitialImportProjectCommandStore
|
||||
{
|
||||
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalInitialImportProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
const currentResult = new ExternalModelStateRepository(tx)
|
||||
.getByProject(input.projectId);
|
||||
if (!currentResult.projectExists) throw new Error("Project not found.");
|
||||
const expected = input.command.payload.expected;
|
||||
if (!sameState(currentResult.state, expected)) {
|
||||
throw new Error("External model state changed before initial import.");
|
||||
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
||||
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
|
||||
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||
const payload = input.command.payload;
|
||||
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
|
||||
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
||||
}
|
||||
const target = input.command.payload.target;
|
||||
if (!isEmptyExternalModelState(target)) {
|
||||
validatePopulatedTarget(tx, input, target);
|
||||
}
|
||||
|
||||
replaceExternalState(tx, input.projectId, target);
|
||||
const persisted = new ExternalModelStateRepository(tx)
|
||||
.getByProject(input.projectId).state;
|
||||
if (!sameState(persisted, target)) {
|
||||
throw new Error("Persisted external initial import differs from its target.");
|
||||
}
|
||||
return createExternalInitialImportProjectCommand(target, currentResult.state);
|
||||
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
|
||||
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
|
||||
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;
|
||||
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
|
||||
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validatePopulatedTarget(
|
||||
database: AppDatabase,
|
||||
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
|
||||
target: ExternalModelStateSnapshot
|
||||
) {
|
||||
const source = target.source!;
|
||||
const batch = target.importBatches[0];
|
||||
if (source.projectId !== input.projectId) {
|
||||
throw new Error("External initial import belongs to a different project.");
|
||||
}
|
||||
if (
|
||||
input.source === "user" &&
|
||||
batch.appliedProjectRevision !== input.expectedRevision + 1
|
||||
) {
|
||||
throw new Error("External initial import revision is invalid.");
|
||||
}
|
||||
const configuration = database
|
||||
.select()
|
||||
.from(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
||||
.get();
|
||||
if (
|
||||
!configuration ||
|
||||
configuration.configurationVersion !== batch.configurationVersion ||
|
||||
canonicalJson(configuration.configuration) !==
|
||||
canonicalJson(batch.configurationSnapshot)
|
||||
) {
|
||||
throw new Error("External initial import configuration changed before apply.");
|
||||
}
|
||||
|
||||
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
|
||||
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
if (sha256 !== batch.sha256) {
|
||||
throw new Error("External initial import checksum is invalid.");
|
||||
}
|
||||
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
|
||||
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
|
||||
throw new Error("External initial import document differs from the original bytes.");
|
||||
}
|
||||
const preview = createExternalCsvPreview({
|
||||
fileName: batch.fileName,
|
||||
bytes,
|
||||
configuration: batch.configurationSnapshot,
|
||||
});
|
||||
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
|
||||
if (targetByIfcGuid.size !== preview.objects.length) {
|
||||
throw new Error("External initial import must contain every CSV object exactly once.");
|
||||
}
|
||||
const mappingsById = new Map(
|
||||
target.roomMappings.map((mapping) => [mapping.id, mapping])
|
||||
);
|
||||
const expectedRoomKeys = new Set<string>();
|
||||
for (const candidate of preview.objects) {
|
||||
const object = targetByIfcGuid.get(candidate.ifcGuid);
|
||||
if (!object) {
|
||||
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
|
||||
}
|
||||
const projected = projectInitialExternalObject(
|
||||
candidate,
|
||||
batch.configurationSnapshot
|
||||
);
|
||||
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
|
||||
throw new Error("External initial import source values differ from the CSV.");
|
||||
}
|
||||
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
|
||||
if (
|
||||
!object.overriddenFields.includes(field) &&
|
||||
canonicalJson(object.planningValues[field]) !==
|
||||
canonicalJson(projected.planningValues[field])
|
||||
) {
|
||||
throw new Error(`External initial planning field ${field} changed without an override.`);
|
||||
}
|
||||
}
|
||||
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||
if (roomKey === null) {
|
||||
if (object.externalRoomMappingId !== null) {
|
||||
throw new Error("External object without a source room must not use a room mapping.");
|
||||
}
|
||||
} else {
|
||||
expectedRoomKeys.add(roomKey);
|
||||
const mapping = object.externalRoomMappingId === null
|
||||
? null
|
||||
: mappingsById.get(object.externalRoomMappingId);
|
||||
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
|
||||
throw new Error("External object source room mapping is incomplete.");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
target.roomMappings.length !== expectedRoomKeys.size ||
|
||||
target.roomMappings.some(
|
||||
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
|
||||
)
|
||||
) {
|
||||
throw new Error("External initial import room mappings do not match CSV rooms.");
|
||||
}
|
||||
validateInternalLinks(database, input.projectId, target);
|
||||
}
|
||||
|
||||
function validateInternalLinks(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
target: ExternalModelStateSnapshot
|
||||
) {
|
||||
const roomIds = new Set(
|
||||
database.select({ id: rooms.id }).from(rooms)
|
||||
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
|
||||
);
|
||||
const boardIds = new Set(
|
||||
database.select({ id: distributionBoards.id }).from(distributionBoards)
|
||||
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
|
||||
);
|
||||
const deviceIds = new Set(
|
||||
database.select({ id: projectDevices.id }).from(projectDevices)
|
||||
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
|
||||
);
|
||||
for (const mapping of target.roomMappings) {
|
||||
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
|
||||
throw new Error("External room mapping references another project.");
|
||||
}
|
||||
if (
|
||||
mapping.defaultDistributionBoardId !== null &&
|
||||
!boardIds.has(mapping.defaultDistributionBoardId)
|
||||
) {
|
||||
throw new Error("External room mapping distribution board belongs to another project.");
|
||||
}
|
||||
}
|
||||
for (const object of target.objects) {
|
||||
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
|
||||
throw new Error("External object distribution board belongs to another project.");
|
||||
}
|
||||
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
|
||||
throw new Error("External object project device belongs to another project.");
|
||||
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
|
||||
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
|
||||
const batch = state.importBatches[0];
|
||||
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 bytes = decodeCanonicalBase64(batch.originalContentBase64!);
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports 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.configuration.deviceTypes.map((type) => type.id));
|
||||
for (const mapping of state.roomMappings) {
|
||||
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.");
|
||||
}
|
||||
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(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
target: ExternalModelStateSnapshot
|
||||
) {
|
||||
database.delete(externalModelSources)
|
||||
.where(eq(externalModelSources.projectId, projectId)).run();
|
||||
if (target.source === null) return;
|
||||
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
|
||||
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
|
||||
if (target) 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();
|
||||
database.insert(externalImportBatches).values(
|
||||
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
|
||||
...batch,
|
||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||
}))
|
||||
).run();
|
||||
if (target.roomMappings.length) {
|
||||
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||
}
|
||||
if (target.objects.length) {
|
||||
database.insert(externalModelObjects).values(target.objects).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 decodeCanonicalBase64(value: string) {
|
||||
const bytes = Buffer.from(value, "base64");
|
||||
if (bytes.toString("base64") !== value) {
|
||||
throw new Error("External initial import bytes are not canonical base64.");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
|
||||
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
|
||||
}
|
||||
|
||||
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
|
||||
const byId = <T extends { id: string }>(entries: readonly T[]) =>
|
||||
[...entries].sort((left, right) => left.id.localeCompare(right.id));
|
||||
return {
|
||||
source: state.source,
|
||||
importBatches: byId(state.importBatches),
|
||||
roomMappings: byId(state.roomMappings),
|
||||
objects: byId(state.objects),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map(
|
||||
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
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; }
|
||||
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); }
|
||||
|
|
|
|||
|
|
@ -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 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 { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export class ExternalCsvConfigurationRepository
|
||||
implements ExternalCsvConfigurationReader
|
||||
{
|
||||
export class ExternalModelConfigurationRepository
|
||||
implements ExternalModelConfigurationReader {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
getByProject(projectId: string) {
|
||||
|
|
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
|
|||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return { projectExists: false, configuration: null };
|
||||
}
|
||||
if (!project) return { projectExists: false, configuration: null };
|
||||
const configuration = this.database
|
||||
.select()
|
||||
.from(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||
.from(externalModelConfigurations)
|
||||
.where(eq(externalModelConfigurations.projectId, projectId))
|
||||
.get() ?? null;
|
||||
return { projectExists: true, configuration };
|
||||
}
|
||||
|
|
@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
|||
.select()
|
||||
.from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.sourceId, source.id))
|
||||
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
|
||||
.orderBy(
|
||||
asc(externalModelObjects.worksharingId),
|
||||
asc(externalModelObjects.id)
|
||||
)
|
||||
.all();
|
||||
|
||||
return {
|
||||
|
|
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
|||
source,
|
||||
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
||||
...batch,
|
||||
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
|
||||
originalContentBase64:
|
||||
originalBytes === null
|
||||
? null
|
||||
: Buffer.from(originalBytes).toString("base64"),
|
||||
})),
|
||||
roomMappings,
|
||||
objects,
|
||||
|
|
|
|||
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