Compare commits
No commits in common. "main" and "feature/multiplesections" have entirely different histories.
main
...
feature/mu
464 changed files with 20792 additions and 78361 deletions
|
|
@ -1,11 +1,8 @@
|
||||||
.git
|
.git
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
.next
|
.next
|
||||||
|
data
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
data
|
npm-debug.log*
|
||||||
*.db
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image.
|
|
||||||
.codex
|
|
||||||
.agents
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# 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,13 +1,6 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
|
||||||
/drizzle.config.js
|
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
/docs/spec/kbp-Stromkreis.xlsx
|
|
||||||
/data/revit-reference/*.xlsx
|
|
||||||
|
|
|
||||||
384
AGENTS.md
384
AGENTS.md
|
|
@ -33,22 +33,17 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
||||||
- General application repositories also require an explicit `AppDatabase`;
|
- General application repositories also require an explicit `AppDatabase`;
|
||||||
`src/server/composition/application-repositories.ts` owns their runtime
|
`src/server/composition/application-repositories.ts` owns their runtime
|
||||||
instances, and controllers never import the global SQLite client.
|
instances, and controllers never import the global SQLite client.
|
||||||
|
- The upgrade-only legacy migration service follows the same dependency rule;
|
||||||
|
its concrete repositories are instantiated only in
|
||||||
|
`scripts/db-migrate-legacy-consumers.ts`.
|
||||||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||||
repositories expose only active reads. Runtime writes belong in typed command
|
repositories expose only active reads. Runtime writes belong in typed command
|
||||||
repositories; direct integration fixtures belong under `tests/support`.
|
repositories; direct integration fixtures belong under `tests/support`.
|
||||||
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
|
|
||||||
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
|
|
||||||
middleware), `src/domain/services/user.service.ts`,
|
|
||||||
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
|
|
||||||
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
|
|
||||||
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
|
|
||||||
(`src/app/layout.tsx`). Every unsafe API request also requires the explicit
|
|
||||||
`X-Leistungsbilanz-Request: 1` application header, including over plain HTTP.
|
|
||||||
Account settings are available to every signed-in user; user administration
|
|
||||||
remains admin-only. See `docs/authentication.md` for the full picture.
|
|
||||||
|
|
||||||
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
The supported runtime model is Circuit-First. The former Consumer UI and API are
|
||||||
tables and upgrade tooling are removed; do not reintroduce them.
|
removed. Retained `consumers` rows, migration mappings and reports are upgrade-only
|
||||||
|
data accessed by `npm run db:migrate:legacy-consumers`; do not build application
|
||||||
|
features on them.
|
||||||
|
|
||||||
See `docs/current-architecture.md` for the complete module and request flow.
|
See `docs/current-architecture.md` for the complete module and request flow.
|
||||||
|
|
||||||
|
|
@ -62,8 +57,7 @@ See `docs/current-architecture.md` for the complete module and request flow.
|
||||||
- Device rows inside a circuit do not have their own equipment identifiers.
|
- Device rows inside a circuit do not have their own equipment identifiers.
|
||||||
- Protection and cable data belong to the circuit, not to individual devices.
|
- Protection and cable data belong to the circuit, not to individual devices.
|
||||||
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
|
||||||
- Circuit total power is the sum of all device rows in that circuit plus the
|
- Circuit total power is the sum of all device rows in that circuit.
|
||||||
read-only loads of logically linked downstream boards on that feeder circuit.
|
|
||||||
- Existing equipment identifiers must never be changed automatically.
|
- Existing equipment identifiers must never be changed automatically.
|
||||||
- Renumbering is always an explicit user action.
|
- Renumbering is always an explicit user action.
|
||||||
|
|
||||||
|
|
@ -175,53 +169,19 @@ Keyboard behavior:
|
||||||
- Escape cancels
|
- Escape cancels
|
||||||
- Tab / Shift+Tab navigates editable cells
|
- Tab / Shift+Tab navigates editable cells
|
||||||
- Arrow keys navigate cells when not editing
|
- 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.
|
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 / Styling Rule
|
||||||
|
|
||||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
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.
|
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.
|
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.
|
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
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||||
|
|
@ -290,104 +250,16 @@ Immutable revision metadata is available through the paginated
|
||||||
stored command payloads.
|
stored command payloads.
|
||||||
Named logical snapshots can be created and listed through project-scoped API
|
Named logical snapshots can be created and listed through project-scoped API
|
||||||
endpoints. Their schema-versioned payload contains the complete supported
|
endpoints. Their schema-versioned payload contains the complete supported
|
||||||
project runtime state and a SHA-256, excludes global data outside the project and does
|
project runtime state and a SHA-256, excludes global/upgrade-only data and does
|
||||||
not change the project revision or undo/redo stacks. Restoring a server-stored
|
not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
Snapshot schema version 7 also contains circuit-group identity,
|
||||||
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
distribution-board components and the separate circuit/component protection
|
||||||
versions containing legacy CSV configuration or external CSV state are rejected
|
records. Versions 1 through 6 remain readable. Portable duplicate imports
|
||||||
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
|
|
||||||
are additionally upgraded with an empty external CSV configuration. Supported
|
|
||||||
baseline version 1 snapshots are additionally upgraded
|
|
||||||
with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All
|
|
||||||
supported versions contain circuit-group identity, distribution-board components and the
|
|
||||||
separate circuit/component protection records. Portable duplicate imports
|
|
||||||
remap component ids and protection-owner references together with the existing
|
remap component ids and protection-owner references together with the existing
|
||||||
project graph.
|
project graph.
|
||||||
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.
|
|
||||||
`external-object.update-row-assignment` atomically assigns external objects to
|
|
||||||
existing rows, moves them between existing rows or detaches them. It preserves
|
|
||||||
the manual quantity, validates the materialized total against every target
|
|
||||||
link, rejects project/board/category/room/selection-marker mixing and requires
|
|
||||||
explicitly listed confirmations for other planning-value conflicts. Its exact
|
|
||||||
inverse supports persistent Undo/Redo, and detach never deletes the row.
|
|
||||||
`external-object.assign-to-new-row` atomically inserts unassigned external
|
|
||||||
objects as one external-only row (`manualQuantity = 0`) into an existing
|
|
||||||
circuit. Its history-only inverse removes the row only while its complete
|
|
||||||
snapshot and exact object-link set remain unchanged. Ordinary detach continues
|
|
||||||
to preserve the row.
|
|
||||||
`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit
|
|
||||||
with stable BMK/UUIDs, derived voltage, one external-only row, required default
|
|
||||||
protection and its external object links. Its history-only inverse requires the
|
|
||||||
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
|
||||||
the BMK. All three external assignment stores share
|
|
||||||
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
|
||||||
The current read-only
|
|
||||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
|
||||||
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
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
project. Named snapshots are never removed by this retention policy.
|
project. Named snapshots are never removed by this retention policy.
|
||||||
|
|
@ -417,29 +289,18 @@ global-to-project copy API/UI paths use these persistent commands and track the
|
||||||
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||||
paths do the same; their undo action uses the project-wide history endpoint.
|
paths do the same; their undo action uses the project-wide history endpoint.
|
||||||
Project settings use the persistent `project.update-settings` command. Project
|
Project settings use the persistent `project.update-settings` command. Project
|
||||||
metadata, the public-building classification, both voltage defaults and the
|
metadata, both voltage defaults and the enabled distribution-board supply types
|
||||||
enabled distribution-board supply types change in one revision and Undo/Redo
|
change in one revision and Undo/Redo restores them together; the project PUT
|
||||||
restores them together; the project PUT route requires `expectedRevision`.
|
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||||
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
|
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||||
cannot be disabled.
|
cannot be disabled.
|
||||||
Distribution-board setup uses `distribution-board.insert` schema version 1
|
Distribution-board setup uses `distribution-board.insert` schema version 3
|
||||||
with a complete stable snapshot of the board, circuit list, three default
|
with a complete stable snapshot of the board, circuit list, three default
|
||||||
groups, main switch `-Q0` and surge protective device `-FA`. Its
|
groups, main switch `-Q0` and surge protective device `-FA`. Its
|
||||||
inverse removes only the same unchanged and still-empty structure; the POST
|
inverse removes only the same unchanged and still-empty structure; the POST
|
||||||
route requires `expectedRevision` and returns the updated history state.
|
route requires `expectedRevision` and returns the updated history state.
|
||||||
Complete populated distribution-board copying and deletion use
|
Stored schema-version 1 and 2 setup commands remain executable with their four
|
||||||
`distribution-board.insert-subtree` and `distribution-board.delete-subtree`.
|
legacy sections and without invented components.
|
||||||
Their exact snapshots include the board, circuit list, groups, circuits,
|
|
||||||
device rows, components and all one-to-one protection data. Copy remaps all
|
|
||||||
owning ids but preserves project-device and room links. Both operations are
|
|
||||||
atomic persistent commands with restart-safe Undo/Redo; deletion requires an
|
|
||||||
explicit UI warning.
|
|
||||||
Mutable group-protection and auxiliary distribution-board components use
|
Mutable group-protection and auxiliary distribution-board components use
|
||||||
`distribution-board-component.insert` and
|
`distribution-board-component.insert` and
|
||||||
`distribution-board-component.delete`; edits and footer reordering use
|
`distribution-board-component.delete`; edits and footer reordering use
|
||||||
|
|
@ -471,58 +332,18 @@ snapshot contains the group, optional component protections, complete circuits,
|
||||||
circuit protections and all device-row link/override metadata. Delete
|
circuit protections and all device-row link/override metadata. Delete
|
||||||
re-captures and compares the subtree before cascading; restore preserves every
|
re-captures and compares the subtree before cascading; restore preserves every
|
||||||
UUID in foreign-key-safe order.
|
UUID in foreign-key-safe order.
|
||||||
The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
|
The circuit-tree read model exposes fixed `headerComponents`, group-owned
|
||||||
`components`, `footerComponents`, optional group category/number and optional
|
`components`, `footerComponents`, optional group category/number and optional
|
||||||
one-to-one protection DTOs for circuits and group components. These are
|
one-to-one protection DTOs for circuits and group components. These are
|
||||||
the only supported circuit-protection source; no flat compatibility fields remain.
|
additive to the retained flat circuit protection fields during the transition.
|
||||||
It resolves logical board feeds once for the complete project and adds each
|
|
||||||
downstream board's demand after its own board simultaneity factor to the chosen
|
|
||||||
feeding circuit as a read-only virtual row. Recursive chains and several boards
|
|
||||||
on one circuit are supported. A board-only link affects hierarchy but adds no
|
|
||||||
load until a feeding circuit is selected.
|
|
||||||
The distribution-board dialog can create one empty feeder circuit and link it
|
|
||||||
in the same `distribution-board.create-feeder-circuit` project command. Its
|
|
||||||
history inverse restores the complete previous board update before removing
|
|
||||||
only the unchanged generated circuit; Redo reuses its UUID and BMK. No
|
|
||||||
CircuitDeviceRow is created. The three-phase group is suggested, the optional
|
|
||||||
circuit display name stays empty by default, and protection remains open for
|
|
||||||
planner sizing. A circuit with exactly one downstream board and no device rows
|
|
||||||
is rendered as one compact row using the live board number/name; several
|
|
||||||
downstream boards or additional devices retain summary and child rows. Editing
|
|
||||||
the name or remark of an empty reserve circuit changes the circuit itself and
|
|
||||||
does not implicitly create a device row.
|
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
persistent undo step.
|
||||||
The same command uses schema version 2 for the logical
|
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
||||||
`feedingDistributionBoardId` and optional matching `feedingCircuitId` together
|
complete stable snapshots. Their inverses remove only unchanged records and
|
||||||
with the existing fields. Version-1 history commands remain executable and do
|
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||||
not change logical feed references. The command rejects self-links, cycles and
|
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||||
circuits from another board. The
|
the updated history state.
|
||||||
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. 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:
|
Required operations:
|
||||||
|
|
||||||
|
|
@ -538,117 +359,9 @@ Required operations:
|
||||||
- edit equipment identifier
|
- edit equipment identifier
|
||||||
- synchronization changes
|
- synchronization changes
|
||||||
|
|
||||||
|
|
||||||
## Print View (Seitenansicht)
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Revit / Linear Excel External Model Work
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
## Future Sizing
|
||||||
|
|
||||||
Do not implement full automatic cable/protection sizing of all circuits
|
Do not implement full cable/protection sizing unless explicitly requested.
|
||||||
unless explicitly requested. The cable sizing module above only suggests
|
|
||||||
values for one circuit on demand.
|
|
||||||
|
|
||||||
Keep the structure ready for it.
|
Keep the structure ready for it.
|
||||||
|
|
||||||
|
|
@ -666,44 +379,10 @@ Future sizing will need:
|
||||||
|
|
||||||
Users must be able to override sizing suggestions.
|
Users must be able to override sizing suggestions.
|
||||||
|
|
||||||
## Estimate Balance Module (Grobe Leistungsbilanz)
|
|
||||||
|
|
||||||
`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module
|
|
||||||
for the early-phase power balance by DIN 276 cost group (see
|
|
||||||
`docs/estimate-balance.md`). Rules:
|
|
||||||
|
|
||||||
- It has its own tables (`estimate_*`) and never writes circuit, board or
|
|
||||||
project-device data; it only reads device rows for the comparison.
|
|
||||||
- Writes use the project command mechanism (`estimate-balance.insert/.update/
|
|
||||||
.delete` with complete documents), so revision checks and Undo/Redo apply.
|
|
||||||
- The calculation and comparison live in the import-free
|
|
||||||
`src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI;
|
|
||||||
displayed values use `roundEstimateValue`.
|
|
||||||
- It is deliberately not part of named/automatic project snapshots or snapshot
|
|
||||||
restore; those operations leave estimate balances unchanged. Portable project
|
|
||||||
transfer format version 2 includes every estimate balance and remaps balance,
|
|
||||||
group and row ids when duplicating a project. Version 1 transfers remain
|
|
||||||
importable and contain no estimate balances.
|
|
||||||
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
|
|
||||||
`src/frontend/estimate-balance/`). The editor autosaves the whole document
|
|
||||||
debounced (one Undo step per save), keeps invalid raw input local, and on a
|
|
||||||
409 reloads only if the balance itself changed; otherwise it re-saves with
|
|
||||||
the new revision. `request()` throws `ApiRequestError` (status, code).
|
|
||||||
- A single estimate row can be copied explicitly through the existing
|
|
||||||
ProjectDevice dialog. The dialog is only a prefilled proposal; creation
|
|
||||||
happens after confirmation through `project-device.insert`, with the current
|
|
||||||
project revision and normal Undo/Redo. The copy stores no link back to the
|
|
||||||
estimate row, never places a circuit row automatically, and does not change
|
|
||||||
estimate, snapshot or transfer schemas. Area rows require manual quantity
|
|
||||||
and power because W/m² remains outside the ProjectDevice model.
|
|
||||||
|
|
||||||
## Persistence and Migration Rules
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
- SQLite is the currently supported database.
|
- SQLite is the currently supported database.
|
||||||
- Migration `0000` is the clean release baseline for empty databases.
|
- Never edit an already applied migration.
|
||||||
- Pre-baseline development databases, snapshots and stored commands are not
|
|
||||||
supported.
|
|
||||||
- After the first release, never edit an already applied migration.
|
|
||||||
- Back up an existing database before applying a new migration.
|
- Back up an existing database before applying a new migration.
|
||||||
- Inspect generated SQL; it must contain only the intended schema change.
|
- Inspect generated SQL; it must contain only the intended schema change.
|
||||||
- Keep database backups separate from logical project snapshots.
|
- Keep database backups separate from logical project snapshots.
|
||||||
|
|
@ -714,17 +393,10 @@ for the early-phase power balance by DIN 276 cost group (see
|
||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- 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.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
54
Dockerfile
54
Dockerfile
|
|
@ -1,56 +1,12 @@
|
||||||
# syntax=docker/dockerfile:1
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
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 . .
|
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
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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
|
EXPOSE 3000 3001
|
||||||
CMD ["node", "dist/server/index.js"]
|
|
||||||
|
|
|
||||||
93
README.md
93
README.md
|
|
@ -4,30 +4,15 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||||
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
implementiert.
|
||||||
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
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
- Projekte und Verteilungen verwalten; Gebäude, Etagen und Räume auf einer
|
- Projekte, Verteilungen, Etagen und Räume verwalten
|
||||||
eigenen Seite pflegen, Räume optional mit Fläche und Summen je Etage und
|
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
|
||||||
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
|
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
||||||
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
||||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||||
|
|
@ -36,14 +21,6 @@ Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüf
|
||||||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
- 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
|
## Technik
|
||||||
|
|
||||||
|
|
@ -63,72 +40,39 @@ Voraussetzungen:
|
||||||
```powershell
|
```powershell
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
Set-Location leistungsbilanz-ts
|
Set-Location leistungsbilanz-ts
|
||||||
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
|
||||||
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach:
|
Danach:
|
||||||
|
|
||||||
- Frontend: <http://localhost:3001>
|
- Frontend: <http://localhost:3001>
|
||||||
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
- API-Healthcheck: <http://localhost:3000/health>
|
||||||
|
|
||||||
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
|
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
||||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||||
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
`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
|
```powershell
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs --follow
|
docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
||||||
ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne
|
Produktionsdeployment. Details stehen in
|
||||||
root-Rechte im Container. Details stehen in
|
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
[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
|
## Direkte lokale Entwicklung
|
||||||
|
|
||||||
Voraussetzungen:
|
Voraussetzungen:
|
||||||
|
|
||||||
- Node.js 24
|
- Node.js 22
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm ci
|
npm ci
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:verify:circuit-schema
|
npm run db:verify:circuit-schema
|
||||||
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
|
|
||||||
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
|
|
||||||
npm run dev:api
|
npm run dev:api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -146,7 +90,6 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
|
||||||
npm test
|
npm test
|
||||||
npm run build:api
|
npm run build:api
|
||||||
npm run build:web
|
npm run build:web
|
||||||
npm run typecheck:scripts
|
|
||||||
npx tsc --noEmit -p tsconfig.next.json
|
npx tsc --noEmit -p tsconfig.next.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -160,10 +103,11 @@ npm run db:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
`db:backup` verwendet die SQLite-Online-Backup-API und prüft das Ergebnis auf
|
`db:backup` verwendet die SQLite-Online-Backup-API und prüft das Ergebnis auf
|
||||||
Integrität und Fremdschlüsselverletzungen. Migration `0000` ist die
|
Integrität und Fremdschlüsselverletzungen. Vor jeder Migration einer bestehenden
|
||||||
Release-Baseline für eine leere Datenbank; nachfolgende nummerierte Migrationen
|
Datenbank ist ein Backup erforderlich.
|
||||||
entwickeln diese Baseline additiv weiter. Datenbanken aus Entwicklungsständen
|
|
||||||
vor der Baseline werden nicht unterstützt.
|
`db:migrate:legacy-consumers` und `db:backfill:sections` sind ausschließlich
|
||||||
|
Upgrade-Werkzeuge für ältere Datenbanken. Neue Installationen benötigen sie nicht.
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
|
|
@ -171,15 +115,8 @@ vor der Baseline werden nicht unterstützt.
|
||||||
- [Aktuelle Architektur](docs/current-architecture.md)
|
- [Aktuelle Architektur](docs/current-architecture.md)
|
||||||
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
||||||
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
|
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
|
||||||
- [Seitenansicht und PDF-Export](docs/print-view.md)
|
|
||||||
- [Gebäudestruktur und Raumflächen](docs/building-structure.md)
|
|
||||||
- [Grobe Leistungsbilanz](docs/estimate-balance.md)
|
|
||||||
- [Leitungsberechnung](docs/cable-sizing-module.md)
|
|
||||||
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
|
|
||||||
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
||||||
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
||||||
- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md)
|
|
||||||
- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md)
|
|
||||||
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
||||||
|
|
||||||
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
||||||
|
|
|
||||||
105
compose.dev.yaml
105
compose.dev.yaml
|
|
@ -1,105 +0,0 @@
|
||||||
# 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
|
|
||||||
104
compose.yaml
104
compose.yaml
|
|
@ -1,112 +1,68 @@
|
||||||
# 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
|
name: leistungsbilanz
|
||||||
|
|
||||||
x-build: &build
|
|
||||||
context: .
|
|
||||||
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"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
<<: *service-defaults
|
build:
|
||||||
build: *build
|
context: .
|
||||||
# "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und
|
|
||||||
# SIGTERM erreicht node nie.
|
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js
|
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
|
init: true
|
||||||
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
|
ports:
|
||||||
# .env - siehe docs/authentication.md.
|
- "3000:3000"
|
||||||
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:
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./scripts:/app/scripts
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
<<: *service-defaults
|
build:
|
||||||
build: *build
|
context: .
|
||||||
command:
|
command:
|
||||||
- node_modules/.bin/next
|
- npm
|
||||||
- start
|
- run
|
||||||
- -p
|
- dev:web
|
||||||
- "3001"
|
- --
|
||||||
- --hostname
|
- --hostname
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
init: true
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
mem_limit: 1g
|
volumes:
|
||||||
memswap_limit: 1g
|
- ./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:
|
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:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
|
||||||
BIN
data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db
Normal file
BIN
data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db
Normal file
Binary file not shown.
|
|
@ -12,39 +12,22 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
fachliches Modell und Grid-Aufteilung
|
fachliches Modell und Grid-Aufteilung
|
||||||
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
|
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
|
||||||
Drag-and-drop, Sortierung, Filterung und Undo/Redo
|
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
|
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||||
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
|
||||||
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
|
||||||
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
|
|
||||||
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
|
|
||||||
|
|
||||||
## Entwicklung und Betrieb
|
## Entwicklung und Betrieb
|
||||||
|
|
||||||
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
||||||
- [Deployment und Betrieb](deployment.md)
|
- [Deployment und Betrieb](deployment.md)
|
||||||
|
- [Lokale Circuit-First-Datenbankmigration](local-db-circuit-first-migration.md)
|
||||||
## Revit / Linear XLSX
|
- [Retained Legacy-Datenmigration](circuit-list-editor-migration.md) –
|
||||||
|
ausschließlich für Upgrades alter Datenbanken
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.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
|
## Zukunftsarchitektur
|
||||||
|
|
||||||
|
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||||
|
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||||
|
|
@ -66,6 +49,3 @@ Limitierungen und Roadmap.
|
||||||
|
|
||||||
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
|
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
|
||||||
Arbeitsstand, keine aktuelle Implementierungsanweisung
|
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`
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,99 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
# 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,43 +59,15 @@ this value for optimistic concurrency checks.
|
||||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||||
the generic command endpoint
|
the generic command endpoint
|
||||||
|
|
||||||
Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable
|
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||||
Projektexport unter `GET /projects/:projectId/export` verwendet dagegen
|
`circuit.delete`, `circuit-device-row.update`,
|
||||||
Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege
|
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||||
`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre
|
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||||
Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
`circuit.reorder-section`, `circuit.reorder-sections`,
|
||||||
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
`circuit.renumber-section` and
|
||||||
importierbar.
|
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||||
|
`project-device.sync-rows` as well as `project.update-settings`, all with
|
||||||
Das aktuelle Projekt-Snapshot-Schema 10 enthält Linear-XLSX-Zustand und die in
|
schema version `1`. Other command types
|
||||||
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`,
|
|
||||||
`circuit-device-row.delete`, `circuit-device-row.move`,
|
|
||||||
`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`,
|
|
||||||
`circuit.reorder-sections` and `circuit.renumber-section`.
|
|
||||||
|
|
||||||
Protected-board structure uses
|
|
||||||
`distribution-board-component.insert|update|delete`,
|
|
||||||
`circuit-group.insert|update|delete|reorder|renumber`,
|
|
||||||
`circuit.move-group`, `circuit-group.delete-subtree` and
|
|
||||||
`circuit-protection.update`. A subtree restore is normally emitted only as the
|
|
||||||
persisted inverse of a delete. The dispatcher also supports the documented
|
|
||||||
project-device, project settings, distribution-board and project-location
|
|
||||||
commands. The generic editor commands listed here use schema version `1`.
|
|
||||||
`project.update-settings` uses version `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
|
are rejected. Insert commands contain the complete entity or circuit block
|
||||||
with stable ids; delete commands include the expected parent identity. Circuit
|
with stable ids; delete commands include the expected parent identity. Circuit
|
||||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||||
|
|
@ -129,24 +101,6 @@ targets occupied by other sections, then applies swaps through collision-safe
|
||||||
temporary identifiers. Undo restores the exact prior identifiers; sort
|
temporary identifiers. Undo restores the exact prior identifiers; sort
|
||||||
positions and device rows remain unchanged.
|
positions and device rows remain unchanged.
|
||||||
|
|
||||||
`circuit-group.reorder` changes only complete group sort assignments.
|
|
||||||
`circuit-group.renumber` carries every affected group, child circuit and group
|
|
||||||
component with expected and target identifiers so swaps can be applied
|
|
||||||
collision-safely. `circuit.move-group` changes one complete circuit's group,
|
|
||||||
BMK and position and stores the deterministic target BMK. The target group must
|
|
||||||
have the same category.
|
|
||||||
|
|
||||||
`circuit-group.delete-subtree` carries the complete warned group state,
|
|
||||||
including both protection layers and every device-row link/override value. The
|
|
||||||
server captures the current subtree again inside the transaction and rejects a
|
|
||||||
stale or incomplete snapshot. Undo restores the same UUIDs through the stored
|
|
||||||
inverse command.
|
|
||||||
|
|
||||||
`circuit-protection.update` inserts or replaces the complete validated
|
|
||||||
one-to-one circuit protection state. Users cannot remove the required
|
|
||||||
protection row; removal is available only to a stored inverse when undoing its
|
|
||||||
initial creation.
|
|
||||||
|
|
||||||
`project-device.sync-rows` represents `synchronize`, `disconnect` and
|
`project-device.sync-rows` represents `synchronize`, `disconnect` and
|
||||||
`reconnect` operations. Every selected row carries complete expected and target
|
`reconnect` operations. Every selected row carries complete expected and target
|
||||||
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
|
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
|
||||||
|
|
@ -191,15 +145,12 @@ synchronizes linked circuit rows implicitly.
|
||||||
|
|
||||||
- `PUT /projects/:projectId`
|
- `PUT /projects/:projectId`
|
||||||
- request:
|
- request:
|
||||||
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
`{ "expectedRevision": 12, "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
|
- executes `project.update-settings` as one atomic revision
|
||||||
- response:
|
- response:
|
||||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- persistent Undo/Redo restores metadata, the public-building flag, voltage
|
- persistent Undo/Redo restores metadata, voltage values and the enabled
|
||||||
values and enabled distribution-board supply types together; project-device and circuit
|
distribution-board supply types together; project-device and circuit
|
||||||
voltages are recalculated from the restored project settings in the same
|
voltages are recalculated from the restored project settings in the same
|
||||||
transaction
|
transaction
|
||||||
- project-device and circuit voltage are derived values and are not accepted
|
- project-device and circuit voltage are derived values and are not accepted
|
||||||
|
|
@ -232,12 +183,6 @@ A stale `expectedRevision` returns HTTP `409` with
|
||||||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||||
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||||
|
|
||||||
## Estimate Balance (Grobe Leistungsbilanz)
|
|
||||||
|
|
||||||
Separate module under `/projects/:projectId/estimate-balances`; complete
|
|
||||||
documents are written through `estimate-balance.insert/.update/.delete` project
|
|
||||||
commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
|
||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
@ -255,109 +200,41 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
`{ "distributionBoard": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- persistent Undo removes only the unchanged and still-empty generated
|
- persistent Undo removes only the unchanged and still-empty generated
|
||||||
structure; Redo restores the same ids
|
structure; Redo restores the same ids
|
||||||
|
- stored command schema versions 1 and 2 remain executable with their four
|
||||||
|
legacy sections and without generated components
|
||||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
|
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
- executes `distribution-board.update`; floor and supply type are restored
|
||||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
together by persistent Undo/Redo
|
||||||
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, 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 Buildings, Floors and Rooms
|
### Project 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`
|
- `POST /projects/:projectId/floors`
|
||||||
- body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
|
- body: `{ "name": "EG", "expectedRevision": 13 }`
|
||||||
(`buildingId` optional, `null` = no building)
|
- executes `project-floor.insert` with a stable floor id
|
||||||
- executes `project-floor.insert` (command version 2) with a stable floor id
|
|
||||||
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- `PUT /projects/:projectId/floors/:floorId`
|
|
||||||
- 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`
|
- `POST /projects/:projectId/rooms`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }`
|
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
||||||
- executes `project-room.insert` command version 2 with a stable room id;
|
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
||||||
`floorId` and `areaM2` are optional. An omitted or explicit `null` area
|
and must belong to the project when present
|
||||||
creates a room without area; a value must be finite and non-negative.
|
|
||||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- `PUT /projects/:projectId/rooms/:roomId`
|
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
||||||
- body:
|
assigned rooms or distribution boards and a room referenced by device rows
|
||||||
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }`
|
or retained upgrade data are rejected instead of silently clearing foreign
|
||||||
- updates number, name, optional floor and area as one persistent version-2
|
keys.
|
||||||
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`.
|
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
||||||
- `GET /projects/:projectId/circuit-trees`
|
|
||||||
- returns all circuit trees for one project from one project-wide read and is
|
|
||||||
used by the print view and feeding-board selection
|
|
||||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
- Purpose: returns fixed header components, grouped sections with optional
|
- Purpose: returns fixed header components, grouped sections with optional
|
||||||
protection components, complete circuit/device-row blocks and auxiliary
|
protection components, complete circuit/device-row blocks and auxiliary
|
||||||
footer components. Circuit and component protection devices use their
|
footer components. Circuit and component protection devices use their
|
||||||
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
separate one-to-one DTOs. Calculated row, circuit, section and board totals
|
||||||
remain included. Circuit totals contain the demand of linked downstream
|
remain included.
|
||||||
boards. `downstreamBoardLoads` exposes those contributions as read-only
|
|
||||||
values; the grid combines exactly one downstream board without device rows
|
|
||||||
into the circuit row and keeps child rows for mixed or multiple loads.
|
|
||||||
`feedingReference` resolves the current board/circuit labels by id.
|
|
||||||
|
|
||||||
Response sketch:
|
Response sketch:
|
||||||
|
|
||||||
|
|
@ -431,9 +308,8 @@ Response sketch:
|
||||||
|
|
||||||
### Circuit Structure
|
### Circuit Structure
|
||||||
|
|
||||||
- `GET /projects/:projectId/circuit-sections/:sectionId/next-identifier`
|
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||||
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
- preview next identifier for section (`prefix + maxSuffix + 1`)
|
||||||
- returns 404 if the section does not belong to the given project
|
|
||||||
|
|
||||||
Circuit and device-row field updates, standalone insertions/deletions, single
|
Circuit and device-row field updates, standalone insertions/deletions, single
|
||||||
or bulk device-row moves, circuit reorders and explicit renumbering are
|
or bulk device-row moves, circuit reorders and explicit renumbering are
|
||||||
|
|
@ -442,6 +318,12 @@ available only as the corresponding versioned commands through
|
||||||
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
structure POST, move, reorder, renumber, identifier-restore, Circuit DELETE or
|
||||||
CircuitDeviceRow DELETE routes.
|
CircuitDeviceRow DELETE routes.
|
||||||
|
|
||||||
|
## Removed Legacy Endpoints
|
||||||
|
|
||||||
|
The former `/consumers` read/write endpoints were removed after every retained
|
||||||
|
consumer had a verified Circuit-First migration mapping. Database upgrade tooling
|
||||||
|
reads retained legacy rows directly; application features must use the Circuit-First
|
||||||
|
endpoints above.
|
||||||
|
|
||||||
## Linked Project Device Review
|
## Linked Project Device Review
|
||||||
|
|
||||||
|
|
@ -462,77 +344,3 @@ inverse command. The former direct restore/reconnect endpoints are removed.
|
||||||
|
|
||||||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
project device never triggers synchronization implicitly.
|
project device never triggers synchronization implicitly.
|
||||||
|
|
||||||
## Cable Sizing (optional module)
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
- `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" } }`
|
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,13 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||||
## Domain Model Overview
|
## Domain Model Overview
|
||||||
|
|
||||||
- `CircuitSection`
|
- `CircuitSection`
|
||||||
- Represents a protected circuit group in one of the categories lighting,
|
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
|
||||||
single-phase or three-phase.
|
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
|
||||||
- Owns category, positive group number, generated prefix, display name and
|
|
||||||
ordering.
|
|
||||||
- `Circuit`
|
- `Circuit`
|
||||||
- Core electrical unit in the list.
|
- Core electrical unit in the list.
|
||||||
- Owns circuit-level identifiers and technical data:
|
- Owns circuit-level identifiers and technical data:
|
||||||
- `equipmentIdentifier` (BMK)
|
- `equipmentIdentifier` (BMK)
|
||||||
- one explicit one-to-one protection device
|
- protection data
|
||||||
- cable data
|
- cable data
|
||||||
- reserve state
|
- reserve state
|
||||||
- voltage and optional control requirement for future sizing
|
- voltage and optional control requirement for future sizing
|
||||||
|
|
@ -45,10 +43,11 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||||
- `ProjectDevice`
|
- `ProjectDevice`
|
||||||
- Reusable device template entity at project level.
|
- Reusable device template entity at project level.
|
||||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||||
- `DistributionBoardComponent`
|
- Retained legacy migration source
|
||||||
- Represents fixed header components, optional group protection and manually
|
- Old `consumers` rows, mappings and reports are available only to explicit
|
||||||
named auxiliary footer devices with their own unique BMK.
|
database upgrade tooling.
|
||||||
- Group protection may own a separate one-to-one protection configuration.
|
- They are not application-domain entities and have no UI or API.
|
||||||
|
|
||||||
## Why A Circuit Is Not One Row
|
## Why A Circuit Is Not One Row
|
||||||
|
|
||||||
A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks:
|
A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks:
|
||||||
|
|
@ -78,9 +77,7 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating
|
||||||
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
|
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
|
||||||
|
|
||||||
- Device rows do not have their own BMK.
|
- Device rows do not have their own BMK.
|
||||||
- Existing identifiers stay stable on sort, insert and delete. Explicit
|
- Existing identifiers must stay stable unless explicitly changed by user action.
|
||||||
renumbering may change them, and a user-initiated complete-circuit move to
|
|
||||||
another same-category group assigns the next target-group identifier.
|
|
||||||
- Renumbering is explicit, not implicit on move/sort/delete.
|
- Renumbering is explicit, not implicit on move/sort/delete.
|
||||||
|
|
||||||
## Data Ownership Split: Circuit vs Device Row
|
## Data Ownership Split: Circuit vs Device Row
|
||||||
|
|
@ -111,14 +108,14 @@ Primary circuit-first editor route:
|
||||||
|
|
||||||
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
|
- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit`
|
||||||
|
|
||||||
Old `/projects/:projectId/circuit-lists` bookmarks redirect to the project
|
The sibling `/tree` route is a read-only structure preview. Old
|
||||||
page. A future print view will provide a dedicated read-only presentation.
|
`/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
|
||||||
|
|
||||||
## Future Persistence Direction
|
## Future Persistence Direction
|
||||||
|
|
||||||
Persistent Undo/Redo, project revisions and logical snapshots are implemented
|
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
|
||||||
through project-scoped commands and the shared transaction boundary. Database
|
|
||||||
backups remain separate from user-visible project snapshots. External-model
|
Critical multi-write commands already use explicit persistence transaction
|
||||||
exchange and PostgreSQL readiness are specified in
|
adapters. The next step is to compose these command boundaries into server-side,
|
||||||
[Project History and External Model Architecture](./project-history-and-external-model-architecture.md)
|
project-scoped revisions and change sets. Database backups remain separate from
|
||||||
and remain future work.
|
user-visible project snapshots.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
# Circuit List Editor Interactions
|
# 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
|
## Editing Model
|
||||||
|
|
||||||
Inline cells are static text by default. A cell enters edit mode by:
|
Inline cells are static text by default. A cell enters edit mode by:
|
||||||
|
|
@ -31,7 +28,6 @@ The editor can have a selected cell without an active editor input. `editingCell
|
||||||
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
|
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
|
||||||
|
|
||||||
- section header rows
|
- section header rows
|
||||||
- fixed header, group-protection and auxiliary footer component rows
|
|
||||||
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
|
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
|
||||||
- device rows (`deviceRow`)
|
- device rows (`deviceRow`)
|
||||||
- section placeholder rows (`placeholder`)
|
- section placeholder rows (`placeholder`)
|
||||||
|
|
@ -47,7 +43,6 @@ Selection, keyboard movement, and editability checks run against this normalized
|
||||||
- arrow keys: move selected cell in grid when not editing
|
- 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
|
- 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
|
- `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
|
- `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.
|
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.
|
||||||
|
|
@ -56,23 +51,6 @@ 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.
|
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 Identifier Validation
|
||||||
|
|
||||||
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
|
||||||
|
|
@ -104,21 +82,13 @@ Intent is separated by drag source type:
|
||||||
- device row drag:
|
- device row drag:
|
||||||
- drop to existing circuit -> move row(s) into that circuit
|
- drop to existing circuit -> move row(s) into that circuit
|
||||||
- drop to placeholder -> create new target circuit and move row(s)
|
- drop to placeholder -> create new target circuit and move row(s)
|
||||||
- drop on the upper or lower edge of a circuit row -> create a new target
|
|
||||||
circuit directly before or after that circuit and move the row(s)
|
|
||||||
- the center of a circuit row remains the explicit target for adding the
|
|
||||||
row(s) to that existing circuit
|
|
||||||
- crossing a section boundary requires explicit confirmation
|
- crossing a section boundary requires explicit confirmation
|
||||||
- phase type, category, linked project device and local row values remain unchanged
|
- phase type, category, linked project device and local row values remain unchanged
|
||||||
- target creation, row assignments and reserve-state updates use one SQLite transaction
|
- target creation, row assignments and reserve-state updates use one SQLite transaction
|
||||||
- circuit drag (BMK handle):
|
- circuit drag (BMK handle):
|
||||||
- reorder one or multiple circuits inside the same group without changing BMKs
|
- reorder circuits inside same section only
|
||||||
- move exactly one circuit into another group of the same category
|
- cross-section reorder is rejected
|
||||||
- a cross-group move assigns the highest existing target suffix plus one;
|
- the complete section order is validated and stored in one SQLite transaction
|
||||||
gaps are not filled
|
|
||||||
- cross-category and cross-group multi-circuit moves are rejected
|
|
||||||
- same-group reorder validates the complete group order; cross-group move
|
|
||||||
stores the one exact source/target transition
|
|
||||||
- bulk device row move:
|
- bulk device row move:
|
||||||
- supported via multi-selection + drag
|
- supported via multi-selection + drag
|
||||||
- multi-circuit move:
|
- multi-circuit move:
|
||||||
|
|
@ -126,52 +96,15 @@ Intent is separated by drag source type:
|
||||||
|
|
||||||
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
|
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
|
||||||
|
|
||||||
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move.
|
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
|
||||||
|
|
||||||
## Protected Group and Component Controls
|
|
||||||
|
|
||||||
- New groups are created manually in one of the three supported categories and
|
|
||||||
receive the highest existing category group number plus one.
|
|
||||||
- Group headings always show the stored editable display name; category labels
|
|
||||||
do not overwrite names of the default first groups.
|
|
||||||
- Group reorder buttons move only relative to category peers and never change a
|
|
||||||
group number or BMK.
|
|
||||||
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
|
|
||||||
It follows the persisted group order and updates group prefixes, optional
|
|
||||||
`.0` protection BMKs and circuit BMKs together.
|
|
||||||
- Optional group upstream protection and group FI as well as auxiliary footer
|
|
||||||
components use labeled, type-dependent modals.
|
|
||||||
- Each circuit protection device uses its own modal; only fields valid for the
|
|
||||||
selected type are shown.
|
|
||||||
- Removing a populated group warns with circuit, device-row and group
|
|
||||||
protection counts. The complete deletion remains persistently undoable.
|
|
||||||
|
|
||||||
## Filtering and Sorting
|
## 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.
|
- Per-column filtering works on normalized displayed values.
|
||||||
- Sorting is view-level first and treats circuits as blocks.
|
- Sorting is view-level first and treats circuits as blocks.
|
||||||
- Multi-device circuits are not split during sort.
|
- Multi-device circuits are not split during sort.
|
||||||
- Sorting alone does not persist order.
|
- 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
|
||||||
|
|
||||||
`Apply sorted order` persists current sorted block order to backend by section.
|
`Apply sorted order` persists current sorted block order to backend by section.
|
||||||
|
|
@ -185,8 +118,7 @@ unchanged. The existing command handlers and deletion confirmations are reused.
|
||||||
|
|
||||||
- Column visibility and order are configurable.
|
- Column visibility and order are configurable.
|
||||||
- BMK column (`equipmentIdentifier`) is locked as first column.
|
- BMK column (`equipmentIdentifier`) is locked as first column.
|
||||||
- Layout is saved in local storage under a project-specific key so switching
|
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
|
||||||
distribution boards preserves the same project layout.
|
|
||||||
|
|
||||||
## Undo/Redo
|
## Undo/Redo
|
||||||
|
|
||||||
|
|
@ -201,20 +133,13 @@ across multiple sections is atomic and occupies one history step. Explicit
|
||||||
renumbering uses a collision-safe persistent project command and one
|
renumbering uses a collision-safe persistent project command and one
|
||||||
project-history step.
|
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:
|
Covered operations include:
|
||||||
|
|
||||||
- insert/delete circuit
|
- insert/delete circuit
|
||||||
- insert/delete row
|
- insert/delete row
|
||||||
- edit cell values
|
- edit cell values
|
||||||
- moves (single/bulk rows, circuit reorder and same-category group move)
|
- moves (single/bulk rows, circuit reorder)
|
||||||
- circuit and group renumber/identifier flows
|
- renumber and identifier update flows
|
||||||
- component, group and protection editing
|
|
||||||
- warned populated-group deletion and exact restoration
|
|
||||||
- apply sorted order
|
- apply sorted order
|
||||||
|
|
||||||
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,12 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport,
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
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.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
- There is no automatic sizing of all circuits. The optional cable sizing
|
- Final electrical sizing logic is not implemented yet.
|
||||||
module suggests a cross-section for one circuit on demand using
|
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||||
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
|
|
||||||
burial, grouped method E and neutral-current sizing above 33 % third
|
|
||||||
harmonic, and it does not check short-circuit withstand, disconnection time
|
|
||||||
or selectivity. See `docs/cable-sizing-module.md`.
|
|
||||||
- Sorting is view-only until users explicitly apply sorted order.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||||
same category. Cross-category moves and multi-circuit moves across group
|
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
|
||||||
boundaries are intentionally rejected; multi-circuit reorder inside one
|
|
||||||
group remains supported.
|
|
||||||
- Protection and cable values are planner-owned manual selections. The later
|
|
||||||
sizing/warning engine does not yet validate them against load, length or
|
|
||||||
voltage-drop rules.
|
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
90
docs/circuit-list-editor-migration.md
Normal file
90
docs/circuit-list-editor-migration.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Circuit List Editor Migration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Upgrade-only. New installations already use the Circuit-First schema and do not
|
||||||
|
run this workflow.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Migrate legacy row-first consumers into the circuit-first model without deleting legacy data.
|
||||||
|
|
||||||
|
## Legacy Mapping
|
||||||
|
|
||||||
|
Legacy `Consumer` rows map into:
|
||||||
|
|
||||||
|
- `Circuit` for shared circuit identity and circuit-level technical fields
|
||||||
|
- `CircuitDeviceRow` for per-device load rows
|
||||||
|
|
||||||
|
Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
|
||||||
|
|
||||||
|
For one circuit list, all new circuits, device rows, trace mappings and the migration report are committed in one SQLite transaction. A failed run therefore leaves none of those prepared migration writes behind.
|
||||||
|
|
||||||
|
## Grouping Strategy (`circuitNumber`)
|
||||||
|
|
||||||
|
Migration groups legacy rows by normalized `circuitNumber`:
|
||||||
|
|
||||||
|
- valid/normalizable values become one target circuit per normalized value
|
||||||
|
- duplicates are grouped under that circuit (multiple `CircuitDeviceRow`s)
|
||||||
|
- missing/invalid values trigger generated identifiers and may fall back to `unassigned` section
|
||||||
|
|
||||||
|
## Default Section Backfill
|
||||||
|
|
||||||
|
Before migration, default sections are created/backfilled per circuit list.
|
||||||
|
This guarantees a valid target section space, including `unassigned` when no section can be inferred.
|
||||||
|
|
||||||
|
## Migration Commands
|
||||||
|
|
||||||
|
Run in this order for local database workflows:
|
||||||
|
|
||||||
|
1. Backup:
|
||||||
|
- `npm run db:backup`
|
||||||
|
2. Migrate schema:
|
||||||
|
- `npm run db:migrate`
|
||||||
|
3. Verify circuit schema:
|
||||||
|
- `npm run db:verify:circuit-schema`
|
||||||
|
4. Backfill missing sections:
|
||||||
|
- `npm run db:backfill:sections`
|
||||||
|
5. Migrate legacy consumers:
|
||||||
|
- `npm run db:migrate:legacy-consumers`
|
||||||
|
|
||||||
|
The migration command finishes with a cutover verification across the complete
|
||||||
|
database. It exits with an error while any legacy consumer lacks a migration
|
||||||
|
mapping, including consumers that cannot be migrated because they have no circuit
|
||||||
|
list assignment. The legacy multi-list UI was removed only after this check passed;
|
||||||
|
old `/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
|
||||||
|
|
||||||
|
## Validation Checks
|
||||||
|
|
||||||
|
After migration, verify:
|
||||||
|
|
||||||
|
- tree endpoint returns sections/circuits/rows
|
||||||
|
- grouped duplicate circuit numbers are reported
|
||||||
|
- generated identifiers are reported where expected
|
||||||
|
- migrated rows include `legacyConsumerId` traceability
|
||||||
|
- no duplicate BMKs exist inside one circuit list
|
||||||
|
|
||||||
|
## If Tree Endpoint Returns Empty Sections
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- circuit-first tables missing (migration not run)
|
||||||
|
- sections not backfilled yet
|
||||||
|
- migrated dataset genuinely empty for selected list
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
1. Run schema migration and verification commands.
|
||||||
|
2. Run section backfill command.
|
||||||
|
3. Run legacy-consumer migration command.
|
||||||
|
4. Retry tree endpoint.
|
||||||
|
|
||||||
|
## Legacy Data Retention
|
||||||
|
|
||||||
|
Do not delete legacy consumers yet.
|
||||||
|
|
||||||
|
- legacy read/write endpoints have been removed from the application server
|
||||||
|
- migration trace tables reference old/new mapping
|
||||||
|
- retained rows allow upgrade verification and audit of the completed mapping
|
||||||
|
- a future automatic cleanup migration must migrate and verify old databases
|
||||||
|
before dropping these source and trace tables
|
||||||
|
|
@ -9,8 +9,10 @@ Der unterstützte Editor ist Circuit-First:
|
||||||
Ein Stromkreis ist nicht dasselbe wie eine Gerätezeile. BMK, Schutz- und Kabeldaten
|
Ein Stromkreis ist nicht dasselbe wie eine Gerätezeile. BMK, Schutz- und Kabeldaten
|
||||||
gehören zum Stromkreis; Last-, Raum- und Kategoriedaten gehören zur Gerätezeile.
|
gehören zum Stromkreis; Last-, Raum- und Kategoriedaten gehören zur Gerätezeile.
|
||||||
|
|
||||||
Die frühere Consumer-Oberfläche, ihre API, Tabellen und Upgrade-Werkzeuge sind
|
Die frühere Consumer-Oberfläche und ihre API sind entfernt. Die Tabelle
|
||||||
entfernt. Neue Funktionen bauen ausschließlich auf dem Circuit-First-Modell auf.
|
`consumers` sowie Mappings und Reports bleiben ausschließlich erhalten, damit
|
||||||
|
ältere Datenbanken über den expliziten Upgrade-Befehl migriert und geprüft werden
|
||||||
|
können.
|
||||||
|
|
||||||
## Laufzeit
|
## Laufzeit
|
||||||
|
|
||||||
|
|
@ -27,38 +29,15 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
||||||
data/leistungsbilanz.db (SQLite)
|
data/leistungsbilanz.db (SQLite)
|
||||||
```
|
```
|
||||||
|
|
||||||
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
||||||
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
||||||
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
|
liegt über einen Host-Mount außerhalb des Containers.
|
||||||
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
|
## Wichtige Einstiegspunkte
|
||||||
|
|
||||||
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
|
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
|
||||||
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen
|
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen,
|
||||||
und Projektgeräte
|
Räume und Projektgeräte
|
||||||
- `src/app/projects/[projectId]/struktur/page.tsx` – Gebäude, Etagen, Räume
|
|
||||||
und Raumflächen
|
|
||||||
- `src/app/projects/[projectId]/grobbilanz/` – Stände und Editor der groben
|
|
||||||
Leistungsbilanz
|
|
||||||
- `src/app/projects/[projectId]/print/page.tsx` – Seitenansicht und PDF-Export
|
|
||||||
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
||||||
– unterstützte Editorroute
|
– unterstützte Editorroute
|
||||||
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
|
||||||
|
|
@ -66,11 +45,6 @@ vor dem Command und bleiben über Undo/Redo unverändert.
|
||||||
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
|
||||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||||
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
|
||||||
- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung
|
|
||||||
und erzwungener Wechsel eines Startpassworts
|
|
||||||
- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz
|
|
||||||
- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` –
|
|
||||||
kontobezogene Regeln und schmale Persistenzgrenze
|
|
||||||
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
|
||||||
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
|
||||||
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
|
||||||
|
|
@ -156,8 +130,8 @@ Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
|
||||||
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
|
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
|
||||||
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
|
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
|
||||||
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
||||||
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte sind nicht
|
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
||||||
Teil des Projekt-Snapshots.
|
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
||||||
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
||||||
`kind` unterscheidet benannte und automatische Stände. Die zentrale
|
`kind` unterscheidet benannte und automatische Stände. Die zentrale
|
||||||
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
|
Revisionspersistenz erzeugt nach jeweils 25 weiteren Projektänderungen
|
||||||
|
|
@ -170,7 +144,9 @@ Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen
|
||||||
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
|
Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer
|
||||||
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
|
Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie
|
||||||
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
||||||
Restore nach einem Neustart exakt zurücknehmen oder wiederholen.
|
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
||||||
|
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||||
|
nicht Bestandteil des logischen Snapshots sind.
|
||||||
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||||
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||||
benennen, jeden aufgeführten benannten oder automatischen Stand nach
|
benennen, jeden aufgeführten benannten oder automatischen Stand nach
|
||||||
|
|
@ -260,11 +236,8 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||||
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||||
`project.update-settings` versioniert Projektname, interne und externe
|
`project.update-settings` versioniert Projektname, interne und externe
|
||||||
Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
|
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
|
||||||
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
|
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
|
||||||
`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
|
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||||
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
|
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
|
||||||
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
|
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
|
||||||
|
|
@ -273,65 +246,33 @@ Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
|
||||||
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
||||||
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||||
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
|
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
||||||
Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
|
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
||||||
versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
|
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||||
Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite
|
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||||
„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können
|
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||||
einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare,
|
|
||||||
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
|
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||||
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
|
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||||
`IconButton`/`IconLink`.
|
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||||
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
|
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
|
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
||||||
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
|
die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||||
Fachliche Verknüpfungen
|
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben
|
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||||
erhalten. Die
|
|
||||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
||||||
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||||
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||||
projektgebundenen Endpunkt beschränkt.
|
projektgebundenen Endpunkt beschränkt.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
|
`distribution-board.insert` versioniert die Anlage einer Verteilung mit
|
||||||
Command-Schema 1 als vollständigen Block aus Verteilung, Stromkreisliste, den
|
Command-Schema 3 als vollständigen Block aus Verteilung, Stromkreisliste, den
|
||||||
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
|
drei Gruppen `Beleuchtung 1`, `1-phasig 1`, `3-phasig 1`, Hauptschalter `-Q0`
|
||||||
und Überspannungsableiter `-FA`.
|
und Überspannungsableiter `-FA`.
|
||||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
|
|
@ -342,16 +283,8 @@ Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
|
||||||
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||||
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||||
Freigabe der Netzart in den Projekteinstellungen.
|
Freigabe der Netzart in den Projekteinstellungen.
|
||||||
Bereits befüllte Verteilungen werden über
|
Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier
|
||||||
`distribution-board.insert-subtree` und `distribution-board.delete-subtree`
|
Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar.
|
||||||
als vollständiger Unterbaum kopiert beziehungsweise gelöscht. Der Snapshot
|
|
||||||
umfasst Verteilung, Stromkreisliste, Gruppen, Stromkreise, Gerätezeilen,
|
|
||||||
Verteilerkomponenten und alle zugehörigen Schutzgeräte. Beim Kopieren werden
|
|
||||||
sämtliche besitzenden UUIDs neu vergeben, während fachliche Verknüpfungen zu
|
|
||||||
Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare
|
|
||||||
Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die
|
|
||||||
Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an;
|
|
||||||
Löschen verlangt dort eine ausdrückliche Bestätigung.
|
|
||||||
`distribution-board-component.insert` und
|
`distribution-board-component.insert` und
|
||||||
`distribution-board-component.delete` sowie
|
`distribution-board-component.delete` sowie
|
||||||
`distribution-board-component.update` versionieren Anlage, Entfernung,
|
`distribution-board-component.update` versionieren Anlage, Entfernung,
|
||||||
|
|
@ -363,15 +296,15 @@ einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
|
||||||
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
|
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
|
||||||
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
||||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||||
allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten
|
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
||||||
werden im Editor über eigene Modale und diese Commands verwaltet.
|
nächsten Arbeitspaketen.
|
||||||
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||||
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||||
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||||
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
||||||
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
||||||
Gruppenkomponenten. Befüllte Gruppen verwenden getrennt davon den vollständigen
|
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
|
||||||
`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung.
|
eigener späterer Command.
|
||||||
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
||||||
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
||||||
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||||
|
|
@ -413,146 +346,47 @@ Teilwiederherstellungen.
|
||||||
Der Circuit-Tree liefert zusätzlich `headerComponents`,
|
Der Circuit-Tree liefert zusätzlich `headerComponents`,
|
||||||
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
|
gruppenbezogene `components` und `footerComponents`. Abschnitte enthalten
|
||||||
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
|
optionale Gruppenkategorie und -nummer; Stromkreise und Gruppenkomponenten
|
||||||
enthalten ihre getrennte Schutzgerätekonfiguration. Die
|
enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen
|
||||||
1:1-Schutzgerätetabellen sind die einzige unterstützte Quelle dieser Daten.
|
flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv
|
||||||
Separate aktive Read-Repositories lesen Komponenten- und beide
|
erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide
|
||||||
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
|
Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands.
|
||||||
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
|
`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree
|
||||||
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
||||||
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
||||||
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
||||||
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
||||||
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
|
|
||||||
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
|
|
||||||
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
|
|
||||||
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
|
|
||||||
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
|
|
||||||
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
|
|
||||||
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
|
|
||||||
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
|
|
||||||
`distribution-board-component.*`-Commands; die verbindliche
|
|
||||||
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
|
|
||||||
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
|
|
||||||
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
|
|
||||||
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
|
|
||||||
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
|
|
||||||
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
|
|
||||||
gesperrt.
|
|
||||||
`circuit-protection.update` bildet die eigene persistente Schreibgrenze für
|
|
||||||
die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen
|
|
||||||
erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die
|
|
||||||
Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
|
|
||||||
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
|
|
||||||
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
|
|
||||||
wiederherstellen.
|
|
||||||
Der vollständige `CircuitSnapshot` enthält den zugehörigen
|
|
||||||
`protectionDevice`-Datensatz, sofern einer vorhanden ist. Neue Stromkreise und
|
|
||||||
durch Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
|
|
||||||
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
|
|
||||||
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
|
|
||||||
Im Editor sind die Schutzspalten deshalb eine
|
|
||||||
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
|
|
||||||
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
|
|
||||||
Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
|
|
||||||
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
|
|
||||||
vollständige erwartete und gewünschte Sortierreihenfolge über
|
|
||||||
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
|
|
||||||
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
|
|
||||||
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
|
|
||||||
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
|
|
||||||
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
|
|
||||||
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
|
|
||||||
Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen
|
|
||||||
Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
|
|
||||||
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
|
|
||||||
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
|
|
||||||
Lücken werden nicht automatisch gefüllt.
|
|
||||||
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
|
|
||||||
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
|
|
||||||
Client sendet dafür den vollständigen aktuellen Unterbaum an
|
|
||||||
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
|
|
||||||
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
|
|
||||||
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
|
|
||||||
wieder her.
|
|
||||||
`distribution-board.update` versioniert Etage, Netzart und den
|
`distribution-board.update` versioniert Etage, Netzart und den
|
||||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||||
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
|
||||||
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
|
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
|
||||||
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
|
||||||
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||||
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
|
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||||
externe Einspeisungen und Hinweise.
|
Projektauswahl hochgestuft.
|
||||||
`distribution-board.update` verwendet Schema-Version 2 für die optionalen
|
Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer,
|
||||||
logischen Verweise `feedingDistributionBoardId` und `feedingCircuitId`.
|
Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture,
|
||||||
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
|
|
||||||
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
|
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||||
JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte
|
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter
|
||||||
Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit
|
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch
|
||||||
leerer externer Konfiguration hochgestuft. Die unterstützte
|
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer
|
||||||
Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor
|
und bestehende flache Schutzangaben unverändert erhalten.
|
||||||
dieser Baseline werden nicht eingelesen.
|
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||||
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||||
exakten Vorher-/Nachher-Snapshots. Raumbefehle verwenden Version 2 und führen
|
|
||||||
die nullable, nicht negative Netto-Raumfläche `areaM2`; Version-1-Befehle
|
|
||||||
werden mit `null` hochgestuft und bleiben durch den vollständigen
|
|
||||||
Snapshotvergleich konfliktfest. Der Raumdialog pflegt die Fläche manuell mit
|
|
||||||
deutscher Dezimaleingabe, die Gebäudestruktur zeigt zwei Nachkommastellen sowie
|
|
||||||
Summen und die Anzahl fehlender Flächen je Etage und Gebäude. Ein Geschoss wird nur
|
|
||||||
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||||
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
Raum wird nur entfernt,
|
||||||
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
||||||
Historienstand.
|
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
||||||
|
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
||||||
|
mehr.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
Die Projektgerätekategorie `lighting`, `single_phase` oder `three_phase` ist die
|
`ProjectDevice` verwendet ausschließlich die kanonischen Circuit-First-Felder:
|
||||||
fachliche Klassifikation und Zielgruppe. `phaseType` ist keine separate
|
`phaseType`, `powerPerUnit`, `simultaneityFactor`, `cosPhi`, `remark` sowie
|
||||||
Benutzereingabe, sondern wird daraus für Spannung, Verknüpfung und spätere
|
optionale technische und kategorisierende Felder.
|
||||||
Dimensionierung abgeleitet.
|
|
||||||
|
|
||||||
Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird
|
Beim Einfügen entsteht eine verknüpfte `CircuitDeviceRow`. Der Anzeigename wird
|
||||||
kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät
|
kopiert, aber nicht still synchronisiert. Spätere Änderungen am Projektgerät
|
||||||
|
|
@ -562,148 +396,48 @@ werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
|
||||||
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
|
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
|
||||||
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||||
|
|
||||||
## Leitungsberechnung
|
|
||||||
|
|
||||||
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
|
|
||||||
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
|
|
||||||
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
|
|
||||||
Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit
|
|
||||||
den in der Moduldokumentation beschriebenen Grenzen geprüft. Die
|
|
||||||
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
|
|
||||||
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
|
|
||||||
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
|
|
||||||
Querschnitt und Länge über den regulären `circuit.update`-Befehl, also mit
|
|
||||||
Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
|
|
||||||
`docs/cable-sizing-module.md`.
|
|
||||||
|
|
||||||
## Grobe Leistungsbilanz
|
|
||||||
|
|
||||||
Das Modul `src/estimate-balance` mit der importfreien Rechnung in
|
|
||||||
`src/shared/estimate-balance` führt Leistungsbilanzen für LPH 2/3 nach
|
|
||||||
DIN-276-Kostengruppen in eigenen Tabellen (`estimate_*`, Migration `0010`).
|
|
||||||
Es ist nicht mit der Stromkreisstruktur verknüpft und liest Gerätezeilen nur
|
|
||||||
für den Vergleich „geschätzt ↔ detailliert“ über das längste passende
|
|
||||||
Kostengruppen-Präfix. Schreibzugriffe laufen über `estimate-balance.insert`,
|
|
||||||
`.update` und `.delete` mit vollständigen Dokumenten, also mit Revision und
|
|
||||||
Undo/Redo. Grobbilanzen sind Teil von Transferformat 2, aber bewusst nicht Teil
|
|
||||||
der logischen Snapshots; ein Restore lässt sie unverändert. Einzelne Zeilen
|
|
||||||
lassen sich über den vorbelegten Projektgeräte-Dialog ausdrücklich als
|
|
||||||
Projektgerät kopieren (`project-device.insert`, ohne gespeicherte Verknüpfung).
|
|
||||||
Oberfläche: `/projects/:projectId/grobbilanz`. Details:
|
|
||||||
`docs/estimate-balance.md`.
|
|
||||||
|
|
||||||
## Persistenz und Migration
|
## Persistenz und Migration
|
||||||
|
|
||||||
- SQLite ist die aktuell unterstützte Datenbank.
|
- SQLite ist die aktuell unterstützte Datenbank.
|
||||||
- Fremdschlüssel werden für jeden Datenbankkontext aktiviert.
|
- Fremdschlüssel werden für jeden Datenbankkontext aktiviert.
|
||||||
- `npm run db:migrate` richtet mit Baseline-Migration `0000` und den folgenden
|
- `npm run db:migrate` wendet Drizzle-Migrationen an.
|
||||||
additiven Migrationen das aktuelle Schema ein.
|
|
||||||
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
|
- `npm run db:verify:circuit-schema` prüft erforderliche und entfernte Spalten.
|
||||||
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
|
- `npm run db:backup` erzeugt ein konsistentes und verifiziertes Online-Backup.
|
||||||
- `npm run typecheck:scripts` prüft die TypeScript-Wartungsskripte, ohne Code zu
|
- `npm run typecheck:scripts` prüft alle TypeScript-Wartungs- und
|
||||||
erzeugen.
|
Upgrade-Skripte mit ihren Anwendungspfaden, ohne Code zu erzeugen.
|
||||||
- Die Baseline ersetzt alle vorigen Entwicklungsmigrationen. Datenbanken,
|
- Angewendete Migrationen werden niemals nachträglich verändert.
|
||||||
Snapshots und persistierte Commands aus Vor-Baseline-Ständen werden bewusst
|
- `db:migrate:legacy-consumers` ist Upgrade-Werkzeug, kein Anwendungspfad.
|
||||||
nicht unterstützt. Ab der ersten veröffentlichten Version sind angewendete
|
Der fachliche Migrationsdienst kennt nur schmale Reader-/Store-Ports unter
|
||||||
Migrationen unveränderlich und Änderungen erfolgen additiv.
|
`src/domain/ports`; konkrete SQLite-Repositories werden ausschließlich im
|
||||||
|
CLI-Skript zusammengesetzt.
|
||||||
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
|
- Allgemeine Circuit-, Gerätezeilen-, CircuitList- und DistributionBoard-
|
||||||
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
|
Repositories stellen im Anwendungspfad nur noch benötigte Leseabfragen bereit.
|
||||||
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories.
|
Fachliche Schreibvorgänge liegen in den typisierten Command-Repositories;
|
||||||
|
Upgrade-Schreibvorgänge bleiben in expliziten Migrationsadaptern.
|
||||||
- Die vollständige Verteilungs-Testfixture liegt unter
|
- Die vollständige Verteilungs-Testfixture liegt unter
|
||||||
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
|
||||||
Produktions-Schreibweg.
|
Produktions-Schreibweg.
|
||||||
- Die Baseline bildet die relationale Grundlage für geschützte Stromkreisgruppen
|
- Migration `0024` bildet die additive relationale Grundlage für geschützte
|
||||||
und Verteilerkomponenten. Separate
|
Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden
|
||||||
1:1-Tabellen halten Stromkreis- und Komponenten-Schutzgeräte. Die früheren
|
Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate
|
||||||
flachen Stromkreis-Schutzfelder sind aus der Baseline entfernt.
|
1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die
|
||||||
Ein triggergeführtes Register erzwingt eine normalisierte,
|
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
|
||||||
|
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
|
||||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||||
Verteilerkomponenten. Der DB-Index normalisiert dabei nur über SQLites
|
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||||
eingebautes `lower()` (rein ASCII), erkennt also z.B. `"Ä1"` und `"ä1"` nicht
|
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||||
als denselben Wert. Die gemeinsame Prüfung
|
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
|
||||||
`src/db/repositories/equipment-identifier-uniqueness.persistence.ts`
|
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
|
||||||
schließt diese Lücke: Sie normalisiert mit JavaScripts Unicode-fähigem
|
und UI folgen in abgegrenzten Arbeitspaketen.
|
||||||
`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 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
|
|
||||||
an und bearbeitet veränderliche Gruppen- und Fußkomponenten über dedizierte
|
|
||||||
Command-Modale. Gruppenanlage, -umbenennung, -sortierung, explizite
|
|
||||||
Neunummerierung, Same-Category-Stromkreiswechsel, geschütztes
|
|
||||||
Unterbaumlöschen und Stromkreisschutz sind integriert.
|
|
||||||
Migration `0006` ergänzt additiv Indizes auf `circuits.section_id` und
|
|
||||||
`circuit_device_rows.circuit_id`, den beiden am häufigsten gefilterten
|
|
||||||
Fremdschlüsselspalten sowie den Cascade-Delete-Pfaden von Abschnitten und
|
|
||||||
Stromkreisen.
|
|
||||||
|
|
||||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||||
|
|
||||||
## Revit-Anbindung: Linear-XLSX nach R10
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`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
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
|
- Revit-/CSV-/IFCGUID-Round-trip
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
- Produktionsdeployment
|
- Produktionsdeployment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,64 +2,21 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## Aktueller Status
|
||||||
|
|
||||||
Es gibt zwei Compose-Stacks.
|
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
||||||
|
|
||||||
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
||||||
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
||||||
für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles,
|
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
||||||
also als Benutzer `node` statt als root, ohne devDependencies, mit
|
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
||||||
`cap_drop: ALL`, `no-new-privileges` und harten Speichergrenzen.
|
öffentlich erreichbar gemacht werden.
|
||||||
|
|
||||||
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
## Entwicklungs-Topologie
|
||||||
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.
|
|
||||||
|
|
||||||
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
|
| Komponente | Port | Healthcheck | Persistenz |
|
||||||
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
|
| --- | ---: | --- | --- |
|
||||||
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
|
| Next.js Web | 3001 | `GET /` | keine |
|
||||||
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
|
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
||||||
|
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
## 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:
|
Verwendete Umgebungsvariablen:
|
||||||
|
|
||||||
|
|
@ -67,99 +24,19 @@ Verwendete Umgebungsvariablen:
|
||||||
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||||
`http://api:3000`
|
`http://api:3000`
|
||||||
- `NEXT_TELEMETRY_DISABLED=1`
|
- `NEXT_TELEMETRY_DISABLED=1`
|
||||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
||||||
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
Dateibeobachtung in Docker
|
||||||
- `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.
|
|
||||||
|
|
||||||
Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen,
|
Beim API-Start laufen zuerst `npm run db:migrate` und
|
||||||
nicht versionierten `.env` neben `compose.yaml` gesetzt werden:
|
`npm run db:verify:circuit-schema`.
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
Beide Dienste schreiben strukturierte, einzeilige JSON-Log-Zeilen nach
|
|
||||||
stdout/stderr (`docker compose logs --follow`). Jede Zeile enthält
|
|
||||||
`timestamp`, `level`, `scope` und `message`. `compose.yaml` konfiguriert für
|
|
||||||
beide Dienste den `json-file`-Treiber mit Rotation (`max-size: 20m`,
|
|
||||||
`max-file: 10`, also bis zu 200 MB je Dienst); ohne diese Einstellung würde
|
|
||||||
Docker mit der Standardkonfiguration unbegrenzt in eine einzelne Datei unter
|
|
||||||
`/var/lib/docker/containers/<container-id>/` schreiben. Die Logs überleben
|
|
||||||
einen Container-Neustart (`docker compose restart`), aber nicht das Entfernen
|
|
||||||
des Containers (`docker compose down` gefolgt von `up` erzeugt neue
|
|
||||||
Container und damit neue, leere Logdateien); für ein echtes Langzeitarchiv
|
|
||||||
über Rebuilds hinweg müssten die Zeilen zusätzlich in eine Datei im
|
|
||||||
gemounteten `./data`-Verzeichnis oder an ein externes Log-System geschrieben
|
|
||||||
werden. Die Express-API protokolliert
|
|
||||||
jede abgeschlossene Anfrage (Methode, Pfad, Status, Dauer; `/health` wird
|
|
||||||
nicht mitgeloggt) sowie unbehandelte Exceptions/Promise-Rejections. Das
|
|
||||||
Next.js-Frontend protokolliert Seitenanfragen (Navigation) über
|
|
||||||
`src/proxy.ts` und unbehandelte Fehler über `src/instrumentation.ts`.
|
|
||||||
Beide Prozesse schreiben zusätzlich alle fünf Minuten einen `verbose`-Heartbeat
|
|
||||||
mit Laufzeit und Speicherverbrauch – nützlich, um Speicherlecks oder Hänger vor
|
|
||||||
einem 502 über einen längeren Zeitraum nachzuvollziehen. Für die Detailsuche
|
|
||||||
`LOG_LEVEL=debug` setzen; das protokolliert zusätzlich den Start jeder
|
|
||||||
API-Anfrage und macht damit hängende (nie abgeschlossene) Requests sichtbar.
|
|
||||||
Ein `close`-Ereignis ohne vorheriges `finish` wird als `request aborted before
|
|
||||||
response finished` (`warn`) geloggt und zeigt damit vom Client oder einem
|
|
||||||
vorgeschalteten Proxy abgebrochene Verbindungen.
|
|
||||||
|
|
||||||
Eine unbehandelte Exception oder Promise-Rejection wird geloggt und beendet
|
|
||||||
den jeweiligen Prozess anschließend bewusst (`process.exit(1)`), statt in
|
|
||||||
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
|
## Voraussetzungen für ein späteres Produktionssetup
|
||||||
|
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
|
|
||||||
|
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
|
||||||
- TLS-Termination und Reverse Proxy
|
- TLS-Termination und Reverse Proxy
|
||||||
- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei
|
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
||||||
festen Rollen später nicht ausreichen
|
|
||||||
- definierte Secrets- und Konfigurationsverwaltung
|
- definierte Secrets- und Konfigurationsverwaltung
|
||||||
- persistenter, gesicherter Datenbankbetrieb
|
- persistenter, gesicherter Datenbankbetrieb
|
||||||
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
|
||||||
|
|
@ -168,17 +45,14 @@ Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||||
Mehrbenutzerbetrieb erforderlich ist
|
Mehrbenutzerbetrieb erforderlich ist
|
||||||
|
|
||||||
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
||||||
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
|
lokalen Entwicklungsstart in der README.
|
||||||
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
|
|
||||||
|
|
||||||
## Backup und Wiederherstellung
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
|
`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
|
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
|
||||||
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
|
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
|
||||||
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
|
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
|
||||||
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
|
||||||
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
||||||
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
|
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:
|
Für direkte Node.js-Entwicklung:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -50,16 +45,10 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
|
||||||
Testbestand:
|
Testbestand:
|
||||||
|
|
||||||
1. In der UI ein Projekt `Demo` anlegen.
|
1. In der UI ein Projekt `Demo` anlegen.
|
||||||
2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält
|
2. Im Projekt eine Verteilung `UV-01` erstellen.
|
||||||
danach Hauptschalter, Überspannungsableiter und je eine Gruppe für
|
|
||||||
Beleuchtung, 1-phasige und 3-phasige Stromkreise.
|
|
||||||
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
|
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
|
||||||
4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe
|
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
|
||||||
ziehen.
|
ziehen.
|
||||||
5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein
|
|
||||||
zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung,
|
|
||||||
explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne
|
|
||||||
reale Projekt- oder Kundendaten prüfen.
|
|
||||||
|
|
||||||
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
|
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
|
||||||
|
|
||||||
|
|
@ -91,8 +80,7 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
docker compose ps
|
docker compose ps
|
||||||
Invoke-WebRequest http://localhost:3001/health
|
Invoke-WebRequest http://localhost:3000/health
|
||||||
Invoke-WebRequest http://localhost:3001/web-health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||||
|
|
@ -102,19 +90,18 @@ erneut direkte Schreibmethoden erhalten.
|
||||||
|
|
||||||
## Migrationen
|
## Migrationen
|
||||||
|
|
||||||
1. Schema unter `src/db/schema/` ändern.
|
1. Bestehende lokale Datenbank sichern: `npm run db:backup`.
|
||||||
2. `npm run db:generate` ausführen.
|
2. Schema unter `src/db/schema/` ändern.
|
||||||
3. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung
|
3. `npm run db:generate` ausführen.
|
||||||
|
4. Das generierte SQL vollständig prüfen; es darf nur die beabsichtigte Änderung
|
||||||
enthalten.
|
enthalten.
|
||||||
4. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert
|
5. Upgrade-/Erhaltungstest ergänzen, wenn Spalten oder Beziehungen geändert
|
||||||
werden.
|
werden.
|
||||||
5. `npm test`, `npm run db:migrate` und
|
6. `npm test`, `npm run db:migrate` und
|
||||||
`npm run db:verify:circuit-schema` ausführen.
|
`npm run db:verify:circuit-schema` ausführen.
|
||||||
|
|
||||||
`0000` ist die saubere Baseline für leere Neuinstallationen. Vor-Baseline-
|
Bereits angewendete SQL-Migrationen dürfen nicht geändert oder neu sortiert
|
||||||
Entwicklungsdatenbanken werden nicht aktualisiert. Nach Veröffentlichung der
|
werden. Korrekturen erfolgen immer über eine neue Migration.
|
||||||
ersten Version dürfen angewendete SQL-Migrationen nicht geändert oder neu
|
|
||||||
sortiert werden; Korrekturen erfolgen dann immer über eine neue Migration.
|
|
||||||
|
|
||||||
## Pull-Request-Handoff
|
## Pull-Request-Handoff
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
# 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.
|
|
||||||
137
docs/local-db-circuit-first-migration.md
Normal file
137
docs/local-db-circuit-first-migration.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Local DB Migration: Circuit-First Schema
|
||||||
|
|
||||||
|
This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in `src/db/migrations`.
|
||||||
|
|
||||||
|
This document is an upgrade runbook for databases created before the Circuit-First
|
||||||
|
cutover. A clean installation only needs the normal `db:migrate` and schema
|
||||||
|
verification steps documented in the main README.
|
||||||
|
|
||||||
|
## Safe command order
|
||||||
|
|
||||||
|
1. Backup local DB (required before schema/data migration)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:backup
|
||||||
|
```
|
||||||
|
|
||||||
|
The command uses SQLite's online backup API, so committed WAL changes are included
|
||||||
|
even while the application is running. It opens the resulting standalone database
|
||||||
|
and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass.
|
||||||
|
Database backups are operational recovery files and remain separate from the future
|
||||||
|
user-visible project version history.
|
||||||
|
|
||||||
|
2. Apply pending schema migrations (includes `0008_circuit_first_model`, the additive
|
||||||
|
`0009_project_device_circuit_fields` transition and the post-cutover
|
||||||
|
`0011_project_device_canonical_fields` cleanup)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify circuit-first tables exist
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:verify:circuit-schema
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Backfill default sections for existing `circuit_lists`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:backfill:sections
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run legacy consumer -> circuit/device-row migration explicitly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate:legacy-consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification SQL
|
||||||
|
|
||||||
|
Run these against `data/leistungsbilanz.db`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name IN (
|
||||||
|
'circuit_sections',
|
||||||
|
'circuits',
|
||||||
|
'circuit_device_rows',
|
||||||
|
'legacy_consumer_circuit_migrations',
|
||||||
|
'legacy_consumer_migration_reports'
|
||||||
|
)
|
||||||
|
ORDER BY name;
|
||||||
|
```
|
||||||
|
|
||||||
|
The verification command also checks the circuit-first project-device columns added by migration `0009`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT circuit_list_id, key, prefix, sort_order
|
||||||
|
FROM circuit_sections
|
||||||
|
ORDER BY circuit_list_id, sort_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT circuit_list_id, COUNT(*) AS circuits
|
||||||
|
FROM circuits
|
||||||
|
GROUP BY circuit_list_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT c.circuit_list_id, COUNT(r.id) AS device_rows
|
||||||
|
FROM circuits c
|
||||||
|
LEFT JOIN circuit_device_rows r ON r.circuit_id = c.id
|
||||||
|
GROUP BY c.circuit_list_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## API verification
|
||||||
|
|
||||||
|
After the steps above:
|
||||||
|
|
||||||
|
`GET /api/projects/:projectId/circuit-lists/:circuitListId/tree`
|
||||||
|
|
||||||
|
Expected response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"circuitListId": "string",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"key": "lighting|single_phase|three_phase|unassigned|...",
|
||||||
|
"displayName": "string",
|
||||||
|
"prefix": "-1F|-2F|-3F|-UF|...",
|
||||||
|
"sortOrder": 10,
|
||||||
|
"circuits": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"equipmentIdentifier": "-2F1",
|
||||||
|
"displayName": "string",
|
||||||
|
"sortOrder": 10,
|
||||||
|
"isReserve": false,
|
||||||
|
"circuitTotalPower": 1.23,
|
||||||
|
"deviceRows": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"displayName": "string",
|
||||||
|
"quantity": 1,
|
||||||
|
"powerPerUnit": 0.3,
|
||||||
|
"simultaneityFactor": 1,
|
||||||
|
"rowTotalPower": 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If migrations were not applied, endpoint may return an empty fallback with a warning.
|
||||||
|
|
||||||
|
## Visual verification
|
||||||
|
|
||||||
|
Manual device rows and multi-device circuits can be created directly in the
|
||||||
|
circuit-list editor. The former development scripts for direct database writes
|
||||||
|
were removed because they bypassed project revisions and persistent undo/redo.
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
# 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,8 +9,7 @@ This document records architectural direction for future work. It is not a final
|
||||||
- persist undo/redo across page reloads and application restarts
|
- persist undo/redo across page reloads and application restarts
|
||||||
- provide a project-wide version history with named restore points
|
- provide a project-wide version history with named restore points
|
||||||
- preserve an auditable sequence of user, import, migration and restore operations
|
- preserve an auditable sequence of user, import, migration and restore operations
|
||||||
- support the Revit/Linear-XLSX round-trip using stable, exact
|
- support future Revit/CSV round-trips using stable IFCGUID-based links
|
||||||
Worksharing-ID-based external identity
|
|
||||||
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
||||||
|
|
||||||
## Separate Concerns
|
## Separate Concerns
|
||||||
|
|
@ -85,8 +84,7 @@ Implemented named-snapshot foundation:
|
||||||
stores the complete project state in one SQLite transaction
|
stores the complete project state in one SQLite transaction
|
||||||
- the versioned payload contains project settings, distribution boards,
|
- the versioned payload contains project settings, distribution boards,
|
||||||
circuit lists, sections, circuits with device rows, project devices, floors
|
circuit lists, sections, circuits with device rows, project devices, floors
|
||||||
and rooms while excluding the global device catalog and other
|
and rooms while excluding global and upgrade-only data
|
||||||
non-project-owned data
|
|
||||||
- snapshot creation/listing does not create a project revision or affect
|
- snapshot creation/listing does not create a project revision or affect
|
||||||
undo/redo eligibility
|
undo/redo eligibility
|
||||||
- public Create/List endpoints expose metadata but not the logical payload
|
- public Create/List endpoints expose metadata but not the logical payload
|
||||||
|
|
@ -95,6 +93,8 @@ Implemented named-snapshot foundation:
|
||||||
- restore records a new `restore` revision containing both the selected target
|
- restore records a new `restore` revision containing both the selected target
|
||||||
state and the complete pre-restore inverse; undo/redo therefore remains
|
state and the complete pre-restore inverse; undo/redo therefore remains
|
||||||
available after application restarts
|
available after application restarts
|
||||||
|
- compatible upgrade-only Consumer links, mappings and reports are preserved
|
||||||
|
separately and are never imported into the logical snapshot payload
|
||||||
- arbitrary restore payloads are rejected by the generic user-command
|
- arbitrary restore payloads are rejected by the generic user-command
|
||||||
endpoint; only server-stored snapshots can initiate a forward restore
|
endpoint; only server-stored snapshots can initiate a forward restore
|
||||||
- the project page provides an initially collapsed snapshot/timeline panel,
|
- the project page provides an initially collapsed snapshot/timeline panel,
|
||||||
|
|
@ -112,12 +112,7 @@ Implemented automatic-snapshot policy:
|
||||||
- automatic capture and retention run inside the same transaction as the
|
- automatic capture and retention run inside the same transaction as the
|
||||||
triggering project revision
|
triggering project revision
|
||||||
|
|
||||||
## Revit / Linear Excel Round-Trip Direction
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
Phase 14 is active and follows the
|
|
||||||
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
|
|
||||||
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The CSV/IFCGUID runtime has
|
|
||||||
been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID.
|
|
||||||
|
|
||||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
|
|
||||||
|
|
@ -126,12 +121,9 @@ Conceptual entities:
|
||||||
- `ExternalModelSource`
|
- `ExternalModelSource`
|
||||||
- identifies a Revit/IFC model or another external source within a project
|
- identifies a Revit/IFC model or another external source within a project
|
||||||
- `ExternalImportBatch`
|
- `ExternalImportBatch`
|
||||||
- file name, checksum, import time, object count and processing status
|
- file name, checksum, import time, format/version and processing status
|
||||||
- only the latest batch keeps original workbook bytes, matrix and the
|
|
||||||
configuration copy; older batches keep date, filename, checksum and count
|
|
||||||
- `ExternalModelObject`
|
- `ExternalModelObject`
|
||||||
- exact case-sensitive Worksharing-ID as external identity
|
- stable IFCGUID and optional Revit element identifiers
|
||||||
- optional IfcGUID as non-unique source redundancy
|
|
||||||
- source attributes and last-seen import batch
|
- source attributes and last-seen import batch
|
||||||
- `ExternalObjectLink`
|
- `ExternalObjectLink`
|
||||||
- explicit relationship to a project device, circuit device row or another future domain entity
|
- explicit relationship to a project device, circuit device row or another future domain entity
|
||||||
|
|
@ -142,33 +134,13 @@ Conceptual entities:
|
||||||
|
|
||||||
Required behavior:
|
Required behavior:
|
||||||
|
|
||||||
- Worksharing-ID uniqueness is scoped to the external source and uses exact,
|
- IFCGUID uniqueness is scoped to project and external model source.
|
||||||
case-sensitive comparison. IfcGUID is not a matching key.
|
|
||||||
- Parsing writes to staging first.
|
- Parsing writes to staging first.
|
||||||
- Users preview additions, changes, missing objects and conflicts before applying them.
|
- Users preview additions, changes, missing objects and conflicts before applying them.
|
||||||
- Applying an import creates one project revision and one auditable change set.
|
- Applying an import creates one project revision and one auditable change set.
|
||||||
- Imported source values and locally assigned planning values remain distinguishable.
|
- Imported source values and locally assigned planning values remain distinguishable.
|
||||||
- Export retains Worksharing-ID so Linear can match rows deterministically.
|
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
|
||||||
- Missing objects and duplicate/conflicting Worksharing-IDs are reported,
|
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
|
||||||
never silently reassigned.
|
|
||||||
|
|
||||||
R10 verifies the complete implemented path through initial import, planning,
|
|
||||||
row assignment, return workbook, follow-up import, snapshot restore, portable
|
|
||||||
duplicate and restart-safe Undo/Redo. Large state replacement uses bounded
|
|
||||||
SQLite insert batches so imports, restores and duplicates do not exceed the
|
|
||||||
driver's SQL-variable limit.
|
|
||||||
|
|
||||||
The project configuration has no separate revision counter. Stateless plans
|
|
||||||
and apply requests bind to `expectedRevision`; every applied import batch holds
|
|
||||||
its configuration copy. Apply reparses the retransmitted workbook. Undo and
|
|
||||||
Redo operate only on persisted complete before/after states and never parse an
|
|
||||||
Excel file.
|
|
||||||
|
|
||||||
Deleting a linked row, circuit or group detaches affected Revit objects in the
|
|
||||||
same typed command after an explicit warning; its inverse restores all links.
|
|
||||||
Only deleting referenced rooms and distribution boards is blocked. Linked
|
|
||||||
objects always derive their board from their row, while a room default-board
|
|
||||||
change affects only unassigned objects.
|
|
||||||
|
|
||||||
## SQLite and PostgreSQL Direction
|
## SQLite and PostgreSQL Direction
|
||||||
|
|
||||||
|
|
@ -291,9 +263,8 @@ Completed foundation:
|
||||||
- `project-device.insert` and `project-device.delete` preserve stable device
|
- `project-device.insert` and `project-device.delete` preserve stable device
|
||||||
ids; delete undo restores links only from complete unchanged disconnected-row
|
ids; delete undo restores links only from complete unchanged disconnected-row
|
||||||
snapshots in the same transaction
|
snapshots in the same transaction
|
||||||
- `project.update-settings` persists the public-building classification and
|
- `project.update-settings` persists both project voltage defaults, its exact
|
||||||
both project voltage defaults with their exact inverse, revision and history
|
inverse, revision and history transition atomically; the project page and
|
||||||
transition atomically; the project page and
|
|
||||||
existing PUT route require the current expected revision; the same
|
existing PUT route require the current expected revision; the same
|
||||||
transaction derives every project-device and circuit voltage again, so
|
transaction derives every project-device and circuit voltage again, so
|
||||||
Undo/Redo cannot leave mixed voltage states
|
Undo/Redo cannot leave mixed voltage states
|
||||||
|
|
@ -304,10 +275,6 @@ Completed foundation:
|
||||||
- `project-floor.insert` and `project-room.insert` persist complete location
|
- `project-floor.insert` and `project-room.insert` persist complete location
|
||||||
records with stable ids; their inverses reject changed or referenced records
|
records with stable ids; their inverses reject changed or referenced records
|
||||||
so Undo never clears room assignments or device-row links implicitly
|
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
|
- the floor and room POST routes require the current expected revision and the
|
||||||
project page tracks the returned history state
|
project page tracks the returned history state
|
||||||
|
|
||||||
|
|
@ -317,8 +284,7 @@ Remaining constraints before completing project version history:
|
||||||
boundary; extend it whenever further project mutations are introduced
|
boundary; extend it whenever further project mutations are introduced
|
||||||
- project creation remains initialization at revision zero, while global device
|
- project creation remains initialization at revision zero, while global device
|
||||||
templates remain outside any single project history
|
templates remain outside any single project history
|
||||||
- do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
identifier or an internal relation id
|
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
## Deferred Decisions
|
## Deferred Decisions
|
||||||
|
|
@ -326,6 +292,5 @@ Remaining constraints before completing project version history:
|
||||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
- branch visualization after undo followed by new edits
|
- branch visualization after undo followed by new edits
|
||||||
- user/role model and actor attribution
|
- user/role model and actor attribution
|
||||||
- Linear-XLSX compatibility beyond the observed sample and any future
|
- supported Revit CSV dialects and parameter mapping UI
|
||||||
project-independent mapping profiles
|
|
||||||
- PostgreSQL deployment topology and migration date
|
- PostgreSQL deployment topology and migration date
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,269 @@
|
||||||
# LLM-Kontext für die Revit-Integration
|
# Kontext für die Revit-Anforderungsplanung
|
||||||
|
|
||||||
Die frühere Revit-/CSV-Anforderungsplanung ist abgelöst und Phase 14 ist mit
|
## Zweck dieser Datei
|
||||||
R10 abgeschlossen. Für Arbeiten an der Linear-XLSX-Anbindung sind vollständig
|
|
||||||
zu lesen:
|
|
||||||
|
|
||||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||||
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||||
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||||
- [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)
|
|
||||||
|
|
||||||
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
|
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
||||||
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad
|
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||||
ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen
|
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||||
Migrationsmeldung abgelehnt.
|
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,
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ This is a conceptual model for Codex. It is not a final database schema, but the
|
||||||
|
|
||||||
A distribution board contains a circuit list.
|
A distribution board contains a circuit list.
|
||||||
|
|
||||||
A circuit list contains circuit groups. `CircuitSection` is the persisted name
|
A circuit list contains sections.
|
||||||
of that group aggregate.
|
|
||||||
|
|
||||||
A circuit group contains circuits of one category.
|
A section contains circuits.
|
||||||
|
|
||||||
A circuit contains zero, one or multiple device rows.
|
A circuit contains zero, one or multiple device rows.
|
||||||
|
|
||||||
|
|
@ -61,28 +60,29 @@ User-facing fields:
|
||||||
- `section`
|
- `section`
|
||||||
- `displayName` optional
|
- `displayName` optional
|
||||||
- `circuitTotalPower`
|
- `circuitTotalPower`
|
||||||
- `protectionDevice`
|
- `protectionType`
|
||||||
|
- `protectionRatedCurrent`
|
||||||
|
- `protectionCharacteristic`
|
||||||
- `cableType`
|
- `cableType`
|
||||||
- `cableCrossSection`
|
- `cableCrossSection`
|
||||||
- `cableLength`
|
- `cableLength`
|
||||||
|
- `rcdAssignment` optional
|
||||||
- `terminalDesignation` optional
|
- `terminalDesignation` optional
|
||||||
- `voltage`, derived from project settings and phase category
|
- `voltage` optional
|
||||||
- `controlRequirement` optional, for example DALI or KNX
|
- `controlRequirement` optional, for example DALI or KNX
|
||||||
- `status` optional
|
- `status` optional
|
||||||
- `isReserve` optional
|
- `isReserve` optional
|
||||||
- `remark` optional
|
- `remark` optional
|
||||||
|
|
||||||
`protectionDevice` is the circuit-owned one-to-one protection configuration.
|
|
||||||
It contains the device type, rated current and only the type-dependent values
|
|
||||||
such as trip characteristic or residual current. Newly created circuits
|
|
||||||
receive the category default defined in the protection catalog.
|
|
||||||
|
|
||||||
Circuit-level fields only:
|
Circuit-level fields only:
|
||||||
|
|
||||||
- protection device and all of its technical values
|
- protection type
|
||||||
|
- protection rated current
|
||||||
|
- protection characteristic
|
||||||
- cable type
|
- cable type
|
||||||
- cable cross-section
|
- cable cross-section
|
||||||
- cable length
|
- cable length
|
||||||
|
- RCD assignment
|
||||||
- terminal designation
|
- terminal designation
|
||||||
- circuit total power
|
- circuit total power
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,12 @@ Future suggestions or checks may include:
|
||||||
- cable type
|
- cable type
|
||||||
- number of cores
|
- number of cores
|
||||||
- cable cross-section
|
- cable cross-section
|
||||||
- halogen-free cable or line requirement for public buildings
|
|
||||||
- minimum cable cross-section based on current
|
- minimum cable cross-section based on current
|
||||||
- maximum permissible cable length based on voltage drop
|
- maximum permissible cable length based on voltage drop
|
||||||
- warning messages if cable length, cross-section or protection device do not match
|
- warning messages if cable length, cross-section or protection device do not match
|
||||||
|
|
||||||
Users must remain able to manually override suggestions.
|
Users must remain able to manually override suggestions.
|
||||||
|
|
||||||
The project-owned `isPublicBuilding` setting is the authoritative input for
|
|
||||||
the later halogen-free selection rule. The setting is persisted now; this
|
|
||||||
document does not yet define or implement the complete cable catalog or sizing
|
|
||||||
algorithm.
|
|
||||||
|
|
||||||
## Initial Design Rule Examples
|
## Initial Design Rule Examples
|
||||||
|
|
||||||
These are common planning defaults, not a replacement for full norm-compliant calculation.
|
These are common planning defaults, not a replacement for full norm-compliant calculation.
|
||||||
|
|
|
||||||
|
|
@ -342,8 +342,9 @@ Implemented foundation:
|
||||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||||
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||||
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||||
- the former Consumer migration service, mappings and reports were removed with
|
- the legacy consumer migration service depends only on narrow domain ports;
|
||||||
the pre-release database cutover
|
all concrete repositories are composed at the script entry point
|
||||||
|
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
|
||||||
- circuit and device-row persistence value mapping is separated from the general repositories
|
- circuit and device-row persistence value mapping is separated from the general repositories
|
||||||
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
|
- obsolete direct Circuit, CircuitDeviceRow, CircuitList, DistributionBoard and
|
||||||
Project repository writes are removed; integration fixtures live under
|
Project repository writes are removed; integration fixtures live under
|
||||||
|
|
@ -361,9 +362,8 @@ Implemented foundation:
|
||||||
direct global SQLite access
|
direct global SQLite access
|
||||||
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
|
- TypeScript maintenance and upgrade scripts have a dedicated no-emit
|
||||||
typecheck, including all referenced application modules
|
typecheck, including all referenced application modules
|
||||||
- the former Consumer UI, API, tables and upgrade tooling are removed
|
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
|
||||||
- migration `0000` is the clean baseline for new installations; pre-baseline
|
- retained legacy rows are accessible only through explicit database upgrade tooling
|
||||||
development databases are intentionally unsupported
|
|
||||||
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
|
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
|
||||||
|
|
||||||
## Phase 12: Project Revisions and Persistent Undo / Redo
|
## Phase 12: Project Revisions and Persistent Undo / Redo
|
||||||
|
|
@ -465,9 +465,9 @@ Implemented foundation:
|
||||||
- ProjectDevice synchronization and disconnect endpoints also require
|
- ProjectDevice synchronization and disconnect endpoints also require
|
||||||
`expectedRevision`; their UI undo uses project-wide persistent history and
|
`expectedRevision`; their UI undo uses project-wide persistent history and
|
||||||
the obsolete direct restore/reconnect write paths are removed
|
the obsolete direct restore/reconnect write paths are removed
|
||||||
- project settings use `project.update-settings`; metadata, public-building
|
- project voltage settings use `project.update-settings`; both values, inverse,
|
||||||
classification, voltage values, inverse, revision and history transition
|
revision and history transition commit atomically and the former direct
|
||||||
commit atomically and the former direct settings write is removed
|
settings write is removed
|
||||||
- distribution-board setup uses `distribution-board.insert` with a complete
|
- distribution-board setup uses `distribution-board.insert` with a complete
|
||||||
stable board/list/default-section snapshot; Undo removes only an unchanged
|
stable board/list/default-section snapshot; Undo removes only an unchanged
|
||||||
empty setup and the former direct controller write is removed
|
empty setup and the former direct controller write is removed
|
||||||
|
|
@ -532,37 +532,26 @@ Acceptance criteria:
|
||||||
|
|
||||||
## Phase 14: External Model Round-Trip
|
## Phase 14: External Model Round-Trip
|
||||||
|
|
||||||
Status: abgeschlossen am 30.09.2026 mit R10. Siehe
|
Goal:
|
||||||
[R10-Abnahme](revit-excel-r10-validation.md).
|
|
||||||
|
|
||||||
Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf
|
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
|
||||||
Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
|
|
||||||
abgelöst und wird nicht fortgeschrieben.
|
|
||||||
|
|
||||||
Verbindliche Quellen:
|
Tasks:
|
||||||
|
|
||||||
- [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md)
|
- model external sources, import batches and stable IFCGUID objects
|
||||||
- [R0–R10-Plan](revit-excel-phase-14-plan.md)
|
- stage and validate imports before applying them
|
||||||
- [R0-Bestandsaudit](revit-excel-r0-audit.md)
|
- 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
|
Acceptance criteria:
|
||||||
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.
|
|
||||||
|
|
||||||
Kernabnahme der Phase:
|
- repeated imports match objects deterministically by source and IFCGUID
|
||||||
|
- imports never silently overwrite local planning values
|
||||||
- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
|
- users approve the diff before it changes the project
|
||||||
- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
|
- exported data can be assigned back to the correct Revit objects
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
@ -570,11 +559,11 @@ PostgreSQL should be introduced when shared multi-user operation, background job
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
|
|
||||||
With the former Consumer path removed, provide one accurate entry point for developers, contributors and LLM-assisted work.
|
After the legacy consumer path has been removed, provide one accurate entry point for developers, contributors and LLM-assisted work.
|
||||||
|
|
||||||
Timing:
|
Timing:
|
||||||
|
|
||||||
- keep the post-cleanup documentation limited to the supported architecture
|
- perform the final rewrite after legacy cutover so the documentation describes only the supported architecture
|
||||||
- update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design
|
- update documentation incrementally during earlier phases, but do not present transitional paths as the long-term design
|
||||||
|
|
||||||
README target structure:
|
README target structure:
|
||||||
|
|
@ -629,8 +618,7 @@ Implemented handoff:
|
||||||
|
|
||||||
- README is a concise setup and project entry point without prototype status lists
|
- README is a concise setup and project entry point without prototype status lists
|
||||||
- `docs/README.md` separates current, operational, future, specification and archived material
|
- `docs/README.md` separates current, operational, future, specification and archived material
|
||||||
- current runtime and module boundaries are documented without compatibility
|
- current runtime, module boundaries and upgrade-only legacy data are documented explicitly
|
||||||
paths for unsupported pre-release data formats
|
|
||||||
- contributor workflow covers clean databases, safe demo data, migrations, tests and commits
|
- contributor workflow covers clean databases, safe demo data, migrations, tests and commits
|
||||||
- development Compose is clearly separated from the currently unsupported production deployment
|
- development Compose is clearly separated from the currently unsupported production deployment
|
||||||
- `AGENTS.md` points LLM-assisted work to supported code paths and deferred architecture
|
- `AGENTS.md` points LLM-assisted work to supported code paths and deferred architecture
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@ requirements and intended sequencing, not proof of implementation.
|
||||||
|
|
||||||
## Distribution Board Components and Protection Groups
|
## Distribution Board Components and Protection Groups
|
||||||
|
|
||||||
- [x] Implement the agreed protected circuit-group and distribution-board
|
- [ ] Implement the agreed protected circuit-group and distribution-board
|
||||||
component model in phased work packages.
|
component model in phased work packages.
|
||||||
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
|
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
|
||||||
BMK rules.
|
BMK rules.
|
||||||
- [x] Phase B1: relational group, component and protection tables with shared
|
- [x] Phase B1: additive relational schema, group backfill, component/protection
|
||||||
BMK uniqueness.
|
tables and shared BMK uniqueness.
|
||||||
- [x] Phase B2: complete snapshot/transfer schema for the supported project
|
- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
|
||||||
state.
|
mapping.
|
||||||
- [x] Phase C1: new-board command with three groups and fixed header
|
- [x] Phase C1: versioned new-board command with three groups and fixed header
|
||||||
components.
|
components.
|
||||||
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
||||||
protection and auxiliary components.
|
protection and auxiliary components.
|
||||||
|
|
@ -33,44 +33,32 @@ requirements and intended sequencing, not proof of implementation.
|
||||||
- [x] Phase D3b: persistent populated-group delete/restore command.
|
- [x] Phase D3b: persistent populated-group delete/restore command.
|
||||||
- [x] Phase E1: tree read model for components, groups and protection devices.
|
- [x] Phase E1: tree read model for components, groups and protection devices.
|
||||||
- [x] Phase E2a: pure header/group/footer structure projection.
|
- [x] Phase E2a: pure header/group/footer structure projection.
|
||||||
- [x] Phase E2b: render the structure projection in the editor grid.
|
- [ ] Phase E2b: render the structure projection in the editor grid.
|
||||||
- [x] Phase E3a: create, edit and delete mutable group and footer components.
|
- [ ] Phase E: editor projection and editing.
|
||||||
- [x] Phase E3b1: create, rename and safely delete empty circuit groups.
|
- [ ] Phase F: documentation and full GUI verification.
|
||||||
- [x] Phase E3b2a: persistent circuit-protection update command.
|
|
||||||
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
|
|
||||||
- [x] Phase E4a: persistent same-category group reorder controls.
|
|
||||||
- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
|
|
||||||
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
|
|
||||||
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
|
|
||||||
- [x] Populated-group delete warning and explicit subtree deletion.
|
|
||||||
- [x] Phase E: editor projection and editing.
|
|
||||||
- [x] Phase F: documentation and full GUI verification.
|
|
||||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||||
the planner supplies the calculation requirements. The optional per-circuit
|
the planner supplies the calculation requirements.
|
||||||
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
|
|
||||||
tables were confirmed against DIN VDE 0298-4:2023-06 with
|
|
||||||
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
|
|
||||||
- Detailed specification:
|
- Detailed specification:
|
||||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||||
|
|
||||||
## First Release Baseline
|
## First Release Baseline
|
||||||
|
|
||||||
The pre-release cleanup established the following compatibility boundary:
|
Complete this immediately before the first supported release, once the schema
|
||||||
|
and initial feature set are frozen:
|
||||||
|
|
||||||
- [x] Pre-baseline databases, portable JSON exports, logical snapshots and
|
- Decide explicitly whether pre-release databases, portable JSON exports,
|
||||||
persisted command histories are explicitly unsupported.
|
logical snapshots and persisted command histories remain supported.
|
||||||
- [x] Migration `0000` builds the complete supported schema on an empty
|
- Create one clean baseline migration that builds the complete release schema
|
||||||
database.
|
on an empty database.
|
||||||
- [x] The development migration chain and Drizzle metadata were replaced
|
- Replace the active development migration chain and Drizzle metadata only if
|
||||||
before any supported release depended on them.
|
no released installation depends on it.
|
||||||
- [x] Upgrade-only database, snapshot and command compatibility paths were
|
- Remove upgrade-only database, snapshot and command compatibility code only
|
||||||
removed.
|
for formats that are explicitly declared unsupported.
|
||||||
- [x] Pre-cleanup development history remains available through Git history.
|
- Preserve the pre-release migration history in Git history or a release tag.
|
||||||
- [x] Clean installation, migration, startup and core workflows were verified
|
- Verify installation, migrations, application startup and core workflows
|
||||||
against an empty database.
|
against a completely empty database.
|
||||||
- [ ] Mark this compatibility boundary with the first supported release tag.
|
- Mark the resulting compatibility boundary with the first release tag.
|
||||||
- After that release, every published migration is immutable and later schema
|
- Treat every migration published after that release as immutable.
|
||||||
changes must use new additive migrations.
|
|
||||||
|
|
||||||
## Project History
|
## Project History
|
||||||
|
|
||||||
|
|
@ -112,16 +100,9 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
device rows with a valid phase are three-phase.
|
device rows with a valid phase are three-phase.
|
||||||
- [x] Changing project voltage settings updates all project devices and circuits
|
- [x] Changing project voltage settings updates all project devices and circuits
|
||||||
atomically in the same persistent Undo/Redo step.
|
atomically in the same persistent Undo/Redo step.
|
||||||
- [x] Baseline migration `0000` stores only canonical derived values.
|
- [x] Database migration `0019` and snapshot schema version `5` normalize older
|
||||||
Pre-baseline imports are intentionally unsupported.
|
stored values. Version-four and older imports remain supported and are
|
||||||
|
normalized while being upgraded.
|
||||||
## Building Classification
|
|
||||||
|
|
||||||
- [x] Store whether a project represents a public building.
|
|
||||||
- [x] Edit the value in the project settings and preserve it through persistent
|
|
||||||
Undo/Redo, snapshots and portable project transfers.
|
|
||||||
- [x] Provide the value as an explicit future sizing input for selecting
|
|
||||||
halogen-free cables and lines; automatic sizing remains deferred.
|
|
||||||
|
|
||||||
## Circuit List Power Summary
|
## Circuit List Power Summary
|
||||||
|
|
||||||
|
|
@ -135,26 +116,6 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
- [x] Keep column visibility and order as a project-specific UI preference so
|
- [x] Keep column visibility and order as a project-specific UI preference so
|
||||||
all distribution boards in the same project open with the same columns.
|
all distribution boards in the same project open with the same columns.
|
||||||
|
|
||||||
## Integrated Contributor Features (September 2026)
|
|
||||||
|
|
||||||
- [x] Client ids without secure-context browser APIs (`createClientId`).
|
|
||||||
- [x] Icon actions, compact column headers and the AMEV picker in the circuit
|
|
||||||
list; icon actions and collapsible filters on the project overview.
|
|
||||||
- [x] Print view with cover, board overview, board plans, board info card and
|
|
||||||
office branding.
|
|
||||||
- [x] Buildings above floors and a dedicated building structure page.
|
|
||||||
- [x] Optional net room area with sums per floor and building.
|
|
||||||
- [x] Optional cable-sizing module for one circuit on demand
|
|
||||||
(DIN VDE 0298-4:2023-06 with Berichtigung 1).
|
|
||||||
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison
|
|
||||||
with the detailed balance and the explicit copy of one row as a
|
|
||||||
ProjectDevice.
|
|
||||||
- [x] Logical distribution-board feed link with an optional feeding circuit,
|
|
||||||
recursive load rollup and deletion guards (#3).
|
|
||||||
|
|
||||||
Open follow-ups (Gitea issues): bill of quantities export (#5), area-based loads in W/m² for
|
|
||||||
ProjectDevices and device rows (#20).
|
|
||||||
|
|
||||||
## Recommended Sequence
|
## Recommended Sequence
|
||||||
|
|
||||||
1. [x] Distribution-board floor and supply fields.
|
1. [x] Distribution-board floor and supply fields.
|
||||||
|
|
@ -162,7 +123,6 @@ ProjectDevices and device rows (#20).
|
||||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
4. [x] Distribution-board power summary and project column layout.
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses
|
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||||
exact Worksharing-ID identity and must not be inferred from these unrelated
|
foundation as a separate jointly planned phase and must not be inferred from
|
||||||
backlog tasks. See
|
these tasks.
|
||||||
[the R0–R10 plan](revit-excel-phase-14-plan.md).
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This document records the agreed and implemented behavior for
|
This document defines the agreed target behavior for distribution-board
|
||||||
distribution-board components, protected circuit groups and circuit
|
components, protected circuit groups and circuit protection devices. Phase A
|
||||||
protection devices. Delivery phases A through F are complete. The current
|
and the relational Phase-B1 foundation are implemented; later sections remain
|
||||||
runtime paths are documented in `docs/current-architecture.md`.
|
requirements and are not proof of runtime or editor support.
|
||||||
|
|
||||||
Full rule-based protection and cable sizing is deliberately deferred. This
|
Full rule-based protection and cable sizing is deliberately deferred. This
|
||||||
phase establishes the structure and manually selected technical values that a
|
phase establishes the structure and manually selected technical values that a
|
||||||
|
|
@ -33,7 +33,7 @@ later calculation and warning engine will evaluate.
|
||||||
- channel-level relationships between actors and controlled circuits
|
- channel-level relationships between actors and controlled circuits
|
||||||
- PostgreSQL or multi-user operation
|
- PostgreSQL or multi-user operation
|
||||||
|
|
||||||
## Implemented Model Boundary
|
## Existing Model Audit
|
||||||
|
|
||||||
### Keep
|
### Keep
|
||||||
|
|
||||||
|
|
@ -43,28 +43,33 @@ later calculation and warning engine will evaluate.
|
||||||
- Circuit-level load, cable and control fields remain circuit-owned.
|
- Circuit-level load, cable and control fields remain circuit-owned.
|
||||||
- Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo
|
- Existing stable UUIDs, project revisions, snapshots and persistent Undo/Redo
|
||||||
remain authoritative.
|
remain authoritative.
|
||||||
- `CircuitSection` is the persisted representation of a protected circuit
|
- `CircuitSection` remains the migration starting point for a protected circuit
|
||||||
group.
|
group.
|
||||||
- Grid projection remains separate from persisted domain structure.
|
- Grid projection remains separate from persisted domain structure.
|
||||||
|
|
||||||
### Change
|
### Change
|
||||||
|
|
||||||
- A `CircuitSection` has an explicit category and group number and represents
|
- A `CircuitSection` gains an explicit category and group number and represents
|
||||||
one protected circuit group.
|
one protected circuit group.
|
||||||
- Circuit protection uses an explicit
|
- Circuit protection changes from loosely related flat strings to an explicit
|
||||||
one-to-one protection-device model.
|
one-to-one protection-device model.
|
||||||
- Distribution-board components become independent BMK-bearing list entries.
|
- Distribution-board components become independent BMK-bearing list entries.
|
||||||
- Equipment-identifier uniqueness covers circuits and all distribution-board
|
- Equipment-identifier uniqueness covers circuits and all distribution-board
|
||||||
components in the complete circuit list.
|
components in the complete circuit list.
|
||||||
- Moving a circuit to another group of the same category atomically assigns the
|
- Moving a circuit to another group of the same category atomically assigns the
|
||||||
next identifier of the target group.
|
next identifier of the target group.
|
||||||
- New distribution boards create exactly three default groups.
|
- New distribution boards create three default groups rather than treating the
|
||||||
|
optional unassigned compatibility section as a normal fourth group.
|
||||||
|
|
||||||
### Remaining Model Cleanup
|
### Remove Eventually
|
||||||
|
|
||||||
- The older optional `rcdAssignment` circuit field still exists separately
|
- hard-coded assumptions that each category has exactly one section
|
||||||
from the explicit group relationship. Removing it requires its own reviewed
|
- numbering that only understands `prefix + integer`
|
||||||
domain and UI change; it is not a compatibility path for pre-release data.
|
- free-text protection-device combinations that cannot be validated
|
||||||
|
- `rcdAssignment` as a substitute for an explicit group relationship
|
||||||
|
|
||||||
|
Removal happens only after migrated state, snapshots, commands and the editor
|
||||||
|
use the replacement model.
|
||||||
|
|
||||||
## Target Domain Structure
|
## Target Domain Structure
|
||||||
|
|
||||||
|
|
@ -458,7 +463,7 @@ Every command must:
|
||||||
- commit the domain mutation, revision and history transition atomically
|
- commit the domain mutation, revision and history transition atomically
|
||||||
- remain undoable and redoable after application restart
|
- remain undoable and redoable after application restart
|
||||||
|
|
||||||
## Snapshot and Transfer Boundary
|
## Snapshot and Transfer Compatibility
|
||||||
|
|
||||||
The supported logical project snapshot and portable JSON transfer must include:
|
The supported logical project snapshot and portable JSON transfer must include:
|
||||||
|
|
||||||
|
|
@ -467,15 +472,28 @@ The supported logical project snapshot and portable JSON transfer must include:
|
||||||
- circuit protection-device state
|
- circuit protection-device state
|
||||||
- all new component relationships and sort positions
|
- all new component relationships and sort positions
|
||||||
|
|
||||||
Current project-state schema version `2` contains the complete supported
|
Introducing the model requires a snapshot-schema version increase and explicit
|
||||||
structure above and the project building classification. It is shared by
|
upgraders for supported older payloads.
|
||||||
logical snapshots, restore, Undo/Redo and portable JSON transfer. Supported
|
|
||||||
baseline version `1` is upgraded with the non-public default; payloads from
|
|
||||||
before that baseline are rejected before any write.
|
|
||||||
|
|
||||||
Migration `0000` creates the complete relational model on an empty database.
|
Database migration rules:
|
||||||
New defaults apply when boards, groups or circuits are created; they never
|
|
||||||
silently overwrite a planner's later selection.
|
- preserve all existing stable UUIDs
|
||||||
|
- preserve existing circuit equipment identifiers during schema migration
|
||||||
|
- map the existing lighting, single-phase and three-phase sections to group 1
|
||||||
|
of the respective category
|
||||||
|
- preserve an existing unassigned section only when required by existing data;
|
||||||
|
it is not created for a new distribution board
|
||||||
|
- preserve existing flat circuit protection values when creating the new
|
||||||
|
one-to-one protection state
|
||||||
|
- do not silently replace an existing protection selection with a new default
|
||||||
|
- allow legacy identifiers to remain until an explicit, collision-safe
|
||||||
|
conversion or renumber action is approved
|
||||||
|
|
||||||
|
New defaults apply to newly created circuits and boards, not as an implicit
|
||||||
|
rewrite of existing planning data.
|
||||||
|
|
||||||
|
Historical persisted commands and snapshots that remain in the supported
|
||||||
|
compatibility window must still deserialize and execute after the migration.
|
||||||
|
|
||||||
## Later Sizing and Warning Engine
|
## Later Sizing and Warning Engine
|
||||||
|
|
||||||
|
|
@ -525,7 +543,7 @@ Acceptance:
|
||||||
- invalid rating/type/field combinations are rejected
|
- invalid rating/type/field combinations are rejected
|
||||||
- defaults produce the exact agreed values
|
- defaults produce the exact agreed values
|
||||||
|
|
||||||
### B. Persistence and Snapshot Model
|
### B. Persistence and Compatibility Model
|
||||||
|
|
||||||
Status: Complete.
|
Status: Complete.
|
||||||
|
|
||||||
|
|
@ -533,31 +551,44 @@ Status: Complete.
|
||||||
- add distribution-board component persistence
|
- add distribution-board component persistence
|
||||||
- add one-to-one circuit protection persistence
|
- add one-to-one circuit protection persistence
|
||||||
- implement shared BMK uniqueness
|
- implement shared BMK uniqueness
|
||||||
- create the complete relational baseline schema
|
- generate and inspect one additive migration
|
||||||
- include the model in snapshots and portable transfers
|
- add snapshot/transfer schema upgrade
|
||||||
- preserve identifiers and protection values within supported project state
|
- preserve existing identifiers and protection values
|
||||||
|
|
||||||
Implemented persistence:
|
Implemented in B1:
|
||||||
|
|
||||||
- baseline migration `0000` creates group identity, components and protection
|
- additive migration `0024` adds group identity to circuit sections and maps
|
||||||
state directly for clean installations
|
the three established sections to group 1 without changing UUIDs
|
||||||
- separate component and circuit/component protection tables preserve the
|
- separate component and circuit/component protection tables preserve the
|
||||||
one-to-one ownership boundaries
|
one-to-one ownership boundaries
|
||||||
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
|
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
|
||||||
across circuits and distribution-board components
|
across circuits and distribution-board components
|
||||||
- snapshot schema version `2` includes group identity, distribution-board
|
- the existing flat circuit protection fields remain untouched until their
|
||||||
|
deterministic snapshot/runtime transition is implemented
|
||||||
|
- clean and populated pre-migration databases are covered by migration tests
|
||||||
|
- newly created legacy-compatible board structures receive the same initial
|
||||||
|
group identity while their persisted command format remains unchanged
|
||||||
|
|
||||||
|
Implemented in B2:
|
||||||
|
|
||||||
|
- snapshot schema version 7 includes group identity, distribution-board
|
||||||
components and both one-to-one protection collections
|
components and both one-to-one protection collections
|
||||||
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
|
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
|
||||||
transfers preserve the complete new relational state
|
transfers preserve the complete new relational state
|
||||||
- duplicate imports remap component UUIDs and both protection-owner references
|
- duplicate imports remap component UUIDs and both protection-owner references
|
||||||
together with the existing project structure
|
together with the existing project structure
|
||||||
- snapshots reject invalid ownership, duplicate group numbers,
|
- versions 1 through 6 remain readable; version 6 maps the three established
|
||||||
|
section keys to group 1 and leaves new collections empty
|
||||||
|
- older flat circuit protection values remain present and are not guessed into
|
||||||
|
strict new protection configurations
|
||||||
|
- current snapshots reject invalid ownership, duplicate group numbers,
|
||||||
cross-entity BMKs and invalid protection configurations before persistence
|
cross-entity BMKs and invalid protection configurations before persistence
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
|
- a current database migrates without data loss
|
||||||
- an empty database creates the complete schema
|
- an empty database creates the complete schema
|
||||||
- unsupported pre-release snapshots and transfers are rejected before writing
|
- old supported snapshots and transfers upgrade deterministically
|
||||||
- BMK collisions across circuits and components are rejected
|
- BMK collisions across circuits and components are rejected
|
||||||
|
|
||||||
### C. Persistent Commands and Board Defaults
|
### C. Persistent Commands and Board Defaults
|
||||||
|
|
@ -571,13 +602,14 @@ Status: Complete.
|
||||||
|
|
||||||
Implemented in C1:
|
Implemented in C1:
|
||||||
|
|
||||||
- `distribution-board.insert` schema version `1` carries stable UUIDs for three
|
- `distribution-board.insert` schema version 3 carries stable UUIDs for three
|
||||||
initial groups, main switch `-Q0` and surge protective device `-FA`
|
initial groups, main switch `-Q0` and surge protective device `-FA`
|
||||||
- its inverse verifies and removes the unchanged complete structure; Redo
|
- its inverse verifies and removes the unchanged complete structure; Redo
|
||||||
restores the same UUIDs
|
restores the same UUIDs
|
||||||
- a late revision/history failure rolls back board, list, groups and components
|
- a late revision/history failure rolls back board, list, groups and components
|
||||||
together
|
together
|
||||||
- unsupported command schema versions are rejected
|
- stored schema versions 1 and 2 retain four legacy sections and receive no
|
||||||
|
invented components
|
||||||
|
|
||||||
Implemented in C2a1:
|
Implemented in C2a1:
|
||||||
|
|
||||||
|
|
@ -707,7 +739,8 @@ Acceptance:
|
||||||
|
|
||||||
### E. Editor Projection and Editing
|
### E. Editor Projection and Editing
|
||||||
|
|
||||||
Status: Complete.
|
Status: In progress. The complete tree read model E1 and pure structural
|
||||||
|
projection E2a are complete. Grid rendering and editing remain pending.
|
||||||
|
|
||||||
- render fixed header components
|
- render fixed header components
|
||||||
- render group components and circuit blocks
|
- render group components and circuit blocks
|
||||||
|
|
@ -716,7 +749,7 @@ Status: Complete.
|
||||||
- add type-dependent protection editing
|
- add type-dependent protection editing
|
||||||
- keep spreadsheet selection, keyboard and drag behavior coherent
|
- keep spreadsheet selection, keyboard and drag behavior coherent
|
||||||
|
|
||||||
Implemented in E1/E2a/E2b/E3a:
|
Implemented in E1/E2a:
|
||||||
|
|
||||||
- the tree response exposes fixed header, group and auxiliary footer
|
- the tree response exposes fixed header, group and auxiliary footer
|
||||||
components plus both one-to-one protection layers
|
components plus both one-to-one protection layers
|
||||||
|
|
@ -726,43 +759,6 @@ Implemented in E1/E2a/E2b/E3a:
|
||||||
circuit blocks and free placeholder together
|
circuit blocks and free placeholder together
|
||||||
- empty groups remain visible and circuit device rows remain owned by their
|
- empty groups remain visible and circuit device rows remain owned by their
|
||||||
circuit block
|
circuit block
|
||||||
- the editor renders fixed header, group protection and auxiliary footer
|
|
||||||
components as read-only full-width rows
|
|
||||||
- structural component rows are excluded from spreadsheet cell navigation,
|
|
||||||
insertion and deletion commands until their dedicated editing controls exist
|
|
||||||
- group headings offer at most one optional upstream protection device and one
|
|
||||||
optional group RCD; the agreed group-RCD default is applied only when it is
|
|
||||||
created
|
|
||||||
- mutable group protection and auxiliary footer components use labeled modals,
|
|
||||||
type-dependent protection fields and persistent project commands
|
|
||||||
- fixed main-switch and surge-protection header components remain read-only
|
|
||||||
- users can create a group in any supported category; its number is the
|
|
||||||
category's highest existing number plus one and its prefix is generated
|
|
||||||
- group editing changes only the display name; category, number, prefix and
|
|
||||||
child BMKs remain stable
|
|
||||||
- an exactly empty group can be removed through the compact persistent group
|
|
||||||
command
|
|
||||||
- `circuit-protection.update` inserts or updates one validated 1:1 protection
|
|
||||||
snapshot, rejects stale state and preserves exact persistent Undo/Redo
|
|
||||||
- user commands cannot remove circuit protection; a nullable target exists
|
|
||||||
only as the inverse of adding protection to a retained circuit without it
|
|
||||||
- newly inserted circuits and placeholder targets carry the category-specific
|
|
||||||
default protection in their complete atomic snapshot
|
|
||||||
- circuit deletion and restoration preserve the exact 1:1 protection row
|
|
||||||
together with the circuit and all device rows
|
|
||||||
- the editor modal exposes only fields valid for the selected protection type;
|
|
||||||
the old flat protection columns now project the new 1:1 data read-only
|
|
||||||
- group controls move a group only to the adjacent peer of the same category;
|
|
||||||
the complete list order is one persistent command and no number or BMK changes
|
|
||||||
- explicit category-wide group renumbering follows the visible persisted group
|
|
||||||
order, updates every affected prefix and child BMK atomically and preserves
|
|
||||||
outgoing-circuit suffixes
|
|
||||||
- dragging one circuit into another group of the same category moves the
|
|
||||||
complete circuit and assigns the highest target suffix plus one; same-group
|
|
||||||
multi-circuit drag remains a pure reorder
|
|
||||||
- populated-group deletion shows the affected circuit, device-row and group
|
|
||||||
protection counts before sending the complete subtree to the guarded
|
|
||||||
delete/restore command
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
|
|
@ -773,12 +769,8 @@ Acceptance:
|
||||||
|
|
||||||
### F. Documentation and Full Verification
|
### F. Documentation and Full Verification
|
||||||
|
|
||||||
Status: Complete. Documentation, automated verification and the agreed GUI
|
|
||||||
checklist are complete.
|
|
||||||
|
|
||||||
- update current architecture, API and interaction documentation
|
- update current architecture, API and interaction documentation
|
||||||
- update the safe local demo-data instructions; no versioned customer-like
|
- update demo/sample data
|
||||||
seed data is introduced
|
|
||||||
- run focused domain and transaction tests
|
- run focused domain and transaction tests
|
||||||
- run the complete required test, build and type-check suite
|
- run the complete required test, build and type-check suite
|
||||||
- perform the agreed GUI test checklist
|
- perform the agreed GUI test checklist
|
||||||
|
|
@ -786,13 +778,13 @@ checklist are complete.
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- documentation distinguishes implemented behavior from later sizing
|
- documentation distinguishes implemented behavior from later sizing
|
||||||
- clean installation and baseline migration work
|
- clean install and migrated database both work
|
||||||
- snapshots, JSON transfer and Undo/Redo cover all new state
|
- snapshots, JSON transfer and Undo/Redo cover all new state
|
||||||
|
|
||||||
## Risks and Mitigations
|
## Risks and Mitigations
|
||||||
|
|
||||||
- Incorrect implicit BMK changes:
|
- Mixed legacy and grouped BMKs:
|
||||||
preserve identifiers during sorting and require explicit renumbering.
|
preserve old identifiers and provide an explicit conversion path.
|
||||||
- Cross-entity BMK collisions:
|
- Cross-entity BMK collisions:
|
||||||
enforce one transaction-level uniqueness boundary for the complete list.
|
enforce one transaction-level uniqueness boundary for the complete list.
|
||||||
- Partial destructive writes:
|
- Partial destructive writes:
|
||||||
|
|
@ -803,19 +795,17 @@ Acceptance:
|
||||||
use one shared typed catalog for UI, API and domain validation.
|
use one shared typed catalog for UI, API and domain validation.
|
||||||
- Silent planning changes:
|
- Silent planning changes:
|
||||||
apply defaults only on creation and keep later recommendations explicit.
|
apply defaults only on creation and keep later recommendations explicit.
|
||||||
- Future snapshot evolution:
|
- Snapshot incompatibility:
|
||||||
introduce a new explicit schema version and upgrader only after defining its
|
version payloads and test every supported upgrade path.
|
||||||
supported compatibility window.
|
|
||||||
|
|
||||||
## Resolved Implementation Decisions
|
## Open Implementation Decisions
|
||||||
|
|
||||||
- a trigger-maintained circuit-list registry enforces shared BMK uniqueness
|
These decisions may be resolved during the relevant delivery phase without
|
||||||
across circuits and distribution-board components
|
changing the agreed domain behavior:
|
||||||
- group headings and full-width structural rows show ownership without adding
|
|
||||||
a permanent group column
|
- exact relational implementation of the shared BMK ownership boundary
|
||||||
- structural component rows remain outside editable-cell filtering and
|
- presentation of group ownership through indentation, headings or a column
|
||||||
spreadsheet navigation; circuit filtering continues to preserve complete
|
- filter behavior for structural component rows
|
||||||
circuit blocks
|
- explicit UI for converting retained legacy identifiers to grouped numbering
|
||||||
- explicit group renumbering updates grouped identifiers collision-safely
|
- whether fixed header components become editable beyond name and BMK in this
|
||||||
- fixed header components remain read-only; mutable group and auxiliary
|
phase
|
||||||
components use their dedicated modals
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# Abgelöst: Revit-CSV-Anforderungen
|
|
||||||
|
|
||||||
Diese CSV-/IfcGUID-Spezifikation ist seit 29.09.2026 abgelöst. Die verbindliche
|
|
||||||
Quelle ist [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md).
|
|
||||||
|
|
||||||
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
|
|
||||||
`ae318a5` erhalten. Er darf nicht mehr als Implementierungsanweisung oder
|
|
||||||
Statusquelle verwendet werden.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# Abgelöst: alter Phase-14-CSV-Plan
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
|
|
||||||
`ae318a5` erhalten und darf nicht fortgeschrieben werden.
|
|
||||||
|
|
@ -1,501 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,528 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
# 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.
|
|
||||||
11
drizzle.config.js
Normal file
11
drizzle.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"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
Normal file
17
index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -2,31 +2,9 @@
|
||||||
const apiInternalUrl = (process.env.API_INTERNAL_URL || "http://localhost:3000").replace(/\/$/, "");
|
const apiInternalUrl = (process.env.API_INTERNAL_URL || "http://localhost:3000").replace(/\/$/, "");
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
allowedDevOrigins: [
|
|
||||||
"192.168.3.13",
|
|
||||||
"docker01.int.jappel.io",
|
|
||||||
"lb.jappel.io"
|
|
||||||
],
|
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
tsconfigPath: "./tsconfig.next.json",
|
tsconfigPath: "./tsconfig.next.json",
|
||||||
},
|
},
|
||||||
poweredByHeader: false,
|
|
||||||
// Clickjacking: the Config pages hold admin actions, so no foreign page
|
|
||||||
// may frame the app. Same-origin framing stays allowed.
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/:path*",
|
|
||||||
headers: [
|
|
||||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
|
||||||
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
|
|
||||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
||||||
{ key: "Referrer-Policy", value: "same-origin" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -42,4 +20,3 @@ const nextConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
||||||
|
|
|
||||||
834
package-lock.json
generated
834
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -3,9 +3,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"engines": {
|
|
||||||
"node": "24.x"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:api",
|
"dev": "npm run dev:api",
|
||||||
"dev:api": "tsx watch src/server/index.ts",
|
"dev:api": "tsx watch src/server/index.ts",
|
||||||
|
|
@ -13,21 +10,19 @@
|
||||||
"docker:up": "docker compose up --build --detach",
|
"docker:up": "docker compose up --build --detach",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs --follow",
|
"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": "npm run build:api",
|
||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "node scripts/run-tests.mjs",
|
"test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "node scripts/run-tests.mjs --watch",
|
"test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "node scripts/run-migrations.js",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
|
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
||||||
|
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
@ -36,7 +31,6 @@
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"exceljs": "^4.4.0",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.4",
|
||||||
|
|
@ -47,7 +41,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
|
|
||||||
26
scripts/db-backfill-sections.ts
Normal file
26
scripts/db-backfill-sections.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { db } from "../src/db/client.js";
|
||||||
|
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||||
|
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||||
|
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||||
|
|
||||||
|
const projectRepository = new ProjectRepository(db);
|
||||||
|
const circuitListRepository = new CircuitListRepository(db);
|
||||||
|
const circuitSectionRepository = new CircuitSectionRepository(db);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const projects = await projectRepository.list();
|
||||||
|
let totalLists = 0;
|
||||||
|
for (const project of projects) {
|
||||||
|
const lists = await circuitListRepository.listByProject(project.id);
|
||||||
|
for (const list of lists) {
|
||||||
|
await circuitSectionRepository.createDefaults(list.id);
|
||||||
|
totalLists += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Section backfill done for ${totalLists} circuit list(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Section backfill failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
61
scripts/db-migrate-legacy-consumers.ts
Normal file
61
scripts/db-migrate-legacy-consumers.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { db } from "../src/db/client.js";
|
||||||
|
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
||||||
|
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||||
|
import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js";
|
||||||
|
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||||
|
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||||
|
import { RoomRepository } from "../src/db/repositories/room.repository.js";
|
||||||
|
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
||||||
|
|
||||||
|
const projectRepository = new ProjectRepository(db);
|
||||||
|
const circuitListRepository = new CircuitListRepository(db);
|
||||||
|
const migrationRepository = new LegacyConsumerMigrationRepository(db);
|
||||||
|
const migrationService = new LegacyConsumerMigrationService({
|
||||||
|
circuitListReader: circuitListRepository,
|
||||||
|
sectionStore: new CircuitSectionRepository(db),
|
||||||
|
circuitReader: new CircuitRepository(db),
|
||||||
|
roomReader: new RoomRepository(db),
|
||||||
|
migrationStore: migrationRepository,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const projects = await projectRepository.list();
|
||||||
|
const reports = [];
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const lists = await circuitListRepository.listByProject(project.id);
|
||||||
|
for (const list of lists) {
|
||||||
|
const report = await migrationService.migrateCircuitList(project.id, list.id);
|
||||||
|
reports.push({
|
||||||
|
projectId: project.id,
|
||||||
|
circuitListId: list.id,
|
||||||
|
legacyConsumerCount: report.legacyConsumerCount,
|
||||||
|
createdCircuitCount: report.createdCircuitCount,
|
||||||
|
createdDeviceRowCount: report.createdDeviceRowCount,
|
||||||
|
generatedIdentifiers: report.generatedIdentifiers.length,
|
||||||
|
unassignedRows: report.unassignedRows.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmigratedConsumers =
|
||||||
|
await migrationRepository.listUnmigratedConsumers();
|
||||||
|
if (unmigratedConsumers.length > 0) {
|
||||||
|
const withoutCircuitList = unmigratedConsumers.filter(
|
||||||
|
(consumer) => !consumer.circuitListId
|
||||||
|
).length;
|
||||||
|
throw new Error(
|
||||||
|
`Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` +
|
||||||
|
` (${withoutCircuitList} without a circuit list).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Legacy consumer migration summary:");
|
||||||
|
console.table(reports);
|
||||||
|
console.log("Cutover verification passed: all legacy consumers are mapped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Legacy consumer migration failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -5,10 +5,6 @@ const dbPath = path.resolve("data", "leistungsbilanz.db");
|
||||||
const db = new Database(dbPath, { readonly: true });
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
const requiredTables = [
|
const requiredTables = [
|
||||||
"buildings",
|
|
||||||
"estimate_balances",
|
|
||||||
"estimate_groups",
|
|
||||||
"estimate_rows",
|
|
||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
"circuits",
|
"circuits",
|
||||||
"circuit_device_rows",
|
"circuit_device_rows",
|
||||||
|
|
@ -16,8 +12,8 @@ const requiredTables = [
|
||||||
"circuit_protection_devices",
|
"circuit_protection_devices",
|
||||||
"distribution_board_components",
|
"distribution_board_components",
|
||||||
"distribution_board_component_protection_devices",
|
"distribution_board_component_protection_devices",
|
||||||
"document_branding_assets",
|
"legacy_consumer_circuit_migrations",
|
||||||
"document_branding_settings",
|
"legacy_consumer_migration_reports",
|
||||||
];
|
];
|
||||||
|
|
||||||
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||||
|
|
@ -46,13 +42,6 @@ const projectDeviceColumns = new Set(
|
||||||
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
|
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
|
||||||
(name) => !projectDeviceColumns.has(name)
|
(name) => !projectDeviceColumns.has(name)
|
||||||
);
|
);
|
||||||
const requiredCircuitDeviceRowColumns = ["manual_quantity"];
|
|
||||||
const circuitDeviceRowColumns = new Set(
|
|
||||||
db.prepare("PRAGMA table_info(circuit_device_rows)").all().map((column) => column.name)
|
|
||||||
);
|
|
||||||
const missingCircuitDeviceRowColumns = requiredCircuitDeviceRowColumns.filter(
|
|
||||||
(name) => !circuitDeviceRowColumns.has(name)
|
|
||||||
);
|
|
||||||
const removedProjectDeviceColumns = [
|
const removedProjectDeviceColumns = [
|
||||||
"installed_power_per_unit_kw",
|
"installed_power_per_unit_kw",
|
||||||
"demand_factor",
|
"demand_factor",
|
||||||
|
|
@ -68,14 +57,6 @@ const circuitColumns = new Set(
|
||||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||||
);
|
);
|
||||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||||
const removedCircuitColumns = [
|
|
||||||
"protection_type",
|
|
||||||
"protection_rated_current",
|
|
||||||
"protection_characteristic",
|
|
||||||
];
|
|
||||||
const remainingRemovedCircuitColumns = removedCircuitColumns.filter((name) =>
|
|
||||||
circuitColumns.has(name)
|
|
||||||
);
|
|
||||||
const requiredCircuitSectionColumns = ["category", "group_number"];
|
const requiredCircuitSectionColumns = ["category", "group_number"];
|
||||||
const circuitSectionColumns = new Set(
|
const circuitSectionColumns = new Set(
|
||||||
db
|
db
|
||||||
|
|
@ -91,12 +72,7 @@ const requiredProjectColumns = [
|
||||||
"external_project_number",
|
"external_project_number",
|
||||||
"building_owner",
|
"building_owner",
|
||||||
"description",
|
"description",
|
||||||
"is_public_building",
|
|
||||||
"enabled_distribution_board_supply_types",
|
"enabled_distribution_board_supply_types",
|
||||||
"site_address",
|
|
||||||
"work_stage",
|
|
||||||
"project_lead",
|
|
||||||
"project_editor",
|
|
||||||
];
|
];
|
||||||
const projectColumns = new Set(
|
const projectColumns = new Set(
|
||||||
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||||
|
|
@ -104,21 +80,10 @@ const projectColumns = new Set(
|
||||||
const missingProjectColumns = requiredProjectColumns.filter(
|
const missingProjectColumns = requiredProjectColumns.filter(
|
||||||
(name) => !projectColumns.has(name)
|
(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 = [
|
const requiredDistributionBoardColumns = [
|
||||||
"floor_id",
|
"floor_id",
|
||||||
"supply_type",
|
"supply_type",
|
||||||
"simultaneity_factor",
|
"simultaneity_factor",
|
||||||
"board_number",
|
|
||||||
"location",
|
|
||||||
"fed_from",
|
|
||||||
"remark",
|
|
||||||
];
|
];
|
||||||
const distributionBoardColumns = new Set(
|
const distributionBoardColumns = new Set(
|
||||||
db
|
db
|
||||||
|
|
@ -159,14 +124,12 @@ console.log("Database:", dbPath);
|
||||||
console.log("Required tables:", requiredTables.join(", "));
|
console.log("Required tables:", requiredTables.join(", "));
|
||||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||||
console.log("Required circuit-device-row columns:", requiredCircuitDeviceRowColumns.join(", "));
|
|
||||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||||
console.log(
|
console.log(
|
||||||
"Required circuit-section columns:",
|
"Required circuit-section columns:",
|
||||||
requiredCircuitSectionColumns.join(", ")
|
requiredCircuitSectionColumns.join(", ")
|
||||||
);
|
);
|
||||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||||
console.log("Required floor columns:", requiredFloorColumns.join(", "));
|
|
||||||
console.log(
|
console.log(
|
||||||
"Required distribution-board columns:",
|
"Required distribution-board columns:",
|
||||||
requiredDistributionBoardColumns.join(", ")
|
requiredDistributionBoardColumns.join(", ")
|
||||||
|
|
@ -183,11 +146,6 @@ if (missing.length > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingFloorColumns.length > 0) {
|
|
||||||
console.error("Missing floor columns:", missingFloorColumns.join(", "));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingProjectDeviceColumns.length > 0) {
|
if (missingProjectDeviceColumns.length > 0) {
|
||||||
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
|
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -206,22 +164,6 @@ if (missingCircuitColumns.length > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingCircuitDeviceRowColumns.length > 0) {
|
|
||||||
console.error(
|
|
||||||
"Missing circuit-device-row columns:",
|
|
||||||
missingCircuitDeviceRowColumns.join(", ")
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remainingRemovedCircuitColumns.length > 0) {
|
|
||||||
console.error(
|
|
||||||
"Transitional circuit protection columns still present:",
|
|
||||||
remainingRemovedCircuitColumns.join(", ")
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingCircuitSectionColumns.length > 0) {
|
if (missingCircuitSectionColumns.length > 0) {
|
||||||
console.error(
|
console.error(
|
||||||
"Missing circuit-section columns:",
|
"Missing circuit-section columns:",
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
const { migrate } = require('drizzle-orm/better-sqlite3/migrator');
|
|
||||||
const { drizzle } = require('drizzle-orm/better-sqlite3');
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const dataDir = path.resolve(
|
|
||||||
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
|
|
||||||
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const sqlite = new Database(databasePath);
|
|
||||||
try {
|
|
||||||
if (requiresLinearXlsxBackup(sqlite)) {
|
|
||||||
const backupDir = path.join(dataDir, 'backups');
|
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const backupPath = path.join(
|
|
||||||
backupDir,
|
|
||||||
`leistungsbilanz-before-linear-xlsx-${timestamp}.db`
|
|
||||||
);
|
|
||||||
await sqlite.backup(backupPath);
|
|
||||||
verifyBackup(backupPath);
|
|
||||||
console.log(`Verified pre-migration backup created: ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate(drizzle(sqlite), {
|
|
||||||
migrationsFolder: path.resolve('src/db/migrations'),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
sqlite.close();
|
|
||||||
}
|
|
||||||
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,30 +0,0 @@
|
||||||
import { readdirSync } from "node:fs";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const testFiles = readdirSync(path.resolve("tests"), {
|
|
||||||
withFileTypes: true,
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts"))
|
|
||||||
.map((entry) => path.join("tests", entry.name))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
if (testFiles.length === 0) {
|
|
||||||
throw new Error("No TypeScript test files found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const watch = process.argv.includes("--watch");
|
|
||||||
const result = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
[
|
|
||||||
"--import",
|
|
||||||
"tsx",
|
|
||||||
"--test",
|
|
||||||
"--test-concurrency=1",
|
|
||||||
...(watch ? ["--watch"] : []),
|
|
||||||
...testFiles,
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" }
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export default function ConfigPage() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
2301
src/app/globals.css
2301
src/app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -1,49 +1,16 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Script from "next/script";
|
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "../frontend/components/AppShell";
|
|
||||||
import { AuthProvider } from "../frontend/components/AuthProvider";
|
|
||||||
import { LoginGate } from "../frontend/components/LoginGate";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Leistungsbilanz",
|
title: "Leistungsbilanz",
|
||||||
description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten",
|
description: "Leistungsbilanz für elektrische Verbraucher und Stromkreislisten",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep this key literal in sync with THEME_STORAGE_KEY in ThemeToggle.tsx.
|
|
||||||
// It must stay inline (not imported) so it runs before hydration and never
|
|
||||||
// flashes the wrong theme on load.
|
|
||||||
const THEME_INIT_SCRIPT = `
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var stored = localStorage.getItem("leistungsbilanz:theme");
|
|
||||||
var theme =
|
|
||||||
stored === "dark" || stored === "light"
|
|
||||||
? stored
|
|
||||||
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
|
||||||
} catch (error) {
|
|
||||||
// Storage/matchMedia unavailable: fall back to the default light theme.
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de">
|
||||||
<body>
|
<body>{children}</body>
|
||||||
<Script id="theme-init" strategy="beforeInteractive">
|
|
||||||
{THEME_INIT_SCRIPT}
|
|
||||||
</Script>
|
|
||||||
<AuthProvider>
|
|
||||||
<LoginGate>
|
|
||||||
<AppShell>{children}</AppShell>
|
|
||||||
</LoginGate>
|
|
||||||
</AuthProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||||
|
|
@ -11,21 +10,17 @@ export default function CircuitTreeEditPage() {
|
||||||
return (
|
return (
|
||||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
<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 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>
|
<div>
|
||||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${params.projectId}/circuit-lists/${params.circuitListId}/tree`}
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Strukturvorschau
|
||||||
|
</Link>
|
||||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||||
Zum Projekt
|
Zum Projekt
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { CircuitTreePreview } from "../../../../../../frontend/components/circuit-tree-preview";
|
||||||
|
|
||||||
|
export default function CircuitTreePreviewPage() {
|
||||||
|
const params = useParams<{ projectId: string; circuitListId: string }>();
|
||||||
|
const projectId = params.projectId;
|
||||||
|
const circuitListId = params.circuitListId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="h4 mb-1">Vorschau der Stromkreisstruktur</h1>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Schreibgeschützte Vorschau der Bereiche, Stromkreise und Gerätezeilen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}/circuit-lists/${circuitListId}/tree-edit`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
Editor öffnen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Zum Projekt
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CircuitTreePreview projectId={projectId} circuitListId={circuitListId} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,896 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
"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
|
|
@ -1,359 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,855 +0,0 @@
|
||||||
"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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,15 +8,14 @@ import {
|
||||||
deleteGlobalDevice,
|
deleteGlobalDevice,
|
||||||
importProjectTransferAsNew,
|
importProjectTransferAsNew,
|
||||||
listGlobalDevices,
|
listGlobalDevices,
|
||||||
|
listProjects,
|
||||||
updateGlobalDevice,
|
updateGlobalDevice,
|
||||||
} from "../../frontend/utils/api";
|
} from "../../frontend/utils/api";
|
||||||
import type {
|
import type {
|
||||||
CreateGlobalDeviceInput,
|
CreateGlobalDeviceInput,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
|
ProjectDto,
|
||||||
} from "../../frontend/types";
|
} from "../../frontend/types";
|
||||||
import { GzfPickerModal } from "../../frontend/components/gzf-picker-modal";
|
|
||||||
|
|
||||||
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
|
|
||||||
|
|
||||||
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -39,6 +38,7 @@ function toOptionalNumber(value: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||||
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState("");
|
||||||
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
||||||
|
|
@ -64,15 +64,12 @@ export default function ProjectsPage() {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [createdProject, setCreatedProject] = useState<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
|
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setGlobalDevices(await listGlobalDevices());
|
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
|
||||||
|
setProjects(loadedProjects);
|
||||||
|
setGlobalDevices(loadedGlobalDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -88,12 +85,10 @@ export default function ProjectsPage() {
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCreatedProject(null);
|
|
||||||
try {
|
try {
|
||||||
const created = await createProject(projectName.trim());
|
const created = await createProject(projectName.trim());
|
||||||
setCreatedProject({ id: created.id, name: created.name });
|
setProjects((current) => [...current, created]);
|
||||||
setProjectName("");
|
setProjectName("");
|
||||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -128,11 +123,11 @@ export default function ProjectsPage() {
|
||||||
setImportedProject(null);
|
setImportedProject(null);
|
||||||
try {
|
try {
|
||||||
const imported = await importProjectTransferAsNew(projectImport);
|
const imported = await importProjectTransferAsNew(projectImport);
|
||||||
|
setProjects(await listProjects());
|
||||||
setImportedProject(imported);
|
setImportedProject(imported);
|
||||||
setProjectImport(null);
|
setProjectImport(null);
|
||||||
setProjectImportFilename("");
|
setProjectImportFilename("");
|
||||||
setProjectImportInputKey((current) => current + 1);
|
setProjectImportInputKey((current) => current + 1);
|
||||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
|
|
@ -227,20 +222,20 @@ export default function ProjectsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container py-4">
|
||||||
<div className="page-header">
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="kicker">Leistungsbilanz</div>
|
<h1 className="h3 mb-1">Projekte</h1>
|
||||||
<h1>Projekte</h1>
|
|
||||||
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||||
|
|
||||||
<div className="d-flex flex-column gap-4">
|
<div className="row g-4">
|
||||||
<section className="card shadow-sm">
|
<section className="col-12 col-lg-4">
|
||||||
|
<div className="card shadow-sm mb-4">
|
||||||
<div className="card-header">Neues Projekt</div>
|
<div className="card-header">Neues Projekt</div>
|
||||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
<div className="card-body">
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Projektname</label>
|
<label className="form-label">Projektname</label>
|
||||||
|
|
@ -255,25 +250,11 @@ export default function ProjectsPage() {
|
||||||
Projekt erstellen
|
Projekt erstellen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{createdProject ? (
|
|
||||||
<div className="alert alert-success mt-3 mb-0">
|
|
||||||
<div className="small mb-2">
|
|
||||||
„{createdProject.name}“ wurde angelegt.
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
className="btn btn-sm btn-success"
|
|
||||||
href={`/projects/${createdProject.id}`}
|
|
||||||
>
|
|
||||||
Projekt öffnen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="card shadow-sm">
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card shadow-sm">
|
|
||||||
<div className="card-header">Projekt importieren</div>
|
<div className="card-header">Projekt importieren</div>
|
||||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
<div className="card-body">
|
||||||
<p className="text-secondary small">
|
<p className="text-secondary small">
|
||||||
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||||
mit neuen internen IDs angelegt.
|
mit neuen internen IDs angelegt.
|
||||||
|
|
@ -324,9 +305,46 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card shadow-sm">
|
<section className="col-12 col-lg-8">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Alle Projekte</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Projekt</th>
|
||||||
|
<th className="text-end">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<tr key={project.id}>
|
||||||
|
<td>{project.name}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
|
||||||
|
Projekt öffnen
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!projects.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="text-center text-secondary py-4">
|
||||||
|
Noch keine Projekte vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card shadow-sm mt-4">
|
||||||
<div className="card-header">Globale Geräte / Verbraucher</div>
|
<div className="card-header">Globale Geräte / Verbraucher</div>
|
||||||
<div className="card-body border-bottom">
|
<div className="card-body border-bottom">
|
||||||
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
||||||
|
|
@ -382,8 +400,7 @@ export default function ProjectsPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-2">
|
<div className="col-6 col-md-1">
|
||||||
<div className="input-group">
|
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -395,15 +412,6 @@ export default function ProjectsPage() {
|
||||||
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
|
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={() => setIsGzfPickerOpen(true)}
|
|
||||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
AMEV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-1">
|
<div className="col-6 col-md-1">
|
||||||
<select
|
<select
|
||||||
|
|
@ -516,17 +524,6 @@ export default function ProjectsPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
{isGzfPickerOpen ? (
|
|
||||||
<GzfPickerModal
|
|
||||||
onClose={() => setIsGzfPickerOpen(false)}
|
|
||||||
onSelect={(value) => {
|
|
||||||
setGlobalDeviceForm((current) => ({ ...current, demandFactor: String(value) }));
|
|
||||||
setIsGzfPickerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,705 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// 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,10 +7,3 @@ const defaultDatabaseContext = createDatabaseContext(
|
||||||
|
|
||||||
export const db = defaultDatabaseContext.db;
|
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
28
src/db/migrations/0000_bizarre_colossus.sql
Normal file
28
src/db/migrations/0000_bizarre_colossus.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
CREATE TABLE `consumers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`distribution_board_id` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`category` text,
|
||||||
|
`quantity` integer NOT NULL,
|
||||||
|
`installed_power_per_unit_kw` real NOT NULL,
|
||||||
|
`demand_factor` real NOT NULL,
|
||||||
|
`voltage_v` real,
|
||||||
|
`phase_count` integer,
|
||||||
|
`power_factor` real,
|
||||||
|
`note` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `distribution_boards` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `projects` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
CREATE TABLE `circuit_device_rows` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`circuit_id` text NOT NULL,
|
|
||||||
`linked_project_device_id` text,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`display_name` text NOT NULL,
|
|
||||||
`phase_type` text,
|
|
||||||
`connection_kind` text,
|
|
||||||
`cost_group` text,
|
|
||||||
`category` text,
|
|
||||||
`level` text,
|
|
||||||
`room_id` text,
|
|
||||||
`room_number_snapshot` text,
|
|
||||||
`room_name_snapshot` text,
|
|
||||||
`quantity` integer NOT NULL,
|
|
||||||
`power_per_unit` real NOT NULL,
|
|
||||||
`simultaneity_factor` real NOT NULL,
|
|
||||||
`cos_phi` real,
|
|
||||||
`remark` text,
|
|
||||||
`overridden_fields` text,
|
|
||||||
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `circuit_list_equipment_identifiers` (
|
|
||||||
`owner_type` text NOT NULL,
|
|
||||||
`owner_id` text NOT NULL,
|
|
||||||
`circuit_list_id` text NOT NULL,
|
|
||||||
`equipment_identifier` text NOT NULL,
|
|
||||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_owner_unique` ON `circuit_list_equipment_identifiers` (`owner_type`,`owner_id`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_list_identifier_unique` ON `circuit_list_equipment_identifiers` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `circuit_lists` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`distribution_board_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_lists_distribution_board_id_unique` ON `circuit_lists` (`distribution_board_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `circuit_protection_devices` (
|
|
||||||
`circuit_id` text PRIMARY KEY NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`rated_current_a` real NOT NULL,
|
|
||||||
`fuse_utilization_category` text,
|
|
||||||
`trip_characteristic` text,
|
|
||||||
`rcd_type` text,
|
|
||||||
`rated_residual_current_ma` real,
|
|
||||||
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `circuit_sections` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`circuit_list_id` text NOT NULL,
|
|
||||||
`key` text NOT NULL,
|
|
||||||
`display_name` text NOT NULL,
|
|
||||||
`prefix` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`category` text,
|
|
||||||
`group_number` integer,
|
|
||||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_sections_list_key_unique` ON `circuit_sections` (`circuit_list_id`,`key`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_sections_list_prefix_unique` ON `circuit_sections` (`circuit_list_id`,`prefix`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_sections_list_category_group_unique` ON `circuit_sections` (`circuit_list_id`,`category`,`group_number`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `circuits` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`circuit_list_id` text NOT NULL,
|
|
||||||
`section_id` text NOT NULL,
|
|
||||||
`equipment_identifier` text NOT NULL,
|
|
||||||
`display_name` text,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`cable_type` text,
|
|
||||||
`cable_cross_section` text,
|
|
||||||
`cable_length` real,
|
|
||||||
`rcd_assignment` text,
|
|
||||||
`terminal_designation` text,
|
|
||||||
`voltage` real,
|
|
||||||
`control_requirement` text,
|
|
||||||
`status` text,
|
|
||||||
`is_reserve` integer DEFAULT 0 NOT NULL,
|
|
||||||
`remark` text,
|
|
||||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuits_list_equipment_identifier_unique` ON `circuits` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `distribution_board_component_protection_devices` (
|
|
||||||
`component_id` text PRIMARY KEY NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`rated_current_a` real NOT NULL,
|
|
||||||
`fuse_utilization_category` text,
|
|
||||||
`trip_characteristic` text,
|
|
||||||
`rcd_type` text,
|
|
||||||
`rated_residual_current_ma` real,
|
|
||||||
FOREIGN KEY (`component_id`) REFERENCES `distribution_board_components`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `distribution_board_components` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`circuit_list_id` text NOT NULL,
|
|
||||||
`section_id` text,
|
|
||||||
`equipment_identifier` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`role` text NOT NULL,
|
|
||||||
`placement` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `distribution_board_components_list_identifier_unique` ON `distribution_board_components` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `distribution_board_components_section_role_unique` ON `distribution_board_components` (`section_id`,`role`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `distribution_boards` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`floor_id` text,
|
|
||||||
`supply_type` text,
|
|
||||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `floors` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `global_devices` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`display_name` text NOT NULL,
|
|
||||||
`category` text,
|
|
||||||
`quantity` integer NOT NULL,
|
|
||||||
`installed_power_per_unit_kw` real NOT NULL,
|
|
||||||
`demand_factor` real NOT NULL,
|
|
||||||
`voltage_v` real,
|
|
||||||
`phase_count` integer,
|
|
||||||
`power_factor` real,
|
|
||||||
`note` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `project_change_sets` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_revision_id` text NOT NULL,
|
|
||||||
`command_type` text NOT NULL,
|
|
||||||
`payload_schema_version` integer NOT NULL,
|
|
||||||
`forward_payload_json` text NOT NULL,
|
|
||||||
`inverse_payload_json` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `project_devices` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`display_name` text NOT NULL,
|
|
||||||
`phase_type` text DEFAULT 'single_phase' NOT NULL,
|
|
||||||
`connection_kind` text,
|
|
||||||
`cost_group` text,
|
|
||||||
`category` text,
|
|
||||||
`quantity` integer NOT NULL,
|
|
||||||
`power_per_unit` real DEFAULT 0 NOT NULL,
|
|
||||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
|
||||||
`cos_phi` real,
|
|
||||||
`remark` text,
|
|
||||||
`voltage_v` real,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `project_history_stack_entries` (
|
|
||||||
`change_set_id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`stack` text NOT NULL,
|
|
||||||
`position` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `project_revisions` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`revision_number` integer NOT NULL,
|
|
||||||
`created_at_iso` text NOT NULL,
|
|
||||||
`actor_id` text,
|
|
||||||
`source` text NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `project_snapshots` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_revision` integer NOT NULL,
|
|
||||||
`schema_version` integer NOT NULL,
|
|
||||||
`kind` text DEFAULT 'named' NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`payload_json` text NOT NULL,
|
|
||||||
`payload_sha256` text NOT NULL,
|
|
||||||
`created_at_iso` text NOT NULL,
|
|
||||||
`created_by_actor_id` text,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `projects` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`internal_project_number` text,
|
|
||||||
`external_project_number` text,
|
|
||||||
`building_owner` text,
|
|
||||||
`description` text,
|
|
||||||
`single_phase_voltage_v` integer DEFAULT 230 NOT NULL,
|
|
||||||
`three_phase_voltage_v` integer DEFAULT 400 NOT NULL,
|
|
||||||
`enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL,
|
|
||||||
`current_revision` integer DEFAULT 0 NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `rooms` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`floor_id` text,
|
|
||||||
`room_number` text NOT NULL,
|
|
||||||
`room_name` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_normalized_unique`
|
|
||||||
ON `circuit_list_equipment_identifiers` (`circuit_list_id`, lower(trim(`equipment_identifier`)));
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `distribution_board_components_fixed_role_unique`
|
|
||||||
ON `distribution_board_components` (`circuit_list_id`, `role`)
|
|
||||||
WHERE `role` IN ('main_switch', 'surge_protective_device');
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `circuits_equipment_identifier_insert`
|
|
||||||
AFTER INSERT ON `circuits`
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO `circuit_list_equipment_identifiers`
|
|
||||||
(`owner_type`, `owner_id`, `circuit_list_id`, `equipment_identifier`)
|
|
||||||
VALUES ('circuit', NEW.`id`, NEW.`circuit_list_id`, NEW.`equipment_identifier`);
|
|
||||||
END;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `circuits_equipment_identifier_update`
|
|
||||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `circuits`
|
|
||||||
BEGIN
|
|
||||||
UPDATE `circuit_list_equipment_identifiers`
|
|
||||||
SET `circuit_list_id` = NEW.`circuit_list_id`,
|
|
||||||
`equipment_identifier` = NEW.`equipment_identifier`
|
|
||||||
WHERE `owner_type` = 'circuit' AND `owner_id` = OLD.`id`;
|
|
||||||
END;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `circuits_equipment_identifier_delete`
|
|
||||||
AFTER DELETE ON `circuits`
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM `circuit_list_equipment_identifiers`
|
|
||||||
WHERE `owner_type` = 'circuit' AND `owner_id` = OLD.`id`;
|
|
||||||
END;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_insert`
|
|
||||||
AFTER INSERT ON `distribution_board_components`
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO `circuit_list_equipment_identifiers`
|
|
||||||
(`owner_type`, `owner_id`, `circuit_list_id`, `equipment_identifier`)
|
|
||||||
VALUES ('distribution_board_component', NEW.`id`, NEW.`circuit_list_id`, NEW.`equipment_identifier`);
|
|
||||||
END;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_update`
|
|
||||||
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `distribution_board_components`
|
|
||||||
BEGIN
|
|
||||||
UPDATE `circuit_list_equipment_identifiers`
|
|
||||||
SET `circuit_list_id` = NEW.`circuit_list_id`,
|
|
||||||
`equipment_identifier` = NEW.`equipment_identifier`
|
|
||||||
WHERE `owner_type` = 'distribution_board_component' AND `owner_id` = OLD.`id`;
|
|
||||||
END;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TRIGGER `distribution_board_components_equipment_identifier_delete`
|
|
||||||
AFTER DELETE ON `distribution_board_components`
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM `circuit_list_equipment_identifiers`
|
|
||||||
WHERE `owner_type` = 'distribution_board_component' AND `owner_id` = OLD.`id`;
|
|
||||||
END;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE `projects` ADD `is_public_building` integer DEFAULT false NOT NULL;
|
|
||||||
12
src/db/migrations/0001_global_devices.sql
Normal file
12
src/db/migrations/0001_global_devices.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
CREATE TABLE `global_devices` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`category` text,
|
||||||
|
`quantity` integer NOT NULL,
|
||||||
|
`installed_power_per_unit_kw` real NOT NULL,
|
||||||
|
`demand_factor` real NOT NULL,
|
||||||
|
`voltage_v` real,
|
||||||
|
`phase_count` integer,
|
||||||
|
`power_factor` real,
|
||||||
|
`note` text
|
||||||
|
);
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
CREATE TABLE `external_csv_configurations` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`configuration_version` integer NOT NULL,
|
|
||||||
`configuration` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_csv_configurations_project_id_unique` ON `external_csv_configurations` (`project_id`);
|
|
||||||
3
src/db/migrations/0002_project_voltage_defaults.sql
Normal file
3
src/db/migrations/0002_project_voltage_defaults.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE `projects` ADD COLUMN `single_phase_voltage_v` integer NOT NULL DEFAULT 230;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD COLUMN `three_phase_voltage_v` integer NOT NULL DEFAULT 400;
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
CREATE TABLE `external_import_batches` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`import_kind` text NOT NULL,
|
|
||||||
`imported_at_iso` text NOT NULL,
|
|
||||||
`file_name` text NOT NULL,
|
|
||||||
`sha256` text NOT NULL,
|
|
||||||
`applied_project_revision` integer NOT NULL,
|
|
||||||
`configuration_snapshot` text NOT NULL,
|
|
||||||
`original_bytes` blob NOT NULL,
|
|
||||||
`document` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_model_objects` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`ifc_guid` text NOT NULL,
|
|
||||||
`last_seen_import_batch_id` text NOT NULL,
|
|
||||||
`last_accepted_import_batch_id` text NOT NULL,
|
|
||||||
`accepted_source_values` text NOT NULL,
|
|
||||||
`planning_values` text NOT NULL,
|
|
||||||
`overridden_fields` text NOT NULL,
|
|
||||||
`external_room_mapping_id` text,
|
|
||||||
`distribution_board_id` text,
|
|
||||||
`linked_project_device_id` text,
|
|
||||||
`circuit_device_row_id` text,
|
|
||||||
`presence_status` text DEFAULT 'present' NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
|
||||||
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
|
||||||
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_model_objects_source_ifc_guid_unique` ON `external_model_objects` (`source_id`,`ifc_guid`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_model_sources` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`source_type` text DEFAULT 'revit_csv' NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_model_sources_project_type_unique` ON `external_model_sources` (`project_id`,`source_type`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_room_mappings` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`normalized_source_room_key` text NOT NULL,
|
|
||||||
`source_floor_name` text,
|
|
||||||
`source_room_number` text NOT NULL,
|
|
||||||
`source_room_name` text NOT NULL,
|
|
||||||
`room_id` text,
|
|
||||||
`default_distribution_board_id` text,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);
|
|
||||||
19
src/db/migrations/0003_project_floors_rooms.sql
Normal file
19
src/db/migrations/0003_project_floors_rooms.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
CREATE TABLE `floors` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`sort_order` integer NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `rooms` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`floor_id` text,
|
||||||
|
`room_number` text NOT NULL,
|
||||||
|
`room_name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`floor_id`) REFERENCES `floors`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `room_id` text REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null;
|
||||||
44
src/db/migrations/0004_circuit_lists_and_entry_fields.sql
Normal file
44
src/db/migrations/0004_circuit_lists_and_entry_fields.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
CREATE TABLE `circuit_lists` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`distribution_board_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `circuit_lists_distribution_board_id_unique` ON `circuit_lists` (`distribution_board_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `circuit_lists` (`id`, `project_id`, `distribution_board_id`, `name`)
|
||||||
|
SELECT `id`, `project_id`, `id`, `name` || ' Stromkreisliste'
|
||||||
|
FROM `distribution_boards`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `circuit_list_id` text REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `consumers` SET `circuit_list_id` = `distribution_board_id` WHERE `distribution_board_id` IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `circuit_number` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `description` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `consumers` SET `description` = `name` WHERE `description` IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `device_type` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `phase_type` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `trade_or_cost_group` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `group_name` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `protection_type` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `protection_rated_current` real;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `protection_characteristic` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `cable_type` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `cable_cross_section` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `comment` text;
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE `external_import_batches` ADD `configuration_version` integer NOT NULL;
|
|
||||||
14
src/db/migrations/0005_project_devices.sql
Normal file
14
src/db/migrations/0005_project_devices.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE `project_devices` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`category` text,
|
||||||
|
`quantity` integer NOT NULL,
|
||||||
|
`installed_power_per_unit_kw` real NOT NULL,
|
||||||
|
`demand_factor` real NOT NULL,
|
||||||
|
`voltage_v` real,
|
||||||
|
`phase_count` integer,
|
||||||
|
`power_factor` real,
|
||||||
|
`note` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE `circuit_device_rows` ADD `manual_quantity` integer DEFAULT 0 NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
UPDATE `circuit_device_rows` SET `manual_quantity` = `quantity`;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
CREATE INDEX `circuit_device_rows_circuit_id_idx` ON `circuit_device_rows` (`circuit_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `circuits_section_id_idx` ON `circuits` (`section_id`);
|
|
||||||
7
src/db/migrations/0006_device_display_name.sql
Normal file
7
src/db/migrations/0006_device_display_name.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
ALTER TABLE `global_devices` ADD COLUMN `display_name` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `global_devices` SET `display_name` = `name` WHERE `display_name` IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `display_name` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` SET `display_name` = `name` WHERE `display_name` IS NULL;
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
3
src/db/migrations/0007_consumer_device_link.sql
Normal file
3
src/db/migrations/0007_consumer_device_link.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `project_device_id` text REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `consumers` ADD COLUMN `is_linked_to_device` integer NOT NULL DEFAULT 0;
|
||||||
99
src/db/migrations/0008_circuit_first_model.sql
Normal file
99
src/db/migrations/0008_circuit_first_model.sql
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
CREATE TABLE `circuit_sections` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`circuit_list_id` text NOT NULL,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`display_name` text NOT NULL,
|
||||||
|
`prefix` text NOT NULL,
|
||||||
|
`sort_order` integer NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `circuit_sections_list_key_unique` ON `circuit_sections` (`circuit_list_id`,`key`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `circuit_sections_list_prefix_unique` ON `circuit_sections` (`circuit_list_id`,`prefix`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `circuits` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`circuit_list_id` text NOT NULL,
|
||||||
|
`section_id` text NOT NULL,
|
||||||
|
`equipment_identifier` text NOT NULL,
|
||||||
|
`display_name` text,
|
||||||
|
`sort_order` integer NOT NULL DEFAULT 0,
|
||||||
|
`protection_type` text,
|
||||||
|
`protection_rated_current` real,
|
||||||
|
`protection_characteristic` text,
|
||||||
|
`cable_type` text,
|
||||||
|
`cable_cross_section` text,
|
||||||
|
`cable_length` real,
|
||||||
|
`rcd_assignment` text,
|
||||||
|
`terminal_designation` text,
|
||||||
|
`voltage` real,
|
||||||
|
`status` text,
|
||||||
|
`is_reserve` integer NOT NULL DEFAULT 0,
|
||||||
|
`remark` text,
|
||||||
|
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `circuits_list_equipment_identifier_unique` ON `circuits` (`circuit_list_id`,`equipment_identifier`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `circuit_device_rows` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`circuit_id` text NOT NULL,
|
||||||
|
`linked_project_device_id` text,
|
||||||
|
`legacy_consumer_id` text,
|
||||||
|
`sort_order` integer NOT NULL DEFAULT 0,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`display_name` text NOT NULL,
|
||||||
|
`phase_type` text,
|
||||||
|
`connection_kind` text,
|
||||||
|
`cost_group` text,
|
||||||
|
`category` text,
|
||||||
|
`level` text,
|
||||||
|
`room_id` text,
|
||||||
|
`room_number_snapshot` text,
|
||||||
|
`room_name_snapshot` text,
|
||||||
|
`quantity` integer NOT NULL,
|
||||||
|
`power_per_unit` real NOT NULL,
|
||||||
|
`simultaneity_factor` real NOT NULL,
|
||||||
|
`cos_phi` real,
|
||||||
|
`remark` text,
|
||||||
|
`overridden_fields` text,
|
||||||
|
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `legacy_consumer_circuit_migrations` (
|
||||||
|
`consumer_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`circuit_id` text NOT NULL,
|
||||||
|
`circuit_device_row_id` text NOT NULL,
|
||||||
|
`circuit_list_id` text NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `legacy_consumer_migration_reports` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`circuit_list_id` text NOT NULL,
|
||||||
|
`legacy_consumer_count` integer NOT NULL,
|
||||||
|
`created_circuit_count` integer NOT NULL,
|
||||||
|
`created_device_row_count` integer NOT NULL,
|
||||||
|
`duplicate_grouped_count` integer NOT NULL,
|
||||||
|
`generated_identifier_count` integer NOT NULL,
|
||||||
|
`unassigned_row_count` integer NOT NULL,
|
||||||
|
`warnings_json` text NOT NULL,
|
||||||
|
`generated_identifiers_json` text NOT NULL,
|
||||||
|
`duplicate_groups_json` text NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `legacy_consumer_migration_reports_list_unique` ON `legacy_consumer_migration_reports` (`circuit_list_id`);
|
||||||
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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;
|
|
||||||
24
src/db/migrations/0009_project_device_circuit_fields.sql
Normal file
24
src/db/migrations/0009_project_device_circuit_fields.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `phase_type` text NOT NULL DEFAULT 'single_phase';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices`
|
||||||
|
SET `phase_type` = CASE WHEN `phase_count` = 3 THEN 'three_phase' ELSE 'single_phase' END;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `connection_kind` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `cost_group` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `power_per_unit` real NOT NULL DEFAULT 0;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` SET `power_per_unit` = `installed_power_per_unit_kw`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `simultaneity_factor` real NOT NULL DEFAULT 1;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` SET `simultaneity_factor` = `demand_factor`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `cos_phi` real;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` SET `cos_phi` = `power_factor`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` ADD COLUMN `remark` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` SET `remark` = `note`;
|
||||||
1
src/db/migrations/0010_circuit_control_requirement.sql
Normal file
1
src/db/migrations/0010_circuit_control_requirement.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `circuits` ADD COLUMN `control_requirement` text;
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
ALTER TABLE `rooms` ADD `area_m2` real;
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
ALTER TABLE `project_devices` DROP COLUMN `installed_power_per_unit_kw`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` DROP COLUMN `demand_factor`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` DROP COLUMN `phase_count`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` DROP COLUMN `power_factor`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `project_devices` DROP COLUMN `note`;
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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`);
|
|
||||||
24
src/db/migrations/0012_project_revision_foundation.sql
Normal file
24
src/db/migrations/0012_project_revision_foundation.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
CREATE TABLE `project_change_sets` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_revision_id` text NOT NULL,
|
||||||
|
`command_type` text NOT NULL,
|
||||||
|
`payload_schema_version` integer NOT NULL,
|
||||||
|
`forward_payload_json` text NOT NULL,
|
||||||
|
`inverse_payload_json` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `project_revisions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`revision_number` integer NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
`actor_id` text,
|
||||||
|
`source` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
|
||||||
11
src/db/migrations/0013_project_history_stacks.sql
Normal file
11
src/db/migrations/0013_project_history_stacks.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE `project_history_stack_entries` (
|
||||||
|
`change_set_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`stack` text NOT NULL,
|
||||||
|
`position` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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`);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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/0014_project_snapshots.sql
Normal file
16
src/db/migrations/0014_project_snapshots.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE `project_snapshots` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_revision` integer NOT NULL,
|
||||||
|
`schema_version` integer NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`payload_json` text NOT NULL,
|
||||||
|
`payload_sha256` text NOT NULL,
|
||||||
|
`created_at_iso` text NOT NULL,
|
||||||
|
`created_by_actor_id` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
|
||||||
2
src/db/migrations/0015_modern_madame_masque.sql
Normal file
2
src/db/migrations/0015_modern_madame_masque.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE `project_snapshots` ADD `kind` text DEFAULT 'named' NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX `project_snapshots_project_kind_revision_idx` ON `project_snapshots` (`project_id`,`kind`,`source_revision`);
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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`);
|
|
||||||
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