Compare commits

...
Sign in to create a new pull request.

71 commits

Author SHA1 Message Date
431f9ff059 Normalize external model state comparisons 2026-09-30 21:19:16 +02:00
4ff01b2ccd Harden Linear XLSX end to end 2026-09-30 21:02:20 +02:00
2581beee3c Add Linear XLSX follow-up import 2026-09-30 20:40:40 +02:00
1a4c8cad8c Add Revit return workbook export 2026-09-30 15:15:17 +02:00
77d775fde1 Complete Revit object editor assignments 2026-09-30 14:35:14 +02:00
4489cd76f0 Allow saving default Revit settings 2026-09-30 13:51:43 +02:00
4ac32e103d Add virtualized Revit object planning 2026-09-30 10:03:36 +02:00
bfdd048269 Add Linear XLSX initial import 2026-09-30 09:50:58 +02:00
c633e78cd4 Add Revit XLSX settings page 2026-09-30 09:02:52 +02:00
1cfeb06ec7 Replace Revit CSV backend with Linear XLSX 2026-09-30 08:46:07 +02:00
52d3b1a270 Add Linear Excel transport 2026-09-30 07:42:16 +02:00
d4803a18d2 Document Linear Excel integration 2026-09-29 22:28:16 +02:00
ae318a51ac Audit Linear Excel integration 2026-09-29 21:57:40 +02:00
cf022b250f Format project device power values on project page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:46:32 +02:00
68d986ff80 Merge pull request 'User management with login, roles and a Config area' (#25) from feature/user-management into main 2026-09-28 22:43:14 +02:00
3670f02ccd Document authenticated deployment 2026-09-28 22:42:11 +02:00
847f4e9493 Harden user management integration 2026-09-28 22:42:03 +02:00
73579d1e79 Merge branch 'main' into feature/user-management 2026-09-28 22:32:48 +02:00
0a37783635 Improve feeder circuit creation workflow 2026-09-28 22:15:12 +02:00
9165b8238c Compact single-board feeder circuit rows 2026-09-28 22:14:39 +02:00
ade21fe386 Create feeder circuits while linking boards 2026-09-28 22:14:30 +02:00
Grovy311
728132e52a Warn at startup when no user exists and no bootstrap admin is configured
Also document that Sec-Fetch-Site is only sent over HTTPS, so plain-HTTP
LAN access relies on SameSite=Lax alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:47:30 +02:00
Grovy311
95bfba2f67 Merge origin/main into feature/user-management
Renumber the users migration to 0013_users so it follows the upstream
0012_good_gateway (logical board feeds); AGENTS.md deferred-work list keeps
both the new feed items and the auth items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:46:21 +02:00
Grovy311
5073d9342a Harden authentication and put filter toggle and fields in one row
Security: per-username login throttling, constant-time unknown-user check,
Sec-Fetch-Site CSRF guard for writes, session rotation on login and
password change, absolute session lifetime, sessions ended on reset and
deactivation, last-admin protection, input length caps, ASCII usernames,
clickjacking headers. Covered by tests/authentication.test.ts.

Filter panel: toggle, fields and count share one row to save space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:30:30 +02:00
552362bad1 Merge Issue #3 logical distribution board feeds 2026-09-28 20:58:35 +02:00
b74b495fab Update print view and documentation for logical feeds 2026-09-28 20:57:58 +02:00
Grovy311
8d2948cdb1 Match sidebar bottom layout to the Wochenplanung reference (theme toggle + Abmelden in one row)
Square icon-only theme toggle sits next to the Abmelden button in a shared
row, collapsing to just the toggle when the sidebar is collapsed - same
arrangement as pm-app's Sidebar.tsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:32:26 +02:00
Grovy311
c56cfb30ad Add login-required user management (admin/user roles, Config->Benutzer)
No self-signup: admins create every account in Config -> Benutzer with a
typed start password; a forced password change gates first use. Session-
cookie auth protects all /api/* routes; /api/users additionally requires
the admin role. See docs/authentication.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:51:47 +02:00
042a050876 Show logical feeder loads in project and circuit views 2026-09-27 22:37:04 +02:00
dfeb0e3cb6 Add recursive distribution board load projection 2026-09-27 22:31:56 +02:00
855a04e655 Protect referenced feeder structures from deletion 2026-09-27 22:25:47 +02:00
b893c72534 Add logical distribution board feed links 2026-09-27 22:21:58 +02:00
b50c00c0ac Bring documentation in line with integrated features
List the building structure, room areas, rough power balance and cable
sizing in the README and documentation map, add an architecture section
for the rough power balance, correct stale snapshot schema references and
record the integrated contributor features in the backlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:36:43 +02:00
dce31ee143 Add optional room areas 2026-09-27 20:00:03 +02:00
a6500e2b26 Add estimate row project device handoff 2026-09-27 18:36:48 +02:00
8b1923e8ec Merge optional cable sizing module 2026-09-27 17:06:08 +02:00
5cbbd3a84a Align cable sizing with DIN VDE 0298-4 2026-09-27 17:01:23 +02:00
598003554a Merge remote-tracking branch 'origin/main' into feature/cable-sizing 2026-09-27 16:54:12 +02:00
060a203425 Merge rough estimate balance 2026-09-27 12:38:32 +02:00
573487666c Integrate rough estimate balance 2026-09-27 12:37:59 +02:00
57a62b7e4a Record the confirmed building structure decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:17:24 +02:00
b9462e61c5 Defer Revit work and remove Dynamo scripts
Further Revit exchange will use the CSV export/import of the Linear
plugin instead of Dynamo, as preferred by the internal Revit/CAD
steering committee. Mark the Revit documentation as deferred and remove
the Dynamo diagnostic scripts, which stay reachable through the tag
archive/dynamo-vorueberlegungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:51:13 +02:00
92da72bf14 Archive the Revit Dynamo integration concept
Keep the earlier Dynamo/API considerations as historical reference only.
Further Revit exchange will use CSV via the Linear plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:50:23 +02:00
868f48e7ac Integrate project building structure 2026-09-27 10:45:10 +02:00
4643c8d11a Integrate print view and document branding 2026-09-27 10:32:07 +02:00
3ffa50be4c Integrate consistent card styling
Document the shared card appearance. Validation: 431 tests, API/web builds, scripts and Next typechecks passed. GUI retest waived by the user.
2026-09-26 21:18:53 +02:00
2e223be8b4 Integrate project overview filters and icon actions
Document filter combinations, reset and browser persistence. Keep the search hint applicable before board information fields are introduced.

Validation: 20 focused tests, 431 total tests, API/web builds, scripts and Next typechecks, and diff checks passed. GUI retest skipped as authorized based on the colleague's prior testing.
2026-09-26 21:16:48 +02:00
641513c2ef Integrate circuit list navigation and AMEV cell picker
Preserve the ID fallback and compact editor controls. Add dialog keyboard focus, Tab containment and Escape handling; document navigation and picker behavior.

Validation: 418 tests, API/web builds, scripts and Next typechecks, isolated AMEV dialog checks and diff checks passed. Full GUI retest waived by the user based on the colleague's prior testing; the keyboard follow-up was checked in isolation.
2026-09-26 21:14:20 +02:00
203757b92c Integrate compact editor actions and fix header units 2026-09-26 21:07:36 +02:00
0a17da2b8e Align runtime and editor documentation with current implementation 2026-09-26 21:02:48 +02:00
Grovy311
7b2419b347 Polish the printed rough power balance
The printed page kept the dark app background in the page margins, the
consumer column wrapped while the empty remark column took the space, an
empty "bearbeitet von" label was printed, and numbers were printed as
typed (11200). Print margins are white now, the consumer column is wider,
empty editors are omitted and numbers print with thousands separators
(11.200) like the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:04:54 +02:00
Grovy311
b7811085a3 Keep print margins white in dark mode
The dark theme sets color-scheme: dark on the root element; Chrome then
paints the page margins of printed pages dark, even with a white
background. The print view forces the light scheme and a white root
background while printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:04:54 +02:00
Grovy311
72189d23a5 Offer supply suggestions, drop the circuit column, center cells
The Versorgung field suggests ELT and MSR (plus values already used in
the balance) through a datalist and still accepts free text. The circuit
number column is not needed in early design phases; it is removed from
the data model, the regenerated migration 0010, the editor and the
print. All cells are centered except "Verbraucher / Flächen", group
titles and total labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:56:53 +02:00
Grovy311
e06c7d91e2 Print the rough power balance in the kbp design
The print of the rough power balance was plain grey. It now follows the
kbp design of the print view: a title block with office logo, stand,
project, number, owner, address, work stage, date, editor and office;
header row in kbp blue, cost-group rows tinted with a lilac rule, bold
subtotals, the total including reserve as a blue band, colored
deviations in the comparison, Arial, exact print colors, and a footer
with project, stand and "Seite x von y" on every page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:48:37 +02:00
Grovy311
37a70b6b36 Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power
balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz -
KG400": each trade enters consumers by piece or by area (W/m²) with GLF
and cos phi; the balance sums AV and SV per cost group, adds a reserve and
derives the maximum current.

The module is separate from the circuit structure: own tables
(migration 0010), own folder src/estimate-balance and an import-free
shared calculation used by API and UI. Writes go through project commands
(estimate-balance.insert/.update/.delete with complete documents), so
revision checks and Undo/Redo apply; it is not part of project snapshots
or transfer yet. A comparison endpoint sums the device rows of all circuit
lists by cost group (longest prefix) against the estimate.

The UI adds a sidebar entry, a list of Stände (template KG 400, empty or
copy) and a spreadsheet-like editor with autosave, AMEV picker for the
GLF, totals, reserve, maximum current, comparison table and print layout.
The tests reproduce the template example exactly (143.24 kW, 148.48 kVA,
157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See
docs/estimate-balance.md and issue #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:39:28 +02:00
Grovy311
80dbc73230 Merge fix/client-id-insecure-context for client-side ids 2026-09-26 14:22:35 +02:00
Grovy311
68b9ee850b Search board info fields and confirm project device deletion
The distribution-board search also matches board number and location as
soon as the boards carry them (distribution-board info card of
feature/print-view); the fields are optional in the filter, so the branch
works with and without them. Deleting a project device now asks for
confirmation like the other delete actions, since the row action is a
small trash icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:16:22 +02:00
Grovy311
acff38afb8 Show a printer icon on the print view button
The "Seitenansicht" link in the project header had text only, while the
other header buttons show an icon plus text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:15:54 +02:00
Grovy311
837b29c7c5 Add icon actions and collapsible filters to the project overview
The overview now uses the same icon buttons as the circuit list editor:
row actions are icon-only IconButtons (Pencil, Link2, Library, Trash2),
card-header and panel actions show icon plus text, and the circuit-list
link uses the new IconLink. The version history uses Undo2/Redo2, a
chevron toggle and a RotateCcw restore action. Page-header buttons stay
Bootstrap buttons with a lucide icon so the upcoming "Seitenansicht"
button of feature/print-view merges without conflicts and matches them.

The Verteilungen and Projektgeräte cards get a PM-style filter bar:
search plus MultiSelectDropdown (checkbox list with "Alle", closes on
outside click and Escape) and "Zurücksetzen" while a filter is active.
Only the bar collapses; the state is kept per card in localStorage and a
collapsed active filter shows "Filter aktiv". The header row shows
"x von y", and an empty-state row explains when filters hide everything.
The filter logic is a pure module (overview-filters.ts) with node tests;
selections that are no longer offered (deleted floor) are ignored.

IconButton now imports React explicitly like the other components that
are rendered in node tests with the classic JSX runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:01:31 +02:00
Grovy311
5f4d9bf789 Show the sidebar on every page except the print view
AppShell showed the sidebar only on the project list and overview, so it
disappeared on the building structure page and in the circuit list
editor. It is collapsible, so it now stays on every page. Only the print
view, a stand-alone document opened in its own tab and printed as PDF,
keeps the full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:00:20 +02:00
Grovy311
ead01fe5bd Keep card markup and map shadow-sm to the card shadow
Removing the shadow-sm classes touched the page markup of the project
overview, which other branches change as well. The card rule now sets
Bootstrap's --bs-box-shadow-sm to the card shadow instead, so cards with
and without shadow-sm look the same and the pages stay as they are on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:53:24 +02:00
Grovy311
cd89c4a499 Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:52:34 +02:00
Grovy311
51cbab1750 Give cards the surface look of the PM app
Cards had a 3px petrol bar on the top edge, and Bootstrap's shadow-sm
class overrode their own shadow with !important. They now use the PM app
surface: a 1px border all around, a 16px radius and a soft diffuse shadow
with a faint inner highlight, with a stronger shadow and a weaker highlight
in dark mode. The shadow-sm classes are removed from the cards so the
shared rule applies. Menus, drawers and dialogs keep their shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:20:09 +02:00
Grovy311
79fcb75ffb Create client ids without crypto.randomUUID
crypto.randomUUID() is only defined in secure contexts (HTTPS or
localhost). Opened over plain HTTP from another machine in the LAN, the
circuit list editor failed with "crypto.randomUUID is not a function"
whenever it created a circuit, device row, circuit group or distribution
board component, including typing into the -frei- row.

createClientId() uses crypto.randomUUID() where available and otherwise
builds an RFC 4122 version 4 UUID from crypto.getRandomValues(), which
works in every context. AGENTS.md notes the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:01:43 +02:00
Grovy311
ced4917a86 Offer the AMEV simultaneity-factor table in the circuit list
The selected GZF cell shows a small table-icon button, and Alt+ArrowDown opens
the same picker from the selected or edited cell; Escape closes it. The
shared GzfPickerModal is reused. The picked mean value is committed through
commitEdit with an explicit target cell, so it is a regular undoable edit on
device rows, compact circuits, reserve circuits and the -frei- row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:52:38 +02:00
Grovy311
db4a6c3a2e Add back arrow to the circuit list page
The arrow next to the "Stromkreisliste" heading returns to the project's
distribution board overview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:38:16 +02:00
Grovy311
49488f4e1b Add optional cable-sizing module
Per-circuit cross-section recommendation as a separate module attached to
the circuit list. It never writes project data itself: "Übernehmen" applies
cable type, cross-section and length through the regular circuit.update
command, so revisions and Undo/Redo stay intact.

- Pure domain module (src/cable-sizing) with IEC 60364-5-52:2009 Annex B
  tables for PVC and XLPE, copper and aluminium, methods A1, A2, B1, B2, C,
  D1, D2 and E. Every value was cross-checked against public reproductions;
  where sources differ the lower value is used (docs/cable-sizing-module.md).
- Temperature and grouping between table values round to the safer value;
  values outside the tables return no result instead of a factor of 1.
  Ground methods use B.52.18 (D2) and B.52.19 A (D1).
- Overload protection: circuit-breakers In <= Iz; gG fuses additionally
  I2 <= 1.45 Iz with I2 = 1.6/1.9/2.1 In.
- Inputs prefilled from the circuit: power, phase (group or device rows),
  project voltage, power-weighted cos phi, length and protective device.
- Opened from a "Leitungsberechnung" button in the row actions, not from a
  grid cell, so cable cells keep the spreadsheet behavior.
- Stateless API under /api/cable-sizing; no database table or migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:16:18 +02:00
Grovy311
7692699ee2 Add print cover fields and office profile
- Project settings gain siteAddress, workStage, projectLead and
  projectEditor (project.update-settings schema version 3, migration 0009);
  versions 2 and 1 stay executable and keep the current cover values.
  Snapshot schema 6 carries the fields; older versions upgrade with null.
- Global office profile (office name/address) next to the document logo:
  table document_branding_settings, PUT /api/document-branding/office,
  editable from the print view toolbar.
- Cover page reads all fields; empty values show the "[ergänzen]" marker.
  The Planstände editor column shows the project editor.
- Shared logo constants moved to a dependency-free module so the Next.js
  bundler can import them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:39:18 +02:00
Grovy311
4779e4a1a7 Add print view, board info card and document logo upload
- Seitenansicht (/projects/:projectId/print): tabular cover page, board
  overview with supply structure and power sums, one plan per board with a
  repeating title block; PDF via the browser print dialog with named @page
  rules (A4 portrait cover, A4/A3 landscape plans, page x of y).
- Distribution-board info card (board number, location, fed-from, remark)
  in the board edit dialog, persisted through the additively extended
  distribution-board.update command (migration 0008, snapshot schema 6,
  legacy subtree commands normalized).
- Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project
  revisions and snapshots.
- fed-from stays free text; the overview hierarchy and cumulated sums are
  preliminary until the feed link is modeled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:38:59 +02:00
Grovy311
d95f0bf5c0 Compact circuit list column headers with filter icons
- Replace the "Filtern" text buttons with a funnel icon next to the column
  label; an active filter is highlighted and shows its value count.
- Header labels no longer break inside words (e.g. "Bemerkung"); hyphens
  are non-breaking and a trailing unit moves to its own line, so all
  headers share one height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:30:15 +02:00
Grovy311
e394ff93f4 Use icon buttons in the circuit list editor
- New IconButton/IconButtonSeparator component based on lucide-react.
- Toolbar: icon plus text, grouped into history, view and insert blocks.
- Group headers: compact icons for add circuit, upstream fuse (VS), group
  RCD (FI), edit, move up/down, renumber and delete; the header no longer
  overflows the grid.
- Rows: add device, delete circuit (trash) and delete device (ListX).
- The previous German labels remain accessible names and tooltips;
  destructive actions highlight in the danger color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:23:36 +02:00
332 changed files with 61696 additions and 9289 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Nur beim ersten Start mit leerer Benutzertabelle erforderlich.
AUTH_BOOTSTRAP_USERNAME=
AUTH_BOOTSTRAP_PASSWORT=
AUTH_SESSION_HOURS=12
AUTH_SESSION_MAX_DAYS=7
# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false.
COOKIE_SECURE=false

6
.gitignore vendored
View file

@ -1,9 +1,13 @@
node_modules/
dist/
.next/
.env
.env.*
!.env.example
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
/drizzle.config.js
data/*.db
data/backups/*.db
.codex/*.log
dynamo/output/
/docs/spec/kbp-Stromkreis.xlsx
/data/revit-reference/*.xlsx

411
AGENTS.md
View file

@ -36,6 +36,16 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`.
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
middleware), `src/domain/services/user.service.ts`,
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
(`src/app/layout.tsx`). Every unsafe API request also requires the explicit
`X-Leistungsbilanz-Request: 1` application header, including over plain HTTP.
Account settings are available to every signed-in user; user administration
remains admin-only. See `docs/authentication.md` for the full picture.
The supported runtime model is Circuit-First. The former Consumer UI, API,
tables and upgrade tooling are removed; do not reintroduce them.
@ -52,7 +62,8 @@ See `docs/current-architecture.md` for the complete module and request flow.
- Device rows inside a circuit do not have their own equipment identifiers.
- Protection and cable data belong to the circuit, not to individual devices.
- Device-level values include quantity, power per unit, simultaneity factor, cosPhi, room data, cost group and category.
- Circuit total power is the sum of all device rows in that circuit.
- Circuit total power is the sum of all device rows in that circuit plus the
read-only loads of logically linked downstream boards on that feeder circuit.
- Existing equipment identifiers must never be changed automatically.
- Renumbering is always an explicit user action.
@ -164,19 +175,53 @@ Keyboard behavior:
- Escape cancels
- Tab / Shift+Tab navigates editable cells
- Arrow keys navigate cells when not editing
- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the
selected GZF cell also shows a small table-icon button. The picked value is
committed like a typed edit.
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
`lucide-react` icons). The German action text is always the accessible name and
tooltip; the toolbar shows icon plus text, group headers and rows show icons
only, separated into logical blocks with `IconButtonSeparator`. Destructive
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
removing a device row).
Column headers show the label (click to sort) with a funnel icon for the
value filter; an active filter is highlighted and shows its value count.
Header labels break only between words and put a trailing unit such as
`[kW]` on a second line.
The project overview uses the same rule: row actions are icon-only
`IconButton`s, card-header and panel actions show icon plus text, navigation
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
the bar collapses, its state is kept per card in `localStorage`, a collapsed
active filter shows "Filter aktiv".
## Bootstrap / Styling Rule
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
Cards follow the PM app look: 1px border all around, 16px radius and the
soft shadow defined on `.card` in `globals.css` (own dark-mode value). The
`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other
`shadow-*` classes or a colored top border to cards.
Do not use Bootstrap as the core table/grid framework for the circuit list editor.
The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior.
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
## Browser Runtime
The web UI is also opened over plain HTTP from other machines in the LAN, which
is not a secure context. Do not call secure-context-only browser APIs such as
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
from `src/frontend/utils/client-id.ts`.
## Drag-and-Drop Rules
Dragging from the circuit identifier / circuit handle moves the whole circuit.
@ -250,8 +295,24 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 5 additionally stores the manual quantity share
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
Current snapshot schema version 10 stores the Linear-XLSX configuration and
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
versions containing legacy CSV configuration or external CSV state are rejected
with a German migration error. Version 9 additionally stores
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
versions 1 to 8 are upgraded with both references set to `null`. Version 8
stores the optional net room area `rooms[].areaM2` (see
`docs/building-structure.md`); versions 1 to 7 are upgraded with
`areaM2 = null`. Version 7 stores the project buildings and
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
`buildingId = null`. Version 6 stores the
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
`remark` and the project cover fields `siteAddress`, `workStage`,
`projectLead` and `projectEditor`; all older supported versions are upgraded
with `null` for these fields.
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3
snapshots are upgraded with an empty external-model state; version 2 snapshots
@ -262,29 +323,45 @@ supported versions contain circuit-group identity, distribution-board components
separate circuit/component protection records. Portable duplicate imports
remap component ids and protection-owner references together with the existing
project graph.
Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
project duplication preserve the configuration; duplication remaps its internal
id and project link.
The project page exposes the Revit CSV modal. It edits the complete
transport/column/family-rule configuration through the revision-safe endpoint,
requests a stateless preview and then shows the Phase 14.2 initial-import
decisions. Preview responses include all mapped objects, while the UI renders
only the first 25. No preview or plan creates external objects, rows, revisions
or server-side drafts.
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration
version introduced by additive migration `0004`; it is distinct from the CSV
configuration JSON schema version.
R3 replaced the runtime configuration and persistence foundation with Linear
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
the old development-only external CSV data after resetting linked row totals to
their manual share. Snapshot schema 10, restore and project transfer use the
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
rejected with a German error. The backend CSV parser and routes are removed.
R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
server-side sample detection, and removes the old project-page CSV modal. See
`docs/spec/revit-excel-integration-requirements.md` and
`docs/spec/revit-excel-phase-14-plan.md`.
R5 adds stateless XLSX preview/planning and the six-step initial-import wizard.
`external-model.apply-initial-import` stores the configuration and complete
external model state atomically; its inverse powers persistent Undo/Redo
without parsing workbook bytes. Apply retransmits and reparses the XLSX,
verifies its preview SHA-256 and creates no circuit, equipment identifier or
CircuitDeviceRow.
R6 adds one compact project-wide external-object GET without pagination or
batch payloads. The Revit page filters and sorts it in the client and renders a
virtualized window. `external-object.update-planning` applies multi-object
board, ProjectDevice and planning-value changes as one persistent command;
linked objects are excluded because they follow their CircuitDeviceRow board.
R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs
exactly, separates source decisions from return deviations, updates accepted
linked quantities atomically, retains missing objects and compacts every older
batch to metadata. `external-import.apply-follow-up` stores complete inverse
states, so Undo/Redo never parses Excel. A separately confirmed
`external-model-object.delete-missing` command removes missing objects and
adjusts linked row quantities; Undo restores objects, links and quantities.
R10 verifies the complete path through return workbook, follow-up import,
snapshot restore, portable duplicate and restart-safe Undo/Redo. A 5,000-object
synthetic workbook covers parser, command/snapshot size, transactions and the
client-side virtual window. Complete external-state inserts use bounded batches
inside their existing transaction for initial/follow-up import, restore and
duplicate. See `docs/spec/revit-excel-r10-validation.md`.
The target configuration has no independent version counter; `expectedRevision`
invalidates stale plans, and each import batch stores a configuration copy.
Only the newest import batch retains workbook bytes and the full matrix; older
batches retain date, filename, SHA-256 and object count.
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
existing rows from `quantity` and keeps `quantity` as the materialized total.
Normal manual row inserts and quantity edits update both values together.
@ -305,37 +382,12 @@ protection and its external object links. Its history-only inverse requires the
complete unchanged circuit snapshot and exact link set. Redo never recalculates
the BMK. All three external assignment stores share
`external-object-assignment.persistence.ts` for compatibility and link safety.
The read-only
The current read-only
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
projection resolves the board through the owned CircuitList and returns only
that board's compact external objects, unassigned first. It exposes no original
CSV bytes or command payloads and returns an empty list before optional import.
Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
unchanged external state, and Redo restores the same UUIDs and bytes.
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless
read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
domain row. The wide modal renders these groups and catalogs as explicit
decisions and identifies exact, ambiguous and new room matches. Missing rooms
and ProjectDevices are created through the reused project forms with mapped
Revit values as visible defaults. Each catalog creation remains its own project
command; the wizard adds and selects the result without discarding its plan.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
version and project revision. It requires one explicit decision per source-room
and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The UI
blocks unclassified families, requires a final confirmation and applies the
whole import as one persistent Undo/Redo step.
projection resolves the board through the owned CircuitList and returns compact
legacy external objects. The target Revit page will load one compact project
list and filter/sort it client-side in a virtualized table, without server
pagination or batch blobs in the DTO.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
@ -368,6 +420,10 @@ Project settings use the persistent `project.update-settings` command. Project
metadata, the public-building classification, both voltage defaults and the
enabled distribution-board supply types change in one revision and Undo/Redo
restores them together; the project PUT route requires `expectedRevision`.
Schema version 3 additionally carries the print cover fields `siteAddress`,
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
(without them) and baseline version 1 stay executable and keep the current
cover values. The PUT route accepts the cover fields optionally.
The public-building flag is the future sizing input for requiring halogen-free
cables and lines. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
@ -415,18 +471,58 @@ snapshot contains the group, optional component protections, complete circuits,
circuit protections and all device-row link/override metadata. Delete
re-captures and compares the subtree before cascading; restore preserves every
UUID in foreign-key-safe order.
The circuit-tree read model exposes fixed `headerComponents`, group-owned
The project-wide circuit-tree read model exposes fixed `headerComponents`, group-owned
`components`, `footerComponents`, optional group category/number and optional
one-to-one protection DTOs for circuits and group components. These are
the only supported circuit-protection source; no flat compatibility fields remain.
It resolves logical board feeds once for the complete project and adds each
downstream board's demand after its own board simultaneity factor to the chosen
feeding circuit as a read-only virtual row. Recursive chains and several boards
on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected.
The distribution-board dialog can create one empty feeder circuit and link it
in the same `distribution-board.create-feeder-circuit` project command. Its
history inverse restores the complete previous board update before removing
only the unchanged generated circuit; Redo reuses its UUID and BMK. No
CircuitDeviceRow is created. The three-phase group is suggested, the optional
circuit display name stays empty by default, and protection remains open for
planner sizing. A circuit with exactly one downstream board and no device rows
is rendered as one compact row using the live board number/name; several
downstream boards or additional devices retain summary and child rows. Editing
the name or remark of an empty reserve circuit changes the circuit itself and
does not implicitly create a device row.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
The same command uses schema version 2 for the logical
`feedingDistributionBoardId` and optional matching `feedingCircuitId` together
with the existing fields. Version-1 history commands remain executable and do
not change logical feed references. The command rejects self-links, cycles and
circuits from another board. The
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
(migration `0007`). They are nullable, trimmed planner values; the PUT route
only includes submitted info fields in the command and its inverse. An empty
board with info values is no longer an unchanged setup, so history deletion of
it is rejected like a changed simultaneity factor. Populated-subtree commands
stored before these fields existed are normalized to `null` values
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` remains free text for
external feeds and notes; project-board feeds use the two UUID references.
Deleting a referenced board or feeding circuit is blocked in every direct,
subtree and history-only removal path. Board copies retain their incoming feed,
and project duplication remaps both references.
These fields are intended to be prefillable from Revit parameters later, but
only through the Phase 14 rules (preview, explicit confirmation, no silent
overwrite).
Floor and room setup uses persistent insert, update and delete commands with
complete stable snapshots. Their inverses remove or restore only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by
device rows. All write routes require `expectedRevision` and return the updated
history state.
complete stable snapshots. Room command schema version 2 stores the optional,
finite, non-negative net room area `areaM2`; persisted version-1 commands are
upgraded with `areaM2 = null` and exact record comparison prevents them from
silently overwriting a newer area. Room areas are maintained manually, shown
with two decimals and summarized per floor and building with a separate missing
count. Revit import and W/m² use remain follow-up work. Command inverses remove
or restore only unchanged records and reject floors with
assigned rooms/distribution boards or rooms referenced by device rows. All
write routes require `expectedRevision` and return the updated history state.
Required operations:
@ -443,62 +539,116 @@ Required operations:
- synchronization changes
## Revit / CSV External Model Work
## Print View (Seitenansicht)
When explicitly working on Phase 14, use
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
truth together with the current architecture documents.
- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project
page button "Seitenansicht". Read-only: it only uses existing GET endpoints.
- Pure read model `src/frontend/print/print-document-model.ts` (tested in
`tests/print-document-model.test.ts`); React sheets in
`src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in
`src/frontend/print/print-document.css`.
- Pages: cover (A4 portrait, tabular), board overview with structure and
power sums, one plan per board (A4/A3 landscape) whose title block sits in
`<thead>` so it repeats on printed pages. PDF export uses the browser print
dialog; page size, margins and "Seite x von y" come from injected named
`@page` rules (`lb-portrait`, `lb-landscape`).
- The board hierarchy follows logical board ids. The overview shows power
before board GZF and demand including linked downstream boards; it does not
add a second cumulative column. Board plans render downstream-board loads as
read-only rows. `fedFrom` groups only external root feeds.
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
`print-document.css`). Empty cover values are shown as
"[ergänzen]"; the Planstände editor column shows the project's current
`projectEditor` because snapshots do not record an editor.
- The document logo and the office profile (office name/address on the
cover) are global office branding: tables `document_branding_assets`
(migration `0007`) and `document_branding_settings` (migration `0008`),
endpoints `GET|PUT|DELETE /api/document-branding/logo`,
`PUT /api/document-branding/office` and `GET /api/document-branding`.
It is intentionally outside project revisions, snapshots and transfer.
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
is served with `nosniff` and a restrictive CSP.
Critical rules:
## Revit / Linear Excel External Model Work
- Revit integration is optional. Do not make imported external data mandatory
for projects, rooms, project devices, circuits or device rows.
- Parse imports into staging first. Never write CSV rows directly into circuits
or device rows.
- Keep external object identity separate from internal UUIDs and circuit BMKs.
`IfcGUID` identifies the external Revit object and must never replace an
internal UUID or `equipmentIdentifier`.
- One imported Revit object may exist without a `CircuitDeviceRow`.
Import, room assignment, distribution-board assignment, classification and
ProjectDevice linking do not create a row.
- Create or change a `CircuitDeviceRow` only after an explicit user action in
the circuit-list editor.
- Several external objects may link to one `CircuitDeviceRow`, while every
external object remains individually traceable for follow-up imports and
object-specific export.
- Multi-socket Revit objects are indivisible. Their effective quantity may be
greater than one, but one external object must never be split across circuits.
- Existing ProjectDevices are canonical project templates and must not be
overwritten from Revit values. When creating a new ProjectDevice, mapped
Revit values may prefill the creation form.
- Store imported source values separately from local planning values and local
overrides. Imports never silently overwrite local values.
- Repeated imports match deterministically by project/source and `IfcGUID`.
New, changed, missing and conflicting objects require preview and explicit
user decisions.
- The first implementation assumes one complete Revit model export per project.
Do not add visible multi-source, linked-model or partial-import management.
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
and unknown rows and columns for the return export.
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
Do not report Revit subtotal rows as missing-identity objects.
- Apply each confirmed import diff through a typed project command with
`expectedRevision`, complete inverse data and the shared project command
transaction boundary.
- External-model state must be added to logical project snapshots and portable
project transfer when it becomes part of the supported runtime.
- Keep CSV parsing/serialization in a transport adapter. Domain services must
not depend on CSV syntax, React, `better-sqlite3` or concrete repositories.
- Do not implement automatic circuit planning, Dynamo/API exchange,
PostgreSQL, background jobs or permissions as part of the initial CSV phase.
Phase 14 is active and follows
`docs/spec/revit-excel-integration-requirements.md` plus
`docs/spec/revit-excel-phase-14-plan.md`. The old CSV requirements and phases
14.0–14.6 are superseded. Dynamo, Revit API and JSON transport remain excluded.
Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
domain objects and commands before adding migrations.
Critical target rules:
- Exchange uses only `.xlsx` files produced and consumed by the Linear plugin.
- Revit integration remains optional. Import never creates a CircuitDeviceRow,
Circuit or equipment identifier.
- Worksharing-ID is the external key and is compared exactly and
case-sensitively. It stays separate from internal UUIDs and circuit BMKs.
IfcGUID is optional redundancy, never a key, and duplicate values are only a
warning.
- A row without Worksharing-ID is suspicious only when `Familie und Typ` is
populated. Show a warning and skip it; do not block. Other ID-less rows,
including the sample summary in column B, are skipped silently.
- Source, planning and override values remain separate. Existing ProjectDevices
remain canonical and are never silently changed.
- Multi-socket objects remain indivisible. Row quantity stays
`manualQuantity + sum(effectiveQuantity)`.
- The project configuration has no independent version counter. Preview and
apply use `expectedRevision`; import batches store a configuration copy.
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
apply only stored before/after states.
- Only the newest import batch retains original bytes and the full matrix;
older batches retain date, filename, SHA-256 and object count.
- The Revit object table loads one compact list, filters/sorts in the client and
renders it virtually; do not add server pagination.
- Linked objects always follow their row's distribution board. Changing a
room's default board affects only unassigned objects and is not blocked.
- Deleting a row, circuit or group warns that X Revit objects become unassigned,
detaches them in the same command and restores links on Undo. Only deletion
of referenced rooms and distribution boards is blocked.
- Missing optional return columns for designation, power, voltage or current
produce warnings and are skipped. Only missing circuit columns for the
selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 through R10 and Phase 14 are complete.
- All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
management, partial imports, PostgreSQL, background jobs or new permissions.
The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is
gitignored and must never be embedded in tests or committed. Use synthetic
workbooks for automated tests and accept the real file only as an explicit
local path in the reference checker.
## Cable Sizing Module
`src/cable-sizing` is an agreed, separate calculator module attached to the
circuit list (see `docs/cable-sizing-module.md`). Rules:
- The domain code stays pure: no imports from `db/`, `server/` or `frontend/`.
- It never writes project data. Results are applied only by an explicit
"Übernehmen", which runs the regular `circuit.update` command.
- It is opened from the circuit row action button "Leitungsberechnung", never
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
- It has no database table and no migration; `/api/cable-sizing/*` is
stateless.
- Current-carrying capacities and correction factors use
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the
separate basis of voltage-drop and protection checks are documented in
`docs/cable-sizing-module.md`. Inputs outside the supported tables return no
result instead of assuming a factor of 1. Direct burial, grouped method E,
aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic
remain unsupported until their required inputs or normative basis exist.
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
length, protective device) and remain editable; manual cable entry without
a calculation stays possible.
## Future Sizing
Do not implement full cable/protection sizing unless explicitly requested.
Do not implement full automatic cable/protection sizing of all circuits
unless explicitly requested. The cable sizing module above only suggests
values for one circuit on demand.
Keep the structure ready for it.
@ -516,6 +666,37 @@ Future sizing will need:
Users must be able to override sizing suggestions.
## Estimate Balance Module (Grobe Leistungsbilanz)
`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module
for the early-phase power balance by DIN 276 cost group (see
`docs/estimate-balance.md`). Rules:
- It has its own tables (`estimate_*`) and never writes circuit, board or
project-device data; it only reads device rows for the comparison.
- Writes use the project command mechanism (`estimate-balance.insert/.update/
.delete` with complete documents), so revision checks and Undo/Redo apply.
- The calculation and comparison live in the import-free
`src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI;
displayed values use `roundEstimateValue`.
- It is deliberately not part of named/automatic project snapshots or snapshot
restore; those operations leave estimate balances unchanged. Portable project
transfer format version 2 includes every estimate balance and remaps balance,
group and row ids when duplicating a project. Version 1 transfers remain
importable and contain no estimate balances.
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
`src/frontend/estimate-balance/`). The editor autosaves the whole document
debounced (one Undo step per save), keeps invalid raw input local, and on a
409 reloads only if the balance itself changed; otherwise it re-saves with
the new revision. `request()` throws `ApiRequestError` (status, code).
- A single estimate row can be copied explicitly through the existing
ProjectDevice dialog. The dialog is only a prefilled proposal; creation
happens after confirmation through `project-device.insert`, with the current
project revision and normal Undo/Redo. The copy stores no link back to the
estimate row, never places a circuit row automatically, and does not change
estimate, snapshot or transfer schemas. Area rows require manual quantity
and power because W/m² remains outside the ProjectDevice model.
## Persistence and Migration Rules
- SQLite is the currently supported database.
@ -533,11 +714,17 @@ Users must be able to override sizing suggestions.
## Current Deferred Work
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing
- multi-user/PostgreSQL operation
- supported production deployment
- second/replacement board feeds, including changeover semantics for SV/SiBe
- Revit prefill proposals for logical board feeds through the Phase 14 preview
and explicit-confirmation workflow
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
- permanent account lockout (only temporary per-username login throttling
exists)
- email/one-time-link password provisioning, OIDC/Entra login
- linking user accounts to a personnel/employee master table
Do not implement these while working on an unrelated phase.

View file

@ -4,19 +4,30 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert.
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R10 sind abgeschlossen;
die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport,
virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe.
Die Phase-14-Kette ist Ende zu Ende und mit 5.000 synthetischen Objekten geprüft.
## Unterstützter Arbeitsablauf
- Projekte, Verteilungen, Etagen und Räume verwalten
- Projekte und Verteilungen verwalten; Gebäude, Etagen und Räume auf einer
eigenen Seite pflegen, Räume optional mit Fläche und Summen je Etage und
Gebäude
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
3-phasige Stromkreise verwalten
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
Verteilergeräte in der Stromkreisliste abbilden
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
- Leitungsquerschnitte für einzelne Stromkreise auf Anforderung nach
DIN VDE 0298-4 berechnen und ausdrücklich übernehmen
- leere, einzeilige und mehrzeilige Stromkreise abbilden
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
@ -25,6 +36,14 @@ implementiert.
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen
und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
übernehmen
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte
prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an
Linear zurückgeben
## Technik
@ -44,6 +63,8 @@ Voraussetzungen:
```powershell
git clone <repository-url>
Set-Location leistungsbilanz-ts
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
docker compose up --build --detach
```
@ -52,9 +73,15 @@ Danach:
- Frontend: <http://localhost:3001>
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der
Zugriff läuft über den Rewrite im Web-Dienst.
Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator,
solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für
einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte
`.env` neben `compose.yaml`; Details stehen unter
[Anmeldung und Benutzerverwaltung](docs/authentication.md).
Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen
API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den
Rewrite im Web-Dienst.
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
@ -100,6 +127,8 @@ Voraussetzungen:
npm ci
npm run db:migrate
npm run db:verify:circuit-schema
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
npm run dev:api
```
@ -142,8 +171,15 @@ vor der Baseline werden nicht unterstützt.
- [Aktuelle Architektur](docs/current-architecture.md)
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
- [Seitenansicht und PDF-Export](docs/print-view.md)
- [Gebäudestruktur und Raumflächen](docs/building-structure.md)
- [Grobe Leistungsbilanz](docs/estimate-balance.md)
- [Leitungsberechnung](docs/cable-sizing-module.md)
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md)
- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen

View file

@ -35,8 +35,12 @@ services:
PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-debug}"
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
# nicht aus dem LAN erreichbar sein.
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

View file

@ -45,8 +45,16 @@ services:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: "${LOG_LEVEL:-info}"
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
# .env - siehe docs/authentication.md.
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}"
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
# Next.js-Rewrite im Web-Dienst erreicht.
expose:
- "3000"
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit

View file

@ -12,21 +12,39 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
fachliches Modell und Grid-Aufteilung
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
Drag-and-drop, Sortierung, Filterung und Undo/Redo
- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt,
Verteilerübersicht, Verteilerpläne, Büro-Logo
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
Raumflächen und Summen, Befehle, Snapshot-Schema 8, eigene Seite
- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen,
Rechnung, Vergleich mit der detaillierten Bilanz, Übernahme als Projektgerät
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
bewusst noch nicht implementierte oder begrenzte Funktionen
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
## Entwicklung und Betrieb
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
- [Deployment und Betrieb](deployment.md)
## Zukunftsarchitektur
## Revit / Linear XLSX
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) –
verbindliche Anforderungen für XLSX und Worksharing-ID
- [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status,
Paketgrenzen und Linear-Freigabepunkt nach R2
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
- [R10-Abnahme](spec/revit-excel-r10-validation.md) – Ende-zu-Ende- und
5.000-Objekte-Nachweis
- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md)
## Zukunftsarchitektur
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
@ -48,3 +66,6 @@ Limitierungen und Roadmap.
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
Arbeitsstand, keine aktuelle Implementierungsanweisung
- [Revit-/Dynamo-Anbindung](archive/revit-dynamo-leistungsbilanz-integration-konzept.md) –
nicht weiterverfolgte Vorüberlegung zur direkten Dynamo-/API-Anbindung;
Diagnoseskripte über den Git-Tag `archive/dynamo-vorueberlegungen`

File diff suppressed because it is too large Load diff

99
docs/authentication.md Normal file
View file

@ -0,0 +1,99 @@
# Anmeldung und Benutzerverwaltung
Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder
Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
## Rollen
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer**
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung
Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
## Ablauf beim Anlegen eines Zugangs
1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit
Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst
eintippt.
2. Der Zugang startet mit `mustChangePassword = true`.
3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das
ein eigenes Passwort verlangt, bevor es weitergeht.
4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues
Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn
jemand sein Passwort vergessen hat.
## Technischer Aufbau
- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) –
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
`role`, `active`, `mustChangePassword`.
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit
absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
(außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und
erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist.
`/api/users/*` verlangt zusätzlich die Rolle `admin`.
- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
**Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für
Administratoren.
## Schutzmechanismen
| Angriff | Schutz |
|---|---|
| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). |
| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash |
| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung |
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. |
| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. |
| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel |
| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) |
| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen |
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. |
| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` |
| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt |
| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben |
| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) |
| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt |
**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im
Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen
Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen.
## Bootstrap-Admin
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die
`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen
ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte
gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
in `compose.yaml` selbst.
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
AUTH_SESSION_HOURS=12
AUTH_SESSION_MAX_DAYS=7
COOKIE_SECURE=false
```
Bei Zugriff über HTTPS muss `COOKIE_SECURE=true` gesetzt werden. Bei direktem
HTTP-Zugriff im LAN bleibt es `false`, da der Browser das Session-Cookie sonst
nicht speichert.
## Bewusst nicht umgesetzt (Stand jetzt)
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich
begrenzte Drosselung, siehe oben)
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle

112
docs/building-structure.md Normal file
View file

@ -0,0 +1,112 @@
# Gebäudestruktur (Gebäude → Etagen → Räume)
Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude**
gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen
hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung
ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es
nicht. Abgestimmt in Issue #6: Das Modell ist so bestätigt, und Gebäude mit
zugeordneten Etagen lassen sich nicht löschen.
## Datenmodell
| Tabelle | Feld | Bedeutung |
|---|---|---|
| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts |
| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude |
| `rooms` | `area_m2` (nullable `REAL`) | optional gepflegte Netto-Raumfläche in m² |
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
Datenbanksicherung anzulegen.
Migration `0011_glossy_norman_osborn` ergänzt ausschließlich das nullable Feld
`rooms.area_m2`. Bestehende Räume behalten damit eine unbekannte Fläche. Vor
dem Anwenden auf eine bestehende Datenbank ist ebenfalls eine separate
Datenbanksicherung erforderlich.
## Befehle, Undo/Redo
Alle Änderungen laufen über die bestehende Orts-Struktur
(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und
persistentem Undo/Redo:
- `project-building.insert` / `project-building.delete` mit vollständigem
Gebäude-Snapshot (Schema-Version 1)
- `project-building.update`: ändert nur den Namen
- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind
(„Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“)
- Etagenbefehle `project-floor.insert/.update/.delete` haben seit
Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name
und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude
muss zum Projekt gehören.
- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie
stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit
`buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten
Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie
exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
- Raumbefehle `project-room.insert/.update/.delete` haben seit Schema-Version
**2** das Feld `areaM2`. Version-1-Befehle werden beim Einlesen mit
`areaM2 = null` hochgestuft. Vor einem alten Update oder Delete wird weiterhin
der vollständige erwartete Datensatz verglichen; eine inzwischen vorhandene
Fläche kann deshalb nicht unbemerkt gelöscht werden.
## Snapshots und Transfer
Projekt-Snapshot-Schema **8** enthält zusätzlich `rooms[].areaM2`. Version 7
wird mit `areaM2 = null` hochgestuft; die älteren unterstützten Versionen 1–6
werden außerdem mit leerer Gebäudeliste und `buildingId = null` hochgestuft.
Gespeicherte Sicherungspunkte bleiben wiederherstellbar. Die Relationen werden
geprüft (Gebäude gehört zum Projekt, `buildingId` verweist auf ein Gebäude des
Snapshots). Restore und Projekttransfer erhalten die Raumfläche. Das portable
Transferformat bleibt Version 2, weil es den versionierten Projektsnapshot
bereits einbettet; beim Duplizieren werden nur IDs und Relationen umgesetzt.
## API
- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach
`sortOrder`, Name
- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }`
- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }`
- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }`
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
- `POST/PUT /api/projects/:projectId/rooms[/:roomId]` akzeptieren zusätzlich
optional `areaM2` (`number | null`, endlich und ≥ 0). Beim Anlegen bedeutet
ein fehlendes Feld `null`; beim Bearbeiten bleibt die vorhandene Fläche
erhalten, wenn das Feld fehlt.
## Oberfläche
Gebäude, Etagen und Räume werden auf der eigenen Seite
`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über
die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten
Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt
nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
Stromkreisliste.
Der Raumdialog pflegt die optionale **Fläche [m²]** mit deutscher
Dezimaltrennzeichen-Eingabe. Gemeint ist die Netto-Raumfläche, wie sie der
Revit-Raumparameter „Fläche“ liefert, nicht eine Flächenart nach DIN 277. Die
Fläche wird ohne fachliche Rundung gespeichert und mit zwei Nachkommastellen
angezeigt. Die Gebäudestruktur zeigt die Fläche je Raum sowie Summen je Etage,
je Gebäude, „Ohne Etage“ und „Ohne Gebäude“. Fehlende Flächen werden nicht als
Nullfläche ausgegeben, sondern separat gezählt, damit Teilsummen erkennbar
bleiben.
## Offen
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
abgeschlossene Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten
Raumzuordnung im Importwizard nicht im Weg.
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
und ausdrücklicher Bestätigung einhalten.
- Eine W/m²-Auswertung oder Übernahme der Raumfläche in Flächenlasten ist nicht
Teil dieser Funktion; das ist Folgethema von Issue #20.
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
Gebäude werden hinten angehängt.

109
docs/cable-sizing-module.md Normal file
View file

@ -0,0 +1,109 @@
# Leitungsberechnung
Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare
Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die
ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`,
`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`.
Revisionen sowie Undo/Redo bleiben dadurch erhalten.
Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil
des Moduls.
## Aufbau
```text
src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen
src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
src/server/routes/cable-sizing.routes.ts Router
src/frontend/components/cable-sizing-api.ts Fetch-Client
src/frontend/components/cable-sizing-defaults.ts Vorbelegung
src/frontend/components/cable-sizing-modal.tsx Dialog
tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle
```
Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist
zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die
Kabelzellen behalten ihr übliches Spreadsheet-Verhalten.
## Eingaben
Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes
cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der
Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung,
zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen.
Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf
hin, dass dann kein Spannungsfall angesetzt wird.
## Normative Grundlage und Grenzen
Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen
**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich
**Berichtigung 1:2023-10** geprüft:
- Strombelastbarkeit: Tabellen 3 bis 6.
- Temperaturfaktoren: Tabellen 17 und 20.
- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung.
- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich.
- Oberschwingungen: Anhang B, insbesondere Tabelle B.1.
Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich
mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im
Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür
auf die Kabel-Produktnorm DIN VDE 0276-603 verweist.
Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der
Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher
ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens
sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt.
Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine
kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter
Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser
nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine
Empfehlung.
Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit
Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre
Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination
mit Leitungsschutzschaltern und gG-Sicherungen gehört zur
Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu
beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig
normative Leitungsbemessung.
## Rechenweg
1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl.
2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen
Schutzorgan.
3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls
Oberschwingungsfaktor.
4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt.
5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall.
6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen
Mindestquerschnitt von 2,5 mm².
Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten
ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs
wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin
manuell eingetragen und bewusst übernommen werden.
## Nicht enthalten
- direkte Erdverlegung
- detaillierte Anordnung mehrerer Kabel bei Verlegeart E
- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer
- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität
- abweichender Bodenwärmewiderstand sowie Verlegearten F und G
- automatische Berechnung oder Änderung mehrerer Stromkreise
## API
`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist
`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält
`supported: false` und einen der Gründe `temperature`, `grouping` oder
`harmonics`, wenn keine belastbare Empfehlung möglich ist.
`GET /api/cable-sizing/laying-methods` und
`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten.

View file

@ -59,6 +59,24 @@ this value for optimistic concurrency checks.
- clients cannot submit arbitrary `project.restore-state` payloads through
the generic command endpoint
Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable
Projektexport unter `GET /projects/:projectId/export` verwendet dagegen
Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege
`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre
Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
importierbar.
Das aktuelle Projekt-Snapshot-Schema 10 enthält Linear-XLSX-Zustand und die in
Schema 9 eingeführten logischen Einspeisungsverweise der Verteilungen;
Versionen 1–8 werden mit leeren Verweisen hochgestuft. Schema 8
enthält außerdem die optionale Netto-Raumfläche `rooms[].areaM2`.
Snapshot-Versionen 1–7 werden dafür mit
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
Flächen und Einspeisungsverweise; beim Duplizieren werden Board- und Circuit-IDs
neu zugeordnet. Eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
versionierten Projektsnapshot einbettet.
The public dispatcher supports the Circuit and CircuitDeviceRow commands
`circuit.update`, `circuit.insert`, `circuit.delete`,
`circuit-device-row.update`, `circuit-device-row.insert`,
@ -74,8 +92,9 @@ Protected-board structure uses
persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`.
`project.update-settings` uses version `2` and still accepts supported baseline
version `1` commands without changing the newer building classification.
`project.update-settings` uses version `3` (including the print cover fields)
and still accepts version `2` and supported baseline version `1` commands
without changing the newer cover fields or building classification.
Pre-baseline formats remain unsupported. Unsupported types or schema versions
are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
@ -173,6 +192,9 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId`
- request:
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- optional print cover fields `siteAddress` (max. 500), `workStage` (max.
100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep
their value, empty strings are stored as `null`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
@ -210,6 +232,12 @@ A stale `expectedRevision` returns HTTP `409` with
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
## Estimate Balance (Grobe Leistungsbilanz)
Separate module under `/projects/:projectId/estimate-balances`; complete
documents are written through `estimate-balance.insert/.update/.delete` project
commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
## Circuit-First Endpoints
### Distribution Board Setup
@ -230,62 +258,106 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored
together by persistent Undo/Redo
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "feedingDistributionBoardId": "board_hv", "feedingCircuitId": "circuit_out", "expectedRevision": 13 }`
- optional info-card fields: `boardNumber` (max. 50), `location`,
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
and an empty string is stored as `null`
- `feedingDistributionBoardId` and `feedingCircuitId` are optional nullable
fields. A feeding circuit requires a feeding board and must belong to it;
self-links and cycles are rejected. A board-only link creates hierarchy
without assigning its load to an upstream circuit.
- executes `distribution-board.update` schema version 2; all submitted values, including the
submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes
- The project-command endpoint additionally accepts
`distribution-board.create-feeder-circuit` with an empty complete circuit
snapshot and an embedded version-2 `distribution-board.update`. Both writes
are one transaction, revision and Undo step. The generated circuit has no
device row; Redo keeps its UUID and BMK. The history-only inverse
`distribution-board.remove-feeder-circuit` restores the previous board values
and removes only the unchanged circuit.
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs
while preserving project-device and room links
while preserving project-device, room and incoming feeding links
- response includes the new `distributionBoard`, its `circuitList`, the
revision and current history state
- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId`
- body: `{ "expectedRevision": 15 }`
- removes the complete, exactly captured distribution-board subtree in one
transaction
- rejects deletion while another board references this board or one of its
circuits as a feeder; circuit and populated-group deletion use the same guard
- response includes `distributionBoardId`, the revision and current history
state
- Copy and deletion use `distribution-board.insert-subtree` and
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
removes the same complete subtree, including protection devices.
### Project Floors and Rooms
### Project Buildings, Floors and Rooms
- `GET /projects/:projectId/buildings`
- `POST /projects/:projectId/buildings`
- body: `{ "name": "Haus A", "expectedRevision": 12 }`
- executes `project-building.insert` with a stable building id
- response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/buildings/:buildingId`
- body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only)
- `DELETE /projects/:projectId/buildings/:buildingId`
- body: `{ "expectedRevision": 14 }`
- rejects buildings with assigned floors
- `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id
- body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
(`buildingId` optional, `null` = no building)
- executes `project-floor.insert` (command version 2) with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/floors/:floorId`
- body: `{ "name": "1. OG", "expectedRevision": 14 }`
- executes an exact persistent floor update
- body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }`
- executes an exact persistent floor update of name and/or building; a
missing `buildingId` keeps the current building
- `DELETE /projects/:projectId/floors/:floorId`
- body: `{ "expectedRevision": 15 }`
- rejects floors with assigned rooms or distribution boards
- `POST /projects/:projectId/rooms`
- body:
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
- executes `project-room.insert` with a stable room id; `floorId` is optional
and must belong to the project when present
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }`
- executes `project-room.insert` command version 2 with a stable room id;
`floorId` and `areaM2` are optional. An omitted or explicit `null` area
creates a room without area; a value must be finite and non-negative.
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/rooms/:roomId`
- body:
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
- updates number, name and optional floor as one persistent command
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }`
- updates number, name, optional floor and area as one persistent version-2
command. If `areaM2` is omitted, the current area is retained; `null`
explicitly clears it.
- `DELETE /projects/:projectId/rooms/:roomId`
- body: `{ "expectedRevision": 17 }`
- rejects rooms referenced by device rows
- Persistent Undo/Redo restores exact previous and target records without
silently clearing foreign keys.
- Stored version-1 room commands remain executable and are upgraded with
`areaM2 = null`. Exact expected-record checks reject an old update or delete
if a newer command has added an area, so legacy history cannot silently
erase it.
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint
- `GET /projects/:projectId/circuit-trees`
- returns all circuit trees for one project from one project-wide read and is
used by the print view and feeding-board selection
- `GET /projects/:projectId/circuit-lists/:circuitListId/tree`
- Purpose: returns fixed header components, grouped sections with optional
protection components, complete circuit/device-row blocks and auxiliary
footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included.
remain included. Circuit totals contain the demand of linked downstream
boards. `downstreamBoardLoads` exposes those contributions as read-only
values; the grid combines exactly one downstream board without device rows
into the circuit row and keeps child rows for mixed or multiple loads.
`feedingReference` resolves the current board/circuit labels by id.
Response sketch:
@ -391,37 +463,76 @@ inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly.
## Revit CSV Foundation
## Cable Sizing (optional module)
- `GET /projects/:projectId/external-csv/configuration`
- returns the complete versioned project configuration or `null`
- `PUT /projects/:projectId/external-csv/configuration`
- request: `expectedRevision` and the complete validated configuration
- executes `external-csv-configuration.update`
- returns configuration, revision and project history state
- `POST /projects/:projectId/external-csv/preview`
- request: file name and Base64-encoded CSV content up to 18 MB
- requires a stored project configuration
- returns transport metadata, SHA-256, row classifications, suspect row
numbers and mapped source values for every recognized IFC object
- does not persist a draft, mutate project state or create a revision
- `POST /projects/:projectId/external-csv/initial-import/plan`
- reparses the file and returns grouped room/family decisions plus current
room, distribution-board and ProjectDevice catalogs
- is stateless and rejects an already imported external source
- `POST /projects/:projectId/external-csv/initial-import/apply`
- requires the expected revision, configuration version, file hash and one
decision for every room and exact family/type group
- reparses the file, blocks unknown family/type values and executes
`external-import.apply-initial` as one undoable project revision
- creates no Circuit or CircuitDeviceRow
- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects`
- resolves the circuit list's distribution board server-side
- returns only external objects assigned to that board, with unassigned
objects first, their source room/marker values and current row-link status
- returns an empty list when the optional Revit import does not exist
- is read-only and exposes no stored command payloads or import bytes
Stateless calculator endpoints mounted at `/api/cable-sizing`. They neither
read nor write project data; applying a result uses `circuit.update` through
`POST /projects/:projectId/commands`.
Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
import uploads and parses the file again and verifies its hash.
- `POST /cable-sizing/calculate`
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` |
`E`), conductorMaterial, insulation (`pvc` | `xlpe`),
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
harmonicNeutralLoad, optional `existingProtection`
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
is `false` with `unsupportedReason` when temperature or grouping lie
outside the tables or the neutral current needed above 33 % third
harmonic is unavailable
- unknown fields are rejected (`400`)
- `GET /cable-sizing/laying-methods`
- `GET /cable-sizing/insulation-materials`
Full request example and calculation rules: `docs/cable-sizing-module.md`.
## Revit / Linear XLSX
- `GET|PUT /projects/:projectId/external-model/configuration` reads or writes
the complete XLSX configuration. PUT requires `expectedRevision`.
- `POST /projects/:projectId/external-model/configuration/detect` detects the
sheet layout and columns from an uploaded `.xlsx` sample without persisting.
- `GET /projects/:projectId/external-model/import-status` returns the latest
batch metadata, object count and current missing-object count.
- `POST .../initial-import/plan|apply` provides the stateless initial plan and
its confirmed atomic application. Apply retransmits and reparses the XLSX.
- `POST .../follow-up-import/plan|apply` classifies exact Worksharing-ID
additions, source changes, unchanged and missing objects. Apply requires the
preview hash and source decisions and updates linked row quantities atomically.
- `GET|PUT .../external-model/objects` loads one compact project list and
applies revision-safe multi-object planning changes.
- `POST .../objects/assign-row|assign-new-row|assign-new-circuit|detach-row`
changes external-object row assignments through persistent commands.
- `POST .../objects/delete-missing` requires `confirmed: true`, deletes only
objects marked missing and adjusts linked row quantities in the same command.
- `POST .../external-model/return-workbook` creates the structure-preserving
return XLSX for all, deviating or selected objects.
- `GET .../external-model/export-batches` returns append-only export metadata.
Uploads accept only `.xlsx` up to 10 MiB. Worksharing-IDs are exact and
case-sensitive; IfcGUID is optional and non-unique. Undo/Redo uses persisted
command states and never reparses workbook bytes.
## Document Branding (print view)
Office-wide branding for the print view. These endpoints are mounted at
`/api/document-branding`, outside `/api/projects`, because the data is global
and intentionally not part of project revisions, snapshots or transfer.
- `GET /document-branding`
- returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null, "office": { "officeName", "officeAddress" } }`
- `GET /document-branding/logo`
- returns the stored image bytes with its media type, or `404` without logo
- responses carry `X-Content-Type-Options: nosniff` and a restrictive
Content-Security-Policy so an SVG logo cannot execute scripts
- `PUT /document-branding/logo`
- request: `{ "fileName": "logo.png", "mimeType": "image/png", "contentBase64": "..." }`
- accepted types: `image/png`, `image/jpeg`, `image/svg+xml`; at most 2 MB
after decoding; the content signature must match the declared type
- replaces an existing logo; returns the logo metadata
- `DELETE /document-branding/logo`
- removes the logo; returns `204`
- `PUT /document-branding/office`
- request: `{ "officeName": "…" | null, "officeAddress": "…" | null }`
(at most 200 / 500 characters; empty text is stored as not set)
- returns `{ "office": { "officeName", "officeAddress" } }`

View file

@ -1,5 +1,8 @@
# Circuit List Editor Interactions
The back-arrow link beside the page title returns to the project's distribution
board overview without changing circuit-list data.
## Editing Model
Inline cells are static text by default. A cell enters edit mode by:
@ -44,6 +47,7 @@ Selection, keyboard movement, and editability checks run against this normalized
- arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table
- `Delete`: delete the selected circuit or device context after confirmation
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
@ -52,6 +56,23 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## AMEV Simultaneity Factor
The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open
the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project
device dialog uses as well. "Übernehmen" commits the mean value of the chosen
consumer group through the same path as a typed edit (`commitEdit` with an
explicit target cell), so it is one undoable command and works on device rows,
compact circuits, reserve circuits and the `-frei-` row. A running inline edit
of the cell is discarded when the picker opens. The button only appears on the
selected cell; the grid keeps its static-text cells.
The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and
closes on Escape without writing a value. Closing returns focus to the grid;
only "Übernehmen" saves the selected mean value. Opening the picker is disabled
while a write or history operation is running. Its cell button stays outside
the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
@ -127,11 +148,30 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Filtering and Sorting
- Clicking a column label changes sorting; the funnel icon next to it opens
the value filter. An active filter is highlighted and shows the number of
selected values. Both controls retain German accessible names and tooltips.
- Header labels wrap between words; trailing units such as `[kW]` appear on
a separate line.
- Per-column filtering works on normalized displayed values.
- Sorting is view-level first and treats circuits as blocks.
- Multi-device circuits are not split during sort.
- Sorting alone does not persist order.
## Action Buttons
The editor toolbar uses icons with visible German labels. Group headers and
circuit/device rows use compact icon buttons with German accessible names and
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
their roles. Dividers separate related actions; destructive actions use the
danger style. Circuit deletion uses a trash icon, while device-row deletion
uses a crossed-out list icon.
The shared `src/frontend/components/icon-button.tsx` component preserves
disabled states and expanded states for toggles. Row and group action buttons
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
unchanged. The existing command handlers and deletion confirmations are reused.
## Apply Sorted Order
`Apply sorted order` persists current sorted block order to backend by section.
@ -161,6 +201,11 @@ across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
project-history step.
The editor creates client-side UUIDs through `createClientId` in
`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available
and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion
also works over plain HTTP from another machine in the LAN.
Covered operations include:
- insert/delete circuit

View file

@ -9,16 +9,20 @@
- Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained.
- The Revit/CSV foundation and confirmed initial import are implemented.
Existing-row assignment, move and detach plus creation of an external-only
row in an existing or newly created circuit have atomic persistent commands.
Their API planning and editor UI, follow-up import, conflict review and return
export are pending.
- Die Linear-XLSX-Anbindung bis R10 ist umgesetzt: Erst- und Folgeimport,
Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
Löschen fehlender Objekte verwenden persistente Commands. Die vollständige
Kette ist mit 5.000 synthetischen Objekten sowie Snapshot-, Transfer- und
Neustarttests geprüft.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model.
- Final electrical sizing logic is not implemented yet.
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
- There is no automatic sizing of all circuits. The optional cable sizing
module suggests a cross-section for one circuit on demand using
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
burial, grouped method E and neutral-current sizing above 33 % third
harmonic, and it does not check short-circuit withstand, disconnection time
or selectivity. See `docs/cable-sizing-module.md`.
- Sorting is view-only until users explicitly apply sorted order.
- Complete-circuit drag supports one circuit at a time between groups of the
same category. Cross-category moves and multi-circuit moves across group
@ -27,3 +31,15 @@
- Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or
voltage-drop rules.
- One logical incoming feed per distribution board is supported. A second or
replacement feed with changeover semantics (for example SV/SiBe) is still a
follow-up. `fedFrom` remains the free-text field for external sources and
supplementary notes. Revit does not yet propose logical feed links.
- A logical link without a selected feeding circuit appears in the hierarchy,
but deliberately contributes no load to an upstream circuit until the planner
selects that circuit (see `docs/print-view.md`).
- The print view uses a kbp corporate design derived from logo and
presentation material. Snapshots do not record an editor, so the cover's
Planstände list shows the project's current editor for every entry. On screen, long
board plans are not split into pages; pagination happens in the browser print
dialog.

View file

@ -27,15 +27,38 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
data/leistungsbilanz.db (SQLite)
```
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
liegt über einen Host-Mount außerhalb des Containers.
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
Datenbank liegt über einen Host-Mount außerhalb des Containers.
Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus
`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar.
Schreibzugriffe verlangen zusätzlich den Anwendungsheader
`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN
ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren
vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu
Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher
des einzelnen API-Prozesses und enden bei dessen Neustart.
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen
auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen
vor dem Command und bleiben über Undo/Redo unverändert.
## Wichtige Einstiegspunkte
- `src/app/projects/page.tsx` – Projektliste und globale Gerätebibliothek
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen,
Räume und Projektgeräte
- `src/app/projects/[projectId]/page.tsx` – Projektstammdaten, Verteilungen
und Projektgeräte
- `src/app/projects/[projectId]/struktur/page.tsx` – Gebäude, Etagen, Räume
und Raumflächen
- `src/app/projects/[projectId]/grobbilanz/` – Stände und Editor der groben
Leistungsbilanz
- `src/app/projects/[projectId]/print/page.tsx` – Seitenansicht und PDF-Export
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
– unterstützte Editorroute
- `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand,
@ -43,6 +66,11 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe
- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung
und erzwungener Wechsel eines Startpassworts
- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz
- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` –
kontobezogene Regeln und schmale Persistenzgrenze
- `src/server/index.ts` und `src/server/routes/` – API-Komposition
- `src/domain/services/` – fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten
@ -232,8 +260,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
@ -245,22 +274,55 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite
„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können
einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare,
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
damit Änderungen weiterhin niemals still synchronisiert werden.
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
`IconButton`/`IconLink`.
Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
Die Verteilersuche berücksichtigt Name, Verteilernummer, Örtlichkeit und
Etagenname; die Gerätesuche
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende
Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise
„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei
der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte.
Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar.
Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe
und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine
Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
bestehenden persistenten Projektbefehl.
`GET /api/projects/:projectId/export` verpackt den vollständigen portablen
Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit
SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen,
ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne
Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der
Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den
Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar.
Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen
Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
Fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben
erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
@ -416,11 +478,54 @@ wieder her.
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` bleibt Freitext für
externe Einspeisungen und Hinweise.
`distribution-board.update` verwendet Schema-Version 2 für die optionalen
logischen Verweise `feedingDistributionBoardId` und `feedingCircuitId`.
Version-1-Befehle aus der Historie bleiben ausführbar und verändern diese
Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören;
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt.
Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein
neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige
Gruppe und das nächste BMK vor. Der Befehl
`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und
wendet die vollständige Verteilungsänderung in derselben Transaktion und
Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst
offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo
stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach
nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut.
Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das
Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten
Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen
Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren
Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine
Gerätezeile.
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den
externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden
hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt.
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
Snapshot-Schema 7 enthält die Gebäude und die Gebäudezuordnung der Etagen;
ältere Versionen werden ohne Gebäude hochgestuft.
Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
Projekts; ältere Versionen werden mit `null`
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
enthielt bereits den damaligen externen CSV-Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
@ -431,7 +536,12 @@ Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft;
dieser Baseline werden nicht eingelesen.
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
exakten Vorher-/Nachher-Snapshots. Raumbefehle verwenden Version 2 und führen
die nullable, nicht negative Netto-Raumfläche `areaM2`; Version-1-Befehle
werden mit `null` hochgestuft und bleiben durch den vollständigen
Snapshotvergleich konfliktfest. Der Raumdialog pflegt die Fläche manuell mit
deutscher Dezimaleingabe, die Gebäudestruktur zeigt zwei Nachkommastellen sowie
Summen und die Anzahl fehlender Flächen je Etage und Gebäude. Ein Geschoss wird nur
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
@ -452,6 +562,36 @@ werden als Diff angezeigt und nur für ausdrücklich gewählte Felder und Zeilen
Die globale Gerätebibliothek ist ein einfacher, datenbankweiter Vorlagenbestand.
Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
## Leitungsberechnung
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit
den in der Moduldokumentation beschriebenen Grenzen geprüft. Die
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
Querschnitt und Länge über den regulären `circuit.update`-Befehl, also mit
Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
`docs/cable-sizing-module.md`.
## Grobe Leistungsbilanz
Das Modul `src/estimate-balance` mit der importfreien Rechnung in
`src/shared/estimate-balance` führt Leistungsbilanzen für LPH 2/3 nach
DIN-276-Kostengruppen in eigenen Tabellen (`estimate_*`, Migration `0010`).
Es ist nicht mit der Stromkreisstruktur verknüpft und liest Gerätezeilen nur
für den Vergleich „geschätzt ↔ detailliert“ über das längste passende
Kostengruppen-Präfix. Schreibzugriffe laufen über `estimate-balance.insert`,
`.update` und `.delete` mit vollständigen Dokumenten, also mit Revision und
Undo/Redo. Grobbilanzen sind Teil von Transferformat 2, aber bewusst nicht Teil
der logischen Snapshots; ein Restore lässt sie unverändert. Einzelne Zeilen
lassen sich über den vorbelegten Projektgeräte-Dialog ausdrücklich als
Projektgerät kopieren (`project-device.insert`, ohne gespeicherte Verknüpfung).
Oberfläche: `/projects/:projectId/grobbilanz`. Details:
`docs/estimate-balance.md`.
## Persistenz und Migration
- SQLite ist die aktuell unterstützte Datenbank.
@ -486,7 +626,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
Transfer-Integration verwenden aktuell Snapshot-Schema 10. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
@ -503,141 +643,67 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage
## Revit-Anbindung: Linear-XLSX nach R10
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
Änderungen laufen als Projekt-Commands über `expectedRevision`.
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped.
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und
Projekttransfer verwenden diesen Zustand und remappen alle internen
Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9
werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem
CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
abgelehnt.
Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite
Konfigurationsversion am Batch, getrennt von der Schema-Version des
Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit
dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes,
Matrix und alle internen Links werden weiterhin vollständig erfasst,
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen
Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes
erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und
validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
erzeugen weiterhin keine dieser Zeilen.
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
seine Endpunkte und Laufzeitverträge sind entfernt. Die Route
`/projects/:projectId/revit` enthält Einstellungen, Importstatus und den
sechsstufigen Erstimportwizard.
Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
`external-object.update-row-assignment` ist diese atomare Schreibgrenze für
bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt
sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden
die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit,
Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft.
Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung;
unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht
still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
bestehen.
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
Die drei Zuordnungsadapter teilen sich
`external-object-assignment.persistence.ts` für Snapshotvergleich,
Kompatibilitätsregeln und link-sichere Updates.
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer.
Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur
dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte
Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige
Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen
Revit-Import ist die Projektion leer.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet
denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen
noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei.
Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen
Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte,
liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die
vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge.
Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben
sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt.
`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei,
erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen.
Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen
Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional
einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende
Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt
stabile interne UUIDs und übergibt den vollständigen Zustand an
`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte
und alle `circuitDeviceRowId` bleiben `null`.
Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der
Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende
Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er
zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume,
Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die
Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog
wird der vollständige Import als eine persistente, rückgängig machbare
Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen.
Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über
die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese
Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt.
Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
ihn für die betroffene Gruppe.
`POST /api/projects/:projectId/external-model/initial-import/plan` liest eine
lokale XLSX zustandslos, gruppiert Quellräume und exakte Familie-und-Typ-Werte
und liefert die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge.
Unbekannte Familien bleiben blockierend; fehlender GZF wird sichtbar mit 1
vorbelegt. `apply` erhält die Datei erneut, parst sie erneut und vergleicht den
Vorschau-SHA-256. Der Command `external-model.apply-initial-import` speichert
die dabei verwendete Konfiguration und den vollständigen externen Zustand in
derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder
BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne
Workbookbytes erneut einzulesen oder zu parsen.
R6 bis R9 ergänzen die einmal geladene, clientseitig gefilterte und
virtualisierte Projektobjektliste, revisionierte Sammelplanung, atomare
Zuordnung im Stromkreislisteneditor, strukturtreue Rückgabe und den
vollständigen Folgeimport. Der Folgeimport trennt fachliche Quelländerungen
von noch nicht nach Revit übernommenen Rückgabewerten, aktualisiert akzeptierte
Mengen samt Row-Summe und markiert fehlende Objekte ohne ihren Link zu lösen.
Eine gesondert bestätigte Aktion löscht fehlende Objekte samt Mengenübergang;
Undo stellt Objekt, Link und Menge wieder her.
R10 weist den Ablauf über Rückgabe, Folgeimport, Snapshot-Restore,
Projektduplikat und Undo/Redo nach Datenbankneustart nach. Persistenzpfade für
vollständige externe Zustände schreiben große Mengen in begrenzten
SQLite-Blöcken. Der synthetische Abnahmetest verarbeitet 5.000 Objekte; Details
und Messwerte stehen in
[R10-Abnahme](spec/revit-excel-r10-validation.md).
## Historische CSV-Unterlagen
Der vor R3 vorhandene CSV-/IfcGUID-Laufzeitpfad ist entfernt. Seine damalige
Ausgangslage bleibt ausschließlich im [R0-Audit](spec/revit-excel-r0-audit.md)
und in den als abgelöst markierten CSV-Spezifikationen nachvollziehbar. Alte
CSV-Snapshots und CSV-History werden weiterhin ausdrücklich abgelehnt.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
strukturtreuer Revit-Rückexport
- vollständige elektrische Dimensionierung
- Produktionsdeployment

View file

@ -16,9 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
erreichbar gemacht werden.
Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen
vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem.
Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt
übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden.
## Topologie
@ -28,12 +29,18 @@ erreichbar gemacht werden.
| 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 mehr auf dem Host veröffentlicht
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
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
@ -66,6 +73,26 @@ Verwendete Umgebungsvariablen:
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,
nicht versionierten `.env` neben `compose.yaml` gesetzt werden:
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
```
Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange
mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in
**Config → Benutzer** an. Details stehen in
[Anmeldung und Benutzerverwaltung](authentication.md).
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
@ -131,7 +158,8 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
Vor einer produktiven Installation werden mindestens benötigt:
- TLS-Termination und Reverse Proxy
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei
festen Rollen später nicht ausreichen
- definierte Secrets- und Konfigurationsverwaltung
- persistenter, gesicherter Datenbankbetrieb
- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout
@ -140,14 +168,17 @@ Vor einer produktiven Installation werden mindestens benötigt:
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
## Backup und Wiederherstellung
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei

View file

@ -13,6 +13,11 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung
mit Quellcode-Mounts und Hot Reload stattdessen
`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste
sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar.
Für direkte Node.js-Entwicklung:
```powershell
@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
```powershell
docker compose up --build --detach
docker compose ps
Invoke-WebRequest http://localhost:3000/health
Invoke-WebRequest http://localhost:3001/health
Invoke-WebRequest http://localhost:3001/web-health
```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.

151
docs/estimate-balance.md Normal file
View file

@ -0,0 +1,151 @@
# Grobe Leistungsbilanz (LPH 2/3)
Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert
nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und
Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als
Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung
folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung:
Issue #7.
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
nichts automatisch in Stromkreislisten, Verteilungen oder Projektgeräte. Eine
ausdrückliche Einzelaktion kann die Werte einer Grobbilanz-Zeile als Vorschlag
in den bestehenden Projektgeräte-Dialog übernehmen; erst dessen Bestätigung
legt über `project-device.insert` ein unplatziertes Projektgerät an.
## Aufbau
```
src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports)
src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle
src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port
src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas
src/db/schema/estimate-balances.ts Tabellen (Migration 0010)
src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store
src/server/controllers/estimate-balance.controller.ts API
src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor
src/frontend/estimate-balance/ Editor-Komponenten und Helfer
```
Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil
das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf).
## Datenmodell
| Tabelle | Inhalt |
|---|---|
| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung |
| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung |
| `estimate_rows` | Versorgung (Auswahl ELT/MSR oder Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung |
Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft,
434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch,
480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden
Stands mit neuen IDs. Stände bleiben bearbeitbar; ein zusätzlicher Status oder
eine Bearbeitungssperre ist derzeit nicht vorgesehen.
## Rechnung
- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ.
- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich
die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt
werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in
`calculateEstimateRow` und lässt sich dort zentral ändern.
- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt),
Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der
Drehstromspannung aus den Projekteinstellungen.
- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`,
40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das
Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 %
Reserve 157,56 kW / 163,33 kVA, 235,75 A).
## Vergleich geschätzt ↔ detailliert
`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des
Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und
ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das
längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440.
Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
## Übernahme als Projektgerät
Die Zeilenaktion „Als Projektgerät übernehmen“ ist eine einmalige, ausdrücklich
bestätigte Kopie. Vor dem Öffnen des Dialogs werden ausstehende Änderungen der
Grobbilanz gespeichert. Das Anlegen läuft anschließend mit der aktuellen
Projektrevision über den normalen Projektgeräte-Befehl und ist dadurch wie ein
manuell angelegtes Projektgerät rückgängig machbar.
- Name und Anzeigename kommen aus dem Zeilennamen; Kostengruppe, GZF, cos φ und
Anmerkung werden vorgeschlagen. `fixed` und `socket` erscheinen als
„Festanschluss“ und „Steckdose“.
- W werden für Stückzeilen in kW umgerechnet. Die Projektgeräte-Anzahl ist 1,
weil das Projektgerät als Vorlage für eine spätere Gerätezeile dient; die
ursprüngliche Stückzahl bleibt im Herkunftshinweis sichtbar.
- Die Kategorie ist mangels Phaseninformation in der Grobbilanz mit
„1-phasig“ vorbelegt und muss im Dialog fachlich geprüft werden.
- Flächenzeilen dürfen übernommen werden. Anzahl und Leistung je Stück bleiben
leer und müssen manuell angegeben werden, da das Projektgerät noch keine
Einheit W/m² kennt (siehe Issue #20).
- SV wird nur im Herkunftshinweis gezeigt. Die tatsächliche Versorgungsart
gehört zur Verteilung.
- Gleichnamige vorhandene Projektgeräte erzeugen eine Warnung, sperren das
Anlegen aber nicht.
Zwischen Grobbilanz-Zeile und Projektgerät wird keine Verbindung gespeichert.
Es gibt keine Stapelübernahme und keine automatische Platzierung in einer
Verteilung oder einem Stromkreis. Snapshot-, Transfer- und
Grobbilanz-Befehlsformate ändern sich dadurch nicht.
## Befehle, Undo/Redo
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
Revision, Inverse und persistentem Undo/Redo:
- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen
Stand (Gruppen und Zeilen)
- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der
Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und
ersetzt ihn atomar. Die Inverse ist das vertauschte Paar.
Der Editor speichert das ganze Dokument verzögert (Autosave); jeder
Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine
Revision (`{ unchanged: true }`).
## Projekttransfer und bewusste Abgrenzung
- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen
mit allen übrigen Anwendungsdaten.
- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das
Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen
vorhandene Grobbilanzen unverändert.
- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2.
Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue
IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben
importierbar und enthalten definitionsgemäß keine Grobbilanzen.
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
mit Präfixen.
- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in
kW. SV- und kVA-Vergleiche können später ergänzt werden.
- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe
Abschätzung ohne Stromkreisplanung ist.
## API
- `GET /api/projects/:projectId/estimate-balances`
- `POST /api/projects/:projectId/estimate-balances` –
`{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }`
(`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`)
- `GET /api/projects/:projectId/estimate-balances/:balanceId`
- `PUT /api/projects/:projectId/estimate-balances/:balanceId` –
`{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }`
- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }`
- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison`
Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und
Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum
`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden
abgelehnt.

139
docs/print-view.md Normal file
View file

@ -0,0 +1,139 @@
# Seitenansicht und PDF-Export
Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
über Projektbefehle mit Undo/Redo gepflegt.
Beim Laden werden alle Stromkreislisten in einer projektweiten Abfrage gelesen;
deren Projektrevision und ein abschließend erneut gelesener Projektstand werden
verglichen. Ändert sich das Projekt
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
automatisch bei späteren Änderungen in einem anderen Tab.
## Aufruf
- Projektseite → Button **Seitenansicht** öffnet
`/projects/:projectId/print` in einem neuen Tab.
- **Als PDF speichern / Drucken** nutzt den Druckdialog des Browsers
(Ziel „Als PDF speichern“). Der Dokumenttitel `Leistungsbilanz – <Projekt>`
wird als Dateiname vorgeschlagen.
- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um.
Deckblatt bleibt A4 hoch.
- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle
Projekte).
- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo.
## Seiten
1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument,
Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben
erscheinen als orangefarbenes „[ergänzen]“.
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
Stück, nur lokale Geräte), Leistung vor Verteiler-GZF einschließlich
gespeister Unterverteilungen, Verteiler-GZF, P<sub>gl</sub> einschließlich
Unterverteilungen und I<sub>B</sub>. Die Hierarchie folgt den logischen IDs.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit
Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten
Unterverteilungszeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten.
Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
`@page`-Regeln (`lb-portrait`, `lb-landscape`), die die Seite zur Laufzeit
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
## Berechnung und offene Punkte
- **Einspeisung / Hierarchie:** `feedingDistributionBoardId` bestimmt die
Hierarchie. Ist zusätzlich `feedingCircuitId` gesetzt, fließt die Leistung
der Unterverteilung nach deren eigenem Verteiler-GZF als virtuelle Last in
genau diesen Abgang ein. Ketten werden rekursiv berechnet; mehrere
Unterverteilungen an einem Abgang sind erlaubt. Ohne Abgang bleibt es bei der
Hierarchie ohne Leistungsaufschlag. `fedFrom` bezeichnet nur externe Quellen
oder ergänzende Hinweise.
- **Keine Doppelzählung:** Die Abgangs- und Verteilersummen enthalten die
Unterverteilungsleistung bereits. Deshalb gibt es keine zusätzliche Spalte
„Σ inkl. unterlagert“. Die Summenzeile verwendet für hierarchische Leistungen
nur Wurzelverteilungen; P<sub>inst</sub> summiert weiterhin die lokalen Geräte.
- **I<sub>B</sub>:** S entsteht aus Σ(P / cos φ) für Geräte und den wirksamen
cos φ der Unterverteilungen, jeweils mit dem fachlich zugehörigen GZF. Bei
einem 1-phasigen speisenden Abgang gilt I = S / U<sub>1~</sub>, sonst
I = S / (√3 · U<sub>3~</sub>). Für externe oder nur bis zur Verteilung
bekannte Einspeisungen bleibt die bisherige Darstellung als symmetrischer
Drehstromwert bestehen.
- **Corporate Design:** Farben (`--pd-*` in
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
CD-Vorgabe ersetzt, falls diese abweicht.
- **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die
aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte
(noch) keinen Bearbeiter speichern.
## Datenquellen des Deckblatts
| Feld | Quelle |
|---|---|
| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen |
| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) |
| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) |
| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch |
| Planstände | benannte Sicherungspunkte |
Die vier Projektfelder laufen über `project.update-settings` (Schema-Version
3, Migration `0008`) und sind Teil von Snapshot-Schema 6 und Projektexport.
Im `PUT /api/projects/:projectId` sind sie optional; fehlende Felder behalten
ihren Wert.
## Büroangaben
- Tabelle `document_branding_settings` (Schlüssel/Wert, Migration `0008`),
global wie das Logo.
- `GET /api/document-branding` liefert zusätzlich
`office: { officeName, officeAddress }`.
- `PUT /api/document-branding/office` mit `{ "officeName", "officeAddress" }`
(max. 200 / 500 Zeichen, leerer Text = nicht gesetzt).
## Logo
- Globale Büro-Einstellung, nicht Teil von Projekten, Revisionen, Snapshots
oder Projektexport.
- Tabelle `document_branding_assets` (Migration `0007`).
- `GET /api/document-branding` → `{ logo: { mimeType, fileName, sizeBytes, updatedAtIso } | null }`
- `GET /api/document-branding/logo` → Bilddaten (`404` ohne Logo), mit
`X-Content-Type-Options: nosniff` und restriktiver CSP.
- `PUT /api/document-branding/logo` mit
`{ "fileName", "mimeType": "image/png" | "image/jpeg" | "image/svg+xml", "contentBase64" }`,
maximal 2 MB; der Dateiinhalt wird gegen das Format geprüft.
- `DELETE /api/document-branding/logo` → `204`.
## Code
- `src/app/projects/[projectId]/print/page.tsx` – Datenladen, Werkzeugleiste,
`@page`-Regeln
- `src/frontend/print/print-document-model.ts` – reine Berechnungen und
Formatierung (Tests: `tests/print-document-model.test.ts`)
- `src/frontend/print/print-document.tsx` – Blätter und Tabellen
- `src/frontend/print/print-document.css` – Layout, auf `.print-view`
begrenzt
- `src/frontend/components/distribution-board-info-card.tsx` – Infokarte im
Bearbeiten-Dialog der Verteilung
## Bestehende Datenbanken
Vor dem Anwenden der additiven Migrationen einschließlich `0012` eine verifizierte
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
Snapshot-Schema 10 enthält den aktuellen Projektzustand; die in Schema 9
eingeführten Einspeisungsverweise werden in den unterstützten Versionen 1–8
mit leeren Verweisen hochgestuft. Restore und
Projektkopie erhalten beziehungsweise remappen die neuen Angaben. Globale
Büroangaben und Logo gehören zur Datenbanksicherung, nicht
zum logischen Projektsnapshot oder Projekttransfer.

View file

@ -9,7 +9,8 @@ This document records architectural direction for future work. It is not a final
- persist undo/redo across page reloads and application restarts
- provide a project-wide version history with named restore points
- preserve an auditable sequence of user, import, migration and restore operations
- support future Revit/CSV round-trips using stable IFCGUID-based links
- support the Revit/Linear-XLSX round-trip using stable, exact
Worksharing-ID-based external identity
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
## Separate Concerns
@ -111,7 +112,12 @@ Implemented automatic-snapshot policy:
- automatic capture and retention run inside the same transaction as the
triggering project revision
## Revit / CSV / IFCGUID Round-Trip Direction
## Revit / Linear Excel Round-Trip Direction
Phase 14 is active and follows the
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The CSV/IFCGUID runtime has
been removed; the implemented runtime uses Linear XLSX and exact Worksharing-ID.
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
@ -120,9 +126,12 @@ Conceptual entities:
- `ExternalModelSource`
- identifies a Revit/IFC model or another external source within a project
- `ExternalImportBatch`
- file name, checksum, import time, format/version and processing status
- file name, checksum, import time, object count and processing status
- only the latest batch keeps original workbook bytes, matrix and the
configuration copy; older batches keep date, filename, checksum and count
- `ExternalModelObject`
- stable IFCGUID and optional Revit element identifiers
- exact case-sensitive Worksharing-ID as external identity
- optional IfcGUID as non-unique source redundancy
- source attributes and last-seen import batch
- `ExternalObjectLink`
- explicit relationship to a project device, circuit device row or another future domain entity
@ -133,13 +142,33 @@ Conceptual entities:
Required behavior:
- IFCGUID uniqueness is scoped to project and external model source.
- Worksharing-ID uniqueness is scoped to the external source and uses exact,
case-sensitive comparison. IfcGUID is not a matching key.
- Parsing writes to staging first.
- Users preview additions, changes, missing objects and conflicts before applying them.
- Applying an import creates one project revision and one auditable change set.
- Imported source values and locally assigned planning values remain distinguishable.
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
- Export retains Worksharing-ID so Linear can match rows deterministically.
- Missing objects and duplicate/conflicting Worksharing-IDs are reported,
never silently reassigned.
R10 verifies the complete implemented path through initial import, planning,
row assignment, return workbook, follow-up import, snapshot restore, portable
duplicate and restart-safe Undo/Redo. Large state replacement uses bounded
SQLite insert batches so imports, restores and duplicates do not exceed the
driver's SQL-variable limit.
The project configuration has no separate revision counter. Stateless plans
and apply requests bind to `expectedRevision`; every applied import batch holds
its configuration copy. Apply reparses the retransmitted workbook. Undo and
Redo operate only on persisted complete before/after states and never parse an
Excel file.
Deleting a linked row, circuit or group detaches affected Revit objects in the
same typed command after an explicit warning; its inverse restores all links.
Only deleting referenced rooms and distribution boards is blocked. Linked
objects always derive their board from their row, while a room default-board
change affects only unassigned objects.
## SQLite and PostgreSQL Direction
@ -275,6 +304,10 @@ Completed foundation:
- `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly
- `project-building.insert/.update/.delete` persist complete building records;
deleting a building with assigned floors is rejected. Floor commands carry
`buildingId` since command version 2; version 1 floor commands from older
history are upgraded with `buildingId = null` before execution
- the floor and room POST routes require the current expected revision and the
project page tracks the returned history state
@ -284,7 +317,8 @@ Remaining constraints before completing project version history:
boundary; extend it whenever further project mutations are introduced
- project creation remains initialization at revision zero, while global device
templates remain outside any single project history
- do not model IFCGUID as an overloaded circuit equipment identifier
- do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment
identifier or an internal relation id
- keep database backup/restore checks separate from project history tests
## Deferred Decisions
@ -292,5 +326,6 @@ Remaining constraints before completing project version history:
- whether large snapshot payloads remain in PostgreSQL or move to object storage
- branch visualization after undo followed by new edits
- user/role model and actor attribution
- supported Revit CSV dialects and parameter mapping UI
- Linear-XLSX compatibility beyond the observed sample and any future
project-independent mapping profiles
- PostgreSQL deployment topology and migration date

View file

@ -1,269 +1,17 @@
# Kontext für die Revit-Anforderungsplanung
# LLM-Kontext für die Revit-Integration
## Zweck dieser Datei
Die frühere Revit-/CSV-Anforderungsplanung ist abgelöst und Phase 14 ist mit
R10 abgeschlossen. Für Arbeiten an der Linear-XLSX-Anbindung sind vollständig
zu lesen:
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
für einen zukünftigen Revit-Datenaustausch zu klären.
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
- [R10-Abnahme](spec/revit-excel-r10-validation.md)
- [Aktuelle Systemarchitektur](current-architecture.md)
- [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md)
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
## Produkt und fachliches Modell
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
Das unterstützte Laufzeitmodell ist:
```text
Project
├── Floor
│ └── Room
├── ProjectDevice
└── DistributionBoard
└── CircuitList
└── CircuitSection
└── Circuit
└── CircuitDeviceRow
```
Wichtige fachliche Grenzen:
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
und Kategorie gehören zur Gerätezeile.
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
lokale Werte und ausdrücklich erfasste Abweichungen.
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
erlaubt.
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
werden.
Für einen späteren Revit-Austausch besonders relevante Daten sind:
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
Kennzeichnung als öffentliches Gebäude und Projektspannungen.
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
Raumname.
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
aktuellen Produkt nicht vorgesehen.
## Aktuelle technische Architektur
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
`src/frontend`.
- API: Express und Zod unter `src/server`.
- Fachregeln und typisierte Commands: `src/domain`.
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
- Laufzeitkomposition konkreter Repositories:
`src/server/composition`.
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
SQLite-Datei unter `data/leistungsbilanz.db`.
- Docker Compose wird für die lokale Entwicklung unterstützt.
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
Der zentrale Editor liegt unter:
```text
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
src/frontend/components/circuit-tree-editor.tsx
```
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
## Revisionen, Undo/Redo und Sicherungspunkte
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
Command- und Transaktionsgrenze:
```text
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
```
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
geben die erwartete Revision mit, damit veraltete Clients keine neueren
Änderungen überschreiben.
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
Anwendungsneustart. Zusätzlich gibt es:
- eine unveränderliche Revisions-Timeline;
- benannte und automatische logische Projektsnapshots;
- wiederherstellbare Snapshots mit Prüfsumme;
- einen portablen, versionierten JSON-Projekttransfer.
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
revisioniert und rückgängig machbar sein.
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
Projekts innerhalb von Leistungsbilanz.
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
für CSV-Dateien oder Revit-Parameter verwendet werden.
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
zuweisen kann.
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
Implementierungsdesign:
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
Gerätezeilen.
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
widersprüchliche Objekte angezeigt.
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
unterscheidbar bleiben.
- Lokale Planungswerte dürfen niemals still überschrieben werden.
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
ausdrücklich modelliert werden.
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
automatisch einem anderen Objekt zugeordnet.
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
die Werte deterministisch zurückschreiben kann.
- Ein Export bezieht sich auf eine bekannte Projektrevision.
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
Serverbetrieb oder unabhängigen Hintergrundjobs.
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
- `ExternalModelSource`
- `ExternalImportBatch`
- `ExternalModelObject`
- `ExternalObjectLink`
- `ExternalParameterMapping`
- `ExternalExportBatch`
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
Tabellen behandeln.
## Auftrag an das andere LLM
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
realen Ablauf in Revit und kläre danach schrittweise mindestens:
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
Revit-Versionen)?
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
aktualisierte Revit-Dateien unterschieden?
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
Leistungsbilanz geplant?
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
Projektgerät, Raum oder einer Verteilung zugeordnet?
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
Einheiten und unter welchen Revit-Parameternamen?
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
Rückgabedatei erzeugt werden?
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
wiederverwendet und zwischen Projekten geteilt werden?
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
doppelte oder nicht mehr sichtbare Revit-Objekte?
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
eine Auswahl?
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
Anwender vor der Übernahme?
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
verhindern oder nur eine Warnung erzeugen?
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
16. Müssen Import und Export synchron im Browser laufen oder werden später
Hintergrundjobs benötigt?
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
Versionshistorie und Support gespeichert werden?
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
Abnahmekriterien?
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
Notizen ausdrücklich:
- vom Anwender bestätigte Anforderungen;
- sinnvolle Vorschläge;
- offene Entscheidungen;
- technische Folgerungen;
- bewusst nicht betrachtete spätere Ausbaustufen.
## Erwartetes Ergebnis der Anforderungsrunde
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
soll mindestens enthalten:
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
Rückexport;
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
- Identitäts-, Matching- und Quellmodell;
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
- Konflikt-, Validierungs- und Freigaberegeln;
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
Architekturregeln zu umgehen;
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
müssen.
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
nur Architekturvorschläge des LLM sind.
## Weiterführende Projektdokumente
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
Command-Pfad
- `docs/project-history-and-external-model-architecture.md` – bisherige
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
- `docs/spec/01-domain-context.md` – fachlicher Kontext
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
kontrollierte Synchronisierung
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
Funktionen
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der CSV-Laufzeitpfad
ist entfernt; alte CSV-Snapshots und CSV-History werden mit einer deutschen
Migrationsmeldung abgelehnt.

View file

@ -532,32 +532,37 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
und die verbindlichen
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
Status: abgeschlossen am 30.09.2026 mit R10. Siehe
[R10-Abnahme](revit-excel-r10-validation.md).
Goal:
Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf
Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
abgelöst und wird nicht fortgeschrieben.
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
Verbindliche Quellen:
Tasks:
- [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md)
- [R0–R10-Plan](revit-excel-phase-14-plan.md)
- [R0-Bestandsaudit](revit-excel-r0-audit.md)
- model external sources, import batches and stable IFCGUID objects
- stage and validate imports before applying them
- show additions, changes, missing objects and conflicts
- link external objects explicitly to project-domain entities
- apply an import as one auditable project revision
- configure inbound and outbound parameter mappings
- export against a known project revision while preserving IFCGUID
Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit
einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie
erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird
zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative
vorgelegt.
Acceptance criteria:
Kernabnahme der Phase:
- repeated imports match objects deterministically by source and IFCGUID
- imports never silently overwrite local planning values
- users approve the diff before it changes the project
- exported data can be assigned back to the correct Revit objects
- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
Rückexport erhalten;
- Import erzeugt keine Rows und überschreibt keine lokale Planung still;
- Zuordnung, Löschung, Folgeimport und Undo/Redo sind atomar und nach Neustart
reproduzierbar;
- Undo/Redo liest keine Excel-Datei;
- nur der letzte Importbatch enthält Workbook und Matrix vollständig;
- mehrere Tausend Objekte bleiben über eine clientseitig gefilterte,
virtualisierte Liste bedienbar.
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.

View file

@ -46,7 +46,10 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E: editor projection and editing.
- [x] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
the planner supplies the calculation requirements.
the planner supplies the calculation requirements. The optional per-circuit
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
tables were confirmed against DIN VDE 0298-4:2023-06 with
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
- Detailed specification:
`docs/spec/09-distribution-board-components-and-protection-groups.md`
@ -132,6 +135,26 @@ The pre-release cleanup established the following compatibility boundary:
- [x] Keep column visibility and order as a project-specific UI preference so
all distribution boards in the same project open with the same columns.
## Integrated Contributor Features (September 2026)
- [x] Client ids without secure-context browser APIs (`createClientId`).
- [x] Icon actions, compact column headers and the AMEV picker in the circuit
list; icon actions and collapsible filters on the project overview.
- [x] Print view with cover, board overview, board plans, board info card and
office branding.
- [x] Buildings above floors and a dedicated building structure page.
- [x] Optional net room area with sums per floor and building.
- [x] Optional cable-sizing module for one circuit on demand
(DIN VDE 0298-4:2023-06 with Berichtigung 1).
- [x] Rough power balance for LPH 2/3 by cost group, including the comparison
with the detailed balance and the explicit copy of one row as a
ProjectDevice.
- [x] Logical distribution-board feed link with an optional feeding circuit,
recursive load rollup and deletion guards (#3).
Open follow-ups (Gitea issues): bill of quantities export (#5), area-based loads in W/m² for
ProjectDevices and device rows (#20).
## Recommended Sequence
1. [x] Distribution-board floor and supply fields.
@ -139,6 +162,7 @@ The pre-release cleanup established the following compatibility boundary:
3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
foundation as a separate jointly planned phase and must not be inferred from
these tasks.
The Revit/Linear-XLSX round-trip was completed separately in Phase 14. It uses
exact Worksharing-ID identity and must not be inferred from these unrelated
backlog tasks. See
[the R0–R10 plan](revit-excel-phase-14-plan.md).

File diff suppressed because it is too large Load diff

View file

@ -1,402 +1,8 @@
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung
# Abgelöst: alter Phase-14-CSV-Plan
## Status
Die Phasen 14.0–14.6 und ihre Erledigt-Markierungen sind abgelöst. Für die
Umstellung auf Linear-Excel gelten der [R0–R10-Plan](revit-excel-phase-14-plan.md)
und die [neuen Anforderungen](revit-excel-integration-requirements.md).
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
noch nicht.
Fachliche Quelle bleibt
[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die
Reihenfolge kleiner Arbeitspakete.
## Ergebnis des Bestandsaudits
### Beibehalten
- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis
viele `CircuitDeviceRow`-Datensätze.
- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf
diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht
ausdrücklich bestätigt.
- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung
zwischen einer Gerätezeile und genau einer Projektgerätevorlage.
- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte,
Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen
vierten, fachlich eigenen Pfad.
- Alle bestätigten Änderungen verwenden typisierte Commands mit
`expectedRevision`, vollständiger Gegenrichtung und
`project-command-transaction.persistence.ts` als gemeinsamer
Transaktionsgrenze.
- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit
fester Fremdschlüsselreihenfolge und UUID-Remapping.
- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte
Laufzeitadapter.
### Erweitern oder gezielt refaktorieren
- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den
bestätigten externen Projektzustand erweitert. Bereits veröffentlichte
Snapshot-Versionen werden nicht verändert.
- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration,
Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine
konkreten SQLite-Abhängigkeiten.
- Der Stromkreislisteneditor erhält später einen separaten Drawer für
unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht
dafür zweckentfremdet.
- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer
`CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“.
### Hinzufügen
- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain;
- projektweite, versionierte Revit-CSV-Konfiguration;
- eine zunächst implizite externe Modellquelle je Projekt;
- Importbatches einschließlich Originaltransport und akzeptiertem
Konfigurationsstand;
- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und
Planungswerten und einzelnem Lebenszyklus;
- Quellraum-Mappings auf interne Räume und Standardverteilungen;
- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler
abweichender Verteilung;
- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices;
- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und
End-to-End-Tests.
### Entfernen
Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14
kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt.
## Vorgeschlagenes Domänenmodell
Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die
jeweilige englische Pluralform in `snake_case`.
### `ExternalModelSource`
- stabile interne UUID;
- `projectId`;
- technischer Name und Quelltyp `revit_csv`;
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
abgeleitet und nicht als redundanter Zeiger gespeichert.
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an
`(sourceId, ifcGuid)`.
### `ExternalCsvConfiguration`
- stabile UUID und `projectId` mit genau einer aktiven Konfiguration;
- Konfigurationsschema-Version;
- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen,
Dezimalformat und Kopfzeilenerkennung;
- exakte Spaltenzuordnung;
- geordnete, exakte Familie-und-Typ-Regeln;
- monotoner Konfigurationsstand.
Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in
einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei
verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar,
ohne eine zweite veränderbare Historientabelle einzuführen.
### `ExternalImportBatch`
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
- Dateiname, SHA-256 und zugehörige Projektrevision;
- verwendete monotone Konfigurationsversion und vollständiger
Konfigurationssnapshot;
- Originaldatei als Bytes;
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes
belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten
Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten,
Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren.
### `ExternalRoomMapping`
- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel;
- Quellgeschoss-, Raumnummer- und Raumname-Snapshot;
- optionales `roomId`;
- optionale `defaultDistributionBoardId`.
Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält
das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung.
### `ExternalModelObject`
- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`;
- letzter gesehener und letzter akzeptierter Importbatch;
- akzeptierte Quellwerte getrennt als strukturierter Snapshot;
- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame
Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe
und Bemerkung;
- explizite Override-Metadaten;
- optionales `externalRoomMappingId`;
- optionale abweichende `distributionBoardId`;
- optionales `linkedProjectDeviceId`;
- optionales `circuitDeviceRowId`;
- Anwesenheitsstatus für bestätigte fehlende Objekte.
Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele
einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet
sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre
wirksame Menge größer eins.
Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem
Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter
Anwesenheits- oder Planungszustand wird persistiert.
## Mengenmodell der Gerätezeile
Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller
Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines
Objekts manuelle Mengen löschen oder doppelt zählen.
Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt:
```text
quantity = manualQuantity
+ Summe(effectiveQuantity aller verknüpften ExternalModelObjects)
```
- Bei der Migration erhält jede bestehende Row
`manualQuantity = quantity`; ihr Ergebnis bleibt unverändert.
- Eine ausschließlich aus externen Objekten erstellte Row startet mit
`manualQuantity = 0`.
- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende
Berechnungen, Filter und Exporte und wird im selben Command aktualisiert.
- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet
sein.
- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte
übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht
automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine
ausdrückliche Nutzerentscheidung.
- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim
Lösen bleibt der manuelle Anteil erhalten.
Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere
Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller
Row-Lebenszyklus ist ausgeschlossen.
## Command-Schnittstellen
Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte
Snapshots und werden mit der aktuellen Projektrevision geplant. Große
Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch
bleiben und dürfen spätere Quellstände nicht erneut auswerten.
- `external-csv-configuration.update`: vollständige projektweite
Konfiguration ändern.
- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe
Objekte und bestätigte Links atomar einfügen.
- `external-import.apply-follow-up`: neuen Batch und die vollständigen
erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte
atomar übernehmen.
- `external-model-object.update-planning`: lokale Werte und Overrides eines
oder mehrerer Objekte ändern.
- `external-model-object.update-project-device-link`: ProjectDevice-Links
explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
und alle betroffenen materialisierten Mengen atomar aktualisieren.
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
Command gespeichert und bei Redo nicht neu berechnet.
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
weiterhin unveränderte fehlende Objekte löschen.
Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen
`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare
Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und
Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern
keine Projektrevision und laufen über einen eigenen Audit-Store.
## Snapshot-, Transfer- und Migrationsfolgen
- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das
Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2
ergänzt `externalCsvConfiguration = null`.
- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und
Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt
leere externe Collections.
- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende
Werte werden einmalig aus `quantity` übernommen.
- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte
Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge.
- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links
remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte
bleiben unverändert.
- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene
Project-State-Schema-Version wird additiv erhöht.
- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert.
## Implementierungsfolge
### 14.1 – CSV-Konfiguration und reine Vorschau
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine
synthetische Fixtures ergänzen. **Erledigt.**
2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
synthetische Testdaten; die Referenzdatei steht noch aus.**
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
gegen die Vorschau geprüft.
**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme
erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
einen Commit aufgenommen.
### 14.2 – Persistente Objekte und Erstimport
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
weiterhin keinen Import-Write außerhalb eines Commands.
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
lassen IFCGUID und Quelltransport jedoch unverändert.
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
### 14.3 – Editor und explizite Zuordnung
1. `manualQuantity` und Mengeninvarianten migrieren und testen.
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
**Teilweise erledigt:** `external-object.update-row-assignment` deckt
Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und
Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum,
Selektionskennzeichen und Planungswerte, verlangt für bestätigbare
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
Standardschutz und genau einer externen Row wird gemeinsam mit den
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
**Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene
GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers,
unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker,
Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden;
Drawer, Filter und Mengenwirkung folgen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen.
Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl,
Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt.
### 14.4 – Strukturtreuer Rückexport
1. Exportprojektion gegen eine bekannte Projektrevision ergänzen.
2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen.
3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten.
4. Download und append-only Exportnachweis ergänzen.
Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich
festgelegt.
### 14.5 – Folgeimport und Konflikte
1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen.
2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren.
3. Einzel- und Sammelentscheidungen im Wizard ergänzen.
4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo
übernehmen.
### 14.6 – Härtung und End-to-End-Abnahme
1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren.
2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport,
Snapshot/Restore und Projektduplikat prüfen.
3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen.
4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich
ausgelieferten Stand nachziehen.
## Risiken und Schutzmaßnahmen
- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile;
Commands ändern Links und materialisierte Menge gemeinsam.
- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind
getrennt; jede Übernahme benötigt eine Nutzerentscheidung.
- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage;
deren bestehende Commands bleiben alleinige Schreibgrenze.
- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix,
Dialekt, Konfiguration, Hash und Projektrevision.
- **Sehr große Undo-Payloads:** Importcommands speichern vollständige
erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen,
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
## Freigabepunkt
Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine
Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket
ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus.
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
`ae318a5` erhalten und darf nicht fortgeschrieben werden.

View file

@ -0,0 +1,501 @@
# Revit-Integration über Linear-Excel – verbindliche Anforderungen
Stand: 29.09.2026. Dieses Dokument ist die fachliche und technische
Arbeitsgrundlage für die neue Phase 14. Es ersetzt die frühere CSV-/IfcGUID-
Spezifikation. Der Umsetzungsstand steht im
[R0–R10-Plan](revit-excel-phase-14-plan.md); der geprüfte Ausgangszustand im
[R0-Audit](revit-excel-r0-audit.md).
## 1. Ziel und Grenzen
Der Austausch mit Revit erfolgt ausschließlich über den XLSX-Export/-Import
des Linear-Plugins. Eine vollständige Revit-Bauteilliste wird importiert, in
Leistungsbilanz geplant und mit gleichem Tabellenaufbau an Linear
zurückgegeben. Linear ordnet enthaltene Zeilen anhand der Worksharing-ID zu.
Nicht Teil dieser Phase sind Dynamo, Revit-API, JSON-Transport, automatische
Stromkreisbildung, Übernahme vorhandener Revit-Stromkreiswerte,
Teilimporte, mehrere Modellquellen, PostgreSQL und neue Rollen/Rechte.
Die Integration bleibt optional. Manuelle Projekte, Räume, ProjectDevices,
Stromkreise und Gerätezeilen funktionieren ohne externes Modell. Der
Circuit-First-Aufbau, stabile interne UUIDs und BMKs, ausdrückliche
Neunummerierung sowie die gemeinsame Project-Command-Transaktionsgrenze
bleiben unverändert.
## 2. Verbindliche fachliche Regeln
- Import erzeugt keine `CircuitDeviceRow`, keinen `Circuit` und kein BMK.
- Quellwerte, lokale Planungswerte und Overrides bleiben getrennt.
- Ein Folgeimport überschreibt keine lokale Planung still.
- Fehlende Objekte werden markiert und erst nach ausdrücklicher Bestätigung
entfernt.
- Mehrfachsteckdosen sind unteilbare externe Objekte. Ihre wirksame Menge kann
größer als eins sein.
- Die Row-Menge ist
`manualQuantity + Summe(effectiveQuantity der verknüpften Objekte)`.
- Nach einer Row-Zuordnung ist die `CircuitDeviceRow` die Planungsebene für
alle verknüpften Objekte.
- Bestehende ProjectDevices bleiben kanonische Vorlagen und werden durch
Importe nie verändert.
- Sortieren, Filtern, Importieren, Zuordnen und Verschieben nummeriert keine
Stromkreise automatisch neu.
## 3. Linear-XLSX-Profil
Die lokale Referenzdatei `docs/spec/kbp-Stromkreis.xlsx` enthält vertrauliche
Projektdaten. Sie wird nicht committed und nicht in Tests eingebettet. Tests
erzeugen synthetische Workbooks. Der lokale Referenzprüfer erhält den Pfad als
Argument.
Beobachtetes Profil:
- genau ein Arbeitsblatt; Blattname nach Excel-Regel maximal 31 Zeichen;
- Kopfzeile in Zeile 1, Zeile 2 leer, Daten ab Zeile 3, erste Datenspalte A;
- Spalte A `Worksharing-ID` im Format `<GUID>-<8 Hex>`;
- Parameterbindungen stehen als klassische Excel-Kommentare in den übrigen
Kopfzellen;
- Zahlen sind typisierte Excel-Zahlen; importierte Werte bleiben unverändert,
fachlich verwendete Leistung wird auf 0,01 W projiziert;
- Objektzeilen dürfen keinen MEP-Raum besitzen;
- eine Schluss-/Summenzeile ohne ID kann vorkommen und wird weder importiert
noch zurückgegeben.
Die Referenzdatei enthält 487 Objektzeilen, eindeutige Worksharing-IDs und
keine doppelten nichtleeren IfcGUIDs bei exaktem, case-sensitivem Vergleich.
Ihre Summenzeile steht in Spalte B (`CAx_Auswahlkenner`), während
`Familie und Typ` leer ist.
### Zeilen ohne Worksharing-ID
Nur eine Zeile ohne Worksharing-ID, aber mit befülltem Feld
`Familie und Typ`, gilt als verdächtige Objektzeile. Sie erzeugt einen Hinweis
und wird übersprungen; sie blockiert den Import nicht. Andere Zeilen ohne ID,
einschließlich einer nur in Spalte B befüllten Summenzeile, werden ohne Hinweis
übersprungen. Eine Objektzeile ist für den Import eine Zeile mit nichtleerer
Worksharing-ID.
Worksharing-ID und IfcGUID werden stets exakt, ordinal und mit
Groß-/Kleinschreibung verglichen. Es findet kein Trimmen, Normalisieren oder
Case-Folding für die Identität statt. Eine nichtleere Worksharing-ID mit
unerlaubten äußeren Leerzeichen oder falschem Format blockiert. Doppelte
Worksharing-IDs blockieren. IfcGUID ist optional, kein Schlüssel und darf
doppelt vorkommen; Duplikate erzeugen lediglich einen Hinweis.
## 4. Upload und Transportadapter
Der Server akzeptiert ausschließlich Dateien mit Endung `.xlsx` bis 10 MiB.
`.xls` und `.xlsm` werden abgelehnt. Parsing findet serverseitig statt. Nach
dem Einlesen gelten feste Zeilen-, Spalten- und Zellgrenzen, die in R2 anhand
synthetischer Grenztests festgelegt und zentral definiert werden. Es gibt in
dieser Phase keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
Der Adapter unter `src/external-model/xlsx` liest eine transportneutrale
Zellmatrix. Eine Zelle ist `string`, `number`, `boolean` oder `empty`.
Zusätzlich werden Blattname, Originalzeilennummer, Kopfzeilenkommentare,
Spaltenbreiten und die für Hinweise nötigen Formelmetadaten erfasst.
Domain-Services kennen weder ExcelJS noch XLSX, React oder SQLite.
Formeln werden niemals ausgeführt. Ist ein gecachter skalarer Wert vorhanden,
wird er verwendet und als Hinweis ausgewiesen. Fehlt er oder ist er ein
Fehlerwert, bleibt das Feld leer; bei der Worksharing-ID oder einem anderen
Pflichtfeld blockiert dies die betreffende Objektzeile beziehungsweise den
Import.
Genau eine gepflegte Bibliothek wird ergänzt. Vorgesehen ist `exceljs`, sofern
R2 die Node-24-Verträglichkeit und den Roundtrip von Kopfkommentaren,
Blattname, Spaltenbreiten, Formaten und Zelltypen bestätigt.
## 5. Projektkonfiguration
Die Konfiguration ist projektweit, revisionssicher und besitzt keine eigene
Versionszählung. Jeder Plan und jedes Apply tragen `expectedRevision`.
Da die Projektrevision auch bei Undo/Redo strikt steigt, wird ein alter Plan
automatisch ungültig. Jeder Importbatch speichert eine vollständige Kopie der
beim Import verwendeten Konfiguration.
Das Einstellungsmodal auf der Revit-Seite erkennt aus einer zustandslos
hochgeladenen Musterdatei Kopfzeile, erste Datenzeile und erste Datenspalte.
Diese Werte bleiben editierbar; Standard ist 1 / 3 / A. Es gibt nur ein Feld
für die Identitätsspalte: `columns.worksharingId`. Ein separates
`layout.idColumn` existiert nicht.
Zielvertrag:
```ts
interface ExternalModelConfiguration {
schemaVersion: 1;
layout: {
headerRow: number;
firstDataRow: number;
firstDataColumn: number;
};
circuitMode: "combined" | "separate";
columns: {
worksharingId: string;
ifcGuid: string | null;
roomNumber: string | null;
roomName: string | null;
familyAndType: string;
selectionMarker: string | null;
socketCount: string | null;
sourceRemark: string | null;
circuitCombined: string | null;
boardName: string | null;
circuitIdentifier: string | null;
designation: string | null;
electricalPowerW: string | null;
voltageV: string | null;
currentA: string | null;
};
deviceTypes: DeviceTypeConfiguration[];
familyTypeRules: FamilyTypeRule[];
}
```
Standardzuordnung:
| Schlüssel | Bedeutung | Standardspalte |
| --- | --- | --- |
| `worksharingId` | externe Identität, Pflicht | `Worksharing-ID` |
| `ifcGuid` | optionale Redundanz | `IfcGUID` |
| `roomNumber` | Raumnummer | `MEP-Raum: Nummer` |
| `roomName` | Raumname | `MEP-Raum: Name` |
| `familyAndType` | Familie und Typ, Pflicht | `Familie und Typ` |
| `selectionMarker` | CAx-Auswahlkenner | `CAx_Auswahlkenner` |
| `socketCount` | Anzahl Einzelsteckdosen | `CAX_AnzahlObjekte` |
| `sourceRemark` | Anmerkung | `CAx_Anmerkung` |
| `circuitCombined` | Stromkreis gesamt | `kbp_Stromkreisnummer` |
| `boardName` | Verteiler | `kbp-E-Verteiler` |
| `circuitIdentifier` | Stromkreis-BMK | `kbp-E-Stromkreis` |
| `designation` | Bezeichnung | `kbp-E-Bezeichnung` |
| `electricalPowerW` | elektrische Leistung [W] | `kbp-E-Elektrische Leistung` |
| `voltageV` | Spannung [V] | `kbp-E-Spannung` |
| `currentA` | Stromstärke [A] | `kbp-E-Stromstärke` |
`worksharingId` und `familyAndType` sind Pflichtmappings. Im Modus
`combined` ist `circuitCombined` für den Rückexport Pflicht; im Modus
`separate` sind `boardName` und `circuitIdentifier` Pflicht. Fehlende optionale
Rückgabespalten `designation`, `electricalPowerW`, `voltageV` und `currentA`
werden mit Hinweis übersprungen und blockieren den Export nicht.
Jedes interne Feld besitzt im Modal ein Dropdown aus den Kopfzellen und eine
freie Eingabe. Exakte kbp-Standardnamen werden vorbelegt. Typ und Einheit aus
dem Linear-Kommentar werden angezeigt. Nicht gemappte Spalten bleiben beim
Rückexport unverändert.
## 6. Gerätearten und Familie-und-Typ-Regeln
Eine Geräteart besitzt stabile interne UUID, Name, Kategorie
`lighting | single_phase | three_phase` sowie optionale Standardwerte für
Leistung je Einheit in W, GZF, cosPhi und Kostengruppe. Jede exakte
`Familie und Typ`-Regel verweist auf genau eine Geräteart und verwendet die
Mengenregel `1` oder `socketCount`.
Mehrere Revit-Familien dürfen auf dieselbe Geräteart zeigen. Jedes Revit-
Objekt bleibt trotzdem einzeln über seine Worksharing-ID nachverfolgbar.
Unbekannte Familien müssen im Importwizard zugeordnet oder über eine neue
Geräteart klassifiziert werden; andernfalls blockiert Apply.
Planungsstartwerte folgen dieser Reihenfolge:
1. ausdrücklich verknüpftes ProjectDevice;
2. Geräteart-Standard;
3. leer.
Der Anzeigename wird aus CAx-Auswahlkenner, dann Geräteartname, dann
`Familie und Typ` vorgeschlagen. Ein fehlender GZF wird mit `1` vorbelegt,
ohne zusätzliche Bestätigung. Standardwerte sind Startwerte, keine dauerhafte
Synchronisierung.
## 7. Persistenter externer Zustand
Pro Projekt existiert genau eine implizite Revit-XLSX-Quelle. Ein externes
Objekt besitzt eine interne UUID und ist je `(sourceId, worksharingId)`
eindeutig. `worksharingId` ist NOT NULL; `ifcGuid` ist nullable und nicht
eindeutig. Interne Relationen verwenden ausschließlich interne UUIDs.
Gespeichert werden:
- akzeptierte Quellwerte einschließlich aller Rückgabefelder;
- lokale Planungswerte und Override-Felder;
- Geräteart;
- optionales Raum-Mapping, abweichender Verteiler, ProjectDevice und
`CircuitDeviceRow`;
- Anwesenheitsstatus und Verweis auf den zuletzt relevanten Import;
- effektive Menge als fachlicher Wert des unteilbaren Objekts.
Der effektive Verteiler eines verknüpften Objekts ist immer der Verteiler
seiner Row. Für ein unzugeordnetes Objekt gilt zuerst die explizite
Objektabweichung, danach der Standardverteiler seines Raum-Mappings. Ändert
sich der Standardverteiler eines Raums, betrifft das nur unzugeordnete
Objekte; verknüpfte Objekte bleiben über ihre Row unverändert und die Änderung
wird deshalb nicht gesperrt.
## 8. Importbatch-Aufbewahrung, Snapshot und Transfer
Der aktive Projektzustand enthält den letzten Importbatch vollständig:
Originalbytes, klassifizierte Matrix, Dateiname, SHA-256, Zeitpunkt,
Objektzahl, Importart, Projektrevision und Konfigurationskopie. Ältere Batches
werden zu Metadaten reduziert: Datum, Dateiname, SHA-256 und Objektzahl.
Sie enthalten keine Originalbytes, Matrix oder Konfigurationskopie mehr.
Snapshot-Schema 10 und portabler Projekttransfer übernehmen genau diese
Aufteilung. Beim Duplizieren werden alle internen UUIDs und Links remapped;
Worksharing-IDs, optionale IfcGUIDs, Quellwerte und das letzte Workbook bleiben
unverändert.
Versionen 1–9 ohne externe CSV-Daten werden verlustfrei auf Version 10
hochgestuft. Enthält ein alter Stand eine CSV-Konfiguration oder einen nicht
leeren CSV-Zustand, wird er mit klarer deutscher Fehlermeldung abgelehnt.
Alte CSV-History-Commands werden ebenfalls vor jedem Write abgelehnt und nie
stillschweigend übersprungen.
## 9. Erstimport
Der Ablauf ist Upload → zustandslose Vorschau/Planung → erneute
Dateiübertragung bei Apply. Apply prüft SHA-256, `expectedRevision`,
Konfigurationskopie, Matrix und sämtliche Entscheidungen erneut. Nur Apply
liest die Excel-Datei erneut ein.
Undo und Redo lesen keine Excel-Datei und parsen keine gespeicherten Bytes.
Sie arbeiten ausschließlich auf den im Command gespeicherten vollständigen
Vorher-/Nachher-Zuständen. Dadurch bleiben sie nach Neustart deterministisch
und unabhängig vom Parser.
Der Wizard umfasst:
1. Vorschau mit Objektzahl, übersprungenen und verdächtigen Zeilen,
doppelten Worksharing-IDs, doppelten IfcGUID-Hinweisen, unbekannten
Familien, nicht lesbaren Zahlen und Formeln;
2. Räume anlegen oder vorhandenen Räumen/Ebenen zuordnen;
3. Standardverteiler je Raum, Sammelzuordnung, Objektabweichungen und Objekte
ohne Raum;
4. Familien exakt Gerätearten und einer Mengenregel zuordnen;
5. optional vorhandene oder neu angelegte ProjectDevices verknüpfen;
6. Zusammenfassung und ausdrückliche Bestätigung.
Die Übernahme ist ein atomarer Command und ein Undo-Schritt. Im Import
entstehen keine Rows. Neu angelegte Räume und ProjectDevices verwenden ihre
vorhandenen Commands und bleiben eigene Undo-Schritte.
## 10. Objektplanung und Revit-Seite
Route: `/projects/:projectId/revit`, erreichbar aus der Projektnavigation.
Der Revit-Button und das CSV-Modal auf der Projektübersicht entfallen.
Die Seite besitzt:
- Statuskarte mit letztem Import, Objektzahl, ohne Verteiler, ohne
Stromkreis, fehlend und Abweichung zu Revit;
- Aktionen `Import`, `Rückgabe-Excel`, `Einstellungen`;
- einklappbare Geräteart-/Familienregel-Übersicht;
- kompakte Objekttabelle mit Suche, Spaltenfiltern, Sortierung,
Mehrfachauswahl und Sammelzuordnung für Verteiler, ProjectDevice und
Planungswerte.
Die kompakte Liste wird einmal geladen, vollständig im Client gefiltert und
sortiert und virtualisiert dargestellt. Es gibt keine Server-Pagination.
Originalbytes, Matrix und Command-Payloads gehören nicht in dieses DTO.
Vor Row-Zuordnung lassen sich Leistung je Einheit, GZF, cosPhi,
Kostengruppe, Anzeigename und Bemerkung einzeln oder gesammelt ändern.
Geänderte Felder werden Overrides. Nach Row-Zuordnung werden diese Werte im
Grid an der Row bearbeitet. Beim ausdrücklichen Lösen erhält das Objekt die
aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
## 11. Editor-Zuordnung und Löschen
Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste
gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen
Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als
Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die
drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen
kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und
Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und
Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen
explizit.
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
ProjectDevice-Link. Die Anzeige nennt Summe der wirksamen Mengen und Zahl der
externen Objekte. Gruppen sind aufklappbar; Einzel- und Mehrfachauswahl sowie
Filter bleiben möglich.
Drag-and-drop:
- auf vorhandene Row: genau diese Row;
- auf Circuit: neue Row mit `manualQuantity = 0`;
- auf freien Bereich: neuer Circuit, Schutz und Row in einem Command.
Neue IDs und BMK werden einmal geplant; Redo rechnet sie nicht neu. Neue Rows
übernehmen den bestätigten Gruppenzustand und die Summe der effektiven Mengen.
An Rows zeigt ein Hinweis die Zahl verknüpfter Revit-Objekte; einzelne Objekte
können ausdrücklich gelöst werden.
Wird eine Row, ein Circuit oder eine Gruppe mit verknüpften Revit-Objekten
gelöscht, zeigt die UI vor Bestätigung:
`X Revit-Objekte werden wieder unzugeordnet`. Derselbe Löschcommand löst die
Objekte, entfernt die Struktur und speichert alle Links in seiner Inversen.
Undo stellt Struktur und Links exakt wieder her. Diese Löschungen werden nicht
gesperrt.
Das Löschen eines Verteilers oder Raums mit referenzierten externen Objekten
bleibt gesperrt. Der Nutzer muss die Verknüpfungen beziehungsweise Zuordnungen
zuvor ausdrücklich ändern. Die Regel gilt auch für Unterbaum- und History-
Pfade.
## 12. Rückgabe-Excel
Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash
und Objektzahl.
Umfang:
- alle im letzten Import vorhandenen Objekte (Standard);
- nur Objekte mit Abweichung zum zuletzt importierten Revit-Wert;
- Auswahl aus der Objekttabelle.
Die Rückgabe basiert auf einer Kopie des zuletzt importierten Workbooks.
Zeilen oberhalb der ersten Datenzeile, Kopfkommentare, Formate,
Spaltenbreiten, Blattname und Spaltenreihenfolge bleiben erhalten. Danach
folgen nur die gewählten Objektzeilen in ihrer Originalreihenfolge. Alle
Spalten bleiben enthalten; Summenzeilen entfallen.
Stromkreiswerte:
- `combined`: `DistributionBoard.name + Circuit.equipmentIdentifier`, ohne
Trennzeichen;
- `separate`: Verteilername und unverändertes BMK in getrennten Spalten;
- ohne Circuit: Stromkreisfeld beziehungsweise beide Felder leer.
Die Wahl des Verteilerwerts liegt in genau einer Funktion und verwendet
zunächst `DistributionBoard.name`.
Weitere Rückgabewerte:
- Bezeichnung = Row-`displayName`, ohne Row Planungs-Anzeigename;
- Leistung [W] = effektive Menge × Einzelleistung, ohne GZF;
- Spannung = Circuit-Spannung, sonst Projektstandard der Kategorie;
- Strom = `P / (U × cosPhi)` beziehungsweise
`P / (sqrt(3) × U × cosPhi)`, auf 0,01 A;
- nicht berechenbar = leere Zelle plus Hinweis.
Zahlen werden als Excel-Zahlen geschrieben. Fehlende optionale Zielspalten
werden mit Hinweis ausgelassen. Nur fehlende Stromkreisspalten des gewählten
Modus blockieren. Dateiname:
`<Projektname>_Revit-Rueckgabe_<YYYY-MM-DD>.xlsx`.
## 13. Verbindliche R2-Abnahme mit Linear
R2 endet mit einer Rückgabedatei, die aus der lokalen realen Musterdatei
erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese
Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach
erfolgreichem Linear-Rückimport beginnen.
Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear
eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben.
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
Alternative bleibt hinter demselben transportneutralen Adaptervertrag.
## 14. Folgeimport
Jeder Import ist eine vollständige Projektbauteilliste. Matching erfolgt
ausschließlich über exakt und case-sensitiv verglichene Worksharing-ID.
- neu: Wizard-Schritte nur für neue Objekte und unbekannte Familien;
- geänderte Quellwerte: Einzel- oder Sammelentscheidung;
- akzeptierte Mengenänderung: verknüpfte Row-Menge im selben Command;
- fehlend: markieren, Link und Menge zunächst beibehalten;
- Löschen: erst nach gesonderter Bestätigung;
- abweichende Rückgabefelder: Hinweis `in Revit noch nicht aktualisiert`,
keine Übernahmeentscheidung.
`external-import.apply-follow-up` ist atomar und ein Undo-Schritt.
Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
auf Metadaten reduziert.
Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute
Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare
`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte
Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command.
Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen
in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte
Löschung verwendet `external-model-object.delete-missing`; Undo stellt
Objekte, Links und Row-Mengen exakt wieder her.
## 15. Commands, Persistenz und Sicherheit
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
Gegenrichtung, `expectedRevision`, Anmeldung, CSRF-Header und
`project-command-transaction.persistence.ts`. Dazu gehören mindestens:
- `external-model-configuration.update`;
- `external-import.apply-initial`;
- `external-import.apply-follow-up`;
- `external-model-object.update-planning`;
- `external-model-object.update-distribution-board`;
- `external-model-room-mapping.update`;
- `external-model-object.update-project-device-link`;
- `external-object.update-row-assignment`;
- `external-object.assign-to-new-row`;
- `external-object.assign-to-new-circuit`;
- `external-model-object.delete-missing`;
- die erweiterten Row-/Circuit-/Gruppen-Löschcommands mit Objekt-Link-
Transitionen.
Die Konfiguration selbst ist Teil der erwarteten/gewünschten Snapshots, aber
hat keinen eigenen monotonen Zähler. Importbatches halten eine unveränderliche
Konfigurationskopie. Preview/Plan sind zustandslos. Apply überträgt und parst
die Datei erneut; Undo/Redo führt nur gespeicherte Zustandsübergänge aus.
Neue Migrationen sind additiv. Vor Anwendung wird die Datenbank gesichert und
das generierte SQL geprüft. Da nur Entwicklungsdaten existieren, darf die
Migration alte externe CSV-Daten verwerfen. Vor dem Löschen externer Objekte
werden betroffene Row-Mengen auf `manualQuantity` zurückgesetzt. Der Verlust
des alten externen Entwicklungszustands wird dokumentiert.
## 16. Abnahme und Leistung
Die Oberfläche muss mehrere Tausend Objekte flüssig bedienen. R10 misst
Import, Command-/Snapshot-Größe, Speicherbedarf und Client-Interaktion mit
synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den
sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal
geladenen kompakten Liste arbeiten.
Umsetzungsstand R10: Die vollständige Kette ist mit dateibasierter SQLite-
Persistenz und Neustart getestet. Der synthetische Lasttest verarbeitet 5.000
Objekte, misst Transport, Planung, Transaktionen, Command-/Snapshotgröße,
Heap und Clientprojektion und prüft Restore sowie Projektduplikat. Große
Zustandsersetzungen verwenden begrenzte Insertblöcke innerhalb derselben
atomaren Transaktion. Die Ergebnisse sind in der
[R10-Abnahme](revit-excel-r10-validation.md) dokumentiert.
Jedes Paket R1–R10 führt fokussierte Tests sowie aus:
```text
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
Jedes Paket erhält einen eigenen knappen imperativen Commit. Die konkrete
Paketfolge und ihre Freigabepunkte stehen im
[R0–R10-Plan](revit-excel-phase-14-plan.md).

View file

@ -0,0 +1,237 @@
# Phase 14 neu – Plan für die Linear-Excel-Anbindung
Verbindliche Fachgrundlage:
[Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md).
Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
## Status
| Paket | Status | Ergebnis / Freigabepunkt |
| --- | --- | --- |
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
| R10 | abgeschlossen | Ende-zu-Ende-Kette, 5.000-Objekte-Lasttest, gebatchte Zustandspersistenz und Abschlussdokumentation. |
## R0 – Audit
Ergebnis: [R0-Audit](revit-excel-r0-audit.md). Die Freigabe vom 29.09.2026
enthält 13 Korrekturen. Bei Abweichungen zwischen dem historischen Audit und
der aktuellen Anforderung gilt das neue Anforderungsdokument.
## R1 – Dokumentation
Lieferumfang:
- neues verbindliches Excel-Anforderungsdokument;
- dieser R0–R10-Plan;
- alte CSV-Anforderungen und alter Plan als kurze Ablösungshinweise;
- LLM-Kontext auf die neue Quelle der Wahrheit kürzen;
- `AGENTS.md`, aktuelle Architektur, History-/External-Model-Architektur,
Roadmap, README, Dokumentationsindex und bekannte Einschränkungen anpassen;
- reale Musterdatei per `.gitignore` vom Commit ausschließen;
- alle internen Links auf die neuen Dokumente umstellen.
Keine Laufzeit-, Schema- oder UI-Änderung. Abnahme: keine aktive Dokumentation
nennt CSV/IfcGUID als künftigen Transport beziehungsweise Schlüssel; der
aktuell noch vorhandene Legacy-Code bleibt als solcher klar benannt.
## R2 – XLSX-Transportadapter
Dateien/Module: neues `src/external-model/xlsx`, synthetische Testfixtures und
ein auf XLSX umgestellter Referenzprüfer mit Dateipfadargument. Eine neue
direkte Bibliothek (`exceljs`, vorbehaltlich der Tests), keine DB-Änderung.
Abnahme:
- Kopfzeilenkommentare, Blattname, Spaltenbreiten, Formate und Zelltypen
überleben einen strukturellen Roundtrip;
- erste Datenzeile/-spalte und Mappings funktionieren ohne separates
`layout.idColumn`;
- Worksharing-ID/IfcGUID werden exakt und case-sensitiv behandelt;
- Zeile ohne ID und ohne `Familie und Typ` wird übersprungen, die Muster-
Summenzeile B490 blockiert nicht;
- Zeile ohne ID mit `Familie und Typ` erzeugt Hinweis und wird übersprungen;
- maximal 10 MiB, nur `.xlsx`, Zeilen-/Spaltengrenzen nach dem Einlesen;
- keine Formelausführung;
- lokale Referenzprüfung bestätigt 487 Objekte und keine ID-Duplikate;
- aus der realen Musterdatei entsteht lokal eine nicht versionierte
Rückgabedatei mit einigen geänderten Werten.
Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein
erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter
OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert.
Implementierungsstand vom 30.09.2026: Der Adapter liegt unter
`src/external-model/xlsx`, der Referenzprüfer unter
`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei
die ignorierte Datei
`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei
enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte
Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen,
Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte
ID-Vergleiche und Grenzen ab.
Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der
Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete
Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer;
ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten
Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete
Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im
fachlich begrenzten R2-Paket aktualisiert.
## R3 – Datenmodell und Backend-Cutover
- Konfiguration ohne eigene Versionszählung;
- `worksharing_id NOT NULL`, Unique je Quelle; `ifc_guid` nullable ohne Unique;
- additive Migration nach vorherigem DB-Backup; alte externe Entwicklungsdaten
verwerfen und Row-Mengen vorher auf `manualQuantity` zurücksetzen;
- Batch-Aufbewahrung: nur letzter Batch vollständig, ältere nur Metadaten;
- Snapshot 10, Restore und Transfer; alte CSV-Zustände/History klar ablehnen;
- Backend-CSV-Parser, Endpunkte und Verträge entfernen;
- Apply parst erneut; Undo/Redo verwenden ausschließlich gespeicherte
Vorher-/Nachher-Zustände.
Risiken: Migration mit verknüpften Row-Mengen, alte History-Spitzen und
vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
## R4 – Revit-Seite und Einstellungen
- Route `/projects/:projectId/revit` und Navigationseintrag;
- Status-/Aktionsgrundgerüst;
- Einstellungsmodal mit Musterdateierkennung, Layout und Feldzuordnung;
- Stromkreismodus `combined | separate`;
- Projektseiten-Button und `revit-csv-modal.tsx` entfernen;
- nur deutsche UI-Texte und bestehende Icon-/Kartenregeln.
## R5 – Erstimport
- Vorschau und Plan mit erneuter Dateiübertragung beim Apply;
- Räume, Verteiler, Gerätearten, exakte Familienregeln und optionale
ProjectDevices;
- fehlender GZF als sichtbarer Startwert `1`, ohne Zusatzbestätigung;
- Warn-/Überspringregel für ID-lose Zeilen;
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
Implementierungsstand vom 30.09.2026: Vorschau und Apply verwenden die
projektweite XLSX-Konfiguration; Apply überträgt und parst die Datei erneut und
vergleicht den SHA-256 der Vorschau. Der Wizard führt über Vorschau, Räume,
Standardverteiler einschließlich Objekten ohne Raum, exakte Familienregeln,
optionale ProjectDevices und Bestätigung. Unbekannte Familien können als
Geräteart mit stabilen Client-UUIDs und sichtbarem GZF-Startwert 1 klassifiziert
werden. `external-model.apply-initial-import` schreibt Konfiguration, Quelle,
vollständigen letzten Batch, Raumzuordnungen und Objekte in einer Revision;
alle `circuitDeviceRowId` bleiben `null`. Seine Inverse speichert beide
Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen.
## R6 – Objekttabelle und Sammelaktionen
- kompakte Objektliste einmal laden;
- clientseitig suchen, filtern und sortieren;
- virtualisierte Darstellung ohne Server-Pagination;
- stabile Mehrfachauswahl und Sammelaktionen für Verteiler, ProjectDevice und
Objektplanungswerte;
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
Objekte folgen unverändert ihrer Row.
Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert
eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite
sucht, filtert und sortiert vollständig im Client und rendert nur den
sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler,
ProjectDevice, Leistung und GZF in einem persistenten
`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese
Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
## R7 – Stromkreislisteneditor
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
- Drop auf Row, Circuit oder freien Bereich über vorhandene, angepasste
Commands;
- Row-Indikator, Objektanzeige und Lösen mit Übernahme aktueller Row-Werte;
- Row-/Circuit-/Gruppen-Löschcommands lösen verknüpfte Objekte atomar und
zeigen `X Revit-Objekte werden wieder unzugeordnet`;
- Undo stellt Objektlinks exakt wieder her;
- nur Raum-/Verteilerlöschung mit externen Referenzen bleibt gesperrt.
## R8 – Rückgabe-Excel
- alle / abweichende / ausgewählte Objekte;
- zuletzt importiertes Workbook als strukturelle Grundlage;
- Stromkreisfelder des gewählten Modus sind Pflicht;
- fehlende optionale Bezeichnung-/Leistung-/Spannung-/Stromspalten nur als
Hinweis überspringen;
- typisierte Zahlen, gemeinsame Export-/Abweichungsprojektion;
- append-only Exportnachweis ohne Projektrevision.
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID;
- neu, geändert, unverändert, fehlend und Rückgabefeldabweichungen;
- Entscheidungen nur für fachliche Quelländerungen;
- akzeptierte Mengenänderungen und Row-Summen atomar;
- fehlende Objekte nicht automatisch lösen oder löschen;
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige
XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend
und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert
Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst
die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe
atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre
Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine
gesondert bestätigte Aktion kann sie anschließend mit passender
Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen;
Undo/Redo liest keine XLSX-Datei.
## R10 – Härtung
Ende-zu-Ende-Test:
```text
Import → Objektplanung → Row-Zuordnung → Rückgabe → Folgeimport
→ Snapshot/Restore → Projektduplikat → Undo/Redo nach Neustart
```
Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI,
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand.
Umsetzungsstand vom 30.09.2026: Der dateibasierte Integrationstest deckt die
vollständige Kette bis zu Undo/Redo nach einem Datenbankneustart ab. Ein
synthetisches Workbook mit 5.000 Objekten durchläuft Erst- und Folgeimport,
Snapshot-Restore, Projektduplikat sowie die extrahierte Clientfilter- und
Virtualisierungslogik. Die dabei gefundene SQLite-Grenze ungebatchter Inserts
ist für alle vollständigen External-Model-Zustandspfade behoben. Messwerte und
Abnahme stehen in [R10-Abnahme](revit-excel-r10-validation.md). Phase 14 ist
damit abgeschlossen.
## Pflichtprüfungen je Paket R1–R10
```text
fokussierte Tests
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
Jedes Paket wird mit einer knappen imperativen Commit-Message abgeschlossen.

View file

@ -0,0 +1,528 @@
# R0 – Audit der Linear-Excel-Anbindung
Stand: 29.09.2026. Geprüfter Code: `cf022b2`.
**Status: R0 abgeschlossen und mit Korrekturen für R1 freigegeben.** Dieser Bericht
ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten
Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und
die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0
ändert weder Laufzeitcode noch Datenbank noch Oberfläche.
Die 13 Freigabekorrekturen vom 29.09.2026 sind in den
[verbindlichen Anforderungen](revit-excel-integration-requirements.md) und im
[R0–R10-Plan](revit-excel-phase-14-plan.md) eingearbeitet. Bei Abweichungen
gelten diese beiden R1-Dokumente.
## 1. Geprüfter Bestand und Keep/Change/Remove
Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`,
`docs/project-history-and-external-model-architecture.md`,
`docs/spec/revit-csv-integration-requirements.md` und
`docs/spec/revit-csv-phase-14-audit-and-plan.md`. Der alte Plan widerspricht
sich bereits intern: Sein Status nennt die Erstübernahme noch nicht vorhanden,
während Implementierung und Tests sie nachweisen. Alte Erledigt-Markierungen
werden deshalb nicht übernommen.
| Bereich / Codebeleg | Entscheidung | Begründung und Änderung |
| --- | --- | --- |
| `src/db/repositories/project-command-transaction.persistence.ts` | Keep | Eine Grenze für Fachänderung, Inverse, Revision und History; auch automatische Snapshots bleiben darin. |
| `src/db/repositories/external-model-state.repository.ts`, `src/db/schema/external-*.ts` | Change | Quelle, Batches, Raum-Mappings und Objekte wiederverwenden; neue Identität, Konfiguration und typisierte Matrix. Schlanke Objekt-Reads von großen Batch-Blobs trennen. |
| `src/domain/models/external-initial-import-project-command.model.ts`, zugehöriger Store | Change | Vollständiger erwarteter/Zielzustand und Row-freier Import bleiben; Worksharing-ID, Gerätearten und atomare Regelübernahme ergänzen. |
| `src/external-model/application/external-initial-import-plan.ts`, `external-initial-import-target.ts` | Change | Raumvorschläge, Kataloge, vollständige Entscheidungen und stabile UUIDs behalten. Objektentscheidungen und Gerätearten ergänzen. |
| `src/db/repositories/external-object-assignment.persistence.ts` und drei Zuordnungs-Stores | Change | Mengeninvariante und exakte Inversen behalten; Geräteart, effektiven Verteiler, Anwesenheit und Übernahme der Row-Werte beim Lösen ergänzen. |
| `CircuitDeviceRow.manualQuantity`, bestehende Mengenprüfungen | Keep | `quantity = manualQuantity + Summe(externer effektiver Mengen)`; Mehrfachsteckdosen bleiben unteilbar. |
| `src/external-model/application/external-circuit-list-objects.ts` | Change | Listen-/Projektprüfung behalten; vollständige Planungswerte, Geräteart, effektiver Verteiler, Status und Row-Anzahlen liefern. |
| `src/domain/models/project-state-snapshot.model.ts`, Snapshot-Persistenz und Transfer | Change | Schema 10; historische CSV-Zustände ausdrücklich ablehnen; alle neuen internen Referenzen remappen. |
| `src/external-model/csv/*`, CSV-Preview, CSV-Validierungen und Controller | Remove/replace | XLSX ersetzt CSV vollständig; kein zweiter aktiver Transportpfad. |
| `src/frontend/components/revit-csv-modal.tsx`, Einbindung auf Projektseite | Remove/replace | Eigene Revit-Seite; Einstellungen nur im Modal, Import in eigenem Wizard. |
| `src/frontend/components/Sidebar.tsx`, `src/frontend/utils/api.ts`, `src/frontend/types.ts` | Change | Route `revit`, neue DTOs und Endpunkte, CSV-Imports entfernen. |
| `src/frontend/components/circuit-tree-editor.tsx`, Grid-Drop-/Projektionsmodule | Change | Separater externer Drag-Payload; Row-, Circuit- und Freiplatz-Ziele bewusst unterscheiden. Bestehende Grid-Bedienung behalten. |
| Raum-/ProjectDevice-Formulare und Commands | Keep | Sichtbar vorbelegte, ausdrücklich bestätigte Anlage als eigener Undo-Schritt. |
| `scripts/verify-revit-reference-csv.ts`, Transporttests | Replace | Pfadargument für lokalen XLSX-Prüfer; ausschließlich synthetische Workbooks in Tests. |
### Konkrete Lücken gegenüber dem Auftrag
- `external-model-contracts.ts` importiert heute CSV-Verträge. Die neutralen
Konfigurations-/Matrixverträge gehören künftig in `external-model/domain`;
nur `external-model/xlsx` darf ExcelJS kennen.
- `ifc_guid` ist derzeit NOT NULL und je Quelle eindeutig; Matching,
Snapshots, DTOs und Commands setzen diese Identität voraus.
- `internalDeviceType` ist derzeit ein freier String in Planungswerten.
Es gibt keinen Geräteart-Katalog mit stabilen IDs und Standardwerten.
- Der Erstimport setzt den ProjectDevice-Link, verwendet im Target aber
weiterhin `suggestedPlanningValues`. Die neue Priorität ProjectDevice vor
Geräteart muss ausdrücklich implementiert werden; ein Link allein genügt
nicht. Revit-Leistung wird künftig nicht automatisch zum Planungswert.
- Der Import-Target setzt `distributionBoardId = null`; die Raumvorgabe liegt
separat im Mapping. Projektion und Zuordnungsprüfung vergleichen heute
direkt das Objektfeld. Eine zentrale Auflösung des effektiven Verteilers
fehlt und ist vor der Editor-Nutzung erforderlich.
- Die bisherige Row-Zuordnung darf laut Validator am Objekt ausschließlich
`circuitDeviceRowId` ändern; der Store schreibt auch nur dieses Feld.
Lösen mit aktuellen Row-Planungswerten benötigt eine neue Command-Version.
- Die Kompatibilitätsprüfung vergleicht Raum, Kategorie, Auswahlkenner und
Planungswerte, aber keine stabile Geräteart. Unterschiedliche Gerätearten
dürfen nicht versehentlich in dieselbe Row aggregiert werden.
- Rows speichern `powerPerUnit` in **kW**, externe Planung
`powerPerUnitW` in **W**. Eine zentrale bidirektionale Konvertierung ist
nötig; Exportleistung darf nicht um Faktor 1000 falsch sein.
- Externe FKs auf Row, ProjectDevice, Raum-Mapping und Verteiler verwenden
teilweise `ON DELETE SET NULL`. Bestehende allgemeine Löschpfade sichern
externe Objektübergänge nicht durchgängig in ihren Inversen. Auch
Unterbaum-/History-Löschungen müssen abgesichert werden.
- Ein Editor-Drawer, projektweite Objekttabelle, Exportnachweis und
ausführbarer Folgeimport fehlen. `follow_up` als vorhandener Enumwert ist
kein implementierter Folgeimport.
- Konfigurationsversionen steigen heute nur bei normalen Updates gegenüber
dem aktuellen Wert; Undo setzt alte Versionen zurück. Für eine tatsächlich
monotone Generation muss diese Semantik korrigiert werden.
- XLSX-Parsing ist asynchron. Das heutige erneute synchrone CSV-Parsing
innerhalb der SQLite-Transaktion darf nicht einfach durch `await` ersetzt
werden.
## 2. Prüfung der lokalen Musterdatei
`docs/spec/kbp-Stromkreis.xlsx` wurde ausschließlich lesend auf ZIP-/OOXML-
Ebene untersucht; keine Inhalte wurden hochgeladen oder in Fixtures kopiert.
Die Datei ist unversioniert und darf nicht committed werden.
| Befund | Ergebnis |
| --- | --- |
| Dateigröße / entpackte ZIP-Einträge | 56.765 / 337.740 Bytes |
| Arbeitsblätter / Blattnamenlänge | 1 / 31 Zeichen |
| Zellbereich | A1:O490 |
| Kopfzeile / Leerzeile / Objekte / Abschluss | 1 / 2 / 3–489 / 490 |
| Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate |
| Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte |
| Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname |
| IfcGUID-Duplikate | Keine bei exaktem, case-sensitivem Vergleich |
| Formeln | Keine; synthetische Tests dafür erforderlich |
| Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind |
Das beobachtete Profil aus dem Auftrag wird damit bestätigt. Zusätzliche
Spaltenformatdefinitionen dürfen beim Export nicht auf die gemappten Spalten
gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und
die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie
dürfen nicht ohne Linear-Parameterbindung erfunden werden.
Die frühere Angabe von zwei IfcGUID-Duplikatgruppen war falsch: PowerShells
standardmäßiges Gruppieren verglich ohne Beachtung der Groß-/Kleinschreibung.
Ein erneuter ordinaler, case-sensitiver Vergleich ergab keine Duplikate. Die
Summenzeile hat nur in B490 einen Wert; `Familie und Typ` ist leer.
Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue
Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 endet
deshalb mit einer aus der Musterdatei erzeugten Rückgabedatei und einem
verbindlichen Linear-Test durch den Nutzer vor R3.
## 3. Bibliothek, Adapter und Uploadgrenzen
**Vorschlag: genau eine neue direkte Abhängigkeit `exceljs`.** Seine
öffentliche API unterstützt XLSX, Zellnotizen, Formate, Spaltenbreiten und
typisierte Werte. Das passt zum beobachteten klassischen Kommentarformat.
Version und transitive Abhängigkeiten werden in R2 festgeschrieben und
geprüft. Die veröffentlichte Version ist laut Paketregister 4.4.0; eine
aktuelle Releasepflege ist damit nicht bewiesen. Dies ist ein ausdrückliches
Wartungsrisiko, kein als erfüllt abgehakter Bibliotheksnachweis.
Quellen, am 29.09.2026 geprüft:
[ExcelJS-Projekt](https://github.com/exceljs/exceljs),
[offizielle Paketdokumentation](https://www.npmjs.com/package/exceljs).
Keine Bibliothek wird in R0 installiert. R2 muss vor Integration Kommentar-
Roundtrip, Node-24-Verträglichkeit und Abhängigkeitsrisiken prüfen; bei einem
Blocker wird die Bibliotheksentscheidung neu vorgelegt.
Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet:
- ausschließlich `.xlsx`, kein `.xls` oder `.xlsm`;
- maximal 10 MiB Upload;
- genau ein Arbeitsblatt und keine Formelausführung;
- feste Zeilen-, Spalten- und Zellgrenzen nach dem Einlesen.
Es gibt keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten
(Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete
Zeilen mit Originalzeilennummer und Klasse sowie Zellen als diskriminierte
Union `string | number | boolean | empty`. Formeltext und Cache-Status sind
optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert.
Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern
einen Blocker. Originalbytes bleiben unverändert gespeichert.
Eine Zeile ohne Worksharing-ID ist nur verdächtig, wenn `Familie und Typ`
gefüllt ist. Sie erzeugt einen Hinweis und wird übersprungen, ohne zu
blockieren. Andere ID-lose Zeilen, auch die Summenzeile mit Inhalt in B,
werden still übersprungen. Nicht passende nichtleere IDs blockieren.
Worksharing-ID und IfcGUID bleiben als Quelltext erhalten und werden exakt,
ordinal und case-sensitiv verglichen; ungültige äußere Leerzeichen werden
gemeldet statt still korrigiert. IfcGUID wird optional gespeichert, doppelte
Werte sind nur Hinweise.
Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte
Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein.
Rückgabe lädt eine Kopie der Originalbytes, bewahrt den Bereich oberhalb der
ersten Datenzeile samt Kommentaren, Formatierung und Spaltenbreiten und
ersetzt den Datenbereich durch gewählte Objektzeilen in Originalreihenfolge.
Alle Spalten bleiben enthalten; Summenzeilen entfallen. Unveränderte Zellen
behalten ihre importierten Werte und Typen. Formeln in Objektzeilen werden
als gecachte Werte ausgegeben, damit Zeilenauswahl keine falschen relativen
Bezüge erzeugt; dies wird vor Export als Hinweis ausgewiesen. Keine Formeln
werden berechnet. Roundtrip bedeutet strukturelle/semantische Gleichheit,
nicht identische ZIP-Bytes.
## 4. Konfigurationsschema
Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1:
```ts
{
schemaVersion: 1,
layout: {
headerRow: 1, firstDataRow: 3, firstDataColumn: 1
},
circuitMode: "combined" | "separate",
columns: {
worksharingId: string, familyAndType: string,
ifcGuid: string | null, roomNumber: string | null,
roomName: string | null, selectionMarker: string | null,
socketCount: string | null, sourceRemark: string | null,
circuitCombined: string | null, boardName: string | null,
circuitIdentifier: string | null, designation: string | null,
electricalPowerW: string | null, voltageV: string | null,
currentA: string | null
},
deviceTypes: [{
id: UUID, name: string,
category: "lighting" | "single_phase" | "three_phase",
defaults: {
powerPerUnitW: number | null, simultaneityFactor: number | null,
cosPhi: number | null, costGroup: string | null
}
}],
familyTypeRules: [{
id: UUID, exactFamilyAndType: string, deviceTypeId: UUID,
quantityRule: "one" | "socketCount"
}]
}
```
Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar.
Es gibt nur `columns.worksharingId` und kein separates `layout.idColumn`.
Jede gemappte Kopfzeile muss eindeutig auflösbar sein;
doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig,
aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten
Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem
Auftrag. Unbekannte Spalten bleiben ohne Mapping erhalten.
Geräteart-IDs sind interne UUIDs, keine Namen als Relationsschlüssel. Regeln
sind exakt und je Familien-/Typtext eindeutig; jede importierte Familie muss
eine Geräteart haben. Gerätearten und Regeln werden als ein versioniertes
Konfigurationsaggregat gespeichert. Keine zusätzliche Gerätearttabelle ist
für diesen Umfang nötig; Validatoren erzwingen referenzielle Integrität.
Referenzierte Gerätearten können nicht kommentarlos entfernt werden.
Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename:
Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt
Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung.
Fehlender GZF wird sichtbar mit `1` vorbelegt, ohne zusätzliche Bestätigung.
Geräteart-
Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um.
## 5. Konkretes Persistenzschema
Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten.
`JSON` bezeichnet validierten JSON-Text in SQLite, nicht ExcelJS-Objekte.
| Tabelle | Zielspalten / Constraints |
| --- | --- |
| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat; keine eigene Versionszählung. |
| `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. |
| `external_import_batches` | Letzter Batch vollständig mit IDs, Importart, Zeit, Dateiname, SHA-256, Objektzahl, `applied_project_revision`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; ältere Batches nur als Metadaten Datum, Dateiname, SHA-256 und Objektzahl. |
| `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. |
| `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. |
| Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. |
| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_snapshot/hash`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. |
`import_batch_id` des Exportnachweises ist absichtlich eine historische UUID
ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis
nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots
und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben.
Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1
dokumentiert. Nur der letzte Importbatch gehört mit Bytes und Matrix zu
Snapshot/Transfer; ältere Batches werden dort nur als Metadaten erhalten.
Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten;
zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`,
`linked_project_device_id`, Export `(project_id, exported_at_iso)`.
Cross-Project-Links und Geräteart-Verweise prüft jeder Command in der
Transaktion; bloße Existenz eines UUID-FK genügt nicht.
Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften
Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die
Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden;
bei fehlender Vorgabe ist das Objekt ohne Verteiler. Eine Änderung des
Raum-Standardverteilers betrifft nur unzugeordnete Objekte; verknüpfte Objekte
folgen immer der Row-Verteilung und blockieren diese Änderung nicht.
Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind
Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export
verwenden die Row. Beim Lösen werden aktuelle Row-Werte einschließlich
Einheitenkonvertierung in die Objektplanung übernommen und als explizite
Overrides markiert. Effektive Objektmenge wird nicht durch die Row-Gesamtmenge
ersetzt. Die Row und ihr manueller Anteil bleiben erhalten.
## 6. Commands und Transaktionsgrenze
Alle öffentlichen Fach-Writes verlangen `expectedRevision`, Anmeldung und
`X-Leistungsbilanz-Request: 1`. `expectedRevision` gehört wie bisher zum
Ausführungsumschlag. Jede Transition enthält vollständige erwartete/Ziel-
Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches.
| Command | Payload-Skizze und Gegenrichtung |
| --- | --- |
| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht den vollständigen Fachinhalt; keine eigene Konfigurationsversion. |
| `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. |
| `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. |
| `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. |
| `external-model-object.update-distribution-board` v1 | `{ objects: transitions }`; explizite Objektabweichung, Projekt-/Row-Konsistenz. |
| `external-model-room-mapping.update` v1 | `{ mappings: transitions }`; interner Raum/Standardverteiler; betroffene Objekt-/Row-Konsistenz mitprüfen. |
| `external-model-object.update-project-device-link` v1 | `{ objects: transitions, confirmedPlanningFields }`; keine Vorlagenänderung; neue Startwerte nur für ausdrücklich bestätigte Felder. Gebundene Objekte werden über ihre Row bearbeitet. |
| `external-object.update-row-assignment` v2 | `{ rows: transitions, objects: transitions, confirmedConflictObjectIds }`; Linkwechsel, materialisierte Mengen, beim Lösen aktuelle Row-Planungswerte; vollständige Inverse. |
| `external-object.assign-to-new-row` v2 | Bestehender vollständiger Row-/Objekt-/Reserveplan mit neuen Objektverträgen; history-only Inverse entfernt nur exakt unveränderte Erzeugnisse. |
| `external-object.assign-to-new-circuit` v2 | Bestehender Circuit-/Protection-/Row-/Objektplan; BMK einmal planen und festschreiben, gleiche UUIDs bei Redo. |
| `external-model-object.delete-missing` v1 | `{ objects: transitionsToNull, rows: quantityTransitions }`; ausschließlich ausdrücklich bestätigte weiterhin fehlende Objekte, kein Row-/Circuit-Löschen; Inverse stellt IDs, Links und Mengen wieder her. |
Bestehende `project-device.insert`, Room-/Floor-Commands bleiben eigenständige
Undo-Schritte. Familien-/Geräteartentscheidungen im Wizard sind lokale
Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater
Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu,
ohne lokale Entscheidungen zu verlieren.
Die Konfiguration hat keine eigene Versionszählung. Vorschau und Apply binden
sich an `expectedRevision`; weil auch Undo/Redo neue, strikt steigende
Projektrevisionen erzeugen, wird ein veralteter Plan eindeutig ungültig.
Importbatches speichern eine vollständige Konfigurationskopie. Prüfen:
Änderung → Undo → anderer Zweig darf niemals denselben Plan wieder gültig
machen.
XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen
Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und
Entscheidungen werden erneut validiert. Im Store werden Revision,
Konfigurationsinhalt, erwartete Objekte, Links und vollständiger
vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client
behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht
an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo lesen und
parsen keine Excel-Datei; sie wenden ausschließlich die gespeicherten
Vorher-/Nachher-Zustände an. Keine asynchronen Callbacks in
`better-sqlite3`-Transaktionen.
Row-/Circuit-/Gruppen-Löschcommands erfassen verknüpfte Objekte vollständig.
Nach der Warnung `X Revit-Objekte werden wieder unzugeordnet` lösen sie die
Links im selben Command; ihre Inverse stellt sie exakt wieder her. Sperren
bleiben nur für das Löschen referenzierter Verteiler und Räume und gelten auch
in Unterbaum- und History-only-Pfaden.
## 7. Migration, Snapshot 10, History und Transfer
Neue additive Migration nach `0013_users` (voraussichtlich `0014`, Nummer
beim Beginn von R3 erneut prüfen). Bestehende SQL-/Metadateien unverändert.
1. Vor R3-Anwendung API-Schreibzugriffe stoppen und `npm run db:backup`
ausführen; verifiziertes Backup samt Wiederherstellungsmöglichkeit sichern.
R0 verändert keine DB und benötigt daher kein Migrationsbackup.
2. Vorhandene externe CSV-Entwicklungsdaten dürfen gemäß Auftrag verworfen
werden. Zuerst je verknüpfter Row `quantity = manual_quantity` setzen;
sonst bliebe nach Entfernen der externen Objekte eine Phantomlast.
Rows, Circuits, manuelle Anteile, UUIDs und BMKs bleiben bestehen.
3. Externe Objekte → Mappings → Batches → Quellen → CSV-Konfiguration in
FK-sicherer Reihenfolge entfernen. Tabellen gezielt neu aufbauen, wo
SQLite NOT-NULL-/Unique-Änderungen dies erfordern. Neue Zieltabellen und
Indices erstellen. Keine leeren Worksharing-IDs als Backfill erfinden.
4. Generiertes SQL und Drizzle-Metadaten prüfen; saubere und bestehende
Test-DBs migrieren; FK-/Integritätsprüfung und Mengenabgleich ausführen.
Der Reset ist eine dokumentierte einmalige Entwicklungsmigration,
kein stiller Laufzeit-Import.
5. Alte Revisionsdaten nicht umschreiben, keine CSV-History heimlich
überspringen. An der Ausführungsgrenze alte CSV-Konfigurationscommands,
Initial-/Zuordnungscommands v1 einschließlich ihrer History-Inversen und
Restores mit CSV-Inhalt vor jedem Write ablehnen.
Fehlermeldung für History:
„Dieser Verlaufsschritt verwendet die frühere CSV-/IfcGUID-Anbindung und
kann mit der Excel-Anbindung nicht ausgeführt werden.“
Der History-Status sollte den blockierten obersten Schritt samt Grund zeigen;
keine automatisch geleerten Stacks und keine Änderung bei Ablehnung. Frühere
allgemeine Commands können nach dem Reset zusätzlich an ihren exakten
Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung.
Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle,
den letzten Batch mit Originalbytes/Matrix, ältere Batches nur als Metadaten
sowie Raum-Mappings, Objekte und Links.
Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn
`externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer
ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration
allein zählt als nicht leerer CSV-Zustand und wird nicht still verworfen.
Snapshot-Checksumme am gespeicherten Original vor Upgrade prüfen.
Fehlermeldung:
„Dieser Projektstand enthält Daten der früheren CSV-/IfcGUID-Anbindung.
Eine automatische Übernahme in die Excel-Anbindung ist nicht möglich.
Bitte verwenden Sie einen Projektstand ohne CSV-Daten.“
Der aktuelle portable Transfer ist bereits **Format 2** (Grobbilanzen),
nicht Format 1 wie im alten Audit. Envelope 2 und dessen Format-1-Lesepfad
bleiben; enthaltenes Projektzustandsschema wird 10. Duplizieren remappt
Projekt-, Konfigurations-, Geräteart-, Regel-, Quellen-, Batch-, Mapping-,
Objekt-UUIDs und alle internen Links, auch in Konfigurationssnapshots der
Batches. Worksharing-IDs, optionale IfcGUIDs und Originalbytes bleiben
unverändert. Grobbilanzen und globale Office-/Auth-Daten behalten ihre
bisherige Abgrenzung. Exportnachweise werden nicht als neue Projektexporte
dupliziert.
## 8. Read-Modelle, Oberfläche und Rückgabe
Vorgeschlagene API-Basis `/api/projects/:projectId/revit`:
`configuration` (GET/PUT), `profile/preview` (POST, zustandslos),
`initial-import/plan|apply`, `follow-up-import/plan|apply` (POST),
`status` und `objects` (GET), `return-workbook` (POST mit Export-Audit).
Der bestehende listenbezogene External-Objects-GET bleibt erhalten.
Objektmutationen verwenden den vorhandenen Project-Command-Endpunkt mit
neuen typisierten Verträgen. CSV-Endpunkte werden entfernt, keine Aliasse.
Export verwendet `expectedRevision` für einen konsistent gelesenen Stand;
die Datei-Erzeugung erfolgt außerhalb des kurzen Read-Snapshots. Danach
separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt.
Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung,
nicht einen tatsächlich abgeschlossenen Browserdownload.
Die Revit-Seite lädt einmal eine kompakte Objektliste. Filterung und Sortierung
laufen im Client; die Tabelle wird virtualisiert und verwendet keine Server-
Pagination. Filter-/Sortierzustand, UUID-Auswahl und Sammelaktionen sind
unabhängig vom DOM. „Alle gefilterten“ wird vor einer Mutation zu einer
expliziten ID-Auswahl an der aktuellen Revision aufgelöst. Große Auswahlmengen
werden nicht in getrennte Undo-Schritte aufgeteilt. Kein vollständiges
Batch-Blob im DTO.
Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen,
Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln
entstehen im Wizard und sind auf der Seite einklappbar nachbearbeitbar.
Fehlende Raumangaben bleiben zulässig, Board-Zuordnung ist auch ohne Raum
möglich. ProjectDevice-Zuordnung bleibt optional.
Rechter Editor-Drawer: nur vorhandene, ungebundene konfliktfreie Objekte des
effektiven Verteilers. Gruppierung nach internem Raum, Auswahlkenner,
Geräteart-ID, Kategorie und identischen Planungswerten; unterschiedliche
ProjectDevice-Links bleiben ebenfalls getrennt, da eine Row nur einen Link
haben kann. Effektive Einzelmengen sind kein Gleichheitsfeld: Einfach- und
Mehrfachsteckdosen sollen gemeinsam gruppierbar sein. Anzeige enthält
Mengensumme und Objektanzahl. Fachlich heterogene Auswahl darf nicht
willkürlich auf eine einzelne Row zusammenfallen; Konfliktanzeige verlangt
eine kompatible Auswahl. Row-Indikator öffnet Objekte und explizites Lösen.
Eine Funktion `getRevitBoardName(board)` liefert ausschließlich `board.name`.
Gemeinsam: Name + unverändertes `equipmentIdentifier`, ohne Separator.
Getrennt: Name und BMK in eigene Parameter. Ohne Circuit bleiben beide
Stromkreiswerte leer, auch wenn ein Planungsverteiler bekannt ist.
Exportprojektion und Abweichungsanzeige teilen dieselbe reine Funktion:
Row-Anzeigename/-Leistung bei Link, sonst Objektplanung; Objektleistung in W
= effektive Menge × Einzelleistung (Row-kW vorher × 1000), ohne GZF.
Spannung vom Circuit, sonst Projektstandard passend zur Kategorie.
Strom aus Objektleistung, U, cosPhi und Phasenart; Drehstrom mit Faktor √3,
auf 0,01 A. Fehlende/ungültige Werte ergeben leer plus Hinweis. Nullleistung
bleibt bei gültigem Nenner numerisch 0. Kein still angenommenes cosPhi.
Abweichungsvergleich normalisiert Zahlpräzision, Leere und skalare Typen
gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand.
Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite
Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden
nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene
Parameter behalten importierte Werte. Fehlende optionale Spalten für
Bezeichnung, Leistung, Spannung und Strom werden mit Hinweis übersprungen.
Nur fehlende Stromkreisspalten des gewählten Modus blockieren. Dateiname gemäß
Auftrag mit bereinigtem Projektnamen und Tagesdatum.
Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert
immer den vollständigen letzten Quellstand. Unveränderte Objekte werden
nicht allein zum Fortschreiben von `lastSeenImportBatchId` neu geschrieben;
aktuelle Sichtbarkeit/Quellwerte kommen aus Batch/Matrix. Akzeptierte
Planungsgrundlage und letzte importierte Beobachtung sind getrennt.
Raum-/Familien-/Marker-/Mengenänderungen brauchen Entscheidungen. Bei
unvereinbarer Row-Zuordnung blockiert die Übernahme bis zum ausdrücklichen
Lösen/Umordnen, statt Rows automatisch zu erzeugen. Übernommene Mengen
ändern alle betroffenen Row-Summen im selben Importcommand. Fehlend setzt
nur den Status, erhält vorerst Link und Menge. Rückgabefeld-Abweichungen
sind Hinweise, keine Übernahmeentscheidungen. Ein völlig unveränderter
Folgeimport erzeugt einen nachvollziehbaren Batch, aber keine unnötigen
Objekt-/Row-Updates.
## 9. Pakete, Verifikation und Risiken
R0 liefert nur diesen Bericht und einen eigenen Commit. R1–R10 starten erst
nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste.
| Paket | Arbeit und entscheidende Abnahme |
| --- | --- |
| R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. |
| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss. Aus der lokalen Musterdatei entsteht eine Rückgabedatei mit geänderten Werten. Danach Nutzerabnahme in Linear; R3 bleibt bis zum Erfolg gesperrt. Bei Fehlschlag vor R3 OOXML-Direktänderung als Alternative vorlegen. Keine DB-Änderung. |
| R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. |
| R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. |
| R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. |
| R6 | Kompakte Objekt-Reads, clientseitige Filter/Sortierung, Virtualisierung, Sammel-Commands und Overrides. |
| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele, atomare Objektlösung beim Row-/Circuit-/Gruppen-Löschen und Reload/Undo/Redo. |
| R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. |
| R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. |
| R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. |
Die temporäre Grenze zwischen R3 und R4 wird ausdrücklich behandelt:
Buildfähige, nicht aktive Kompatibilitätstypen für das alte Modal dürfen bis
R4 bestehen, aber kein aktiver CSV-Backendpfad. Veraltete Projektaktion bis
zur neuen Seite deaktivieren/entfernen, keinen funktionslosen Upload anbieten.
Die Dokumentation darf Zwischenstände nicht als fertigen Workflow ausweisen.
Pflicht je R1–R10: fokussierte Tests, `npm test`, `npm run build:api`,
`npm run build:web`, `npm run typecheck:scripts`,
`npx tsc --noEmit -p tsconfig.next.json`, je ein knapper imperativer Commit.
R0: 31 vorhandene Tests aus sechs Dateien erfolgreich ausgeführt:
`external-model-foundation`, `external-initial-import-plan`,
`external-initial-import-project-command.repository`,
`external-object-row-assignment-project-command.repository`,
`external-circuit-list-objects`,
`external-csv-configuration-project-command.repository` (je `.test.ts`).
Diese decken vorhandene Commit-/Rollback-, Undo-/Redo-, Snapshot-/Transfer-
Grundlagen ab, nicht die neue Excel-Funktion. Der erste Start scheiterte an
Sandbox-`spawn EPERM`; derselbe Lauf außerhalb der Sandbox bestand.
Vollständige Builds/Tests sind für diesen reinen R0-Bericht nicht ausgeführt.
Zusätzlich erforderliche neue Prüfungen:
- Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs.
- v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen
und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen.
- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne über `expectedRevision`; Apply mit anderem
Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig
zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück.
- Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte.
- Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische
Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung.
- Row-/Circuit-/Gruppen-Löschen löst externe Links im Command und stellt sie
per Undo wieder her. Raum-/Verteilerlöschen mit Referenzen blockiert.
- Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt
den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise.
- 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße,
Command-/Snapshot-Dauer und UI-Reaktionszeit messen; nur sichtbare Zeilen im
DOM, stabile Auswahl über virtuelle Bereiche.
Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des
Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu
belegen. Große Originaldateien vervielfachen sich in History/Snapshots;
Limits und Messungen müssen reale Speichergrößen berücksichtigen. Die
CSV-Entwicklungsbereinigung ist absichtlich verlustbehaftet, aber auf den
externen Zustand begrenzt und nur nach verifiziertem Backup zulässig.
Fehlende Zielparameter im Muster verlangen einen neuen vollständigen
Linear-Export, keine geratenen GUID-Kommentare.
**Freigabepunkt:** R0 wurde am 29.09.2026 mit den in R1 dokumentierten
Korrekturen freigegeben. R2 besitzt einen neuen verbindlichen Freigabepunkt:
Vor Beginn von R3 muss der Nutzer die in R2 erzeugte Rückgabedatei erfolgreich
in Linear getestet haben.

View file

@ -0,0 +1,60 @@
# R10-Abnahme der Linear-XLSX-Anbindung
Stand: 30.09.2026. R10 schließt Phase 14 mit synthetischen, reproduzierbaren
Integrations- und Lasttests ab. Die reale Musterdatei wird dafür nicht benötigt
und bleibt unversioniert.
## Ende-zu-Ende-Nachweis
`tests/revit-xlsx-end-to-end.test.ts` verwendet eine dateibasierte SQLite-
Datenbank und prüft in einer Kette:
```text
Erstimport → Objektplanung → Row-Zuordnung → Rückgabe-XLSX
→ Folgeimport → Snapshot/Restore → Projektduplikat
→ Datenbankneustart → Undo → Redo
```
Der Test bestätigt insbesondere die materialisierte Row-Menge, die Reduktion
des älteren Importbatches auf Metadaten, das Remapping aller internen UUIDs im
Duplikat und parserfreies Undo/Redo nach dem Neustart.
## Last- und Größenmessung
`tests/revit-xlsx-scale.test.ts` erzeugt 5.000 synthetische Objekte in einer
Linear-kompatiblen XLSX. Der Test misst Erst- und Folgeimport, Command- und
Snapshotgröße, Heap-Wachstum sowie clientseitiges Filtern, Sortieren und das
virtuelle Fenster. Er führt außerdem Snapshot-Restore und Projektduplikat mit
dem vollständigen Zustand aus.
Messung auf der Entwicklungsumgebung am 30.09.2026:
| Messwert | Ergebnis |
| --- | ---: |
| Objekte | 5.000 |
| XLSX-Datei | 191.019 Bytes |
| Erstimport-Command | 15.598.253 Bytes |
| logischer Projektsnapshot | 15.598.071 Bytes |
| XLSX-Erstplanung | 6.831 ms |
| atomare Erstimporttransaktion | 672 ms |
| XLSX-Folgeplanung | 6.303 ms |
| atomare Folgeimporttransaktion | 887 ms |
| Clientfilter, Sortierung und Fenster | 37 ms |
| Heap-Zuwachs des erfolgreichen Gesamtlaufs | 154.125.568 Bytes |
Der Heap-Wert ist wegen Garbage Collection und der absichtlich verwendeten
In-Memory-Datenbank nicht deterministisch. Wiederholte Auditläufe lagen unter
der Testobergrenze von 768 MiB. Das virtuelle Fenster enthält bei 520 px Höhe
maximal 22 statt 5.000 Zeilen.
## Gefundene und behobene Grenze
Der erste 5.000-Objekte-Lauf scheiterte an einem ungebatchten SQLite-
Mehrfach-Insert. `insertManyInBatches` begrenzt nun die Insertblöcke für
Erstimport, Folgeimport, Snapshot-Restore und Projektduplikat. Alle Blöcke
bleiben Teil der jeweiligen bestehenden Transaktion; ein Fehler rollt weiterhin
die gesamte Operation zurück.
Die vorhandenen Tests decken außerdem die deutschen Ablehnungen alter
CSV-Snapshots und CSV-History, Löschwarnungen, atomare Link-Inversen und den
Schutz referenzierter Räume und Verteiler ab.

View file

@ -1,385 +0,0 @@
"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node.
Optional Dynamo input:
IN[0]: output directory or complete .json file path
The script intentionally performs no Revit transaction and changes no model
data. OUT contains a compact summary plus the complete report.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402
from RevitServices.Persistence import DocumentManager # noqa: E402
CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId")
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {
"hasValue": False,
"raw": None,
"display": None,
}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
data_type = None
group_type = None
if definition is not None:
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def sorted_parameters(element):
parameters = []
try:
parameters = [describe_parameter(parameter) for parameter in element.Parameters]
except Exception:
return []
return sorted(
parameters,
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
),
)
def named_parameter_occurrences(element, parameter_name):
result = []
try:
parameters = element.GetParameters(parameter_name)
if parameters is not None:
result = [describe_parameter(parameter) for parameter in parameters]
except Exception:
parameter = None
try:
parameter = element.LookupParameter(parameter_name)
except Exception:
pass
if parameter is not None:
result = [describe_parameter(parameter)]
return result
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def get_cloud_identity(document):
result = {"isModelInCloud": False}
try:
result["isModelInCloud"] = bool(document.IsModelInCloud)
except Exception:
return result
if not result["isModelInCloud"]:
return result
try:
model_path = document.GetCloudModelPath()
result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
for property_name, output_name in (
("GetProjectGUID", "projectGuid"),
("GetModelGUID", "modelGuid"),
):
try:
result[output_name] = str(getattr(model_path, property_name)())
except Exception:
pass
except Exception as error:
result["readError"] = safe_text(error)
return result
def get_worksharing_identity(document):
result = {"isWorkshared": False}
try:
result["isWorkshared"] = bool(document.IsWorkshared)
except Exception:
return result
if not result["isWorkshared"]:
return result
try:
model_path = document.GetWorksharingCentralModelPath()
result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
except Exception as error:
result["readError"] = safe_text(error)
return result
def resolve_output_path(configured_path, report_name):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report():
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
project_information = document.ProjectInformation
if project_information is None:
raise RuntimeError("The active document has no Project Information element.")
application = document.Application
checked_parameters = {
name: named_parameter_occurrences(project_information, name)
for name in CHECKED_PARAMETER_NAMES
}
warnings = []
for name in CHECKED_PARAMETER_NAMES:
occurrences = checked_parameters[name]
populated = [
parameter
for parameter in occurrences
if parameter.get("value", {}).get("raw") not in (None, "")
]
if not occurrences:
warnings.append(name + " is not bound to Project Information.")
elif not populated:
warnings.append(name + " exists but has no value on Project Information.")
elif len(occurrences) > 1:
warnings.append(name + " occurs more than once; use a shared-parameter GUID later.")
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"isFamilyDocument": bool(document.IsFamilyDocument),
"cloud": get_cloud_identity(document),
"worksharing": get_worksharing_identity(document),
},
"modelIdentityCandidates": {
"projectInformationUniqueId": safe_text(project_information.UniqueId),
"projectInformationElementId": element_id_text(project_information.Id),
"checkedProjectParameters": checked_parameters,
},
"projectInformationParameters": sorted_parameters(project_information),
"warnings": warnings,
}
try:
report = build_report()
output_path = resolve_output_path(get_input(0), "model-identity")
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"projectInformationUniqueId": report["modelIdentityCandidates"][
"projectInformationUniqueId"
],
"warnings": report["warnings"],
"report": report,
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

View file

@ -1,517 +0,0 @@
"""Export all Electrical Fixtures instance/type parameters from Revit 2026.
Optional Dynamo inputs:
IN[0]: output directory or complete .json file path
IN[1]: include empty parameters (default True)
IN[2]: maximum aggregated sample values (default 5)
The script is read-only and performs no Revit transaction.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ( # noqa: E402
BuiltInCategory,
FilteredElementCollector,
ModelPathUtils,
StorageType,
)
from RevitServices.Persistence import DocumentManager # noqa: E402
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {"hasValue": False, "raw": None, "display": None}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter, scope):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
data_type = None
group_type = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"scope": scope,
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def has_meaningful_value(parameter_description):
value = parameter_description.get("value", {})
return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get(
"display"
) not in (None, "")
def read_parameters(element, scope, include_empty):
result = []
try:
for parameter in element.Parameters:
description = describe_parameter(parameter, scope)
if include_empty or has_meaningful_value(description):
result.append(description)
except Exception as error:
return [], [safe_text(error)]
result.sort(
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
)
)
return result, []
def read_space(element, document):
try:
space = element.Space
except Exception as error:
return None, safe_text(error)
if space is None:
return None, None
level_name = None
try:
level = document.GetElement(space.LevelId)
level_name = None if level is None else safe_text(level.Name)
except Exception:
pass
return {
"uniqueId": safe_text(space.UniqueId),
"elementId": element_id_text(space.Id),
"number": safe_text(getattr(space, "Number", None)),
"name": safe_text(getattr(space, "Name", None)),
"levelName": level_name,
}, None
def read_family_identity(element, document):
symbol = None
try:
symbol = element.Symbol
except Exception:
try:
symbol = document.GetElement(element.GetTypeId())
except Exception:
pass
family_name = None
type_name = None
type_unique_id = None
type_element_id = None
if symbol is not None:
try:
family_name = safe_text(symbol.Family.Name)
except Exception:
family_name = safe_text(getattr(symbol, "FamilyName", None))
type_name = safe_text(getattr(symbol, "Name", None))
type_unique_id = safe_text(getattr(symbol, "UniqueId", None))
type_element_id = element_id_text(getattr(symbol, "Id", None))
return {
"familyName": family_name,
"typeName": type_name,
"typeUniqueId": type_unique_id,
"typeElementId": type_element_id,
}, symbol
def parameter_inventory_key(parameter):
stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or ""
return "|".join(
(
parameter.get("scope") or "",
stable_id,
parameter.get("name") or "",
parameter.get("dataTypeId") or "",
)
)
def sample_value_key(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def add_to_inventory(inventory, parameter, max_samples):
key = parameter_inventory_key(parameter)
entry = inventory.get(key)
if entry is None:
entry = {
"scope": parameter.get("scope"),
"name": parameter.get("name"),
"parameterId": parameter.get("parameterId"),
"isShared": parameter.get("isShared"),
"sharedGuid": parameter.get("sharedGuid"),
"storageType": parameter.get("storageType"),
"dataTypeId": parameter.get("dataTypeId"),
"groupTypeId": parameter.get("groupTypeId"),
"unitTypeId": parameter.get("unitTypeId"),
"occurrenceCount": 0,
"populatedCount": 0,
"sampleValues": [],
"_sampleKeys": set(),
}
inventory[key] = entry
entry["occurrenceCount"] += 1
if has_meaningful_value(parameter):
entry["populatedCount"] += 1
value = parameter.get("value")
value_key = sample_value_key(value)
if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]:
entry["_sampleKeys"].add(value_key)
entry["sampleValues"].append(value)
def finalize_inventory(inventory):
result = []
for entry in inventory.values():
clean_entry = dict(entry)
clean_entry.pop("_sampleKeys", None)
result.append(clean_entry)
return sorted(
result,
key=lambda entry: (
entry.get("scope") or "",
(entry.get("name") or "").casefold(),
entry.get("sharedGuid") or entry.get("parameterId") or "",
),
)
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def resolve_output_path(configured_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report(include_empty, max_samples):
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
if document.IsFamilyDocument:
raise RuntimeError("Open a Revit project document, not a family document.")
application = document.Application
collector = (
FilteredElementCollector(document)
.OfCategory(BuiltInCategory.OST_ElectricalFixtures)
.WhereElementIsNotElementType()
)
source_elements = list(collector)
source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "")
elements = []
types_by_unique_id = {}
inventory = {}
errors = []
elements_without_space = 0
for element in source_elements:
element_errors = []
try:
family_identity, symbol = read_family_identity(element, document)
instance_parameters, parameter_errors = read_parameters(
element, "instance", include_empty
)
element_errors.extend(parameter_errors)
for parameter in instance_parameters:
add_to_inventory(inventory, parameter, max_samples)
type_unique_id = family_identity.get("typeUniqueId")
if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id:
type_parameters, type_errors = read_parameters(symbol, "type", include_empty)
element_errors.extend(type_errors)
for parameter in type_parameters:
add_to_inventory(inventory, parameter, max_samples)
types_by_unique_id[type_unique_id] = {
**family_identity,
"parameters": type_parameters,
}
space, space_error = read_space(element, document)
if space_error:
element_errors.append("MEP Space: " + space_error)
if space is None:
elements_without_space += 1
elements.append(
{
"uniqueId": safe_text(element.UniqueId),
"elementId": element_id_text(element.Id),
"categoryName": safe_text(
None if element.Category is None else element.Category.Name
),
"family": family_identity,
"space": space,
"instanceParameters": instance_parameters,
"warnings": element_errors,
}
)
except Exception as error:
errors.append(
{
"uniqueId": safe_text(getattr(element, "UniqueId", None)),
"elementId": element_id_text(getattr(element, "Id", None)),
"error": safe_text(error),
}
)
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"complete": len(errors) == 0,
"scope": {
"builtInCategory": "OST_ElectricalFixtures",
"wholeDocument": True,
"elementTypesExcluded": True,
"includeEmptyParameters": include_empty,
},
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId),
},
"summary": {
"elementCount": len(source_elements),
"exportedElementCount": len(elements),
"typeCount": len(types_by_unique_id),
"parameterDefinitionCount": len(inventory),
"elementsWithoutMepSpace": elements_without_space,
"elementErrorCount": len(errors),
},
"parameterInventory": finalize_inventory(inventory),
"types": sorted(
types_by_unique_id.values(),
key=lambda entry: (
(entry.get("familyName") or "").casefold(),
(entry.get("typeName") or "").casefold(),
entry.get("typeUniqueId") or "",
),
),
"elements": elements,
"errors": errors,
}
try:
include_empty_input = get_input(1, True)
include_empty = bool(include_empty_input)
try:
max_samples = int(get_input(2, 5))
except Exception:
max_samples = 5
max_samples = max(0, min(max_samples, 50))
report = build_report(include_empty, max_samples)
output_path = resolve_output_path(get_input(0))
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"complete": report["complete"],
"summary": report["summary"],
"errors": report["errors"],
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

View file

@ -1,69 +0,0 @@
# Revit 2026 / Dynamo diagnostics
This directory contains self-contained Python scripts for a Dynamo **Python
Script** node. They use only Dynamo's built-in Revit integration, the Revit API
and the Python standard library. No Dynamo package is required.
The scripts are read-only. They do not start a Revit transaction and do not
change the open model.
## Python engine
Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with
Revit; optional PythonNet3 packages are not required by these diagnostics.
## 01 - Check model identity
File: `01_check_model_identity.py`
The script reports:
- Revit, Dynamo and Python versions;
- `ProjectInformation.UniqueId` as a native model-identity candidate;
- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project
Information;
- all Project Information parameters;
- optional cloud/worksharing identity information when the API exposes it.
Input `IN[0]` is optional. It may be either an output directory or a complete
`.json` file path. With no input, the report is written below the current
Windows temporary directory in `leistungsbilanz-dynamo`.
## 02 - Inventory Electrical Fixtures parameters
File: `02_export_electrical_fixture_parameter_inventory.py`
The script reads every instance of
`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It
exports:
- element, family, type and MEP Space identities;
- every instance parameter and value;
- every unique family-type parameter and value;
- an aggregated parameter inventory with occurrence counts and sample values;
- per-element warnings instead of aborting at the first unreadable element.
Inputs:
- `IN[0]` (optional): output directory or complete `.json` path;
- `IN[1]` (optional): include empty parameters, default `true`;
- `IN[2]` (optional): maximum sample values per aggregated parameter, default
`5`.
The default output location is again the Windows temporary directory. The
generated report can contain model paths and project-specific parameter values;
review it before sharing or committing it.
## Running a script
1. Open the target model in Revit 2026.
2. Open Dynamo from **Manage > Visual Programming > Dynamo**.
3. Create a graph and add a **Python Script** node.
4. Select the `CPython3` engine for the node.
5. Copy the complete content of the desired `.py` file into the node.
6. Optionally connect a String node containing the output path to `IN[0]`.
7. Run the graph and inspect `OUT` for status, counts and the generated path.
For the first test, run `01_check_model_identity.py` in the Revit main model.
Then run the parameter inventory. Keep both generated JSON files so their
structure can be checked before the production snapshot DTO is finalized.

View file

@ -11,6 +11,22 @@ const nextConfig = {
typescript: {
tsconfigPath: "./tsconfig.next.json",
},
poweredByHeader: false,
// Clickjacking: the Config pages hold admin actions, so no foreign page
// may frame the app. Same-origin framing stays allowed.
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "same-origin" },
],
},
];
},
async rewrites() {
return [
{

811
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,10 +24,10 @@
"test": "node scripts/run-tests.mjs",
"test:watch": "node scripts/run-tests.mjs --watch",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate": "node scripts/run-migrations.js",
"db:backup": "tsx scripts/db-backup.ts",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts"
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
},
"keywords": [],
"author": "",
@ -36,6 +36,7 @@
"better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"lucide-react": "^1.14.0",
"next": "^16.2.4",

View file

@ -5,6 +5,10 @@ const dbPath = path.resolve("data", "leistungsbilanz.db");
const db = new Database(dbPath, { readonly: true });
const requiredTables = [
"buildings",
"estimate_balances",
"estimate_groups",
"estimate_rows",
"circuit_sections",
"circuits",
"circuit_device_rows",
@ -12,6 +16,8 @@ const requiredTables = [
"circuit_protection_devices",
"distribution_board_components",
"distribution_board_component_protection_devices",
"document_branding_assets",
"document_branding_settings",
];
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
@ -87,6 +93,10 @@ const requiredProjectColumns = [
"description",
"is_public_building",
"enabled_distribution_board_supply_types",
"site_address",
"work_stage",
"project_lead",
"project_editor",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
@ -94,10 +104,21 @@ const projectColumns = new Set(
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredFloorColumns = ["building_id"];
const floorColumns = new Set(
db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name)
);
const missingFloorColumns = requiredFloorColumns.filter(
(name) => !floorColumns.has(name)
);
const requiredDistributionBoardColumns = [
"floor_id",
"supply_type",
"simultaneity_factor",
"board_number",
"location",
"fed_from",
"remark",
];
const distributionBoardColumns = new Set(
db
@ -145,6 +166,7 @@ console.log(
requiredCircuitSectionColumns.join(", ")
);
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log("Required floor columns:", requiredFloorColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
@ -161,6 +183,11 @@ if (missing.length > 0) {
process.exit(1);
}
if (missingFloorColumns.length > 0) {
console.error("Missing floor columns:", missingFloorColumns.join(", "));
process.exit(1);
}
if (missingProjectDeviceColumns.length > 0) {
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
process.exit(1);

View file

@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
const fs = require('node:fs');
const path = require('node:path');
const dataDir = path.resolve('data');
const dataDir = path.resolve(
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
const db = drizzle(sqlite);
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
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);
});

View file

@ -1,81 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import {
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
const filePath = resolve(
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
);
const source = readFileSync(filePath);
const configuration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
configuration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const document = parseExternalCsv(source, configuration);
const counts = Object.fromEntries(
["metadata", "header", "passthrough", "object", "suspect-object"].map(
(classification) => [
classification,
document.rows.filter((row) => row.classification === classification).length,
]
)
);
const nonEmptyPassthroughRows = document.rows.filter(
(row) =>
row.classification === "passthrough" &&
row.cells.some((cell) => cell.value !== "")
).length;
const serialized = serializeExternalCsv(document);
const preview = createExternalCsvPreview({
fileName: filePath,
bytes: source,
configuration,
});
assert.equal(counts.object, 897, "Expected 897 object rows.");
assert.equal(preview.objectCount, 897, "Expected 897 preview objects.");
assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
assert.equal(
Buffer.compare(Buffer.from(serialized), source),
0,
"Expected a byte-identical CSV round-trip."
);
const sha256 = createHash("sha256").update(source).digest("hex");
process.stdout.write(
`${JSON.stringify(
{
file: filePath,
bytes: source.length,
sha256,
dialect: document.dialect,
rows: counts,
nonEmptyPassthroughRows,
roundTripByteIdentical: true,
previewObjectCount: preview.objectCount,
},
null,
2
)}\n`
);

View file

@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxScalar,
} from "../src/external-model/xlsx/external-xlsx-contracts.js";
import {
createExternalXlsxReturn,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
async function main() {
const inputPath = resolve(
process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx"
);
const outputPath = resolve(
process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx"
);
assert.notEqual(
inputPath,
outputPath,
"Eingabe- und Ausgabedatei müssen verschieden sein."
);
const source = readFileSync(inputPath);
const configuration = createDefaultExternalXlsxTransportConfiguration();
const document = await parseExternalXlsx({
fileName: inputPath,
bytes: source,
configuration,
});
const objectRows = document.rows.filter((row) => row.classification === "object");
const suspectRows = document.rows.filter(
(row) => row.classification === "suspect-object"
);
const worksharingIdColumn = document.columns.find(
(column) => column.header === configuration.columns.worksharingId
)?.columnNumber;
assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst.");
assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet.");
assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet.");
assert.equal(
document.columns.filter((column) => column.comment !== null).length,
14,
"Es werden 14 Kopfzeilenkommentare erwartet."
);
assert.equal(
document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length,
0,
"Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten."
);
const testValues = [
{
circuitCombined: "UV_R2_TEST-2F1",
electricalPowerW: 111,
voltageV: 230,
currentA: 0.48,
},
{
circuitCombined: "UV_R2_TEST-2F2",
electricalPowerW: 222,
voltageV: 230,
currentA: 0.97,
},
{
circuitCombined: "UV_R2_TEST-3F1",
electricalPowerW: 333,
voltageV: 400,
currentA: 0.48,
},
] satisfies Record<string, ExternalXlsxScalar>[];
const updates = testValues.map((values, index) => {
const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value;
if (typeof value !== "string") {
throw new Error("Worksharing-ID muss Text sein.");
}
return { worksharingId: value, values };
});
const result = await createExternalXlsxReturn({
fileName: inputPath,
bytes: source,
configuration,
updates,
});
assert.equal(result.exportedObjectCount, 487);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, result.bytes);
const outputDocument = await parseExternalXlsx({
fileName: outputPath,
bytes: result.bytes,
configuration,
});
assert.equal(
outputDocument.rows.filter((row) => row.classification === "object").length,
487
);
assert.equal(
outputDocument.rows.some((row) => row.classification !== "object"),
false,
"Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten."
);
assert.equal(outputDocument.sheetName, document.sheetName);
assert.deepEqual(
outputDocument.columns,
document.columns,
"Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben."
);
assert.deepEqual(
outputDocument.rows.slice(updates.length).map((row) => row.cells),
objectRows.slice(updates.length).map((row) => row.cells),
"Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten."
);
const outputWorkbook = new ExcelJS.Workbook();
await outputWorkbook.xlsx.load(result.bytes as never);
const commentedCells: string[] = [];
outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.equal(
commentedCells.length,
14,
"Nur die 14 Kopfzellen dürfen Kommentare enthalten."
);
assert.equal(
commentedCells.every((address) => address.endsWith("1")),
true,
"Datenzellen dürfen keine Kommentare enthalten."
);
process.stdout.write(
`${JSON.stringify(
{
input: inputPath,
output: outputPath,
inputBytes: source.length,
outputBytes: result.bytes.length,
inputSha256: createHash("sha256").update(source).digest("hex"),
outputSha256: createHash("sha256").update(result.bytes).digest("hex"),
sheetName: document.sheetName,
objectCount: objectRows.length,
headerCommentCount: document.columns.filter(
(column) => column.comment !== null
).length,
exactDuplicateIfcGuidCount: 0,
changedObjectCount: updates.length,
},
null,
2
)}\n`
);
}
void main();

View file

@ -0,0 +1,325 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api";
import { useAuth } from "../../../frontend/components/AuthProvider";
import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
});
export default function BenutzerPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const rows = await userApi.list();
setUsers(rows);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function withBusy(userId: string, action: () => Promise<void>) {
setBusyUserId(userId);
setError(null);
try {
await action();
} catch (err) {
setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
} finally {
setBusyUserId(null);
}
}
async function changeRole(item: UserDto, role: UserRole) {
await withBusy(item.id, async () => {
const updated = await userApi.update(item.id, { role });
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
});
}
async function toggleActive(item: UserDto) {
await withBusy(item.id, async () => {
const updated = await userApi.update(item.id, { active: !item.active });
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
});
}
return (
<div className="card" style={{ padding: "1.5rem" }}>
<div className="page-header" style={{ marginTop: 0 }}>
<h2 className="h5" style={{ margin: 0 }}>
Benutzer
</h2>
</div>
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem
Administrator angelegt. Das Startpasswort wird direkt eingetragen; die
Person muss es beim ersten Anmelden durch ein eigenes ersetzen.
</p>
<NeuerBenutzerFormular
onCreated={(created) => {
setUsers((list) => [...list, created]);
}}
/>
{error && <p className="text-danger">{error}</p>}
{loading && <p>Lädt…</p>}
{!loading && users.length > 0 && (
<table className="table" style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Name</th>
<th>Anmeldename</th>
<th>Rolle</th>
<th>Aktiv</th>
<th>Status</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{users.map((item) => {
const isSelf = item.id === currentUser?.id;
const busy = busyUserId === item.id;
return (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.username}</td>
<td>
<select
className="form-select form-select-sm"
value={item.role}
disabled={busy || isSelf}
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
>
{USER_ROLES.map((role) => (
<option key={role} value={role}>
{USER_ROLE_LABEL[role]}
</option>
))}
</select>
</td>
<td>
<input
type="checkbox"
checked={item.active}
disabled={busy || isSelf}
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
onChange={() => void toggleActive(item)}
/>
</td>
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
<td>
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={busy}
onClick={() => setResetPasswordFor(item)}
>
Passwort zurücksetzen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
{resetPasswordFor && (
<PasswortZuruecksetzenDialog
item={resetPasswordFor}
onClose={() => setResetPasswordFor(null)}
onDone={(updated) => {
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
setResetPasswordFor(null);
}}
/>
)}
</div>
);
}
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
const [username, setUsername] = useState("");
const [name, setName] = useState("");
const [role, setRole] = useState<UserRole>("user");
const [initialPassword, setInitialPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const created = await userApi.create({ username, name, role, initialPassword });
onCreated(created);
setUsername("");
setName("");
setRole("user");
setInitialPassword("");
} catch (err) {
setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden.");
} finally {
setSubmitting(false);
}
}
return (
<form
onSubmit={handleSubmit}
className="row g-2 align-items-end"
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-username">
Anmeldename
</label>
<input
id="new-user-username"
className="form-control form-control-sm"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-name">
Name
</label>
<input
id="new-user-name"
className="form-control form-control-sm"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="col-sm-2">
<label className="form-label" htmlFor="new-user-role">
Rolle
</label>
<select
id="new-user-role"
className="form-select form-select-sm"
value={role}
onChange={(event) => setRole(event.target.value as UserRole)}
>
{USER_ROLES.map((r) => (
<option key={r} value={r}>
{USER_ROLE_LABEL[r]}
</option>
))}
</select>
</div>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-password">
Startpasswort
</label>
<input
id="new-user-password"
type="text"
className="form-control form-control-sm"
value={initialPassword}
onChange={(event) => setInitialPassword(event.target.value)}
minLength={8}
required
/>
</div>
<div className="col-sm-1">
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
{submitting ? "…" : "Anlegen"}
</button>
</div>
{error && (
<div className="col-12">
<p className="text-danger mb-0">{error}</p>
</div>
)}
</form>
);
}
function PasswortZuruecksetzenDialog({
item,
onClose,
onDone,
}: {
item: UserDto;
onClose: () => void;
onDone: (updated: UserDto) => void;
}) {
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const updated = await userApi.resetPassword(item.id, newPassword);
onDone(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden.");
} finally {
setSubmitting(false);
}
}
return (
<div className="modal-backdrop-inline">
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
<div className="mb-3">
<label className="form-label" htmlFor="reset-password">
Neues Startpasswort
</label>
<input
id="reset-password"
type="text"
className="form-control"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
autoFocus
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Speichert…" : "Zurücksetzen"}
</button>
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Abbrechen
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { authApi } from "../../../frontend/utils/auth-api";
import { useAuth } from "../../../frontend/components/AuthProvider";
import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
export default function KontoPage() {
const { user } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (newPassword !== newPasswordRepeat) {
setError("Die beiden neuen Passwörter stimmen nicht überein.");
return;
}
setSubmitting(true);
setError(null);
setSuccess(false);
try {
await authApi.changePassword(currentPassword, newPassword);
setCurrentPassword("");
setNewPassword("");
setNewPasswordRepeat("");
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
} finally {
setSubmitting(false);
}
}
return (
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
<h2 className="h5">Konto</h2>
{user && (
<p className="text-body-secondary">
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
{USER_ROLE_LABEL[user.role]}
</p>
)}
<h3 className="h6 mt-4">Passwort ändern</h3>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label" htmlFor="konto-current-password">
Aktuelles Passwort
</label>
<input
id="konto-current-password"
type="password"
className="form-control"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="konto-new-password">
Neues Passwort
</label>
<input
id="konto-new-password"
type="password"
className="form-control"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="konto-new-password-repeat">
Neues Passwort wiederholen
</label>
<input
id="konto-new-password-repeat"
type="password"
className="form-control"
value={newPasswordRepeat}
onChange={(event) => setNewPasswordRepeat(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
{success && <p className="text-success">Passwort wurde geändert.</p>}
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Speichert…" : "Passwort ändern"}
</button>
</form>
</div>
);
}

52
src/app/config/layout.tsx Normal file
View file

@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuth } from "../../frontend/components/AuthProvider";
import {
getConfigRedirect,
getConfigTabs,
} from "../../frontend/utils/config-access";
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { status, isAdmin } = useAuth();
const tabs = getConfigTabs(isAdmin);
const redirectTarget = getConfigRedirect(pathname, isAdmin);
useEffect(() => {
if (status !== "signed-in") return;
if (redirectTarget) router.replace(redirectTarget);
}, [status, redirectTarget, router]);
if (
status !== "signed-in" ||
redirectTarget !== null
) {
return null;
}
return (
<div className="container-fluid py-4">
<div className="page-header">
<h1>Config</h1>
</div>
<nav className="config-tabs">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
>
{tab.label}
</Link>
))}
</nav>
{children}
</div>
);
}

3
src/app/config/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function ConfigPage() {
return null;
}

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ import Script from "next/script";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
import { AppShell } from "../frontend/components/AppShell";
import { AuthProvider } from "../frontend/components/AuthProvider";
import { LoginGate } from "../frontend/components/LoginGate";
export const metadata: Metadata = {
title: "Leistungsbilanz",
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<AuthProvider>
<LoginGate>
<AppShell>{children}</AppShell>
</LoginGate>
</AuthProvider>
</body>
</html>
);

View file

@ -1,5 +1,6 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -10,10 +11,20 @@ export default function CircuitTreeEditPage() {
return (
<main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<Link
aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div>
<div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt

View file

@ -0,0 +1,896 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Printer,
Redo2,
RefreshCw,
Undo2,
} from "lucide-react";
import {
ApiRequestError,
createProjectDevice,
getEstimateBalance,
getEstimateComparison,
getProject,
getProjectHistory,
isRevisionConflictError,
listProjectDevices,
redoProjectCommand,
undoProjectCommand,
updateEstimateBalance,
} from "../../../../../frontend/utils/api";
import type {
EstimateBalanceDto,
EstimateComparisonDto,
CreateProjectDeviceInput,
ProjectDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
} from "../../../../../frontend/types";
import type {
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../../../../shared/estimate-balance/estimate-balance";
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
import { ProjectDeviceModal } from "../../../../../frontend/components/project-device-modal";
import {
addEstimateGroup,
estimateBalanceFingerprint,
estimateTextLimits,
normalizeEstimateSortOrder,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
import {
formatGermanNumber,
formatIsoDateGerman,
} from "../../../../../frontend/estimate-balance/estimate-balance-format";
import { createEstimateProjectDeviceInitialValues } from "../../../../../frontend/estimate-balance/estimate-project-device-prefill";
import {
EstimateBalanceSheet,
type EstimateBalanceUpdate,
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
import {
EstimateGroupModal,
type EstimateGroupFormValues,
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
const AUTOSAVE_DELAY_MS = 800;
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
type GroupDialog = { group: EstimateGroupSnapshot | null };
type ProjectDeviceDialog = {
group: EstimateGroupSnapshot;
row: EstimateRowSnapshot;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function isNotFoundError(error: unknown) {
return error instanceof ApiRequestError && error.status === 404;
}
export default function EstimateBalanceEditorPage() {
const params = useParams<{ projectId: string; balanceId: string }>();
const { projectId, balanceId } = params;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
const [saveError, setSaveError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMissing, setIsMissing] = useState(false);
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
null
);
const [projectDeviceDialog, setProjectDeviceDialog] =
useState<ProjectDeviceDialog | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isPreparingProjectDevice, setIsPreparingProjectDevice] = useState(false);
const [isSavingProjectDevice, setIsSavingProjectDevice] = useState(false);
// Refs carry the latest values into timers, unload handlers and chained
// async saves without re-creating them.
const draftRef = useRef<EstimateBalanceDto | null>(null);
const revisionRef = useRef<number | null>(null);
const savedFingerprintRef = useRef<string | null>(null);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
const normalized = normalizeEstimateSortOrder(balance);
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
draftRef.current = normalized;
setDraft(normalized);
setSaveStatus("saved");
setSaveError(null);
}, []);
const reloadComparison = useCallback(async () => {
try {
setComparison(await getEstimateComparison(projectId, balanceId));
setComparisonError(null);
} catch (err) {
setComparisonError(
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
);
}
}, [balanceId, projectId]);
/** Reloads project, document and history; replaces the local draft. */
const reloadAll = useCallback(async () => {
const balanceRequest = getEstimateBalance(projectId, balanceId);
// Handled below; avoids an "unhandled rejection" while project/history load.
balanceRequest.catch(() => undefined);
// Project and history are applied even when the balance is missing, so
// Redo stays available after its creation was undone.
const [currentProject, currentHistory] = await Promise.all([
getProject(projectId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
setHistory(currentHistory);
const balance = await balanceRequest;
applyServerBalance(balance);
setIsMissing(false);
void reloadComparison();
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
const handleReloadError = useCallback((err: unknown) => {
if (isNotFoundError(err)) {
setIsMissing(true);
setDraft(null);
draftRef.current = null;
return;
}
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
}, []);
useEffect(() => {
if (!projectId || !balanceId) {
return;
}
setIsLoading(true);
reloadAll()
.then(() => setError(null))
.catch(handleReloadError)
.finally(() => setIsLoading(false));
}, [balanceId, handleReloadError, projectId, reloadAll]);
const clearSaveTimer = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, []);
/**
* Saves the current draft if it differs from the last saved state. Saves
* never overlap: a save requested while another runs waits for it and then
* saves whatever changed in the meantime. Resolves to false when the draft
* could not be saved.
*/
const saveNow = useCallback(async (): Promise<boolean> => {
clearSaveTimer();
while (saveInFlightRef.current) {
await saveInFlightRef.current;
}
const current = draftRef.current;
const revision = revisionRef.current;
if (!current || revision === null) {
return true;
}
const fingerprint = estimateBalanceFingerprint(current);
if (fingerprint === savedFingerprintRef.current) {
setSaveStatus("saved");
return true;
}
const problems = validateEstimateBalance(current);
if (problems.length) {
setSaveStatus("invalid");
setSaveError(problems.join(" "));
return false;
}
const body = toSaveableEstimateBalance(current);
/**
* Another command changed the project revision. When this document is
* still unchanged on the server (e.g. an edit in a circuit list), the
* draft is kept and saved again; otherwise the server state wins.
*/
async function resolveConflict(): Promise<"retry" | "reloaded"> {
const [currentProject, serverBalance, currentHistory] = await Promise.all([
getProject(projectId),
getEstimateBalance(projectId, balanceId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
setHistory(currentHistory);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
return "retry";
}
applyServerBalance(serverBalance);
setNotice(CONFLICT_MESSAGE);
void reloadComparison();
return "reloaded";
}
const run = (async () => {
setSaveStatus("saving");
for (let attempt = 0; ; attempt += 1) {
try {
const result = await updateEstimateBalance(
projectId,
body,
attempt === 0 ? revision : revisionRef.current ?? revision
);
if (!("unchanged" in result)) {
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
}
// The sent document is the saved state; the local draft is kept so
// typing is never interrupted by the server's copy.
savedFingerprintRef.current = fingerprint;
setSaveError(null);
const latest = draftRef.current;
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
setSaveStatus("pending");
} else {
setSaveStatus("saved");
}
void reloadComparison();
return true;
} catch (err) {
if (isRevisionConflictError(err)) {
try {
if (attempt === 0 && (await resolveConflict()) === "retry") {
continue;
}
if (attempt > 0) {
await reloadAll();
setNotice(CONFLICT_MESSAGE);
}
} catch (reloadError) {
handleReloadError(reloadError);
}
return false;
}
if (isNotFoundError(err)) {
handleReloadError(err);
return false;
}
setSaveStatus("error");
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
return false;
}
}
})();
saveInFlightRef.current = run;
try {
return await run;
} finally {
saveInFlightRef.current = null;
}
}, [
applyServerBalance,
balanceId,
clearSaveTimer,
handleReloadError,
projectId,
reloadAll,
reloadComparison,
]);
// Debounced autosave after every change of the draft.
useEffect(() => {
if (!draft) {
return;
}
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
return;
}
setSaveStatus((status) => (status === "saving" ? status : "pending"));
clearSaveTimer();
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, AUTOSAVE_DELAY_MS);
return clearSaveTimer;
}, [clearSaveTimer, draft, saveNow]);
// Save immediately when the tab is hidden; on unload/unmount send the last
// change with keepalive so it survives the page.
useEffect(() => {
function hasUnsavedDraft() {
const current = draftRef.current;
return Boolean(
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
);
}
function sendKeepalive(): boolean {
const current = draftRef.current;
const revision = revisionRef.current;
if (
!current ||
revision === null ||
saveInFlightRef.current ||
validateEstimateBalance(current).length
) {
return false;
}
savedFingerprintRef.current = estimateBalanceFingerprint(current);
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
keepalive: true,
}).catch(() => undefined);
return true;
}
function handleVisibilityChange() {
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
void saveNow();
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedDraft() || sendKeepalive()) {
return;
}
// A save is still running or the draft is invalid: let the browser ask.
event.preventDefault();
event.returnValue = "";
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", handleBeforeUnload);
// In-app navigation (e.g. sidebar) unmounts the editor.
if (hasUnsavedDraft()) {
sendKeepalive();
}
};
}, [projectId, saveNow]);
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
setNotice(null);
setDraft((current) => (current ? update(current) : current));
}, []);
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
setInvalidFieldKeys((current) => {
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
return current;
}
const next = new Set(current);
if (isValid) {
next.delete(fieldKey);
} else {
next.add(fieldKey);
}
return next;
});
}, []);
async function handleHistory(direction: "undo" | "redo") {
setError(null);
setNotice(null);
setIsHistoryBusy(true);
try {
// Pending edits are saved first so they become their own undo step.
await saveNow();
const revision = revisionRef.current;
if (revision === null) {
return;
}
const result =
direction === "undo"
? await undoProjectCommand(projectId, revision)
: await redoProjectCommand(projectId, revision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
await reloadAll();
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setNotice(CONFLICT_MESSAGE);
} else if (isNotFoundError(err)) {
handleReloadError(err);
} else {
setError(
errorMessage(
err,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
}
} finally {
setIsHistoryBusy(false);
}
}
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
const current = draftRef.current;
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
return;
}
event.preventDefault();
await saveNow();
router.push(`/projects/${projectId}/grobbilanz`);
}
function handleSaveGroup(values: EstimateGroupFormValues) {
const editingGroup = groupDialog?.group ?? null;
handleChange((current) =>
editingGroup
? updateEstimateGroup(current, editingGroup.id, values)
: addEstimateGroup(current, values).balance
);
setGroupDialog(null);
}
function handleSelectGzf(value: number) {
if (gzfTarget) {
handleChange((current) =>
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
simultaneityFactor: value,
})
);
}
setGzfTarget(null);
}
async function handleOpenProjectDevice(groupId: string, rowId: string) {
setError(null);
setNotice(null);
setIsPreparingProjectDevice(true);
try {
if (!(await saveNow())) {
setError(
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor ein Projektgerät angelegt werden kann."
);
return;
}
const current = draftRef.current;
const group = current?.groups.find((entry) => entry.id === groupId);
const row = group?.rows.find((entry) => entry.id === rowId);
if (!group || !row) {
setError("Die gewählte Grobbilanz-Zeile ist nicht mehr vorhanden.");
return;
}
const existingDevices = await listProjectDevices(projectId);
setProjectDevices(existingDevices);
setProjectDeviceDialog({ group, row });
} catch (err) {
setError(
errorMessage(err, "Der Projektgeräte-Dialog konnte nicht vorbereitet werden.")
);
} finally {
setIsPreparingProjectDevice(false);
}
}
async function handleCreateProjectDevice(input: CreateProjectDeviceInput) {
const revision = revisionRef.current;
if (!projectDeviceDialog || revision === null) return;
setError(null);
setIsSavingProjectDevice(true);
try {
if (!(await saveNow())) {
setError(
"Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor das Projektgerät angelegt werden kann."
);
return;
}
const currentRevision = revisionRef.current;
if (currentRevision === null) return;
const result = await createProjectDevice(projectId, input, currentRevision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
setProjectDevices((current) => [...current, result.projectDevice]);
setProjectDeviceDialog(null);
setNotice(`Projektgerät „${result.projectDevice.displayName}“ wurde angelegt.`);
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setError(
"Der Projektstand hat sich zwischenzeitlich geändert. Der aktuelle Stand wurde geladen; bitte das Projektgerät erneut speichern."
);
} else {
setError(errorMessage(err, "Das Projektgerät konnte nicht angelegt werden."));
}
} finally {
setIsSavingProjectDevice(false);
}
}
const isEditingDisabled =
!draft || isHistoryBusy || isPreparingProjectDevice || isSavingProjectDevice;
const invalidCount = invalidFieldKeys.size;
const listHref = `/projects/${projectId}/grobbilanz`;
return (
<main className="container-fluid py-4 estimate-print-root">
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
<div className="page-header estimate-page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Übersicht der Stände"
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
href={listHref}
onClick={(event) => void handleBackClick(event)}
title="Zurück zur Übersicht der Stände"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
<p className="text-secondary mb-0 estimate-no-print">
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
</p>
</div>
</div>
<div className="estimate-toolbar estimate-no-print">
<SaveStatusBadge
error={saveError}
invalidCount={invalidCount}
onRetry={() => void saveNow()}
status={saveStatus}
/>
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
<button
aria-label="Rückgängig"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.undoDepth === 0}
onClick={() => void handleHistory("undo")}
title="Rückgängig (letzte Änderung im Projekt)"
type="button"
>
<Undo2 aria-hidden="true" size={16} />
</button>
<button
aria-label="Wiederholen"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.redoDepth === 0}
onClick={() => void handleHistory("redo")}
title="Wiederholen (im Projekt)"
type="button"
>
<Redo2 aria-hidden="true" size={16} />
</button>
</div>
<button
aria-label="Drucken"
className="btn btn-sm btn-outline-secondary"
disabled={!draft}
onClick={() => window.print()}
title="Drucken"
type="button"
>
<Printer aria-hidden="true" className="me-1" size={16} />
Drucken
</button>
</div>
</div>
{notice ? (
<div className="alert alert-info estimate-no-print" role="status">
{notice}
</div>
) : null}
{error ? (
<div className="alert alert-warning estimate-no-print" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanz wird geladen …
</p>
) : null}
{isMissing ? (
<div className="alert alert-warning" role="alert">
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
rückgängig gemacht wurde.{" "}
<Link href={listHref}>Zur Übersicht der Stände</Link>
</div>
) : null}
{draft && project ? (
<>
<section className="card shadow-sm mb-3 estimate-sheet-card">
<div className="card-body">
<div className="estimate-header-fields">
<div className="estimate-header-field">
<span className="estimate-header-label">Projekt</span>
<span className="estimate-header-value">{project.name}</span>
</div>
<label className="estimate-header-field is-wide">
<span className="estimate-header-label">Stand</span>
<DraftInput
ariaLabel="Bezeichnung des Stands"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
format={(value: string) => value}
maxLength={estimateTextLimits.balanceName}
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
onValidityChange={(isValid) =>
handleValidityChange("balance:name", isValid)
}
parse={(text) =>
text.trim()
? { ok: true, value: text }
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
}
value={draft.name}
/>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">LPH</span>
<input
aria-label="Leistungsphase"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
maxLength={estimateTextLimits.workStage}
onChange={(event) =>
handleChange((current) => ({
...current,
workStage: event.target.value,
}))
}
placeholder="z. B. LPH 3"
type="text"
value={draft.workStage ?? ""}
/>
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">Datum</span>
<input
aria-label="Datum"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
onChange={(event) =>
handleChange((current) => ({
...current,
dateIso: event.target.value || null,
}))
}
type="date"
value={draft.dateIso ?? ""}
/>
<span className="estimate-print-value">
{formatIsoDateGerman(draft.dateIso)}
</span>
</label>
</div>
<EstimateBalanceSheet
balance={draft}
disabled={isEditingDisabled}
onAddGroup={() => setGroupDialog({ group: null })}
onChange={handleChange}
onEditGroup={(group) => setGroupDialog({ group })}
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
onTakeOverRow={(groupId, rowId) =>
void handleOpenProjectDevice(groupId, rowId)
}
onValidityChange={handleValidityChange}
threePhaseVoltageV={project.threePhaseVoltageV}
/>
</div>
</section>
<section
className={
isComparisonOpen
? "card shadow-sm estimate-comparison-card"
: "card shadow-sm estimate-comparison-card estimate-no-print"
}
>
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<button
aria-controls="estimate-comparison-body"
aria-expanded={isComparisonOpen}
className="estimate-collapse-toggle"
onClick={() => setIsComparisonOpen((open) => !open)}
type="button"
>
{isComparisonOpen ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
Vergleich mit detaillierter Bilanz
</button>
{isComparisonOpen ? (
<button
aria-label="Vergleich neu laden"
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
onClick={() => void reloadComparison()}
title="Vergleich neu laden"
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
) : null}
</div>
{isComparisonOpen ? (
<div className="card-body" id="estimate-comparison-body">
{comparisonError ? (
<div className="alert alert-warning" role="alert">
{comparisonError}
</div>
) : null}
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
<p className="text-secondary small mt-2 mb-0">
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
Verteiler-GZF.
</p>
</div>
) : null}
</section>
</>
) : null}
{groupDialog ? (
<EstimateGroupModal
group={groupDialog.group}
onClose={() => setGroupDialog(null)}
onSave={handleSaveGroup}
/>
) : null}
{gzfTarget ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
{projectDeviceDialog ? (
<ProjectDeviceModal
context={
<EstimateProjectDeviceContext
balanceName={draft?.name ?? ""}
group={projectDeviceDialog.group}
row={projectDeviceDialog.row}
/>
}
existingDevices={projectDevices}
globalDevices={[]}
initialValues={createEstimateProjectDeviceInitialValues(
projectDeviceDialog.group,
projectDeviceDialog.row
)}
isSaving={isSavingProjectDevice}
onClose={() => setProjectDeviceDialog(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
) : null}
</main>
);
}
function EstimateProjectDeviceContext({
balanceName,
group,
row,
}: {
balanceName: string;
group: EstimateGroupSnapshot;
row: EstimateRowSnapshot;
}) {
const originalUnit = row.unit === "area" ? "m²" : "St.";
const powerUnit = row.unit === "area" ? "W/m²" : "W";
return (
<>
<div>
<strong>Herkunft:</strong> Stand „{balanceName}“, Kostengruppe{" "}
{group.costGroup || "ohne Nummer"}.
</div>
<div>
Originalwerte: {formatGermanNumber(row.quantity, 0, 3)} {originalUnit} ×{" "}
{formatGermanNumber(row.powerPerUnitW, 0, 3)} {powerUnit}, GZF{" "}
{formatGermanNumber(row.simultaneityFactor, 0, 3)}, cos φ{" "}
{formatGermanNumber(row.cosPhi, 0, 3)}.
</div>
<div className="mt-1">
{row.unit === "area"
? "Das Projektgerät dient als Vorlage. Für Flächenwerte sind Anzahl und Leistung je Stück manuell einzutragen."
: "Das Projektgerät ist eine Vorlage mit Anzahl 1."}{" "}
Kategorie bitte prüfen.
{row.safetySupply
? " SV ist nur ein Hinweis; die Versorgungsart wird an der Verteilung festgelegt."
: ""}
</div>
</>
);
}
function SaveStatusBadge({
error,
invalidCount,
onRetry,
status,
}: {
error: string | null;
invalidCount: number;
onRetry: () => void;
status: SaveStatus;
}) {
const label =
status === "saving"
? "Wird gespeichert …"
: status === "pending"
? "Ungespeicherte Änderungen"
: status === "error"
? "Nicht gespeichert – Fehler"
: status === "invalid"
? "Nicht gespeichert – ungültige Eingaben"
: "Gespeichert";
const tone =
status === "error" || status === "invalid"
? "is-error"
: status === "saved"
? "is-saved"
: "is-busy";
return (
<div className="estimate-save-status" role="status" aria-live="polite">
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
{label}
</span>
{invalidCount ? (
<span className="estimate-save-hint">
{invalidCount === 1
? "1 ungültige Eingabe wird nicht gespeichert"
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
</span>
) : null}
{status === "error" ? (
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
Erneut versuchen
</button>
) : null}
</div>
);
}

View file

@ -0,0 +1,292 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
import {
createEstimateBalance,
deleteEstimateBalance,
getProject,
listEstimateBalances,
} from "../../../../frontend/utils/api";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
ProjectCommandResultDto,
ProjectDto,
} from "../../../../frontend/types";
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
import {
EstimateBalanceCreateModal,
type EstimateBalanceCreateDefaults,
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
import {
formatIsoDateGerman,
formatKw,
} from "../../../../frontend/estimate-balance/estimate-balance-format";
function countRows(balance: EstimateBalanceDto) {
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
}
export default function EstimateBalanceListPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
const [createDefaults, setCreateDefaults] =
useState<EstimateBalanceCreateDefaults | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reload = useCallback(async () => {
const [currentProject, loadedBalances] = await Promise.all([
getProject(projectId),
listEstimateBalances(projectId),
]);
setProject(currentProject);
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reload()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reload]);
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<TResult | null> {
if (!project) {
return null;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current ? { ...current, currentRevision: result.history.currentRevision } : current
);
await reload();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh revision and list so a stale state does not block the next
// attempt; the command error stays visible.
await reload().catch(() => undefined);
return null;
} finally {
setIsSaving(false);
}
}
async function handleCreate(input: CreateEstimateBalanceInput) {
const result = await runCommand(
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
"Stand konnte nicht angelegt werden."
);
if (result) {
setCreateDefaults(null);
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
}
}
async function handleDelete(balance: EstimateBalanceDto) {
if (
!window.confirm(
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
"Stand konnte nicht gelöscht werden."
);
}
const isBusy = isSaving || !project;
const voltage = project?.threePhaseVoltageV ?? 400;
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz</h1>
<p className="text-secondary mb-0">
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
</p>
</div>
</div>
<button
className="btn btn-primary"
disabled={isBusy}
onClick={() => setCreateDefaults({})}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={16} />
Neuer Stand
</button>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanzen werden geladen …
</p>
) : null}
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<h2 className="structure-column-title" id="estimate-list-heading">
Stände
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
</h2>
</div>
{balances.length ? (
<div className="table-responsive">
<table className="table table-hover align-middle mb-0 estimate-list-table">
<thead>
<tr>
<th>Stand</th>
<th>LPH</th>
<th>Datum</th>
<th className="text-end">AV kW</th>
<th className="text-end">AV kVA</th>
<th className="text-end">SV kW</th>
<th className="text-end">SV kVA</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => {
const result = calculateEstimateBalance(balance, voltage);
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
const rowCount = countRows(balance);
return (
<tr key={balance.id}>
<td>
<Link className="fw-semibold" href={href}>
{balance.name}
</Link>
<div className="text-secondary small">
{balance.groups.length} Kostengruppen ·{" "}
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
</div>
</td>
<td>{balance.workStage ?? "–"}</td>
<td>{formatIsoDateGerman(balance.dateIso)}</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kva)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kva)}
</td>
<td className="text-end">
<div className="d-inline-flex gap-1">
<Link
aria-label={`Stand „${balance.name}“ öffnen`}
className="btn btn-sm btn-outline-primary"
href={href}
title="Öffnen"
>
<FolderOpen aria-hidden="true" className="me-1" size={14} />
Öffnen
</Link>
<button
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isBusy}
onClick={() =>
setCreateDefaults({
name: createCopyName(balance.name),
workStage: balance.workStage,
template: "copy",
copyFromBalanceId: balance.id,
})
}
title="Als neuen Stand kopieren"
type="button"
>
<Copy aria-hidden="true" size={14} />
</button>
<button
aria-label={`Stand „${balance.name}“ löschen`}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isBusy}
onClick={() => void handleDelete(balance)}
title="Stand löschen"
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-secondary small px-3 py-2 mb-0">
Leistungen jeweils inklusive Reserve.
</p>
</div>
) : !isLoading ? (
<div className="card-body">
<p className="structure-empty mb-0">
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
Basis der Vorlage KG 400 anlegen.
</p>
</div>
) : null}
</section>
{createDefaults ? (
<EstimateBalanceCreateModal
balances={balances}
defaults={createDefaults}
isSaving={isSaving}
onClose={() => setCreateDefaults(null)}
onCreate={(input) => void handleCreate(input)}
/>
) : null}
</main>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,359 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import "../../../../frontend/print/print-document.css";
import {
deleteDocumentLogo,
documentLogoUrl,
getProjectCircuitTrees,
getDocumentBranding,
getProject,
listDistributionBoards,
listFloors,
listProjectSnapshots,
updateOfficeProfile,
uploadDocumentLogo,
} from "../../../../frontend/utils/api";
import type {
DocumentLogoMetadataDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../../../../frontend/types";
import { FormModal } from "../../../../frontend/components/form-modal";
import {
officeProfileFieldLabels,
officeProfileFieldMaxLengths,
} from "../../../../shared/constants/project-cover";
import { PrintDocument } from "../../../../frontend/print/print-document";
import {
assertPrintRevisionConsistency,
buildBoardHierarchy,
summarizePrintBoard,
type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../../../../shared/constants/document-branding";
type PlanFormat = "a4" | "a3";
interface PrintData {
project: ProjectDto;
summaries: PrintBoardSummary[];
namedSnapshots: ProjectSnapshotMetadataDto[];
}
const boardSortCollator = new Intl.Collator("de", { numeric: true });
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
function pageRules(project: ProjectDto, stand: string, format: PlanFormat) {
const marginBoxes = `
@bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }`;
return `
@page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} }
@page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`;
}
export default function ProjectPrintPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [data, setData] = useState<PrintData | null>(null);
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
const [office, setOffice] = useState<OfficeProfileDto>({
officeName: null,
officeAddress: null,
});
const [officeDraft, setOfficeDraft] = useState<{
officeName: string;
officeAddress: string;
} | null>(null);
const [format, setFormat] = useState<PlanFormat>("a4");
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [printedAt] = useState(() => new Date());
useEffect(() => {
if (!projectId) {
return;
}
let cancelled = false;
async function load() {
const [project, boards, projectTrees, floors, snapshots, branding] =
await Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
getProjectCircuitTrees(projectId),
listFloors(projectId),
listProjectSnapshots(projectId),
getDocumentBranding(),
]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const treeByBoardId = new Map(
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
);
const summaries =
[...boards]
.sort(
(left, right) =>
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
boardSortCollator.compare(left.name, right.name)
)
.flatMap((board) => {
const tree = treeByBoardId.get(board.id);
return tree ? [{ board, tree }] : [];
})
.map(({ board, tree }) =>
summarizePrintBoard({
board,
circuitListId: tree.circuitListId,
tree,
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
})
);
const finalProject = await getProject(projectId);
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
if (cancelled) {
return;
}
setData({
project,
summaries,
namedSnapshots: snapshots
.filter((snapshot) => snapshot.kind === "named")
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
});
setLogo(branding.logo);
setOffice(branding.office);
document.title = `Leistungsbilanz – ${project.name}`;
}
load().catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden.");
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const entries = useMemo(
() => (data ? buildBoardHierarchy(data.summaries) : []),
[data]
);
const stand = useMemo(
() =>
new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(printedAt),
[printedAt]
);
const logoSrc = logo
? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}`
: null;
async function handleLogoUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) {
setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen.");
return;
}
if (file.size > maxDocumentLogoBytes) {
setError("Das Logo darf höchstens 2 MB groß sein.");
return;
}
setIsBusy(true);
setError(null);
try {
const result = await uploadDocumentLogo(file);
setLogo(result.logo);
setMessage("Logo hinterlegt. Es gilt für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!officeDraft) {
return;
}
setIsBusy(true);
setError(null);
try {
const result = await updateOfficeProfile({
officeName: officeDraft.officeName.trim() || null,
officeAddress: officeDraft.officeAddress.trim() || null,
});
setOffice(result.office);
setOfficeDraft(null);
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleLogoDelete() {
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
return;
}
setIsBusy(true);
setError(null);
try {
await deleteDocumentLogo();
setLogo(null);
setMessage("Logo entfernt.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden.");
} finally {
setIsBusy(false);
}
}
return (
// Paper output: always light, independent of the app's dark mode.
<div
className={format === "a3" ? "print-view pd-a3" : "print-view"}
data-bs-theme="light"
>
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
<div className="pd-toolbar">
<span className="pd-toolbar-title">
Seitenansicht{data ? ` · ${data.project.name}` : ""}
</span>
{message ? <span className="pd-toolbar-message">{message}</span> : null}
{error ? <span className="pd-toolbar-error">{error}</span> : null}
<label className="d-flex align-items-center gap-2">
Planformat
<select
className="form-select form-select-sm w-auto"
onChange={(event) => setFormat(event.target.value as PlanFormat)}
value={format}
>
<option value="a4">A4 quer</option>
<option value="a3">A3 quer</option>
</select>
</label>
<button
className="btn btn-sm btn-outline-primary"
disabled={isBusy}
onClick={() =>
setOfficeDraft({
officeName: office.officeName ?? "",
officeAddress: office.officeAddress ?? "",
})
}
type="button"
>
Büroangaben
</button>
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
<input
accept={documentLogoMimeTypes.join(",")}
disabled={isBusy}
hidden
onChange={(event) => void handleLogoUpload(event)}
type="file"
/>
</label>
{logo ? (
<button
className="btn btn-sm btn-outline-secondary"
disabled={isBusy}
onClick={() => void handleLogoDelete()}
type="button"
>
Logo entfernen
</button>
) : null}
<button
className="btn btn-sm btn-primary"
disabled={!data}
onClick={() => window.print()}
type="button"
>
Als PDF speichern / Drucken
</button>
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
Zum Projekt
</Link>
</div>
{officeDraft ? (
<FormModal
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
isSaving={isBusy}
onClose={() => setOfficeDraft(null)}
onSubmit={handleOfficeSave}
submitLabel="Büroangaben speichern"
title="Büroangaben"
>
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="office-name">
{officeProfileFieldLabels.officeName}
</label>
<input
autoFocus
className="form-control"
id="office-name"
maxLength={officeProfileFieldMaxLengths.officeName}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
}
value={officeDraft.officeName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="office-address">
{officeProfileFieldLabels.officeAddress}
</label>
<textarea
className="form-control"
id="office-address"
maxLength={officeProfileFieldMaxLengths.officeAddress}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
}
placeholder={"Straße Nr.\nPLZ Ort"}
rows={3}
value={officeDraft.officeAddress}
/>
</div>
</div>
</FormModal>
) : null}
{data ? (
<PrintDocument
entries={entries}
logoSrc={logoSrc}
namedSnapshots={data.namedSnapshots}
office={office}
printedAt={printedAt}
project={data.project}
/>
) : error ? null : (
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
)}
</div>
);
}

View file

@ -0,0 +1,54 @@
"use client";
import { ArrowLeft, FileSpreadsheet, Settings, Upload } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard";
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
import type { ExternalInitialImportStatusDto, ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
export default function RevitPage() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<ProjectDto | null>(null);
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [followUpOpen, setFollowUpOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([getProject(projectId), getExternalModelConfiguration(projectId), getExternalInitialImportStatus(projectId)])
.then(([loadedProject, configuration, status]) => { setProject(loadedProject); setSnapshot(configuration.configuration); setImportStatus(status); })
.catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden."));
}, [projectId]);
async function save(configuration: ExternalModelConfiguration) {
if (!project) return;
setSaving(true); setError(null);
try {
const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration);
setSnapshot(result.configuration);
setProject({ ...project, currentRevision: result.history.currentRevision });
setSettingsOpen(false);
} catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
finally { setSaving(false); }
}
return <main className="container py-4">
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
{followUpOpen && project && snapshot ? <RevitFollowUpImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setFollowUpOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setFollowUpOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
</main>;
}

View file

@ -0,0 +1,855 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
import {
createBuilding,
createFloor,
createRoom,
deleteBuilding,
deleteFloor,
deleteRoom,
getProject,
listBuildings,
listFloors,
listRooms,
updateBuilding,
updateFloor,
updateRoom,
} from "../../../../frontend/utils/api";
import type {
BuildingDto,
CreateRoomInput,
FloorDto,
ProjectCommandResultDto,
ProjectDto,
RoomDto,
} from "../../../../frontend/types";
import {
ALL_SELECTION,
UNASSIGNED_BUILDING_LABEL,
UNASSIGNED_FLOOR_LABEL,
UNASSIGNED_SELECTION,
countFloorsByBuilding,
countRoomsByFloor,
defaultBuildingIdForNewFloor,
defaultFloorIdForNewRoom,
describeRoomLocation,
filterFloorsByBuilding,
filterRooms,
groupFloorsByBuilding,
hasFloorsWithoutBuilding,
hasRoomsWithoutFloor,
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
summarizeRoomAreas,
type RoomAreaSummary,
type StructureSelection,
} from "../../../../frontend/utils/building-structure";
import { FormModal } from "../../../../frontend/components/form-modal";
import { RoomModal } from "../../../../frontend/components/room-modal";
import { formatRoomArea } from "../../../../frontend/utils/room-area";
const BUILDING_HAS_FLOORS_MESSAGE =
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
type StructureDialog =
| { kind: "building"; building: BuildingDto | null }
| { kind: "floor"; floor: FloorDto | null }
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
function formatCount(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`;
}
function formatAreaSummary(summary: RoomAreaSummary) {
return `${formatRoomArea(summary.areaM2)} m² · ${formatCount(
summary.missingAreaCount,
"Raum ohne Fläche",
"Räume ohne Fläche"
)}`;
}
function formatStructureMeta(
count: number,
singular: string,
plural: string,
summary: RoomAreaSummary
) {
return `${formatCount(count, singular, plural)} · ${formatAreaSummary(summary)}`;
}
function compareRooms(left: RoomDto, right: RoomDto) {
return (
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
left.roomName.localeCompare(right.roomName, "de")
);
}
interface StructureColumnProps {
addLabel: string;
children: ReactNode;
count: number;
headingId: string;
isAddDisabled: boolean;
onAdd: () => void;
title: string;
}
function StructureColumn({
addLabel,
children,
count,
headingId,
isAddDisabled,
onAdd,
title,
}: StructureColumnProps) {
return (
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<h2 className="structure-column-title" id={headingId}>
{title}
<span className="badge text-bg-secondary ms-2">{count}</span>
</h2>
<button
aria-label={addLabel}
className="btn btn-sm btn-outline-primary structure-icon-button"
disabled={isAddDisabled}
onClick={onAdd}
title={addLabel}
type="button"
>
<Plus aria-hidden="true" size={16} />
</button>
</div>
<ul className="list-group list-group-flush structure-list">{children}</ul>
</section>
);
}
interface StructureEntryProps {
actions?: ReactNode;
hint?: string;
isSelected?: boolean;
label: ReactNode;
meta?: string;
onSelect?: () => void;
}
function StructureEntry({
actions,
hint,
isSelected = false,
label,
meta,
onSelect,
}: StructureEntryProps) {
const content = (
<>
<span className="structure-entry-text">
<span className="structure-entry-label">{label}</span>
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
</span>
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
</>
);
return (
<li
className={
isSelected
? "list-group-item structure-entry is-selected"
: "list-group-item structure-entry"
}
>
{onSelect ? (
<button
aria-pressed={isSelected}
className="structure-entry-main"
onClick={onSelect}
type="button"
>
{content}
</button>
) : (
<div className="structure-entry-main">{content}</div>
)}
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
</li>
);
}
interface EntryActionsProps {
deleteLabel: string;
editLabel: string;
isDisabled: boolean;
onDelete: () => void;
onEdit: () => void;
}
function EntryActions({
deleteLabel,
editLabel,
isDisabled,
onDelete,
onEdit,
}: EntryActionsProps) {
return (
<>
<button
aria-label={editLabel}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isDisabled}
onClick={onEdit}
title={editLabel}
type="button"
>
<Pencil aria-hidden="true" size={14} />
</button>
<button
aria-label={deleteLabel}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isDisabled}
onClick={onDelete}
title={deleteLabel}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</>
);
}
export default function BuildingStructurePage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [project, setProject] = useState<ProjectDto | null>(null);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [buildingSelection, setBuildingSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [floorSelection, setFloorSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [dialog, setDialog] = useState<StructureDialog | null>(null);
const [dialogName, setDialogName] = useState("");
const [dialogBuildingId, setDialogBuildingId] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reloadStructure = useCallback(async () => {
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
await Promise.all([
getProject(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
]);
setProject(currentProject);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reloadStructure()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Gebäudestruktur konnte nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reloadStructure]);
const effectiveBuildingSelection = normalizeBuildingSelection(
buildingSelection,
floors,
buildings
);
const effectiveFloorSelection = normalizeFloorSelection(
floorSelection,
rooms,
floors,
buildings,
effectiveBuildingSelection
);
const visibleFloors = useMemo(
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
[buildings, effectiveBuildingSelection, floors]
);
const visibleFloorGroups = useMemo(
() =>
effectiveBuildingSelection === ALL_SELECTION && buildings.length
? groupFloorsByBuilding(visibleFloors, buildings)
: [{ buildingId: null, label: "", floors: visibleFloors }],
[buildings, effectiveBuildingSelection, visibleFloors]
);
const visibleRooms = useMemo(
() =>
filterRooms(
rooms,
floors,
buildings,
effectiveBuildingSelection,
effectiveFloorSelection
).sort(compareRooms),
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
);
const roomsInBuildingSelection = useMemo(
() =>
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
.length,
[buildings, effectiveBuildingSelection, floors, rooms]
);
const floorCountByBuilding = useMemo(
() => countFloorsByBuilding(floors, buildings),
[buildings, floors]
);
const roomCountByFloor = useMemo(
() => countRoomsByFloor(rooms, floors),
[floors, rooms]
);
const areaSummaries = useMemo(
() => summarizeRoomAreas(rooms, floors, buildings),
[buildings, floors, rooms]
);
const emptyAreaSummary: RoomAreaSummary = {
areaM2: 0,
roomCount: 0,
missingAreaCount: 0,
};
const selectedBuildingAreaSummary =
effectiveBuildingSelection === ALL_SELECTION
? areaSummaries.total
: areaSummaries.byBuilding.get(
effectiveBuildingSelection === UNASSIGNED_SELECTION
? null
: effectiveBuildingSelection
) ?? emptyAreaSummary;
const showUnassignedBuilding =
hasFloorsWithoutBuilding(floors, buildings) || hasRoomsWithoutFloor(rooms, floors);
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
rooms,
floors,
effectiveBuildingSelection
);
const isBusy = isSaving || !project;
function selectBuilding(selection: StructureSelection) {
setBuildingSelection(selection);
setFloorSelection(ALL_SELECTION);
}
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<boolean> {
if (!project) {
return false;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current
? { ...current, currentRevision: result.history.currentRevision }
: current
);
await reloadStructure();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh the revision and lists so a stale state does not block the
// next attempt; the command error stays visible.
await reloadStructure().catch(() => undefined);
return false;
} finally {
setIsSaving(false);
}
}
function openBuildingDialog(building: BuildingDto | null) {
setDialogName(building?.name ?? "");
setDialog({ kind: "building", building });
}
function openFloorDialog(floor: FloorDto | null) {
setDialogName(floor?.name ?? "");
setDialogBuildingId(
floor
? floor.buildingId ?? ""
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
);
setDialog({ kind: "floor", floor });
}
function openRoomDialog(room: RoomDto | null) {
setDialog({
kind: "room",
room,
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
});
}
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "building" || !dialogName.trim()) {
return;
}
const editingBuilding = dialog.building;
const name = dialogName.trim();
// An unchanged dialog is not a command; the backend rejects no-op updates.
if (editingBuilding && editingBuilding.name === name) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingBuilding
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
: createBuilding(projectId, { name }, expectedRevision),
"Gebäude konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "floor" || !dialogName.trim()) {
return;
}
const editingFloor = dialog.floor;
const input = {
name: dialogName.trim(),
buildingId: dialogBuildingId || null,
};
if (
editingFloor &&
editingFloor.name === input.name &&
editingFloor.buildingId === input.buildingId
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingFloor
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
: createFloor(projectId, input, expectedRevision),
"Etage konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveRoom(input: CreateRoomInput) {
if (dialog?.kind !== "room") {
return;
}
const editingRoom = dialog.room;
if (
editingRoom &&
editingRoom.roomNumber === input.roomNumber.trim() &&
editingRoom.roomName === input.roomName.trim() &&
editingRoom.floorId === (input.floorId || null) &&
editingRoom.areaM2 === input.areaM2
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingRoom
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
: createRoom(projectId, input, expectedRevision),
"Raum konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleDeleteBuilding(building: BuildingDto) {
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
setError(BUILDING_HAS_FLOORS_MESSAGE);
return;
}
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) =>
deleteBuilding(projectId, building.id, expectedRevision),
"Gebäude konnte nicht gelöscht werden."
);
}
async function handleDeleteFloor(floor: FloorDto) {
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
"Etage konnte nicht gelöscht werden."
);
}
async function handleDeleteRoom(room: RoomDto) {
if (
!window.confirm(
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
"Raum konnte nicht gelöscht werden."
);
}
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Gebäudestruktur</h1>
<p className="text-secondary mb-0">
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
</p>
</div>
</div>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Gebäudestruktur wird geladen …
</p>
) : null}
<div className="structure-columns">
<StructureColumn
addLabel="Gebäude hinzufügen"
count={buildings.length}
headingId="structure-buildings-heading"
isAddDisabled={isBusy}
onAdd={() => openBuildingDialog(null)}
title="Gebäude"
>
<StructureEntry
isSelected={effectiveBuildingSelection === ALL_SELECTION}
label="Alle"
meta={formatStructureMeta(
floors.length,
"Etage",
"Etagen",
areaSummaries.total
)}
onSelect={() => selectBuilding(ALL_SELECTION)}
/>
{buildings.map((building) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Gebäude „${building.name}“ löschen`}
editLabel={`Gebäude „${building.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteBuilding(building)}
onEdit={() => openBuildingDialog(building)}
/>
}
isSelected={effectiveBuildingSelection === building.id}
key={building.id}
label={building.name}
meta={formatStructureMeta(
floorCountByBuilding.get(building.id) ?? 0,
"Etage",
"Etagen",
areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary
)}
onSelect={() => selectBuilding(building.id)}
/>
))}
{showUnassignedBuilding ? (
<StructureEntry
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
meta={formatStructureMeta(
floorCountByBuilding.get(null) ?? 0,
"Etage",
"Etagen",
areaSummaries.byBuilding.get(null) ?? emptyAreaSummary
)}
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
/>
) : null}
{!buildings.length && !isLoading ? (
<li className="list-group-item structure-empty">
Noch keine Gebäude vorhanden.
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Etage hinzufügen"
count={visibleFloors.length}
headingId="structure-floors-heading"
isAddDisabled={isBusy}
onAdd={() => openFloorDialog(null)}
title="Etagen"
>
<StructureEntry
isSelected={effectiveFloorSelection === ALL_SELECTION}
label="Alle"
meta={formatStructureMeta(
roomsInBuildingSelection,
"Raum",
"Räume",
selectedBuildingAreaSummary
)}
onSelect={() => setFloorSelection(ALL_SELECTION)}
/>
{visibleFloorGroups.map((group) => (
<FloorGroupEntries
key={group.buildingId ?? "unassigned"}
label={group.label}
>
{group.floors.map((floor) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Etage „${floor.name}“ löschen`}
editLabel={`Etage „${floor.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteFloor(floor)}
onEdit={() => openFloorDialog(floor)}
/>
}
isSelected={effectiveFloorSelection === floor.id}
key={floor.id}
label={floor.name}
meta={formatStructureMeta(
roomCountByFloor.get(floor.id) ?? 0,
"Raum",
"Räume",
areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary
)}
onSelect={() => setFloorSelection(floor.id)}
/>
))}
</FloorGroupEntries>
))}
{showUnassignedFloor ? (
<StructureEntry
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
meta={formatStructureMeta(
roomCountByFloor.get(null) ?? 0,
"Raum",
"Räume",
areaSummaries.byFloor.get(null) ?? emptyAreaSummary
)}
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
/>
) : null}
{!visibleFloors.length && !isLoading ? (
<li className="list-group-item structure-empty">
{floors.length
? "Keine Etagen in dieser Auswahl."
: "Noch keine Etagen vorhanden."}
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Raum hinzufügen"
count={visibleRooms.length}
headingId="structure-rooms-heading"
isAddDisabled={isBusy}
onAdd={() => openRoomDialog(null)}
title="Räume"
>
{visibleRooms.map((room) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteRoom(room)}
onEdit={() => openRoomDialog(room)}
/>
}
hint={
effectiveFloorSelection === ALL_SELECTION
? describeRoomLocation(room, floors, buildings)
: undefined
}
key={room.id}
label={
<>
<span className="structure-room-number">{room.roomNumber}</span>{" "}
{room.roomName}
</>
}
meta={
room.areaM2 === null
? "Fläche fehlt"
: `${formatRoomArea(room.areaM2)} m²`
}
/>
))}
{!visibleRooms.length && !isLoading ? (
<li className="list-group-item structure-empty">
{rooms.length
? "Keine Räume in dieser Auswahl."
: "Noch keine Räume vorhanden."}
</li>
) : null}
</StructureColumn>
</div>
{dialog?.kind === "building" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveBuilding}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
>
<label className="form-label" htmlFor="building-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="building-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. Haus A"
required
value={dialogName}
/>
</FormModal>
) : null}
{dialog?.kind === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveFloor}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. EG"
required
value={dialogName}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-building">
Gebäude
</label>
<select
className="form-select"
id="floor-building"
onChange={(event) => setDialogBuildingId(event.target.value)}
value={dialogBuildingId}
>
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
{buildings.map((building) => (
<option key={building.id} value={building.id}>
{building.name}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{dialog?.kind === "room" ? (
<RoomModal
buildings={buildings}
floors={floors}
initialRoom={dialog.room ?? undefined}
initialValues={
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
}
isSaving={isSaving}
onClose={() => setDialog(null)}
onSave={handleSaveRoom}
/>
) : null}
</main>
);
}
function FloorGroupEntries({
children,
label,
}: {
children: ReactNode;
label: string;
}) {
return (
<>
{label ? (
<li className="list-group-item structure-group-label">
{label}
</li>
) : null}
{children}
</>
);
}

View file

@ -0,0 +1,705 @@
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/;
// the module is an optional calculator attached to the circuit list and never
// writes project data itself (see docs/cable-sizing-module.md).
//
// Reference data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with
// Berichtigung 1:2023-10. The table-by-table scope and the separate sources
// required for voltage drop and protection coordination are documented in
// docs/cable-sizing-module.md.
// -- Reference installation methods (DIN VDE 0298-4, Table 2) ---------------
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const;
export type LayingMethod = (typeof LAYING_METHODS)[number];
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
A1: "air",
A2: "air",
B1: "air",
B2: "air",
C: "air",
E: "air",
D: "ground",
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
A1: "A1 – Einzeladern im Rohr in wärmegedämmter Wand",
A2: "A2 – Mehradrige Leitung im Rohr in wärmegedämmter Wand",
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
D: "D – Mehradriges Kabel im Rohr oder Kanal im Erdreich",
E: "E – Mehradrige Leitung frei in Luft",
};
// -- Insulation / conductor material ----------------------------------------
export const INSULATION_MATERIALS = ["pvc", "xlpe"] as const;
export type InsulationMaterial = (typeof INSULATION_MATERIALS)[number];
export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
pvc: "PVC (70 °C), z. B. NYM, NYY",
xlpe: "VPE/XLPE (90 °C), z. B. N2XH, NHXH",
};
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// -- Harmonic neutral loading (DIN VDE 0298-4, Annex B, three-phase only) ---
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
"none",
"15to33Percent",
"over33Percent",
] as const;
export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number];
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
over33Percent: "3. Harmonische > 33 % – ohne Neutralleiterstrom keine Empfehlung",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
// -- Current-carrying capacity tables ----------------------------------------
export const CROSS_SECTIONS_MM2 = [
1.5, 2.5, 4, 6, 10, 16, 25, 35, 50, 70, 95, 120, 150, 185, 240,
] as const;
type CoreCount = 2 | 3;
type CapacityRow = readonly (number | null)[];
type CapacityTable = Record<LayingMethod, Record<CoreCount, CapacityRow>>;
const n = null;
/** Iz in A at 30 °C air / 20 °C ground, soil resistivity 2.5 K·m/W. */
export const CURRENT_CAPACITY_A: Record<
InsulationMaterial,
Record<ConductorMaterial, CapacityTable>
> = {
pvc: {
copper: {
// DIN VDE 0298-4, tables 3 and 4.
A1: {
2: [15.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 321],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
},
A2: {
2: [15.5, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
},
B1: {
2: [17.5, 24, 32, 41, 57, 76, 101, 125, 151, 192, 232, 269, 300, 341, 400],
3: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, 262, 296, 346],
},
B2: {
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 133, 168, 201, 232, 258, 294, 344],
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, 225, 255, 297],
},
C: {
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
},
D: {
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
},
E: {
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
},
},
aluminum: {
// DIN VDE 0298-4 aluminium values; the normalization below removes all
// entries below the first tabulated aluminium size of 25 mm².
A1: {
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
},
A2: {
2: [n, 14.5, 19.5, 25, 33, 44, 58, 71, 86, 108, 130, 150, 172, 195, 229],
3: [n, 13.5, 17.5, 23, 31, 41, 53, 65, 78, 98, 118, 135, 155, 176, 207],
},
B1: {
2: [n, 18.5, 25, 32, 44, 60, 79, 97, 118, 150, 181, 210, 234, 266, 312],
3: [n, 16.5, 22, 28, 39, 53, 70, 86, 104, 133, 161, 186, 204, 230, 269],
},
B2: {
2: [n, 17.5, 24, 30, 41, 54, 71, 86, 104, 131, 157, 181, 201, 230, 269],
3: [n, 15.5, 21, 27, 36, 48, 62, 77, 92, 116, 139, 160, 176, 199, 232],
},
C: {
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
},
D: {
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
},
E: {
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
},
},
},
xlpe: {
copper: {
// B.52.3 / B.52.5; E: B.52.12 multi-core.
A1: {
2: [19, 26, 35, 45, 61, 81, 106, 131, 158, 200, 241, 278, 318, 362, 424],
3: [17, 23, 31, 40, 54, 73, 95, 117, 141, 179, 216, 249, 285, 324, 380],
},
A2: {
2: [18.5, 25, 33, 42, 57, 76, 99, 121, 145, 183, 220, 253, 290, 329, 386],
3: [16.5, 22, 30, 38, 51, 68, 89, 109, 130, 164, 197, 227, 259, 295, 346],
},
B1: {
2: [23, 31, 42, 54, 75, 100, 133, 164, 198, 253, 306, 354, 393, 449, 528],
3: [20, 28, 37, 48, 66, 88, 117, 144, 175, 222, 269, 312, 342, 384, 450],
},
B2: {
2: [22, 30, 40, 51, 69, 91, 119, 146, 175, 221, 265, 305, 334, 384, 459],
3: [19.5, 26, 35, 44, 60, 80, 105, 128, 154, 194, 233, 268, 300, 340, 398],
},
C: {
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
},
D: {
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
},
E: {
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
},
},
aluminum: {
// B.52.3 / B.52.5 aluminium; E: B.52.13.
A1: {
2: [n, 20, 27, 35, 48, 64, 84, 103, 125, 158, 191, 220, 253, 288, 338],
3: [n, 19, 25, 32, 44, 58, 76, 94, 113, 142, 171, 197, 226, 256, 300],
},
A2: {
2: [n, 19.5, 26, 33, 45, 60, 78, 96, 115, 145, 175, 201, 230, 262, 307],
3: [n, 18, 24, 31, 41, 55, 71, 87, 104, 131, 157, 180, 206, 233, 273],
},
B1: {
2: [n, 25, 33, 43, 59, 79, 105, 130, 157, 200, 242, 281, 307, 351, 412],
3: [n, 22, 29, 38, 52, 71, 93, 116, 140, 179, 217, 251, 267, 300, 351],
},
B2: {
2: [n, 23, 31, 40, 54, 72, 94, 115, 138, 175, 210, 242, 261, 300, 358],
3: [n, 21, 28, 35, 48, 64, 84, 103, 124, 156, 188, 216, 240, 272, 318],
},
C: {
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
},
D: {
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
},
E: {
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
},
},
},
};
// DIN VDE 0298-4 only supplies aluminium current-carrying capacities from
// 25 mm² onward. Smaller values from the former IEC data set must therefore
// never participate in a recommendation.
for (const insulation of INSULATION_MATERIALS) {
for (const method of LAYING_METHODS) {
for (const coreCount of [2, 3] as const) {
const row = CURRENT_CAPACITY_A[insulation].aluminum[method][coreCount] as (number | null)[];
CROSS_SECTIONS_MM2.forEach((crossSection, index) => {
if (crossSection < 25) {
row[index] = null;
}
});
}
}
}
// -- Correction factors ------------------------------------------------------
/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */
export const TEMPERATURE_FACTORS: Record<
"air" | "ground",
Record<InsulationMaterial, Record<number, number>>
> = {
air: {
pvc: { 10: 1.22, 15: 1.17, 20: 1.12, 25: 1.06, 30: 1.0, 35: 0.94, 40: 0.87, 45: 0.79, 50: 0.71, 55: 0.61, 60: 0.5 },
xlpe: {
10: 1.15, 15: 1.12, 20: 1.08, 25: 1.04, 30: 1.0, 35: 0.96, 40: 0.91, 45: 0.87,
50: 0.82, 55: 0.76, 60: 0.71, 65: 0.65, 70: 0.58, 75: 0.5, 80: 0.41,
},
},
ground: {
pvc: { 10: 1.1, 15: 1.05, 20: 1.0, 25: 0.95, 30: 0.89, 35: 0.84, 40: 0.77, 45: 0.71, 50: 0.63, 55: 0.55, 60: 0.45 },
xlpe: {
10: 1.07, 15: 1.04, 20: 1.0, 25: 0.96, 30: 0.93, 35: 0.89, 40: 0.85, 45: 0.8,
50: 0.76, 55: 0.71, 60: 0.65, 65: 0.6, 70: 0.53, 75: 0.46, 80: 0.38,
},
},
};
export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
air: 30,
ground: 20,
};
/**
* Grouping reduction factors according to the corrected references in
* DIN VDE 0298-4 Berichtigung 1:2023-10:
* A1–C use table 22, row 1 (bundled/touching), D uses table 26 for touching
* conduits/ducts in earth. Method E needs details about support, spacing and
* number of trays from table 23, which the current input model does not carry;
* it is therefore supported only for one circuit.
*/
export const GROUPING_FACTORS: Record<"A1_TO_C" | "D" | "E", Record<number, number>> = {
A1_TO_C: {
1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52,
9: 0.5, 10: 0.48, 12: 0.45, 14: 0.43, 16: 0.41, 18: 0.39, 20: 0.38,
},
D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 },
E: { 1: 1.0 },
};
function groupingTableFor(method: LayingMethod) {
return method === "D"
? GROUPING_FACTORS.D
: method === "E"
? GROUPING_FACTORS.E
: GROUPING_FACTORS.A1_TO_C;
}
export function maxGroupingCircuits(method: LayingMethod): number {
return Math.max(...Object.keys(groupingTableFor(method)).map(Number));
}
/**
* Looks up a tabulated factor. Values between tabulated keys use the next
* higher key (the lower, safer factor); values above the table are not
* supported (null) and values below the smallest key use the smallest key
* (whose factor is lower than the true one, i.e. safe).
*/
function lookupConservative(table: Record<number, number>, value: number): { key: number; factor: number } | null {
const keys = Object.keys(table).map(Number).sort((a, b) => a - b);
const key = keys.find((candidate) => candidate >= value);
if (key === undefined) {
return null;
}
return { key, factor: table[key] };
}
/** Tabulated temperatures offered by the UI for a laying method and insulation. */
export function temperatureOptions(method: LayingMethod, insulation: InsulationMaterial): number[] {
return Object.keys(TEMPERATURE_FACTORS[LAYING_METHOD_GROUP[method]][insulation])
.map(Number)
.sort((a, b) => a - b);
}
// -- Conductor resistivity for the voltage drop -------------------------------
/** Conductivity in m/(Ω·mm²) at the maximum operating temperature (conservative). */
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
// -- Practical minimum cross-sections by circuit category --------------------
// A planning convention from docs/spec/06-future-sizing-and-calculations.md
// ("Standard Single-Phase Circuits ... cable cross-section: 2.5 mm²"), not a
// calculation result. It only ever raises a recommendation.
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
single_phase: 2.5,
};
// -- Protective device --------------------------------------------------------
export const PROTECTION_KINDS = ["breaker", "fuse"] as const;
export type ProtectionKind = (typeof PROTECTION_KINDS)[number];
export interface ExistingProtection {
ratedCurrentA: number;
/** breaker: circuit-breaker (I2 ≤ 1.45 In); fuse: gG fuse (I2 up to 2.1 In). */
kind: ProtectionKind;
}
/**
* Conventional tripping/fusing current I2 as multiple of In.
* Circuit-breakers (IEC 60898-1): 1.45. gG fuses (IEC 60269-2):
* In ≤ 4 A: 2.1, 4 A < In < 16 A: 1.9, In ≥ 16 A: 1.6.
*/
export function conventionalTrippingFactor(protection: ExistingProtection): number {
if (protection.kind === "breaker") {
return 1.45;
}
if (protection.ratedCurrentA <= 4) {
return 2.1;
}
return protection.ratedCurrentA < 16 ? 1.9 : 1.6;
}
/**
* Minimum Iz required by overload protection (IEC 60364-4-43, 433.1):
* In ≤ Iz and I2 ≤ 1.45 · Iz.
*/
export function minimumIzForProtection(protection: ExistingProtection): number {
return Math.max(
protection.ratedCurrentA,
(conventionalTrippingFactor(protection) * protection.ratedCurrentA) / 1.45
);
}
// -- Input / result -----------------------------------------------------------
export interface CableSizingInput {
phase: 1 | 3;
mode: "power" | "current";
/** kW, only used when mode === "power" */
powerKw?: number;
/** A, only used when mode === "current" */
currentA?: number;
cosPhi: number;
/** Line voltage in V (project single-/three-phase voltage). */
voltage: number;
/** Single-run cable length in m. */
lengthM: number;
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
/** Ambient air temperature, or ground temperature for D. */
ambientTemperatureC: number;
/** Number of grouped circuits or multi-core cables, including this one. */
groupingCircuits: number;
maxVoltageDropPercent: number;
harmonicNeutralLoad: HarmonicNeutralLoad;
existingProtection?: ExistingProtection;
circuitCategory?: CircuitCategory;
}
export interface CrossSectionRow {
crossSectionMm2: number;
/** Tabulated Iz; null if the table has no value for this size. */
ratedCurrentA: number | null;
correctedCurrentA: number | null;
currentSufficient: boolean | null;
voltageDropPercent: number | null;
voltageDropSufficient: boolean | null;
/** Longest single run meeting maxVoltageDropPercent at this load. */
maxLengthForVoltageDropM: number | null;
recommended: boolean;
}
export interface ProtectionCoordinationResult {
ratedCurrentA: number;
kind: ProtectionKind;
/** I2 / In used for the check. */
conventionalTrippingFactor: number;
/** In ≤ Iz and I2 ≤ 1.45 · Iz for the recommended cross-section. */
coordinated: boolean;
}
export type UnsupportedReason = "temperature" | "grouping" | "harmonics";
export interface CableSizingResult {
/** false: the inputs are outside the tabulated range – no numeric result. */
supported: boolean;
unsupportedReason: UnsupportedReason | null;
operatingCurrentA: number;
/**
* Current the cross-section must carry after correction:
* max(Ib, In, I2/1.45) with an existing protective device, otherwise Ib.
*/
designCurrentA: number;
/** Tabulated temperature/grouping actually used (rounded to the safe side). */
temperatureUsedC: number | null;
groupingUsed: number | null;
temperatureFactor: number | null;
groupingFactor: number | null;
crossSectionByCapacityMm2: number | null;
crossSectionByVoltageDropMm2: number | null;
recommendedCrossSectionMm2: number | null;
voltageDropAtRecommendedPercent: number | null;
combinedDerationFactor: number;
harmonicReductionApplied: boolean;
practicalMinimumApplied: boolean;
rows: CrossSectionRow[];
protectionCoordination: ProtectionCoordinationResult | null;
}
function operatingCurrentA(input: CableSizingInput): number {
if (input.mode === "current") {
return input.currentA ?? 0;
}
const powerW = (input.powerKw ?? 0) * 1000;
return input.phase === 1
? powerW / (input.voltage * input.cosPhi)
: powerW / (Math.sqrt(3) * input.voltage * input.cosPhi);
}
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
const ib = operatingCurrentA(input);
const designCurrentA = input.existingProtection
? Math.max(ib, minimumIzForProtection(input.existingProtection))
: ib;
const group = LAYING_METHOD_GROUP[input.layingMethod];
const temperature = lookupConservative(
TEMPERATURE_FACTORS[group][input.insulation],
input.ambientTemperatureC
);
const grouping =
input.groupingCircuits <= maxGroupingCircuits(input.layingMethod)
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
: null;
const harmonicsSupported = !(
input.phase === 3 && input.harmonicNeutralLoad === "over33Percent"
);
const unsupportedReason: UnsupportedReason | null = !temperature
? "temperature"
: !grouping
? "grouping"
: !harmonicsSupported
? "harmonics"
: null;
const empty: CableSizingResult = {
supported: false,
unsupportedReason,
operatingCurrentA: ib,
designCurrentA,
temperatureUsedC: temperature?.key ?? null,
groupingUsed: grouping?.key ?? null,
temperatureFactor: temperature?.factor ?? null,
groupingFactor: grouping?.factor ?? null,
crossSectionByCapacityMm2: null,
crossSectionByVoltageDropMm2: null,
recommendedCrossSectionMm2: null,
voltageDropAtRecommendedPercent: null,
combinedDerationFactor: 1,
harmonicReductionApplied: false,
practicalMinimumApplied: false,
rows: [],
protectionCoordination: null,
};
if (unsupportedReason || !temperature || !grouping) {
return empty;
}
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
const harmonicReductionApplied =
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
const combinedDerationFactor =
temperature.factor * grouping.factor * (harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1);
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
const kappa = KAPPA[input.conductorMaterial];
const ratedCurrents =
CURRENT_CAPACITY_A[input.insulation][input.conductorMaterial][input.layingMethod][coreCount];
const correctedCurrents = ratedCurrents.map((value) =>
value == null ? null : value * combinedDerationFactor
);
const voltageDrops = CROSS_SECTIONS_MM2.map(
(crossSection) =>
((voltageDropCoefficient * input.lengthM * ib * input.cosPhi) / (kappa * crossSection) / input.voltage) * 100
);
const indexByCapacity = correctedCurrents.findIndex(
(value) => value != null && value >= designCurrentA
);
const indexByVoltageDrop = voltageDrops.findIndex(
(value, index) => ratedCurrents[index] != null && value <= input.maxVoltageDropPercent
);
let indexRecommended =
indexByCapacity >= 0 && indexByVoltageDrop >= 0 ? Math.max(indexByCapacity, indexByVoltageDrop) : -1;
const practicalMinimumMm2 = input.circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[input.circuitCategory]
: undefined;
let practicalMinimumApplied = false;
if (indexRecommended >= 0 && practicalMinimumMm2 != null && CROSS_SECTIONS_MM2[indexRecommended] < practicalMinimumMm2) {
const minimumIndex = CROSS_SECTIONS_MM2.indexOf(practicalMinimumMm2 as (typeof CROSS_SECTIONS_MM2)[number]);
if (minimumIndex >= 0 && ratedCurrents[minimumIndex] != null) {
indexRecommended = minimumIndex;
practicalMinimumApplied = true;
}
}
const voltageDropCurrentBasis = ib * input.cosPhi;
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, index) => {
const rated = ratedCurrents[index];
const corrected = correctedCurrents[index];
return {
crossSectionMm2: crossSection,
ratedCurrentA: rated,
correctedCurrentA: corrected,
currentSufficient: corrected == null ? null : corrected >= designCurrentA,
voltageDropPercent: rated == null ? null : voltageDrops[index],
voltageDropSufficient: rated == null ? null : voltageDrops[index] <= input.maxVoltageDropPercent,
maxLengthForVoltageDropM:
rated == null || voltageDropCurrentBasis <= 0
? null
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
(100 * voltageDropCoefficient * voltageDropCurrentBasis),
recommended: index === indexRecommended,
};
});
let protectionCoordination: ProtectionCoordinationResult | null = null;
if (input.existingProtection && indexRecommended >= 0) {
const iz = rows[indexRecommended].correctedCurrentA;
const factor = conventionalTrippingFactor(input.existingProtection);
const inA = input.existingProtection.ratedCurrentA;
protectionCoordination = {
ratedCurrentA: inA,
kind: input.existingProtection.kind,
conventionalTrippingFactor: factor,
coordinated: iz != null && inA <= iz && factor * inA <= 1.45 * iz,
};
}
return {
...empty,
supported: true,
unsupportedReason: null,
crossSectionByCapacityMm2: indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
crossSectionByVoltageDropMm2: indexByVoltageDrop >= 0 ? CROSS_SECTIONS_MM2[indexByVoltageDrop] : null,
recommendedCrossSectionMm2: indexRecommended >= 0 ? CROSS_SECTIONS_MM2[indexRecommended] : null,
voltageDropAtRecommendedPercent: indexRecommended >= 0 ? voltageDrops[indexRecommended] : null,
combinedDerationFactor,
harmonicReductionApplied,
practicalMinimumApplied,
rows,
protectionCoordination,
};
}
// -- Alerts -------------------------------------------------------------------
export interface CableSizingAlert {
kind: "critical" | "warn" | "info" | "ok";
text: string;
}
const numberFormat = (value: number, digits = 1) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
const crossSectionFormat = (value: number) =>
`${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} mm²`;
export function buildCableSizingAlerts(input: CableSizingInput, result: CableSizingResult): CableSizingAlert[] {
const alerts: CableSizingAlert[] = [];
const group = LAYING_METHOD_GROUP[input.layingMethod];
if (!result.supported) {
const groupingMaximum = maxGroupingCircuits(input.layingMethod);
alerts.push({
kind: "critical",
text:
result.unsupportedReason === "temperature"
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
: result.unsupportedReason === "harmonics"
? "Bei mehr als 33 % dritter Harmonischer muss nach dem Neutralleiterstrom bemessen werden. Ohne diesen Wert wird keine Querschnittsempfehlung ausgegeben."
: input.layingMethod === "E"
? "Für Verlegeart E benötigt die Norm bei Häufung Angaben zu Trägerart, Abstand und Anzahl der Kabelpritschen. Ohne diese Angaben ist nur ein einzelner Stromkreis berechenbar."
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt für Verlegeart ${input.layingMethod} außerhalb der verwendeten Tabelle (max. ${groupingMaximum}) – keine Berechnung möglich.`,
});
return alerts;
}
if (result.temperatureUsedC !== input.ambientTemperatureC) {
alerts.push({
kind: "info",
text: `Temperatur auf den nächsten Tabellenwert ${result.temperatureUsedC} °C gerundet (sichere Seite).`,
});
}
if (result.groupingUsed !== input.groupingCircuits) {
alerts.push({
kind: "info",
text: `Häufung auf den nächsten Tabellenwert ${result.groupingUsed} Stromkreise gerundet (sichere Seite).`,
});
}
const byCapacity = result.crossSectionByCapacityMm2;
const byDrop = result.crossSectionByVoltageDropMm2;
if (byCapacity == null) {
const dueToProtection =
input.existingProtection != null && result.designCurrentA > result.operatingCurrentA;
alerts.push({
kind: "critical",
text: dueToProtection
? `Das vorhandene Schutzorgan (${input.existingProtection!.ratedCurrentA} A) verlangt Iz ≥ ${numberFormat(result.designCurrentA)} A – das erreicht bei Verlegeart ${input.layingMethod} kein Querschnitt bis 240 mm². Verlegeart oder Schutzorgan prüfen.`
: `Der Betriebsstrom ${numberFormat(result.operatingCurrentA)} A übersteigt bei Verlegeart ${input.layingMethod} die Belastbarkeit aller Querschnitte bis 240 mm².`,
});
} else if (byDrop == null) {
alerts.push({
kind: "critical",
text: `Der Spannungsfall von ${numberFormat(input.maxVoltageDropPercent)} % wird bei ${input.lengthM} m mit keinem Querschnitt bis 240 mm² eingehalten.`,
});
} else if (byDrop > byCapacity) {
alerts.push({
kind: "warn",
text: `Der Spannungsfall ist maßgeblich: ${crossSectionFormat(byCapacity)} reichen thermisch, für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} % sind ${crossSectionFormat(byDrop)} nötig.`,
});
}
if (result.harmonicReductionApplied) {
alerts.push({
kind: "info",
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
});
}
if (input.lengthM === 0) {
alerts.push({
kind: "info",
text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.",
});
}
if (group === "ground") {
alerts.push({
kind: "info",
text: "Erdverlegung: Bodenwärmewiderstand 2,5 K·m/W angenommen (Tabellenreferenz).",
});
}
if (result.practicalMinimumApplied) {
alerts.push({
kind: "info",
text: `Auf ${crossSectionFormat(result.recommendedCrossSectionMm2!)} angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine Rechenanforderung).`,
});
}
if (result.protectionCoordination) {
const coordination = result.protectionCoordination;
const kindLabel = coordination.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG";
alerts.push(
coordination.coordinated
? {
kind: "info",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A berücksichtigt: In ≤ Iz und I₂ = ${numberFormat(coordination.conventionalTrippingFactor, 2)} · In ≤ 1,45 · Iz. Kurzschlussschutz und Abschaltbedingungen sind nicht Teil dieser Berechnung.`,
}
: {
kind: "warn",
text: `Schutzorgan ${kindLabel} ${coordination.ratedCurrentA} A ist mit dem empfohlenen Querschnitt nicht koordiniert (In ≤ Iz, I₂ ≤ 1,45 · Iz).`,
}
);
}
const recommendedRow = result.rows.find((row) => row.recommended);
if (recommendedRow && byCapacity != null && byDrop != null) {
if (!alerts.some((alert) => alert.kind === "warn" || alert.kind === "critical")) {
alerts.push({
kind: "ok",
text: `${crossSectionFormat(recommendedRow.crossSectionMm2)} ${input.conductorMaterial === "aluminum" ? "Al" : "Cu"} erfüllt bei ${input.lengthM} m Belastbarkeit (${numberFormat(result.designCurrentA)} A) und Spannungsfall (ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %).`,
});
}
if (recommendedRow.maxLengthForVoltageDropM != null) {
alerts.push({
kind: "info",
text: `Maximale Länge bei ${crossSectionFormat(recommendedRow.crossSectionMm2)} für ΔU ≤ ${numberFormat(input.maxVoltageDropPercent)} %: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
});
}
}
return alerts;
}

View file

@ -0,0 +1,47 @@
// Request validation for the cable-sizing HTTP API. Pure - depends only on
// zod and the sibling calculation module, never on db/server.
import { z } from "zod";
import {
CONDUCTOR_MATERIALS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
LAYING_METHODS,
PROTECTION_KINDS,
type CableSizingInput,
} from "./cable-sizing-calculation.js";
export const cableSizingRequestSchema = z
.object({
phase: z.union([z.literal(1), z.literal(3)]),
mode: z.enum(["power", "current"]),
powerKw: z.number().finite().min(0).optional(),
currentA: z.number().finite().min(0).optional(),
cosPhi: z.number().finite().min(0.1).max(1),
voltage: z.number().finite().positive(),
lengthM: z.number().finite().min(0).max(10000),
layingMethod: z.enum(LAYING_METHODS),
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
insulation: z.enum(INSULATION_MATERIALS),
ambientTemperatureC: z.number().finite().min(-40).max(120),
groupingCircuits: z.number().int().min(1).max(100),
maxVoltageDropPercent: z.number().finite().min(0.5).max(10),
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
existingProtection: z
.object({
ratedCurrentA: z.number().finite().positive(),
kind: z.enum(PROTECTION_KINDS),
})
.strict()
.optional(),
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
})
.strict()
.refine((value) => (value.mode === "power" ? value.powerKw != null : value.currentA != null), {
message: "powerKw is required for mode 'power', currentA for mode 'current'",
});
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
// The request schema must stay assignable to the domain input shape.
type AssertAssignable<T extends CableSizingInput> = T;
export type CableSizingRequestIsDomainInput = AssertAssignable<CableSizingRequest>;

View file

@ -0,0 +1,12 @@
CREATE TABLE `document_branding_assets` (
`key` text PRIMARY KEY NOT NULL,
`mime_type` text NOT NULL,
`file_name` text,
`data` blob NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `remark` text;

View file

@ -0,0 +1,10 @@
CREATE TABLE `document_branding_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_editor` text;

View file

@ -0,0 +1,9 @@
CREATE TABLE `buildings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;

View file

@ -0,0 +1,42 @@
CREATE TABLE `estimate_balances` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`work_stage` text,
`date_iso` text,
`reserve_av_percent` real DEFAULT 0 NOT NULL,
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
CREATE TABLE `estimate_groups` (
`id` text PRIMARY KEY NOT NULL,
`balance_id` text NOT NULL,
`cost_group` text DEFAULT '' NOT NULL,
`title` text NOT NULL,
`editor` text,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
CREATE TABLE `estimate_rows` (
`id` text PRIMARY KEY NOT NULL,
`group_id` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`supply` text,
`name` text DEFAULT '' NOT NULL,
`quantity` real DEFAULT 0 NOT NULL,
`unit` text DEFAULT 'piece' NOT NULL,
`connection` text,
`power_per_unit_w` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real DEFAULT 1 NOT NULL,
`safety_supply` integer DEFAULT false NOT NULL,
`remark` text,
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);

View file

@ -0,0 +1 @@
ALTER TABLE `rooms` ADD `area_m2` real;

View file

@ -0,0 +1,4 @@
ALTER TABLE `distribution_boards` ADD `feeding_distribution_board_id` text REFERENCES distribution_boards(id);--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `feeding_circuit_id` text REFERENCES circuits(id);--> statement-breakpoint
CREATE INDEX `distribution_boards_feeding_board_idx` ON `distribution_boards` (`feeding_distribution_board_id`);--> statement-breakpoint
CREATE INDEX `distribution_boards_feeding_circuit_idx` ON `distribution_boards` (`feeding_circuit_id`);

View file

@ -0,0 +1,13 @@
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`active` integer DEFAULT true NOT NULL,
`must_change_password` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);

View file

@ -0,0 +1,97 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
UPDATE `circuit_device_rows`
SET `quantity` = `manual_quantity`
WHERE EXISTS (
SELECT 1
FROM `external_model_objects`
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
);--> statement-breakpoint
DROP TABLE `external_model_objects`;--> statement-breakpoint
DROP TABLE `external_room_mappings`;--> statement-breakpoint
DROP TABLE `external_import_batches`;--> statement-breakpoint
DROP TABLE `external_model_sources`;--> statement-breakpoint
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
CREATE TABLE `external_model_configurations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`configuration` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_model_sources` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`source_type` text DEFAULT 'revit_xlsx' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_import_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`import_kind` text NOT NULL,
`imported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`object_count` integer NOT NULL,
`applied_project_revision` integer,
`configuration_snapshot` text,
`original_bytes` blob,
`document` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
CREATE TABLE `external_room_mappings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`normalized_source_room_key` text NOT NULL,
`source_floor_name` text,
`source_room_number` text NOT NULL,
`source_room_name` text NOT NULL,
`room_id` text,
`default_distribution_board_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint
CREATE TABLE `external_model_objects` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`worksharing_id` text NOT NULL,
`ifc_guid` text,
`device_type_id` text NOT NULL,
`last_seen_import_batch_id` text NOT NULL,
`last_accepted_import_batch_id` text NOT NULL,
`accepted_source_values` text NOT NULL,
`planning_values` text NOT NULL,
`overridden_fields` text NOT NULL,
`external_room_mapping_id` text,
`distribution_board_id` text,
`linked_project_device_id` text,
`circuit_device_row_id` text,
`presence_status` text DEFAULT 'present' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,16 @@
CREATE TABLE `external_export_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`project_revision` integer NOT NULL,
`import_batch_id` text NOT NULL,
`exported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`configuration_snapshot` text NOT NULL,
`configuration_sha256` text NOT NULL,
`scope` text NOT NULL,
`object_count` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,69 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790411540872,
"tag": "0007_board_info_and_document_logo",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790411560953,
"tag": "0008_project_cover_and_office_profile",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790419185882,
"tag": "0009_buildings",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790438092269,
"tag": "0010_estimate_balances",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1790531351492,
"tag": "0011_glossy_norman_osborn",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1790540142918,
"tag": "0012_good_gateway",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1790624623732,
"tag": "0013_users",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790748107566,
"tag": "0014_linear_xlsx_external_model",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1790773066696,
"tag": "0015_wide_night_thrasher",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,15 @@
import type { AppDatabase } from "../database-context.js";
const defaultInsertBatchSize = 200;
export function insertManyInBatches<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: readonly unknown[],
batchSize = defaultInsertBatchSize
) {
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new Error("Insert batch size must be a positive integer.");
for (let index = 0; index < values.length; index += batchSize) {
database.insert(table).values(values.slice(index, index + batchSize) as never).run();
}
}

View file

@ -0,0 +1,27 @@
import { and, asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
export class BuildingRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
return this.database
.select()
.from(buildings)
.where(eq(buildings.projectId, projectId))
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
}
async findById(projectId: string, buildingId: string) {
return (
this.database
.select()
.from(buildings)
.where(
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
)
.get() ?? null
);
}
}

View file

@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
interface PersistedMoveRow {
id: string;
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
projectId,
destinationCircuitIds
);
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
targetCircuit.id,
]);
const deleted = database
.delete(circuits)
.where(

View file

@ -23,8 +23,15 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand
>(
this.database,
input,
(tx) =>
@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand
): CircuitDeviceRowStructureProjectCommand {
): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> {
if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.row
command.payload.row,
command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database,
projectId,
command.payload.rowId,
command.payload.expectedCircuitId
command.payload.expectedCircuitId,
command.payload.externalObjects
);
}
throw new Error("Unsupported circuit device-row structure command.");
@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot
row: CircuitDeviceRowSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
}
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
if (externalObjects.length > 0) {
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
}
return {
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private delete(
database: AppDatabase,
projectId: string,
rowId: string,
expectedCircuitId: string
expectedCircuitId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const row = database
.select()
@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
const externalObjects = suppliedExternalObjects ??
captureExternalObjectDetachTransitions(database, projectId, [rowId]);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const result = database
.delete(circuitDeviceRows)
@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
expectedCircuitId
);
return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row)
);
return {
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
};
}
private assertCircuitInProject(

View file

@ -25,7 +25,14 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
} from "./external-object-assignment.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
@ -42,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
} else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
}
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand
>(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
@ -53,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
database: AppDatabase,
projectId: string,
command: CircuitGroupSubtreeProjectCommand
) {
): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> {
if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
@ -65,6 +76,19 @@ export class CircuitGroupSubtreeProjectCommandRepository
"Circuit-group subtree changed before deletion."
);
}
assertCircuitsAreNotReferencedAsFeeders(
database,
projectId,
current.circuits.map((entry) => entry.circuit.id)
);
const externalObjects = command.payload.externalObjects ??
captureExternalObjectDetachTransitions(
database,
projectId,
current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id))
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))
@ -72,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted.");
}
return createCircuitGroupRestoreSubtreeProjectCommand(
command.payload.snapshot
);
return {
forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteSubtreeProjectCommand(
command.payload.snapshot
);
const externalObjects = command.payload.externalObjects ?? [];
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
return {
forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects),
inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
throw new Error("Unsupported circuit-group subtree command.");
}

View file

@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitSections } from "../schema/circuit-sections.js";
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
.orderBy(asc(circuitSections.sortOrder));
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuitSections)
.where(inArray(circuitSections.circuitListId, circuitListIds))
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id));
}
async createDefaults(circuitListId: string) {
const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {

View file

@ -25,9 +25,17 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@ -35,11 +43,15 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitStructureProjectCommand,
CircuitStructureProjectCommand,
CircuitStructureProjectCommand
>(
this.database,
input,
(tx) =>
this.applyCommand(
this.applyWithForward(
tx,
input.projectId,
input.source,
@ -48,19 +60,33 @@ export class CircuitStructureProjectCommandRepository
);
}
private applyCommand(
/**
* Applies the circuit write inside an already-owned project transaction.
* Composite project commands use this without creating a second revision.
*/
applyInTransaction(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
return this.applyWithForward(database, projectId, source, command).inverse;
}
private applyWithForward(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): AppliedProjectCommand<CircuitStructureProjectCommand, CircuitStructureProjectCommand> {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.circuit
command.payload.circuit,
command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeleteCommandType) {
@ -69,7 +95,8 @@ export class CircuitStructureProjectCommandRepository
database,
projectId,
command.payload.circuitId,
command.payload.expectedCircuitListId
command.payload.expectedCircuitListId,
command.payload.externalObjects
);
}
throw new Error("Unsupported circuit structure command.");
@ -79,7 +106,8 @@ export class CircuitStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot
snapshot: CircuitSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) {
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
@ -167,17 +195,23 @@ export class CircuitStructureProjectCommandRepository
.run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
snapshot.circuitListId
);
if (externalObjects.length > 0) {
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
}
return {
forward: createCircuitInsertProjectCommand(snapshot, externalObjects),
inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
expectedCircuitListId: string
expectedCircuitListId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const circuit = database
.select()
@ -215,7 +249,12 @@ export class CircuitStructureProjectCommandRepository
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({
const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions(
database, projectId, rows.map((row) => row.id)
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const circuitSnapshot: CircuitSnapshot = {
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
@ -234,7 +273,9 @@ export class CircuitStructureProjectCommandRepository
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
});
};
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
const result = database
.delete(circuits)
@ -248,7 +289,10 @@ export class CircuitStructureProjectCommandRepository
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
return inverse;
return {
forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private assertCircuitLocation(

View file

@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm";
import { asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
@ -14,6 +14,15 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuits)
.where(inArray(circuits.circuitListId, circuitListIds))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async listBySection(sectionId: string) {
const db = this.database;
return db

View file

@ -19,6 +19,18 @@ export class DistributionBoardComponentRepository {
);
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(distributionBoardComponents)
.where(inArray(distributionBoardComponents.circuitListId, circuitListIds))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
);
}
async listProtectionByComponentIds(componentIds: string[]) {
if (componentIds.length === 0) {
return [];

View file

@ -0,0 +1,65 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export function assertBoardsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
distributionBoardIds: string[]
) {
if (distributionBoardIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingDistributionBoardId,
[...new Set(distributionBoardIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
export function assertCircuitsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
if (circuitIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingCircuitId,
[...new Set(circuitIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
function throwFeedingReferenceError(dependent: {
name: string;
boardNumber: string | null;
}): never {
const label = dependent.boardNumber ?? dependent.name;
throw new Error(`Speist ${label} – erst Einspeisung ändern.`);
}

View file

@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { isDeepStrictEqual } from "node:util";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@ -17,6 +18,20 @@ import {
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
import {
assertDistributionBoardFeederCircuitProjectCommand,
createDistributionBoardCreateFeederCircuitProjectCommand,
createDistributionBoardRemoveFeederCircuitProjectCommand,
distributionBoardCreateFeederCircuitCommandType,
distributionBoardRemoveFeederCircuitCommandType,
type DistributionBoardFeederCircuitProjectCommand,
} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
import {
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@ -30,6 +45,9 @@ import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
@ -61,6 +79,30 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
executeFeederCircuit(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
) {
assertDistributionBoardFeederCircuitProjectCommand(input.command);
if (
input.source === "user" &&
input.command.type === distributionBoardRemoveFeederCircuitCommandType
) {
throw new Error(
"Generated feeder circuits may only be removed through project history."
);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyFeederCircuitCommand(tx, input)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
@ -248,6 +290,9 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
const deleted = database
.delete(distributionBoards)
.where(
@ -325,6 +370,16 @@ export class DistributionBoardStructureProjectCommandRepository
patch.supplyType
);
}
this.assertFeedingReference(database, input.projectId, current.id, {
feedingDistributionBoardId:
patch.feedingDistributionBoardId === undefined
? current.feedingDistributionBoardId
: patch.feedingDistributionBoardId,
feedingCircuitId:
patch.feedingCircuitId === undefined
? current.feedingCircuitId
: patch.feedingCircuitId,
});
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
@ -333,7 +388,8 @@ export class DistributionBoardStructureProjectCommandRepository
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
inversePatch,
input.command.schemaVersion
);
const updated = database
.update(distributionBoards)
@ -352,6 +408,135 @@ export class DistributionBoardStructureProjectCommandRepository
}
return inverse;
}
private applyFeederCircuitCommand(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardFeederCircuitProjectCommand;
}
): DistributionBoardFeederCircuitProjectCommand {
const circuitRepository =
new CircuitStructureProjectCommandRepository(database);
const { circuit, boardUpdate } = input.command.payload;
if (
input.command.type === distributionBoardCreateFeederCircuitCommandType
) {
const circuitInverse = circuitRepository.applyInTransaction(
database,
input.projectId,
input.source,
createCircuitInsertProjectCommand(circuit)
);
if (circuitInverse.type !== circuitDeleteCommandType) {
throw new Error("Created feeder circuit returned an invalid inverse.");
}
const boardInverse = this.update(database, {
...input,
command: boardUpdate,
});
return createDistributionBoardRemoveFeederCircuitProjectCommand(
circuit,
boardInverse
);
}
const boardInverse = this.update(database, {
...input,
command: boardUpdate,
});
const circuitInverse = circuitRepository.applyInTransaction(
database,
input.projectId,
input.source,
createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId)
);
if (
circuitInverse.type !== circuitInsertCommandType ||
!isDeepStrictEqual(circuitInverse.payload.circuit, circuit)
) {
throw new Error(
"Generated feeder circuit changed before history removal."
);
}
return createDistributionBoardCreateFeederCircuitProjectCommand(
circuit,
boardInverse
);
}
private assertFeedingReference(
database: AppDatabase,
projectId: string,
distributionBoardId: string,
reference: {
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
) {
const { feedingDistributionBoardId, feedingCircuitId } = reference;
if (feedingDistributionBoardId === null) {
if (feedingCircuitId !== null) {
throw new Error(
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
);
}
return;
}
if (feedingDistributionBoardId === distributionBoardId) {
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
}
const projectBoards = database
.select({
id: distributionBoards.id,
feedingDistributionBoardId:
distributionBoards.feedingDistributionBoardId,
})
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.all();
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
if (!boardById.has(feedingDistributionBoardId)) {
throw new Error(
"Die speisende Verteilung gehört nicht zum Projekt."
);
}
const visited = new Set<string>([distributionBoardId]);
let currentId: string | null = feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen.");
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
if (feedingCircuitId === null) {
return;
}
const feedingCircuit = database
.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuits.id, feedingCircuitId))
.get();
if (
!feedingCircuit ||
feedingCircuit.projectId !== projectId ||
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
) {
throw new Error(
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
);
}
}
}
function assertSupplyTypeEnabled(
@ -388,10 +573,24 @@ function structureMatches(
) {
const {
simultaneityFactor,
boardNumber,
location,
fedFrom,
remark,
feedingDistributionBoardId,
feedingCircuitId,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
[
boardNumber,
location,
fedFrom,
remark,
feedingDistributionBoardId,
feedingCircuitId,
].some((value) => value !== null) ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard

View file

@ -10,6 +10,7 @@ import {
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
normalizeDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import type {
@ -31,6 +32,8 @@ import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore
@ -63,23 +66,28 @@ export class DistributionBoardSubtreeProjectCommandRepository
projectId: string,
command: DistributionBoardSubtreeProjectCommand
) {
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
command.payload.snapshot
);
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
}
if (command.type === distributionBoardDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.distributionBoard.id
snapshot.distributionBoard.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
if (!sameSnapshot(current, snapshot)) {
throw new Error(
"Distribution-board subtree changed before deletion."
);
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id,
]);
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
const deleted = database
.delete(distributionBoards)
.where(
@ -97,9 +105,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree could not be deleted."
);
}
return createDistributionBoardInsertSubtreeProjectCommand(
command.payload.snapshot
);
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
}
throw new Error("Unsupported distribution-board subtree command.");
}
@ -300,6 +306,69 @@ export class DistributionBoardSubtreeProjectCommandRepository
);
}
}
const feedingDistributionBoardId =
snapshot.distributionBoard.feedingDistributionBoardId;
const feedingCircuitId = snapshot.distributionBoard.feedingCircuitId;
if (feedingDistributionBoardId === null) {
if (feedingCircuitId !== null) {
throw new Error(
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
);
}
} else {
if (feedingDistributionBoardId === snapshot.distributionBoard.id) {
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
}
const projectBoards = database
.select({
id: distributionBoards.id,
feedingDistributionBoardId:
distributionBoards.feedingDistributionBoardId,
})
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.all();
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
if (!boardById.has(feedingDistributionBoardId)) {
throw new Error(
"Die speisende Verteilung gehört nicht zum Projekt."
);
}
const visited = new Set<string>([snapshot.distributionBoard.id]);
let currentId: string | null = feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error(
"Die Einspeisungsverknüpfung würde einen Zyklus erzeugen."
);
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
if (feedingCircuitId !== null) {
const feedingCircuit = database
.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuits.id, feedingCircuitId))
.get();
if (
!feedingCircuit ||
feedingCircuit.projectId !== projectId ||
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
) {
throw new Error(
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
);
}
}
}
const linkedProjectDeviceIds = uniqueStrings(
snapshot.circuits.flatMap((circuit) =>
circuit.deviceRows.flatMap((row) =>

View file

@ -0,0 +1,86 @@
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
documentBrandingAssets,
type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js";
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
// Plain repository without revision semantics: branding is global office
// configuration, not a project mutation.
export class DocumentBrandingRepository {
constructor(private readonly db: AppDatabase) {}
get(key: string): DocumentBrandingAsset | undefined {
return this.db
.select()
.from(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.get();
}
upsert(asset: {
key: string;
mimeType: string;
fileName: string | null;
data: Buffer;
}): DocumentBrandingAsset {
const updatedAt = new Date();
return this.db
.insert(documentBrandingAssets)
.values({ ...asset, updatedAt })
.onConflictDoUpdate({
target: documentBrandingAssets.key,
set: {
mimeType: asset.mimeType,
fileName: asset.fileName,
data: asset.data,
updatedAt,
},
})
.returning()
.get();
}
delete(key: string): boolean {
return (
this.db
.delete(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.run().changes > 0
);
}
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
const rows = this.db
.select()
.from(documentBrandingSettings)
.where(inArray(documentBrandingSettings.key, [...keys]))
.all();
return Object.fromEntries(
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
) as Record<TKey, string | null>;
}
/** Stores the given values atomically; `null` removes a setting. */
setSettings(values: Record<string, string | null>) {
const updatedAt = new Date();
this.db.transaction((tx) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) {
tx.delete(documentBrandingSettings)
.where(eq(documentBrandingSettings.key, key))
.run();
} else {
tx.insert(documentBrandingSettings)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: documentBrandingSettings.key,
set: { value, updatedAt },
})
.run();
}
}
});
}
}

View file

@ -0,0 +1,132 @@
import { eq } from "drizzle-orm";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
normalizeEstimateBalanceSnapshot,
sameEstimateBalance,
type EstimateBalanceProjectCommand,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type {
EstimateBalanceProjectCommandStore,
ExecuteEstimateBalanceCommandInput,
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
export class EstimateBalanceProjectCommandRepository
implements EstimateBalanceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteEstimateBalanceCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: EstimateBalanceProjectCommand
): EstimateBalanceProjectCommand {
switch (command.type) {
case estimateBalanceInsertCommandType:
assertEstimateBalanceInsertProjectCommand(command);
return this.insert(database, projectId, command.payload.balance);
case estimateBalanceDeleteCommandType:
assertEstimateBalanceDeleteProjectCommand(command);
return this.delete(database, projectId, command.payload.balance);
case estimateBalanceUpdateCommandType:
assertEstimateBalanceUpdateProjectCommand(command);
return this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
}
}
private insert(
database: AppDatabase,
projectId: string,
balance: EstimateBalanceSnapshot
) {
if (balance.projectId !== projectId) {
throw new Error("Estimate-balance snapshot does not belong to project.");
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: estimateBalances.id })
.from(estimateBalances)
.where(eq(estimateBalances.id, balance.id))
.get();
if (existing) {
throw new Error("Estimate-balance id already exists.");
}
const normalized = normalizeEstimateBalanceSnapshot(balance);
insertEstimateBalance(database, normalized);
return createEstimateBalanceDeleteProjectCommand(normalized);
}
private delete(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "deletion");
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
throw new Error("Estimate balance could not be deleted.");
}
return createEstimateBalanceInsertProjectCommand(expected);
}
private update(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "update");
// Group and row ids stay stable; replacing the whole document keeps the
// command exact and its inverse trivially the swapped pair.
deleteEstimateBalance(database, projectId, expected.id);
const normalized = normalizeEstimateBalanceSnapshot(target);
insertEstimateBalance(database, normalized);
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
}
private assertPersisted(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
action: string
) {
const [persisted] = readEstimateBalances(database, projectId, expected.id);
if (!persisted || !sameEstimateBalance(persisted, expected)) {
throw new Error(`Estimate balance changed before ${action}.`);
}
}
}

View file

@ -0,0 +1,121 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import {
estimateBalances,
estimateGroups,
estimateRows,
} from "../schema/estimate-balances.js";
/** Reads complete balance documents of a project, ordered like the UI shows them. */
export function readEstimateBalances(
database: AppDatabase,
projectId: string,
balanceId?: string
): EstimateBalanceSnapshot[] {
const balances = database
.select()
.from(estimateBalances)
.where(
balanceId
? and(
eq(estimateBalances.projectId, projectId),
eq(estimateBalances.id, balanceId)
)
: eq(estimateBalances.projectId, projectId)
)
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
.all();
if (!balances.length) {
return [];
}
const groups = database
.select()
.from(estimateGroups)
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
.all();
const rows = groups.length
? database
.select()
.from(estimateRows)
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
.all()
: [];
return balances.map((balance) => ({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
groups: groups
.filter((group) => group.balanceId === balance.id)
.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: rows
.filter((row) => row.groupId === group.id)
.map(({ groupId: _groupId, ...row }) => row),
})),
}));
}
/** Writes a complete balance document (the caller removed an older version first). */
export function insertEstimateBalance(
database: AppDatabase,
balance: EstimateBalanceSnapshot
) {
database
.insert(estimateBalances)
.values({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
})
.run();
for (const group of balance.groups) {
database
.insert(estimateGroups)
.values({
id: group.id,
balanceId: balance.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
})
.run();
if (group.rows.length) {
database
.insert(estimateRows)
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
.run();
}
}
}
/** Removes a balance with its groups and rows (foreign keys cascade). */
export function deleteEstimateBalance(
database: AppDatabase,
projectId: string,
balanceId: string
) {
return database
.delete(estimateBalances)
.where(
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
)
.run().changes;
}

View file

@ -0,0 +1,60 @@
import { asc, eq } from "drizzle-orm";
import type {
DetailedDeviceRowPower,
EstimateBalanceSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { readEstimateBalances } from "./estimate-balance.persistence.js";
/** Read side of the estimate-balance module; writes go through commands. */
export class EstimateBalanceRepository {
constructor(private readonly database: AppDatabase) {}
listByProject(projectId: string): EstimateBalanceSnapshot[] {
return readEstimateBalances(this.database, projectId);
}
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
}
nextSortOrder(projectId: string): number {
const rows = this.database
.select({ sortOrder: estimateBalances.sortOrder })
.from(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.orderBy(asc(estimateBalances.sortOrder))
.all();
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
}
/**
* Device rows of all circuit lists of the project with their total power
* (quantity · power per unit · device GZF, like the circuit tree).
*/
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
return this.database
.select({
costGroup: circuitDeviceRows.costGroup,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all()
.map((row) => ({
costGroup: row.costGroup,
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
cosPhi: row.cosPhi,
}));
}
}

View file

@ -1,105 +0,0 @@
import { eq } from "drizzle-orm";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
createExternalCsvConfigurationUpdateProjectCommand,
type ExternalCsvConfigurationSnapshot,
} from "../../domain/models/external-csv-configuration-project-command.model.js";
import type {
ExecuteExternalCsvConfigurationCommandInput,
ExternalCsvConfigurationProjectCommandStore,
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalCsvConfigurationProjectCommandRepository
implements ExternalCsvConfigurationProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const current = tx
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("External CSV configuration belongs to a different project.");
}
if (sameSnapshot(current, target)) {
throw new Error("External CSV configuration did not change.");
}
if (
input.source === "user" &&
target !== null &&
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
) {
throw new Error("External CSV configuration version must advance by one.");
}
if (target === null) {
tx.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.run();
} else if (current === null) {
tx.insert(externalCsvConfigurations).values(target).run();
} else {
const updated = tx
.update(externalCsvConfigurations)
.set({
configurationVersion: target.configurationVersion,
configuration: target.configuration,
})
.where(eq(externalCsvConfigurations.id, current.id))
.run();
if (updated.changes !== 1) {
throw new Error("External CSV configuration changed before update.");
}
}
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalCsvConfigurationSnapshot | null,
expected: ExternalCsvConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("External CSV configuration changed before update.");
}
}
function sameSnapshot(
left: ExternalCsvConfigurationSnapshot | null,
right: ExternalCsvConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -0,0 +1,18 @@
import { desc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalExportBatches } from "../schema/external-export-batches.js";
export class ExternalExportBatchRepository {
constructor(private readonly database: AppDatabase) {}
append(batch: typeof externalExportBatches.$inferInsert) {
this.database.insert(externalExportBatches).values(batch).run();
return batch;
}
listByProject(projectId: string) {
return this.database.select().from(externalExportBatches)
.where(eq(externalExportBatches.projectId, projectId))
.orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
}
}

View file

@ -0,0 +1,88 @@
import { createHash } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { insertManyInBatches } from "./batched-insert.persistence.js";
export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalFollowUpImportProjectCommandStore["execute"]>[0]) {
assertExternalFollowUpImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId);
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get();
if (!currentState.projectExists || !currentConfiguration || !externalModelStatesEqual(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
const rowIds = input.command.payload.rows.map(({ expected }) => expected.id);
const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden.");
const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows);
for (const transition of input.command.payload.rows) {
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert.");
}
replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration);
replaceState(tx, input.projectId, input.command.payload.targetState);
return invertExternalFollowUpImportProjectCommand(input.command);
});
}
}
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt.");
const growing = target.importBatches.length > expectedState.importBatches.length;
const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!;
if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig.");
if (growing) {
if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig.");
const bytes = Buffer.from(latest.originalContentBase64!, "base64");
if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports ist ungültig.");
}
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
const deviceTypeIds = new Set(configuration.deviceTypes.map((entry) => entry.id));
for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); }
for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); }
assertRows(database, projectId, target, rows);
}
function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))];
const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])];
if (!rowsToLoad.length) return;
const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all();
if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt.");
const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition]));
for (const context of contexts) {
const current = toCircuitDeviceRowSnapshot(context.row);
const effective = transitionById.get(current.id)?.target ?? current;
assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
}
}
function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); database.insert(externalModelConfigurations).values(target).run(); }
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))); insertManyInBatches(database, externalRoomMappings, target.roomMappings); insertManyInBatches(database, externalModelObjects, target.objects); }
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }

View file

@ -1,252 +1,89 @@
import crypto from "node:crypto";
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import {
assertExternalInitialImportProjectCommand,
createExternalInitialImportProjectCommand,
isEmptyExternalModelState,
} from "../../domain/models/external-initial-import-project-command.model.js";
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../../external-model/domain/external-model-matching.js";
import { externalModelStatesEqual } from "../../external-model/domain/external-model-state-comparison.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
import { insertManyInBatches } from "./batched-insert.persistence.js";
export class ExternalInitialImportProjectCommandRepository
implements ExternalInitialImportProjectCommandStore
{
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
assertExternalInitialImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentResult = new ExternalModelStateRepository(tx)
.getByProject(input.projectId);
if (!currentResult.projectExists) throw new Error("Project not found.");
const expected = input.command.payload.expected;
if (!sameState(currentResult.state, expected)) {
throw new Error("External model state changed before initial import.");
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
const payload = input.command.payload;
if (!externalModelStatesEqual(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
}
const target = input.command.payload.target;
if (!isEmptyExternalModelState(target)) {
validatePopulatedTarget(tx, input, target);
}
replaceExternalState(tx, input.projectId, target);
const persisted = new ExternalModelStateRepository(tx)
.getByProject(input.projectId).state;
if (!sameState(persisted, target)) {
throw new Error("Persisted external initial import differs from its target.");
}
return createExternalInitialImportProjectCommand(target, currentResult.state);
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
replaceState(tx, input.projectId, payload.targetState);
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
if (!externalModelStatesEqual(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
});
}
}
function validatePopulatedTarget(
database: AppDatabase,
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
target: ExternalModelStateSnapshot
) {
const source = target.source!;
const batch = target.importBatches[0];
if (source.projectId !== input.projectId) {
throw new Error("External initial import belongs to a different project.");
}
if (
input.source === "user" &&
batch.appliedProjectRevision !== input.expectedRevision + 1
) {
throw new Error("External initial import revision is invalid.");
}
const configuration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get();
if (
!configuration ||
configuration.configurationVersion !== batch.configurationVersion ||
canonicalJson(configuration.configuration) !==
canonicalJson(batch.configurationSnapshot)
) {
throw new Error("External initial import configuration changed before apply.");
}
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
if (sha256 !== batch.sha256) {
throw new Error("External initial import checksum is invalid.");
}
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
throw new Error("External initial import document differs from the original bytes.");
}
const preview = createExternalCsvPreview({
fileName: batch.fileName,
bytes,
configuration: batch.configurationSnapshot,
});
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
if (targetByIfcGuid.size !== preview.objects.length) {
throw new Error("External initial import must contain every CSV object exactly once.");
}
const mappingsById = new Map(
target.roomMappings.map((mapping) => [mapping.id, mapping])
);
const expectedRoomKeys = new Set<string>();
for (const candidate of preview.objects) {
const object = targetByIfcGuid.get(candidate.ifcGuid);
if (!object) {
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
}
const projected = projectInitialExternalObject(
candidate,
batch.configurationSnapshot
);
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
throw new Error("External initial import source values differ from the CSV.");
}
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
if (
!object.overriddenFields.includes(field) &&
canonicalJson(object.planningValues[field]) !==
canonicalJson(projected.planningValues[field])
) {
throw new Error(`External initial planning field ${field} changed without an override.`);
}
}
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
if (roomKey === null) {
if (object.externalRoomMappingId !== null) {
throw new Error("External object without a source room must not use a room mapping.");
}
} else {
expectedRoomKeys.add(roomKey);
const mapping = object.externalRoomMappingId === null
? null
: mappingsById.get(object.externalRoomMappingId);
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
throw new Error("External object source room mapping is incomplete.");
}
}
}
if (
target.roomMappings.length !== expectedRoomKeys.size ||
target.roomMappings.some(
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
)
) {
throw new Error("External initial import room mappings do not match CSV rooms.");
}
validateInternalLinks(database, input.projectId, target);
}
function validateInternalLinks(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
const roomIds = new Set(
database.select({ id: rooms.id }).from(rooms)
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
);
const boardIds = new Set(
database.select({ id: distributionBoards.id }).from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
);
const deviceIds = new Set(
database.select({ id: projectDevices.id }).from(projectDevices)
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
);
for (const mapping of target.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
throw new Error("External room mapping references another project.");
}
if (
mapping.defaultDistributionBoardId !== null &&
!boardIds.has(mapping.defaultDistributionBoardId)
) {
throw new Error("External room mapping distribution board belongs to another project.");
}
}
for (const object of target.objects) {
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
throw new Error("External object distribution board belongs to another project.");
}
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
throw new Error("External object project device belongs to another project.");
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
const batch = state.importBatches[0];
if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig.");
if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig.");
const bytes = decodeCanonicalBase64(batch.originalContentBase64!);
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig.");
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id));
for (const mapping of state.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt.");
if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt.");
}
for (const object of state.objects) {
if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart.");
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler.");
if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät.");
}
}
function replaceExternalState(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
database.delete(externalModelSources)
.where(eq(externalModelSources.projectId, projectId)).run();
if (target.source === null) return;
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
if (target) database.insert(externalModelConfigurations).values(target).run();
}
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) {
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
if (!target.source) return;
database.insert(externalModelSources).values(target.source).run();
database.insert(externalImportBatches).values(
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
}))
).run();
if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run();
}
if (target.objects.length) {
database.insert(externalModelObjects).values(target.objects).run();
}
insertManyInBatches(database, externalImportBatches, target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") })));
insertManyInBatches(database, externalRoomMappings, target.roomMappings);
insertManyInBatches(database, externalModelObjects, target.objects);
}
function decodeCanonicalBase64(value: string) {
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("External initial import bytes are not canonical base64.");
}
return bytes;
}
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
}
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
const byId = <T extends { id: string }>(entries: readonly T[]) =>
[...entries].sort((left, right) => left.id.localeCompare(right.id));
return {
source: state.source,
importBatches: byId(state.importBatches),
roomMappings: byId(state.roomMappings),
objects: byId(state.objects),
};
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }

View file

@ -0,0 +1,79 @@
import { eq } from "drizzle-orm";
import {
assertExternalModelConfigurationUpdateProjectCommand,
createExternalModelConfigurationUpdateProjectCommand,
type ExternalModelConfigurationSnapshot,
} from "../../domain/models/external-model-configuration-project-command.model.js";
import type {
ExecuteExternalModelConfigurationCommandInput,
ExternalModelConfigurationProjectCommandStore,
} from "../../domain/ports/external-model-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalModelConfigurationProjectCommandRepository
implements ExternalModelConfigurationProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalModelConfigurationCommandInput) {
assertExternalModelConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx.select({ id: projects.id }).from(projects)
.where(eq(projects.id, input.projectId)).get();
if (!project) throw new Error("Project not found.");
const current = tx.select().from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
}
if (sameSnapshot(current, target)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
}
if (target === null) {
tx.delete(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
} else if (current === null) {
tx.insert(externalModelConfigurations).values(target).run();
} else {
const updated = tx.update(externalModelConfigurations)
.set({ configuration: target.configuration })
.where(eq(externalModelConfigurations.id, current.id)).run();
if (updated.changes !== 1) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
return createExternalModelConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalModelConfigurationSnapshot | null,
expected: ExternalModelConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
function sameSnapshot(
left: ExternalModelConfigurationSnapshot | null,
right: ExternalModelConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -1,12 +1,11 @@
import { eq } from "drizzle-orm";
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js";
export class ExternalCsvConfigurationRepository
implements ExternalCsvConfigurationReader
{
export class ExternalModelConfigurationRepository
implements ExternalModelConfigurationReader {
constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) {
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return { projectExists: false, configuration: null };
}
if (!project) return { projectExists: false, configuration: null };
const configuration = this.database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, projectId))
.get() ?? null;
return { projectExists: true, configuration };
}

View file

@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
.select()
.from(externalModelObjects)
.where(eq(externalModelObjects.sourceId, source.id))
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
.orderBy(
asc(externalModelObjects.worksharingId),
asc(externalModelObjects.id)
)
.all();
return {
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
source,
importBatches: batches.map(({ originalBytes, ...batch }) => ({
...batch,
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
originalContentBase64:
originalBytes === null
? null
: Buffer.from(originalBytes).toString("base64"),
})),
roomMappings,
objects,

View file

@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition {
target: ExternalModelObjectSnapshot;
}
export function captureExternalObjectDetachTransitions(
database: AppDatabase,
projectId: string,
rowIds: string[]
): ExternalObjectLinkTransition[] {
if (rowIds.length === 0) return [];
return database.select().from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
inArray(externalModelObjects.circuitDeviceRowId, rowIds)
)).all()
.map(toExternalObjectSnapshot)
.map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } }));
}
export function invertExternalObjectLinkTransitions(
transitions: ExternalObjectLinkTransition[]
): ExternalObjectLinkTransition[] {
return transitions.map(({ expected, target }) => ({ expected: target, target: expected }));
}
export function loadExpectedExternalObjectTransitions(
database: AppDatabase,
projectId: string,
@ -51,7 +72,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
const markers = new Set(
input.allLinkedObjects.map((object) =>
object.acceptedSourceValues.selectionMarker.trim()
scalarText(object.acceptedSourceValues.selectionMarker).trim()
)
);
if (markers.size > 1) {
@ -86,7 +107,13 @@ export function applyExternalObjectLinks(
) {
for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects)
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
.set({
circuitDeviceRowId: target.circuitDeviceRowId,
distributionBoardId: target.distributionBoardId,
linkedProjectDeviceId: target.linkedProjectDeviceId,
planningValues: target.planningValues,
overriddenFields: target.overriddenFields,
})
.where(and(
eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null
@ -118,13 +145,17 @@ function matchesRowPlanningValues(
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function scalarText(value: string | number | boolean | null) {
return value === null ? "" : String(value);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {

View file

@ -0,0 +1,59 @@
import { and, eq, inArray } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import { assertExternalObjectDeleteMissingProjectCommand, invertExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../../domain/ports/external-object-delete-missing-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { projectDevices } from "../schema/project-devices.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalObjectDeleteMissingProjectCommandRepository implements ExternalObjectDeleteMissingProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectDeleteMissingProjectCommandStore["execute"]>[0]) {
assertExternalObjectDeleteMissingProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const rowById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
const rowIds = [...rowById.keys()];
const rowContexts = rowIds.length ? tx.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
if (rowContexts.length !== rowIds.length || rowContexts.some((entry) => entry.projectId !== input.projectId || !snapshotsEqual(toCircuitDeviceRowSnapshot(entry.row), rowById.get(entry.row.id)!.expected))) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
const currentObjects = tx.select().from(externalModelObjects).where(eq(externalModelObjects.projectId, input.projectId)).all().map(toExternalObjectSnapshot);
const currentById = new Map(currentObjects.map((object) => [object.id, object]));
for (const transition of input.command.payload.objects) {
const current = currentById.get((transition.expected ?? transition.target)!.id) ?? null;
if (transition.expected === null ? current !== null : current === null || !snapshotsEqual(current, transition.expected)) throw new Error("Ein fehlendes Revit-Objekt wurde zwischenzeitlich geändert.");
}
const sourceIds = ids(tx.select({ id: externalModelSources.id }).from(externalModelSources).where(eq(externalModelSources.projectId, input.projectId)).all());
const batchIds = ids(tx.select({ id: externalImportBatches.id }).from(externalImportBatches).where(eq(externalImportBatches.projectId, input.projectId)).all());
const mappingIds = ids(tx.select({ id: externalRoomMappings.id }).from(externalRoomMappings).where(eq(externalRoomMappings.projectId, input.projectId)).all());
const boardIds = ids(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all());
const deviceIds = ids(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all());
for (const { target } of input.command.payload.objects) if (target && (target.projectId !== input.projectId || !sourceIds.has(target.sourceId) || !batchIds.has(target.lastSeenImportBatchId) || !batchIds.has(target.lastAcceptedImportBatchId) || (target.externalRoomMappingId !== null && !mappingIds.has(target.externalRoomMappingId)) || (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) || (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)))) throw new Error("Ein wiederhergestelltes Revit-Objekt verweist auf ein anderes Projekt.");
const targetById = new Map(currentObjects.map((object) => [object.id, object]));
for (const transition of input.command.payload.objects) { const id = (transition.expected ?? transition.target)!.id; if (transition.target) targetById.set(id, transition.target); else targetById.delete(id); }
for (const transition of input.command.payload.rows) assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, externalObjects: [...targetById.values()].filter((object) => object.circuitDeviceRowId === transition.target.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
for (const transition of input.command.payload.rows) {
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Löschens geändert.");
}
for (const transition of input.command.payload.objects) {
const object = transition.expected ?? transition.target!;
if (transition.target === null) {
const result = tx.delete(externalModelObjects).where(and(eq(externalModelObjects.id, object.id), eq(externalModelObjects.projectId, input.projectId))).run();
if (result.changes !== 1) throw new Error("Ein fehlendes Revit-Objekt konnte nicht gelöscht werden.");
} else tx.insert(externalModelObjects).values(transition.target).run();
}
return invertExternalObjectDeleteMissingProjectCommand(input.command);
});
}
}
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }

View file

@ -0,0 +1,37 @@
import { and, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
export function assertDistributionBoardHasNoExternalObjects(
database: AppDatabase,
projectId: string,
distributionBoardId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalModelObjects.distributionBoardId, distributionBoardId)
)).limit(1).get();
if (reference) {
throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}
export function assertRoomHasNoExternalObjects(
database: AppDatabase,
projectId: string,
roomId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId))
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalRoomMappings.roomId, roomId)
)).limit(1).get();
if (reference) {
throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}

View file

@ -33,6 +33,7 @@ import {
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore
@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository
objects,
"External object changed during new-circuit assignment."
);
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
expectedCircuit.id,
]);
const deleted = database.delete(circuits).where(and(
eq(circuits.id, expectedCircuit.id),
eq(circuits.circuitListId, expectedCircuit.circuitListId)

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