Compare commits
10 commits
main
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf58296ba5 | ||
|
|
94688d7a8c | ||
|
|
2742b26b87 | ||
|
|
287da1c0e7 | ||
|
|
68d2bad6e8 | ||
|
|
05964fc15c | ||
|
|
961c96f7a1 | ||
|
|
b6e50539ad | ||
|
|
fa9eaec2c5 | ||
|
|
8b8526b8df |
69 changed files with 13428 additions and 73 deletions
59
AGENTS.md
59
AGENTS.md
|
|
@ -250,8 +250,11 @@ 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 6 additionally 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. 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
|
||||
|
|
@ -265,7 +268,7 @@ 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
|
||||
delete, Undo and Redo are revision-safe. Snapshot schema 5/6, 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
|
||||
|
|
@ -279,7 +282,7 @@ 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
|
||||
to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6
|
||||
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
|
||||
|
|
@ -368,6 +371,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 `0009`); 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
|
||||
|
|
@ -422,6 +429,18 @@ the only supported circuit-protection source; no flat compatibility fields remai
|
|||
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 (still schema version 1, additively extended) carries the
|
||||
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
|
||||
(migration `0008`). 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` is deliberately free
|
||||
text: the logical link to the feeding board/circuit is an open design decision.
|
||||
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
|
||||
|
|
@ -443,6 +462,35 @@ Required operations:
|
|||
- synchronization changes
|
||||
|
||||
|
||||
## Print View (Seitenansicht)
|
||||
|
||||
- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project
|
||||
page button "Seitenansicht". Read-only: it only uses existing GET endpoints.
|
||||
- Pure read model `src/frontend/print/print-document-model.ts` (tested in
|
||||
`tests/print-document-model.test.ts`); React sheets in
|
||||
`src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in
|
||||
`src/frontend/print/print-document.css`.
|
||||
- Pages: cover (A4 portrait, tabular), board overview with structure and
|
||||
power sums, one plan per board (A4/A3 landscape) whose title block sits in
|
||||
`<thead>` so it repeats on printed pages. PDF export uses the browser print
|
||||
dialog; page size, margins and "Seite x von y" come from injected named
|
||||
`@page` rules (`lb-portrait`, `lb-landscape`).
|
||||
- The board hierarchy and "Σ inkl. unterlagert" in the overview are
|
||||
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum,
|
||||
no upstream simultaneity factor). Replace this once the feed link is modeled.
|
||||
- Colors/fonts are the provisional kbp CD (`--pd-*` variables) until the
|
||||
official CD specification exists. 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 `0008`) and `document_branding_settings` (migration `0009`),
|
||||
endpoints `GET|PUT|DELETE /api/document-branding/logo`,
|
||||
`PUT /api/document-branding/office` and `GET /api/document-branding`.
|
||||
It is intentionally outside project revisions, snapshots and transfer.
|
||||
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
||||
is served with `nosniff` and a restrictive CSP.
|
||||
|
||||
## Revit / CSV External Model Work
|
||||
|
||||
When explicitly working on Phase 14, use
|
||||
|
|
@ -538,6 +586,9 @@ Users must be able to override sizing suggestions.
|
|||
- full electrical sizing
|
||||
- multi-user/PostgreSQL operation
|
||||
- supported production deployment
|
||||
- logical feed link between distribution boards (`fedFrom` stays free text
|
||||
until the project owner decides whether it references a board, a feeding
|
||||
circuit or both) and the resulting hierarchical power summation
|
||||
|
||||
Do not implement these while working on an unrelated phase.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ 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
|
||||
- [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
|
||||
|
|
|
|||
243
docs/cable-sizing-module.md
Normal file
243
docs/cable-sizing-module.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# Cable Sizing Module (Proposal)
|
||||
|
||||
**Status: proposal, not yet reviewed or merged by the project owner.** This
|
||||
document and the accompanying `src/cable-sizing/`, `src/db/schema/cable-sizing-calculations.ts`,
|
||||
`src/server/{controllers,routes}/cable-sizing.*` and `src/frontend/components/cable-sizing-*`
|
||||
files were written by a third party (see git history/authorship) against the
|
||||
project's own stated direction in `docs/spec/06-future-sizing-and-calculations.md`
|
||||
("The app should later support rule-based protection and cable sizing") and
|
||||
`AGENTS.md` ("... and later electrical sizing logic"). Nothing here has been
|
||||
pushed to the project's own repository; it exists as a local branch for
|
||||
review, testing and discussion.
|
||||
|
||||
## Why a separate module instead of extending core domain code
|
||||
|
||||
`AGENTS.md` is explicit that critical multi-write commands, the revision/undo
|
||||
system and the Circuit-First domain model are the supported architecture, and
|
||||
that changes should be small and reviewable. A cable-sizing calculation is
|
||||
**not** a project mutation - it doesn't need `expectedRevision`, doesn't
|
||||
belong in the undo/redo stack, and shouldn't grow the `circuit.update`
|
||||
command's `switch` statement or the DTOs it doesn't already have.
|
||||
|
||||
So this module is built exactly like the existing `src/external-model/`
|
||||
adapter (the Revit/CSV import foundation): a one-way dependency boundary.
|
||||
|
||||
```
|
||||
src/cable-sizing/domain/ <- pure functions and types, zero imports from
|
||||
db/, server/ or frontend/
|
||||
src/db/schema/ <- one new, fully additive table
|
||||
src/db/repositories/ <- one plain repository (no revision semantics)
|
||||
src/server/{controllers,routes}/cable-sizing.* <- one new, isolated route
|
||||
group, mounted with a single app.use() line
|
||||
src/frontend/components/cable-sizing-* <- one new modal + one small
|
||||
API client, following the existing
|
||||
FormModal / CircuitProtectionModal pattern
|
||||
```
|
||||
|
||||
Nothing outside these files imports from them except the two required
|
||||
one-line hooks described below. If this module is rejected or needs to move
|
||||
out again, removing it is a matter of deleting these files, the two
|
||||
one-line hooks, and running a `DROP TABLE` migration - it never touches
|
||||
`circuits`, `project_revisions` or any command/history table.
|
||||
|
||||
## The two required hooks into existing code
|
||||
|
||||
1. `src/server/index.ts`: one import + one `app.use("/api/cable-sizing", cableSizingRouter)`
|
||||
line, next to the existing `app.use("/api/projects", ...)` etc.
|
||||
2. `src/frontend/components/circuit-tree-editor.tsx`: one new `useState` for
|
||||
the open modal, one new trigger condition (mirroring the existing
|
||||
`isProtectionTrigger` / `protectionEditorCircuit` pattern almost exactly),
|
||||
and one new conditionally-rendered `<CableSizingModal />` alongside the
|
||||
existing `<CircuitProtectionModal />`. See the diff for the exact lines.
|
||||
|
||||
No changes to `circuit-project-command.model.ts`, `project-command.service.ts`,
|
||||
any Zod command schema, any migration for `circuits`/`project_revisions`, or
|
||||
any existing test.
|
||||
|
||||
## Coupling to the grid UI (the one fragile point)
|
||||
|
||||
Everything described above - the calculation, the audit table, applying a
|
||||
result via `circuit.update` - depends only on stable, explicitly documented
|
||||
domain fields (`cableType`/`cableCrossSection`/`cableLength`/
|
||||
`circuitTotalPower`/`voltage`/`protectionDevice.ratedCurrentA`), the same
|
||||
ones `AGENTS.md` already treats as protected ("Protection and cable data
|
||||
belong to the circuit"). None of that breaks if the grid UI changes.
|
||||
|
||||
The one place that *is* coupled to a UI implementation detail: the click
|
||||
trigger in `circuit-tree-editor.tsx` matches grid **column keys**
|
||||
(`cableSummary` and `cableCrossSection`, defined in
|
||||
`circuit-grid-model.ts`) to decide where to show the calculator icon.
|
||||
Column visibility and order in this app are a per-browser user preference,
|
||||
not fixed - so this is checked against both known cable-related columns to
|
||||
reduce (not eliminate) the chance a user's personal column layout hides the
|
||||
trigger entirely. If a future column rename removes both keys, the failure
|
||||
mode is purely cosmetic: the calculator icon/click stops appearing on that
|
||||
cell, nothing crashes, no data is affected, and the rest of the app
|
||||
(including manual cable-field editing) is completely unaffected - the
|
||||
module's actual logic and persistence never depend on this column key.
|
||||
|
||||
## Feature overview
|
||||
|
||||
- **Calculate a recommendation**: laying method, insulation, conductor
|
||||
material, ambient temperature, grouping, cos phi and max voltage drop -
|
||||
same VDE 0298-4 reference-method model as the sibling Kabelliste tool
|
||||
(see Verification status below).
|
||||
- **Breaker-aware sizing**: if the circuit already has a protection device
|
||||
(`circuit.protectionDevice`), the cross-section is selected against
|
||||
`designCurrentA = max(operatingCurrentA, protectionDevice.ratedCurrentA)`,
|
||||
not the load current alone - the standard `In <= Iz` rule (a breaker only
|
||||
trips at its own rated current, so a cable sized for the actual load
|
||||
alone could overheat under sustained current below that threshold). If no
|
||||
cross-section can cover an oversized breaker, that is reported as the
|
||||
limiting factor by name, not a generic capacity error.
|
||||
- **Maximum length for the voltage-drop limit**: each cross-section option
|
||||
also reports the longest single run that still meets the requested
|
||||
`maxVoltageDropPercent` at the given load - the inverse of the
|
||||
voltage-drop check, shown for the recommended cross-section.
|
||||
- **Practical minimum for socket circuits**: `single_phase`-category
|
||||
circuits are raised to at least 2.5 mm² if the calculation alone would
|
||||
recommend less - see the dedicated note under Verification status. Never
|
||||
lowers a calculation that already needs more.
|
||||
- **Manual entry, always available**: the modal has a plain text Kabeltyp/
|
||||
Querschnitt field. Running a calculation pre-fills it as a suggestion,
|
||||
but the field is the single source of truth for what gets applied, and
|
||||
stays freely editable - satisfying
|
||||
`docs/spec/06-future-sizing-and-calculations.md`'s explicit requirement
|
||||
that "users must remain able to manually override suggestions" without
|
||||
requiring a calculation to have been run first. A non-blocking warning
|
||||
appears if the typed value is not a standard cross-section, is smaller
|
||||
than the last calculation, or is below the practical minimum above -
|
||||
informational only, never forced, matching the same spec doc's "shown as
|
||||
a warning or status indicator, not as an automatic forced change."
|
||||
|
||||
## How a suggestion or manual entry is applied
|
||||
|
||||
The module never writes to `circuits` directly. The modal's "Übernehmen"
|
||||
button calls the **existing, unmodified** frontend helper with whatever is
|
||||
currently in the Kabeltyp/Querschnitt fields, calculated or hand-typed:
|
||||
|
||||
```ts
|
||||
updateCircuitById(projectId, expectedRevision, circuitId, {
|
||||
cableCrossSection: "4 mm²",
|
||||
cableType: "NYM-J 3x2.5",
|
||||
cableLength: 30,
|
||||
});
|
||||
```
|
||||
|
||||
which is the same `circuit.update` command the grid already uses for manual
|
||||
cell edits. This means:
|
||||
|
||||
- optimistic concurrency (`expectedRevision`) is respected automatically
|
||||
- the change appears in the project's revision history and is undoable/
|
||||
redoable exactly like a manual edit
|
||||
- nothing is ever written automatically - calculating only fills the modal's
|
||||
own fields, applying is always an explicit, separate click
|
||||
|
||||
## API contract
|
||||
|
||||
`POST /api/cable-sizing/calculate`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"phase": 1, // 1 | 3
|
||||
"mode": "power", // "power" | "current"
|
||||
"powerKw": 4.2, // circuit.circuitTotalPower, when mode="power"
|
||||
"cosPhi": 1,
|
||||
"voltage": 230, // circuit.voltage (already project-derived, read-only)
|
||||
"lengthM": 23.5, // circuit.cableLength
|
||||
"layingMethod": "C", // DIN VDE 0298-4 reference method, A1|A2|B1|B2|C|D1|D2|E|F|G
|
||||
"conductorMaterial": "copper", // "copper" | "aluminum"
|
||||
"insulation": "pvc", // "pvc" | "xlpe"
|
||||
"ambientTemperatureC": 30,
|
||||
"groupingCircuits": 1,
|
||||
"maxVoltageDropPercent": 3,
|
||||
"harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only
|
||||
"existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice
|
||||
"circuitCategory": "single_phase", // optional, from the circuit's section.category -
|
||||
// enables the practical-minimum convention below
|
||||
"context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" }
|
||||
}
|
||||
```
|
||||
|
||||
Response: `{ calculationId, result: CableSizingResult, alerts: CableSizingAlert[] }`
|
||||
(see `src/cable-sizing/domain/cable-sizing-calculation.ts` for the exact
|
||||
shape). `GET /api/cable-sizing/laying-methods` and `.../insulation-materials`
|
||||
expose the pick-list metadata (including which combinations are verified) so
|
||||
a future non-modal UI could build its own form without hard-coding the enum.
|
||||
|
||||
## Verification status - important
|
||||
|
||||
The reference current-carrying-capacity tables are ported from a sibling
|
||||
project's Kabelliste module, which was itself only verified against ~8
|
||||
public sources for **six** of the ten DIN VDE 0298-4 reference laying
|
||||
methods (A1, B2, C, E, D1, D2) and **PVC insulation only**. The other four
|
||||
methods (A2, B1, F, G) and XLPE/VPE insulation could not be verified without
|
||||
contradiction across sources during that earlier work, so
|
||||
`calculateCableSizing` deliberately returns `dataVerified: false` and no
|
||||
numeric recommendation for those combinations, rather than a guessed value.
|
||||
The UI surfaces this as a plain critical alert. Anyone with access to the
|
||||
actual norm text can extend `LAYING_METHOD_VERIFIED` and
|
||||
`CURRENT_CAPACITY_A` in `src/cable-sizing/domain/cable-sizing-calculation.ts`
|
||||
once the missing tables are confirmed.
|
||||
|
||||
The protection-coordination check (`existingProtectionRatedCurrentA` vs. the
|
||||
recommended cross-section's corrected capacity) is a **simplified** `In <=
|
||||
Iz` check only - it is not a full IEC 60364-4-43 overload (`I2 <= 1.45 x
|
||||
Iz`) or short-circuit withstand check.
|
||||
|
||||
`PRACTICAL_MINIMUM_CROSS_SECTION_MM2` (2.5 mm² for `single_phase`) is
|
||||
**not** a verified norm value at all - it is a named planning convention,
|
||||
sourced directly from this project's own
|
||||
`docs/spec/06-future-sizing-and-calculations.md` ("Standard Single-Phase
|
||||
Circuits ... usually use ... cable cross-section: 2.5 mm²"), applied as a
|
||||
floor on top of the calculated recommendation. It intentionally only
|
||||
covers the one category and one convention that document already states;
|
||||
it is not a general substitute for norm-compliant calculation.
|
||||
|
||||
## Relationship to the project's own future-sizing spec
|
||||
|
||||
`docs/spec/06-future-sizing-and-calculations.md` separately describes simple
|
||||
category-based defaults (e.g. lighting circuits -> 10 A / 1.5 mm²) as
|
||||
"common planning defaults, not a replacement for full norm-compliant
|
||||
calculation". This module is the latter: an on-demand, norm-referenced
|
||||
calculation for one circuit at a time, not a bulk default-filling tool. The
|
||||
two are complementary and could later be wired together (e.g. the category
|
||||
defaults pre-fill this module's laying-method/insulation fields), but that
|
||||
integration is out of scope here.
|
||||
|
||||
The spec's `isPublicBuilding` halogen-free rule and the `Control Requirement`
|
||||
(DALI/KNX/core-count) field are **not** implemented by this module - both
|
||||
concern cable *type* selection, not cross-section sizing, and the latter
|
||||
isn't in the current schema yet. They're natural follow-ups once this module
|
||||
is reviewed.
|
||||
|
||||
## Maintenance plan
|
||||
|
||||
The `src/cable-sizing/domain/` folder has zero imports from the rest of the
|
||||
app by design (see the dependency-direction note above), so it can be
|
||||
extracted into an independently versioned/published package later (e.g. a
|
||||
private npm package or a git subtree) without touching anything outside the
|
||||
five files/folders listed above - "separately maintained but part of the
|
||||
app for now", per the intent of this proposal.
|
||||
|
||||
## Local testing
|
||||
|
||||
Deployed and manually exercised end-to-end on an internal test host at
|
||||
`http://192.168.0.133:3220` (own docker deployment, own subnet/ports to
|
||||
avoid clashing with ~50 other containers on that host - see the two
|
||||
commits marked "Local-only" in this branch, which are not part of this
|
||||
proposal and should not be carried over if it is ever proposed upstream).
|
||||
Exercised against seeded real project/circuit data created through the
|
||||
actual command API (not direct DB writes), including a deliberately long
|
||||
(85 m) circuit to trigger the voltage-drop-critical path.
|
||||
|
||||
`npm test`, `npm run build:api`, `npm run build:web` and
|
||||
`npm run typecheck:scripts` all pass with this module included at every
|
||||
commit in this branch.
|
||||
|
||||
This branch (`feature/cable-sizing-module`) is pushed to a mirror of this
|
||||
repository under this homelab's own Forgejo instance, not to
|
||||
`git.jappel.io` - no write access to the upstream repository was available
|
||||
or used. See the branch's commit history for the incremental history of
|
||||
this module, including fixes made during manual testing (trigger column,
|
||||
icon rendering, breaker-aware sizing).
|
||||
|
|
@ -74,8 +74,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 +174,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": { ... } }`
|
||||
|
|
@ -230,9 +234,13 @@ 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, "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`
|
||||
- executes `distribution-board.update`; all submitted values, including the
|
||||
submitted info fields, are restored together by persistent Undo/Redo
|
||||
- `fedFrom` is free text until the logical feed link is modeled
|
||||
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
||||
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
||||
- duplicates the complete distribution-board subtree with new owning UUIDs
|
||||
|
|
|
|||
|
|
@ -27,3 +27,12 @@
|
|||
- 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.
|
||||
- Distribution-board `fedFrom` is free text. The print overview derives its
|
||||
supply structure and "Σ inkl. unterlagert" only from exact text matches with
|
||||
board numbers/names and adds values without an upstream simultaneity factor
|
||||
(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.
|
||||
|
|
|
|||
|
|
@ -232,8 +232,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
|
||||
`0009`), 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
|
||||
|
|
@ -416,7 +417,14 @@ 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 `0008`). 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` ist vorerst Freitext.
|
||||
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
|
||||
|
|
|
|||
106
docs/print-view.md
Normal file
106
docs/print-view.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Seitenansicht und PDF-Export
|
||||
|
||||
Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
|
||||
|
||||
## 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), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
|
||||
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“.
|
||||
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
||||
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
||||
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
|
||||
(mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen),
|
||||
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.
|
||||
|
||||
## Vorläufige Punkte
|
||||
|
||||
- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet
|
||||
einen Verteiler nur dann unter, wenn der Text genau der Nummer oder
|
||||
Bezeichnung eines anderen Verteilers entspricht; „Σ inkl. unterlagert“ ist
|
||||
eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung
|
||||
(Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind
|
||||
offen.
|
||||
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert
|
||||
S / (√3 · U<sub>3~</sub>) mit S aus P / cos φ je Gerätezeile
|
||||
(fehlendes cos φ = 1) und Verteiler-GZF.
|
||||
- **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 `0009`) 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 `0009`),
|
||||
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 `0008`).
|
||||
- `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
|
||||
|
|
@ -12,6 +12,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 +89,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)
|
||||
|
|
@ -98,6 +104,10 @@ const requiredDistributionBoardColumns = [
|
|||
"floor_id",
|
||||
"supply_type",
|
||||
"simultaneity_factor",
|
||||
"board_number",
|
||||
"location",
|
||||
"fed_from",
|
||||
"remark",
|
||||
];
|
||||
const distributionBoardColumns = new Set(
|
||||
db
|
||||
|
|
|
|||
|
|
@ -1209,6 +1209,48 @@ a.kpi:hover {
|
|||
box-shadow: inset 0 0 0 1px var(--color-signal);
|
||||
}
|
||||
|
||||
.tree-grid .cell-cable-sizing-trigger {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger {
|
||||
color: var(--color-accent-pale);
|
||||
}
|
||||
|
||||
.tree-grid .cell-cable-sizing-trigger:hover {
|
||||
background: rgba(63, 130, 166, 0.15);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.tree-grid .cell-cable-sizing-trigger {
|
||||
position: relative;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.tree-grid .cell-cable-sizing-trigger::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.2em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
opacity: 0.85;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233f82a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger::before {
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a9d2e6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.tree-grid .device-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ import {
|
|||
type DistributionBoardSupplyType,
|
||||
} from "../../../shared/constants/distribution-board";
|
||||
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
|
||||
import {
|
||||
DistributionBoardInfoCard,
|
||||
fromDistributionBoardInfoDraft,
|
||||
toDistributionBoardInfoDraft,
|
||||
type DistributionBoardInfoDraft,
|
||||
} from "../../../frontend/components/distribution-board-info-card";
|
||||
import {
|
||||
ProjectSettingsModal,
|
||||
type ProjectSettingsInput,
|
||||
|
|
@ -101,6 +107,15 @@ export default function ProjectDetailPage() {
|
|||
setEditingBoardSimultaneityFactor,
|
||||
] = useState("1");
|
||||
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
||||
const [editingBoardInfo, setEditingBoardInfo] =
|
||||
useState<DistributionBoardInfoDraft>(
|
||||
toDistributionBoardInfoDraft({
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
})
|
||||
);
|
||||
const [floorName, setFloorName] = useState("");
|
||||
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
||||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
|
||||
|
|
@ -424,6 +439,7 @@ export default function ProjectDetailPage() {
|
|||
String(board.simultaneityFactor)
|
||||
);
|
||||
setEditingBoardCopyName(`${board.name} Kopie`);
|
||||
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
|
||||
}
|
||||
|
||||
function openBoardCreator() {
|
||||
|
|
@ -459,6 +475,7 @@ export default function ProjectDetailPage() {
|
|||
floorId: editingBoardFloorId || null,
|
||||
supplyType: editingBoardSupplyType,
|
||||
simultaneityFactor,
|
||||
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
||||
},
|
||||
project.currentRevision
|
||||
);
|
||||
|
|
@ -849,6 +866,13 @@ export default function ProjectDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
href={`/projects/${projectId}/print`}
|
||||
target="_blank"
|
||||
>
|
||||
Seitenansicht
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
|
|
@ -922,7 +946,9 @@ export default function ProjectDetailPage() {
|
|||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th>
|
||||
<th>Verteilung</th>
|
||||
<th>Örtlichkeit</th>
|
||||
<th>Etage</th>
|
||||
<th>Netzart</th>
|
||||
<th>GZF</th>
|
||||
|
|
@ -934,7 +960,11 @@ export default function ProjectDetailPage() {
|
|||
const circuitList = circuitListByBoardId.get(board.id);
|
||||
return (
|
||||
<tr key={board.id}>
|
||||
<td className="fw-semibold">
|
||||
{board.boardNumber ?? "–"}
|
||||
</td>
|
||||
<td>{board.name}</td>
|
||||
<td>{board.location ?? "–"}</td>
|
||||
<td>
|
||||
{board.floorId
|
||||
? floorById.get(board.floorId)?.name ?? "Unbekannt"
|
||||
|
|
@ -985,7 +1015,7 @@ export default function ProjectDetailPage() {
|
|||
})}
|
||||
{!boards.length ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-secondary py-4">
|
||||
<td colSpan={7} className="text-center text-secondary py-4">
|
||||
Noch keine Verteilungen vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1464,6 +1494,12 @@ export default function ProjectDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DistributionBoardInfoCard
|
||||
draft={editingBoardInfo}
|
||||
onChange={setEditingBoardInfo}
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
<section aria-labelledby="copy-distribution-board-heading">
|
||||
<h3
|
||||
|
|
|
|||
357
src/app/projects/[projectId]/print/page.tsx
Normal file
357
src/app/projects/[projectId]/print/page.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"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,
|
||||
getCircuitTree,
|
||||
getDocumentBranding,
|
||||
getProject,
|
||||
listCircuitLists,
|
||||
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 {
|
||||
buildPreliminaryBoardHierarchy,
|
||||
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, circuitLists, floors, snapshots, branding] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listDistributionBoards(projectId),
|
||||
listCircuitLists(projectId),
|
||||
listFloors(projectId),
|
||||
listProjectSnapshots(projectId),
|
||||
getDocumentBranding(),
|
||||
]);
|
||||
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
|
||||
const summaries = await Promise.all(
|
||||
[...boards]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
|
||||
boardSortCollator.compare(left.name, right.name)
|
||||
)
|
||||
.flatMap((board) => {
|
||||
const circuitList = circuitLists.find(
|
||||
(list) => list.distributionBoardId === board.id
|
||||
);
|
||||
return circuitList ? [{ board, circuitList }] : [];
|
||||
})
|
||||
.map(async ({ board, circuitList }) =>
|
||||
summarizePrintBoard({
|
||||
board,
|
||||
circuitListId: circuitList.id,
|
||||
tree: await getCircuitTree(projectId, circuitList.id),
|
||||
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
|
||||
})
|
||||
)
|
||||
);
|
||||
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 ? buildPreliminaryBoardHierarchy(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>
|
||||
);
|
||||
}
|
||||
547
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
547
src/cable-sizing/domain/cable-sizing-calculation.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
// Pure cable sizing domain logic. No imports from db/, server/ or frontend/ -
|
||||
// mirrors the dependency direction of src/external-model/ (see
|
||||
// docs/cable-sizing-module.md). Everything here is a stateless function on
|
||||
// plain data; persistence and the versioned circuit.update command live
|
||||
// outside this module.
|
||||
//
|
||||
// The numeric reference tables below (cross-section current-carrying
|
||||
// capacity, temperature/grouping correction factors) are ported from a
|
||||
// sibling project's already-verified Kabelliste module
|
||||
// (elt-planung-suite/src/tools/kabel/index.ts), not re-derived. See
|
||||
// docs/cable-sizing-module.md for the verification history and the explicit
|
||||
// "not yet verified" methods, which intentionally return no numeric result
|
||||
// instead of a guessed one (dataVerified: false).
|
||||
|
||||
// -- Laying methods (DIN VDE 0298-4 reference installation methods) --------
|
||||
|
||||
export const LAYING_METHODS = [
|
||||
"A1",
|
||||
"A2",
|
||||
"B1",
|
||||
"B2",
|
||||
"C",
|
||||
"D1",
|
||||
"D2",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
] 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",
|
||||
F: "air",
|
||||
D1: "ground",
|
||||
D2: "ground",
|
||||
G: "ground",
|
||||
};
|
||||
|
||||
// A1, B2, C, E, D1, D2: verified against elt-planung-suite's ported original
|
||||
// tool. A2, B1, F, G: no verified capacity table available yet (see
|
||||
// docs/cable-sizing-module.md) - calculateCableSizing returns
|
||||
// dataVerified: false for these instead of a guessed number.
|
||||
export const LAYING_METHOD_VERIFIED: Record<LayingMethod, boolean> = {
|
||||
A1: true,
|
||||
B2: true,
|
||||
C: true,
|
||||
E: true,
|
||||
D1: true,
|
||||
D2: true,
|
||||
A2: false,
|
||||
B1: false,
|
||||
F: false,
|
||||
G: false,
|
||||
};
|
||||
|
||||
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
|
||||
A1: "A1 - Rohr in wärmegedämmter Wand",
|
||||
A2: "A2 - Mehradriges Kabel im Rohr in wärmegedämmter Wand (Werte nicht hinterlegt)",
|
||||
B1: "B1 - Einzeladern im Rohr auf/unter Putz (Werte nicht hinterlegt)",
|
||||
B2: "B2 - Rohr auf/unter Putz",
|
||||
C: "C - Kabel direkt auf Wand verlegt",
|
||||
E: "E - Kabelpritsche / frei in Luft",
|
||||
F: "F - Kabel frei in Luft, einzeln mit Abstand (Werte nicht hinterlegt)",
|
||||
D1: "D1 - Kabel direkt im Erdreich",
|
||||
D2: "D2 - Kabel im Rohr im Erdreich",
|
||||
G: "G - Erdverlegung, Sonderfall (Werte nicht hinterlegt)",
|
||||
};
|
||||
|
||||
// -- 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 (Grenztemperatur 70 °C)",
|
||||
xlpe: "VPE/XLPE (Grenztemperatur 90 °C) - Strombelastbarkeitswerte nicht hinterlegt",
|
||||
};
|
||||
|
||||
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
|
||||
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
|
||||
|
||||
// Only PVC combined with one of the six verified laying methods returns a
|
||||
// numeric result. XLPE has no verified capacity table for any method yet.
|
||||
export function isCableSizingDataVerified(
|
||||
layingMethod: LayingMethod,
|
||||
insulation: InsulationMaterial
|
||||
): boolean {
|
||||
return insulation === "pvc" && LAYING_METHOD_VERIFIED[layingMethod];
|
||||
}
|
||||
|
||||
// -- Harmonic neutral loading (IEC 60364-5-52, 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 (IEC 60364-5-52)",
|
||||
over33Percent:
|
||||
"3. Harmonische > 33 % - Neutralleiter wie Außenleiter dimensionieren",
|
||||
};
|
||||
|
||||
export const HARMONIC_REDUCTION_FACTOR = 0.86;
|
||||
|
||||
// -- Reference tables (ported, verified subset only) ------------------------
|
||||
|
||||
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;
|
||||
|
||||
export const CURRENT_CAPACITY_A: Partial<
|
||||
Record<LayingMethod, Record<CoreCount, (number | null)[]>>
|
||||
> = {
|
||||
A1: {
|
||||
2: [15.5, 21, 28, 36, 50, 68, 89, 110, 134, 171, 207, 239, null, null, null],
|
||||
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, null, null, null],
|
||||
},
|
||||
B2: {
|
||||
2: [16.5, 23, 30, 38, 52, 69, 90, 111, 134, 171, 207, 239, null, null, null],
|
||||
3: [15, 20, 27, 34, 46, 62, 80, 99, 118, 149, 179, 206, null, null, null],
|
||||
},
|
||||
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],
|
||||
},
|
||||
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],
|
||||
},
|
||||
D1: {
|
||||
2: [26, 34, 44, 56, 74, 96, 123, 147, 174, 216, 256, 290, 328, 367, 424],
|
||||
3: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 361],
|
||||
},
|
||||
D2: {
|
||||
2: [22, 29, 38, 47, 63, 81, 104, 125, 148, 183, 216, 246, 278, 312, 360],
|
||||
3: [18.5, 24, 31, 39, 52, 67, 86, 103, 122, 151, 179, 203, 230, 258, 297],
|
||||
},
|
||||
};
|
||||
|
||||
export const TEMPERATURE_FACTOR_AIR: Record<number, number> = {
|
||||
10: 1.29,
|
||||
15: 1.22,
|
||||
20: 1.15,
|
||||
25: 1.08,
|
||||
30: 1.0,
|
||||
35: 0.91,
|
||||
40: 0.82,
|
||||
45: 0.71,
|
||||
50: 0.58,
|
||||
};
|
||||
|
||||
export const TEMPERATURE_FACTOR_GROUND: Record<number, number> = {
|
||||
10: 1.1,
|
||||
15: 1.05,
|
||||
20: 1.0,
|
||||
25: 0.95,
|
||||
30: 0.89,
|
||||
35: 0.84,
|
||||
40: 0.77,
|
||||
};
|
||||
|
||||
export const GROUPING_FACTOR_AIR: Record<number, number> = {
|
||||
1: 1.0,
|
||||
2: 0.8,
|
||||
3: 0.7,
|
||||
4: 0.65,
|
||||
5: 0.6,
|
||||
6: 0.57,
|
||||
8: 0.54,
|
||||
10: 0.5,
|
||||
};
|
||||
|
||||
export const GROUPING_FACTOR_GROUND: Record<number, number> = {
|
||||
1: 1.0,
|
||||
2: 0.75,
|
||||
3: 0.65,
|
||||
4: 0.6,
|
||||
5: 0.55,
|
||||
6: 0.52,
|
||||
8: 0.49,
|
||||
10: 0.46,
|
||||
};
|
||||
|
||||
export const ALUMINUM_CAPACITY_FACTOR = 0.78;
|
||||
export const KAPPA: Record<ConductorMaterial, number> = { copper: 48, aluminum: 30 };
|
||||
|
||||
export const GROUPING_OPTIONS = [1, 2, 3, 4, 5, 6, 8, 10] as const;
|
||||
|
||||
export const GROUPING_LABELS: Record<number, string> = {
|
||||
1: "1 (keine Häufung)",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
5: "5",
|
||||
6: "6",
|
||||
8: "7-9",
|
||||
10: "≥10",
|
||||
};
|
||||
|
||||
// -- Practical minimum cross-sections by circuit category ------------------
|
||||
// Not a thermal/voltage-drop calculation result - a widely used planning
|
||||
// convention for margin against future load growth, mechanical robustness
|
||||
// and fault-current withstand, beyond what a pure Ib-based calculation
|
||||
// would give. Matches this project's own docs/spec/06-future-sizing-and-
|
||||
// calculations.md ("Standard Single-Phase Circuits ... usually use ...
|
||||
// cable cross-section: 2.5 mm²"). Only applied for the "single_phase"
|
||||
// circuit category (general sockets and similar loads); lighting and
|
||||
// three-phase circuits are not covered by this specific convention.
|
||||
export const PRACTICAL_MINIMUM_CROSS_SECTION_MM2: Partial<Record<CircuitCategory, number>> = {
|
||||
single_phase: 2.5,
|
||||
};
|
||||
|
||||
export type CircuitCategory = "lighting" | "single_phase" | "three_phase";
|
||||
|
||||
// -- 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. The circuit-list app derives this from project
|
||||
* settings (singlePhaseVoltageV / threePhaseVoltageV); pass it through
|
||||
* rather than hard-coding 230/400 here. */
|
||||
voltage: number;
|
||||
/** Single-run cable length in meters (circuits.cableLength). */
|
||||
lengthM: number;
|
||||
layingMethod: LayingMethod;
|
||||
conductorMaterial: ConductorMaterial;
|
||||
insulation: InsulationMaterial;
|
||||
ambientTemperatureC: number;
|
||||
groupingCircuits: number;
|
||||
maxVoltageDropPercent: number;
|
||||
harmonicNeutralLoad: HarmonicNeutralLoad;
|
||||
/** Optional: existing protection device rated current (LS/circuit
|
||||
* breaker), for the simplified coordination check below. */
|
||||
existingProtectionRatedCurrentA?: number;
|
||||
/** Optional: enables the practical-minimum-cross-section convention for
|
||||
* "single_phase" circuits, see PRACTICAL_MINIMUM_CROSS_SECTION_MM2. */
|
||||
circuitCategory?: CircuitCategory;
|
||||
}
|
||||
|
||||
export interface CrossSectionRow {
|
||||
crossSectionMm2: number;
|
||||
ratedCurrentA: number | null;
|
||||
correctedCurrentA: number | null;
|
||||
currentSufficient: boolean | null;
|
||||
voltageDropPercent: number | null;
|
||||
voltageDropSufficient: boolean | null;
|
||||
/** Longest single-run length (m) at which this cross-section still meets
|
||||
* the requested max voltage drop, at the given load/cosPhi/phase - the
|
||||
* inverse of the voltage-drop formula used for voltageDropPercent above.
|
||||
* null when the operating current is zero (division by zero). */
|
||||
maxLengthForVoltageDropM: number | null;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
export interface ProtectionCoordinationResult {
|
||||
ratedCurrentA: number;
|
||||
/** Simplified In <= Iz check only - not full overload (I2 <= 1.45 x Iz)
|
||||
* or short-circuit coordination. */
|
||||
coordinated: boolean;
|
||||
}
|
||||
|
||||
export interface CableSizingResult {
|
||||
/** false: no verified capacity table for this layingMethod/insulation
|
||||
* combination - every field below is null/empty, never a guess. */
|
||||
dataVerified: boolean;
|
||||
operatingCurrentA: number;
|
||||
/** max(operatingCurrentA, existingProtectionRatedCurrentA). The cable
|
||||
* capacity (Iz) must cover the protective device's rated current (In),
|
||||
* not just the actual load current - a breaker only trips at In, so a
|
||||
* cable sized for Ib alone could overheat under sustained load below the
|
||||
* trip threshold. This is the value cross-section selection actually
|
||||
* uses; operatingCurrentA is kept for display/voltage-drop only. */
|
||||
designCurrentA: number;
|
||||
crossSectionByCapacityMm2: number | null;
|
||||
crossSectionByVoltageDropMm2: number | null;
|
||||
recommendedCrossSectionMm2: number | null;
|
||||
voltageDropAtRecommendedPercent: number | null;
|
||||
combinedDerationFactor: number;
|
||||
harmonicReductionApplied: boolean;
|
||||
/** true if the recommendation was raised to satisfy
|
||||
* PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (a convention, not a thermal/
|
||||
* voltage-drop requirement of this specific circuit). */
|
||||
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.existingProtectionRatedCurrentA != null
|
||||
? Math.max(ib, input.existingProtectionRatedCurrentA)
|
||||
: ib;
|
||||
|
||||
if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) {
|
||||
return {
|
||||
dataVerified: false,
|
||||
operatingCurrentA: ib,
|
||||
designCurrentA,
|
||||
crossSectionByCapacityMm2: null,
|
||||
crossSectionByVoltageDropMm2: null,
|
||||
recommendedCrossSectionMm2: null,
|
||||
voltageDropAtRecommendedPercent: null,
|
||||
combinedDerationFactor: 1,
|
||||
harmonicReductionApplied: false,
|
||||
practicalMinimumApplied: false,
|
||||
rows: [],
|
||||
protectionCoordination: null,
|
||||
};
|
||||
}
|
||||
|
||||
const coreCount: CoreCount = input.phase === 1 ? 2 : 3;
|
||||
const aluminumFactor =
|
||||
input.conductorMaterial === "aluminum" ? ALUMINUM_CAPACITY_FACTOR : 1;
|
||||
const kappa = KAPPA[input.conductorMaterial];
|
||||
const group = LAYING_METHOD_GROUP[input.layingMethod];
|
||||
const temperatureTable =
|
||||
group === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
|
||||
const groupingTable = group === "air" ? GROUPING_FACTOR_AIR : GROUPING_FACTOR_GROUND;
|
||||
const temperatureFactor = temperatureTable[input.ambientTemperatureC] ?? 1;
|
||||
const groupingFactor = groupingTable[input.groupingCircuits] ?? 1;
|
||||
|
||||
const harmonicReductionApplied =
|
||||
input.phase === 3 && input.harmonicNeutralLoad === "15to33Percent";
|
||||
const harmonicFactor = harmonicReductionApplied ? HARMONIC_REDUCTION_FACTOR : 1;
|
||||
|
||||
const combinedDerationFactor = temperatureFactor * groupingFactor * harmonicFactor;
|
||||
const voltageDropCoefficient = input.phase === 1 ? 2 : Math.sqrt(3);
|
||||
|
||||
const ratedCurrents = CURRENT_CAPACITY_A[input.layingMethod]![coreCount];
|
||||
const correctedCurrents = ratedCurrents.map((value) =>
|
||||
value == null ? null : value * aluminumFactor * 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) => value <= input.maxVoltageDropPercent
|
||||
);
|
||||
let indexRecommended = -1;
|
||||
if (indexByCapacity >= 0 && indexByVoltageDrop >= 0) {
|
||||
indexRecommended = Math.max(indexByCapacity, indexByVoltageDrop);
|
||||
}
|
||||
|
||||
// Practical-minimum convention (see PRACTICAL_MINIMUM_CROSS_SECTION_MM2):
|
||||
// only ever raises the recommendation, never lowers what the thermal/
|
||||
// voltage-drop calculation already required.
|
||||
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) {
|
||||
indexRecommended = minimumIndex;
|
||||
practicalMinimumApplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
const voltageDropCurrentBasis = ib * input.cosPhi;
|
||||
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
|
||||
const rated = ratedCurrents[i];
|
||||
const corrected = correctedCurrents[i];
|
||||
const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
|
||||
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
|
||||
const maxLengthForVoltageDropM =
|
||||
rated == null || voltageDropCurrentBasis <= 0
|
||||
? null
|
||||
: (input.maxVoltageDropPercent * kappa * crossSection * input.voltage) /
|
||||
(100 * voltageDropCoefficient * voltageDropCurrentBasis);
|
||||
return {
|
||||
crossSectionMm2: crossSection,
|
||||
ratedCurrentA: rated,
|
||||
correctedCurrentA: corrected,
|
||||
currentSufficient,
|
||||
voltageDropPercent: rated == null ? null : voltageDrops[i],
|
||||
voltageDropSufficient: rated == null ? null : voltageDropSufficient,
|
||||
maxLengthForVoltageDropM,
|
||||
recommended: i === indexRecommended,
|
||||
};
|
||||
});
|
||||
|
||||
let protectionCoordination: ProtectionCoordinationResult | null = null;
|
||||
if (input.existingProtectionRatedCurrentA != null && indexRecommended >= 0) {
|
||||
const recommendedRow = rows[indexRecommended];
|
||||
protectionCoordination = {
|
||||
ratedCurrentA: input.existingProtectionRatedCurrentA,
|
||||
coordinated:
|
||||
recommendedRow.correctedCurrentA != null &&
|
||||
input.existingProtectionRatedCurrentA <= recommendedRow.correctedCurrentA,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dataVerified: true,
|
||||
operatingCurrentA: ib,
|
||||
designCurrentA,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CableSizingAlert {
|
||||
kind: "critical" | "warn" | "info" | "ok";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function buildCableSizingAlerts(
|
||||
input: CableSizingInput,
|
||||
result: CableSizingResult
|
||||
): CableSizingAlert[] {
|
||||
const alerts: CableSizingAlert[] = [];
|
||||
|
||||
if (!result.dataVerified) {
|
||||
alerts.push({
|
||||
kind: "critical",
|
||||
text: `Für Verlegeart ${input.layingMethod} mit Isolierstoff ${input.insulation.toUpperCase()} liegen keine geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich.`,
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
const byCapacity = result.crossSectionByCapacityMm2;
|
||||
const byDrop = result.crossSectionByVoltageDropMm2;
|
||||
const recommended = result.recommendedCrossSectionMm2;
|
||||
|
||||
if (byCapacity == null) {
|
||||
const dueToBreaker =
|
||||
input.existingProtectionRatedCurrentA != null &&
|
||||
input.existingProtectionRatedCurrentA > result.operatingCurrentA;
|
||||
alerts.push({
|
||||
kind: "critical",
|
||||
text: dueToBreaker
|
||||
? `Vorhandene Sicherung (${input.existingProtectionRatedCurrentA} A) übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod} - nicht der Betriebsstrom (${result.operatingCurrentA.toFixed(1)} A). Größere Verlegeart oder kleinere Sicherung prüfen.`
|
||||
: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`,
|
||||
});
|
||||
} else if (byDrop != null && byDrop > byCapacity) {
|
||||
alerts.push({
|
||||
kind: "warn",
|
||||
text: `Spannungsfall ist maßgeblich, nicht die Belastbarkeit: ${byCapacity} mm² würde thermisch reichen, ${byDrop} mm² ist wegen ΔU ≤ ${input.maxVoltageDropPercent}% nötig.`,
|
||||
});
|
||||
}
|
||||
if (result.harmonicReductionApplied) {
|
||||
alerts.push({
|
||||
kind: "info",
|
||||
text: "Reduktionsfaktor 0,86 wegen Oberschwingungsanteil 15-33 % im Neutralleiter angewendet (IEC 60364-5-52).",
|
||||
});
|
||||
}
|
||||
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
|
||||
alerts.push({
|
||||
kind: "warn",
|
||||
text: "3. Harmonische > 33 %: Neutralleiter muss wie ein Außenleiter dimensioniert werden - hier nicht automatisch berücksichtigt.",
|
||||
});
|
||||
}
|
||||
if (result.practicalMinimumApplied) {
|
||||
alerts.push({
|
||||
kind: "info",
|
||||
text: `Auf ${result.recommendedCrossSectionMm2} mm² angehoben (Praxis-Mindestquerschnitt für 1-phasige Stromkreise, keine reine Berechnungsanforderung).`,
|
||||
});
|
||||
}
|
||||
if (result.combinedDerationFactor < 1) {
|
||||
alerts.push({
|
||||
kind: "info",
|
||||
text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`,
|
||||
});
|
||||
}
|
||||
if (result.protectionCoordination) {
|
||||
alerts.push(
|
||||
result.protectionCoordination.coordinated
|
||||
? {
|
||||
kind: "info",
|
||||
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) ist bei der Dimensionierung berücksichtigt (In ≤ Iz, vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`,
|
||||
}
|
||||
: {
|
||||
kind: "warn",
|
||||
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen.`,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (alerts.length === 0 && recommended != null) {
|
||||
alerts.push({
|
||||
kind: "ok",
|
||||
text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`,
|
||||
});
|
||||
}
|
||||
const recommendedRow = result.rows.find((row) => row.recommended);
|
||||
if (recommendedRow?.maxLengthForVoltageDropM != null) {
|
||||
alerts.push({
|
||||
kind: "info",
|
||||
text: `Maximale Länge bei ${recommendedRow.crossSectionMm2} mm² und ΔU ≤ ${input.maxVoltageDropPercent}%: ${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m.`,
|
||||
});
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
49
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
49
src/cable-sizing/domain/cable-sizing-contracts.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Request/response 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,
|
||||
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().min(0).optional(),
|
||||
currentA: z.number().min(0).optional(),
|
||||
cosPhi: z.number().min(0.6).max(1),
|
||||
voltage: z.number().positive(),
|
||||
lengthM: z.number().min(0),
|
||||
layingMethod: z.enum(LAYING_METHODS),
|
||||
conductorMaterial: z.enum(CONDUCTOR_MATERIALS),
|
||||
insulation: z.enum(INSULATION_MATERIALS),
|
||||
ambientTemperatureC: z.number(),
|
||||
groupingCircuits: z.number().int().min(1),
|
||||
maxVoltageDropPercent: z.number().min(1).max(5),
|
||||
harmonicNeutralLoad: z.enum(HARMONIC_NEUTRAL_LOAD_OPTIONS),
|
||||
existingProtectionRatedCurrentA: z.number().positive().optional(),
|
||||
circuitCategory: z.enum(["lighting", "single_phase", "three_phase"]).optional(),
|
||||
// Optional context, persisted with the audit-log entry only - never
|
||||
// used for the calculation itself.
|
||||
context: z
|
||||
.object({
|
||||
projectId: z.string().min(1).optional(),
|
||||
circuitId: z.string().min(1).optional(),
|
||||
equipmentIdentifier: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((v) => (v.mode === "power" ? v.powerKw != null : v.currentA != null), {
|
||||
message: "powerKw is required for mode 'power', currentA for mode 'current'",
|
||||
});
|
||||
|
||||
export type CableSizingRequest = z.infer<typeof cableSizingRequestSchema>;
|
||||
|
||||
// Structural check that the request schema stays a superset of the domain
|
||||
// input shape (minus context, which is API-only metadata).
|
||||
type _AssertAssignable<T extends CableSizingInput> = T;
|
||||
type _Check = _AssertAssignable<Omit<CableSizingRequest, "context">>;
|
||||
14
src/db/migrations/0007_watery_kingpin.sql
Normal file
14
src/db/migrations/0007_watery_kingpin.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE `cable_sizing_calculations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`circuit_id` text,
|
||||
`equipment_identifier` text,
|
||||
`input` text NOT NULL,
|
||||
`result` text NOT NULL,
|
||||
`applied_to_circuit` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `cable_sizing_calculations_circuit_id_idx` ON `cable_sizing_calculations` (`circuit_id`);
|
||||
12
src/db/migrations/0008_colossal_terrax.sql
Normal file
12
src/db/migrations/0008_colossal_terrax.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE `document_branding_assets` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`file_name` text,
|
||||
`data` blob NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
|
||||
ALTER TABLE `distribution_boards` ADD `remark` text;
|
||||
10
src/db/migrations/0009_amazing_cable.sql
Normal file
10
src/db/migrations/0009_amazing_cable.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `document_branding_settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_editor` text;
|
||||
2522
src/db/migrations/meta/0007_snapshot.json
Normal file
2522
src/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2596
src/db/migrations/meta/0008_snapshot.json
Normal file
2596
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2656
src/db/migrations/meta/0009_snapshot.json
Normal file
2656
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,27 @@
|
|||
"when": 1786043080323,
|
||||
"tag": "0006_damp_skrulls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1786114831610,
|
||||
"tag": "0007_watery_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1790356758671,
|
||||
"tag": "0008_colossal_terrax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1790360862160,
|
||||
"tag": "0009_amazing_cable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
37
src/db/repositories/cable-sizing-calculation.repository.ts
Normal file
37
src/db/repositories/cable-sizing-calculation.repository.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { desc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import {
|
||||
cableSizingCalculations,
|
||||
type CableSizingCalculation,
|
||||
type NewCableSizingCalculation,
|
||||
} from "../schema/cable-sizing-calculations.js";
|
||||
|
||||
// Plain repository, no revision/command semantics - mirrors
|
||||
// application-repositories.ts's flat repository pattern, not the
|
||||
// project-command-transaction boundary. A calculation is an audit record,
|
||||
// not a project mutation.
|
||||
export class CableSizingCalculationRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
create(input: NewCableSizingCalculation): CableSizingCalculation {
|
||||
return this.db.insert(cableSizingCalculations).values(input).returning().get();
|
||||
}
|
||||
|
||||
listByCircuit(circuitId: string): CableSizingCalculation[] {
|
||||
return this.db
|
||||
.select()
|
||||
.from(cableSizingCalculations)
|
||||
.where(eq(cableSizingCalculations.circuitId, circuitId))
|
||||
.orderBy(desc(cableSizingCalculations.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
markApplied(id: string): CableSizingCalculation | undefined {
|
||||
return this.db
|
||||
.update(cableSizingCalculations)
|
||||
.set({ appliedToCircuit: 1 })
|
||||
.where(eq(cableSizingCalculations.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
|
@ -388,10 +388,15 @@ function structureMatches(
|
|||
) {
|
||||
const {
|
||||
simultaneityFactor,
|
||||
boardNumber,
|
||||
location,
|
||||
fedFrom,
|
||||
remark,
|
||||
...actualDistributionBoard
|
||||
} = actual.board;
|
||||
if (
|
||||
simultaneityFactor !== 1 ||
|
||||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
|
||||
!sameRecord(
|
||||
expected.distributionBoard,
|
||||
actualDistributionBoard
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
|
||||
import {
|
||||
assertDistributionBoardSubtreeSnapshot,
|
||||
normalizeDistributionBoardSubtreeSnapshot,
|
||||
type DistributionBoardSubtreeSnapshot,
|
||||
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
|
||||
import type {
|
||||
|
|
@ -63,19 +64,20 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
|||
projectId: string,
|
||||
command: DistributionBoardSubtreeProjectCommand
|
||||
) {
|
||||
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
|
||||
command.payload.snapshot
|
||||
);
|
||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createDistributionBoardDeleteSubtreeProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
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."
|
||||
);
|
||||
|
|
@ -97,9 +99,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.");
|
||||
}
|
||||
|
|
|
|||
86
src/db/repositories/document-branding.repository.ts
Normal file
86
src/db/repositories/document-branding.repository.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import {
|
||||
documentBrandingAssets,
|
||||
type DocumentBrandingAsset,
|
||||
} from "../schema/document-branding-assets.js";
|
||||
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
|
||||
|
||||
// Plain repository without revision semantics: branding is global office
|
||||
// configuration, not a project mutation.
|
||||
export class DocumentBrandingRepository {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
get(key: string): DocumentBrandingAsset | undefined {
|
||||
return this.db
|
||||
.select()
|
||||
.from(documentBrandingAssets)
|
||||
.where(eq(documentBrandingAssets.key, key))
|
||||
.get();
|
||||
}
|
||||
|
||||
upsert(asset: {
|
||||
key: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
data: Buffer;
|
||||
}): DocumentBrandingAsset {
|
||||
const updatedAt = new Date();
|
||||
return this.db
|
||||
.insert(documentBrandingAssets)
|
||||
.values({ ...asset, updatedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: documentBrandingAssets.key,
|
||||
set: {
|
||||
mimeType: asset.mimeType,
|
||||
fileName: asset.fileName,
|
||||
data: asset.data,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
return (
|
||||
this.db
|
||||
.delete(documentBrandingAssets)
|
||||
.where(eq(documentBrandingAssets.key, key))
|
||||
.run().changes > 0
|
||||
);
|
||||
}
|
||||
|
||||
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
|
||||
const rows = this.db
|
||||
.select()
|
||||
.from(documentBrandingSettings)
|
||||
.where(inArray(documentBrandingSettings.key, [...keys]))
|
||||
.all();
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
|
||||
) as Record<TKey, string | null>;
|
||||
}
|
||||
|
||||
/** Stores the given values atomically; `null` removes a setting. */
|
||||
setSettings(values: Record<string, string | null>) {
|
||||
const updatedAt = new Date();
|
||||
this.db.transaction((tx) => {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) {
|
||||
tx.delete(documentBrandingSettings)
|
||||
.where(eq(documentBrandingSettings.key, key))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(documentBrandingSettings)
|
||||
.values({ key, value, updatedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: documentBrandingSettings.key,
|
||||
set: { value, updatedAt },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectSettingsUpdateProjectCommand,
|
||||
baselineProjectSettingsUpdateCommandSchemaVersion,
|
||||
createProjectSettingsUpdateProjectCommand,
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
|
||||
type ProjectSettingsValues,
|
||||
} from "../../domain/models/project-settings-project-command.model.js";
|
||||
import { projectCoverFields } from "../../shared/constants/project-cover.js";
|
||||
import type {
|
||||
ExecuteProjectSettingsUpdateCommandInput,
|
||||
ProjectSettingsProjectCommandStore,
|
||||
|
|
@ -40,13 +43,27 @@ export class ProjectSettingsProjectCommandRepository
|
|||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
const currentCoverValues = {
|
||||
siteAddress: current.siteAddress,
|
||||
workStage: current.workStage,
|
||||
projectLead: current.projectLead,
|
||||
projectEditor: current.projectEditor,
|
||||
};
|
||||
// Older command versions do not carry every value; missing values keep
|
||||
// the current project state and are therefore also absent from the
|
||||
// inverse of the same version.
|
||||
const command = input.command;
|
||||
const target: ProjectSettingsValues =
|
||||
input.command.schemaVersion === 1
|
||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
||||
? {
|
||||
...input.command.payload,
|
||||
...command.payload,
|
||||
isPublicBuilding: current.isPublicBuilding,
|
||||
...currentCoverValues,
|
||||
}
|
||||
: input.command.payload;
|
||||
: command.schemaVersion ===
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||
? { ...command.payload, ...currentCoverValues }
|
||||
: command.payload;
|
||||
if (projectSettingsEqual(current, target)) {
|
||||
throw new Error("Project settings did not change.");
|
||||
}
|
||||
|
|
@ -62,11 +79,15 @@ export class ProjectSettingsProjectCommandRepository
|
|||
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
...currentCoverValues,
|
||||
};
|
||||
const inverse =
|
||||
input.command.schemaVersion === 1
|
||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
||||
? createBaselineProjectSettingsUpdateCommand(inverseValues)
|
||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||
: command.schemaVersion ===
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
|
||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
|
|
@ -82,11 +103,32 @@ export class ProjectSettingsProjectCommandRepository
|
|||
}
|
||||
}
|
||||
|
||||
function createWithoutCoverProjectSettingsUpdateCommand(
|
||||
values: ProjectSettingsValues
|
||||
) {
|
||||
return {
|
||||
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: "project.update-settings" as const,
|
||||
payload: {
|
||||
name: values.name,
|
||||
internalProjectNumber: values.internalProjectNumber,
|
||||
externalProjectNumber: values.externalProjectNumber,
|
||||
buildingOwner: values.buildingOwner,
|
||||
description: values.description,
|
||||
isPublicBuilding: values.isPublicBuilding,
|
||||
singlePhaseVoltageV: values.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: values.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
values.enabledDistributionBoardSupplyTypes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaselineProjectSettingsUpdateCommand(
|
||||
values: ProjectSettingsValues
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
|
||||
type: "project.update-settings" as const,
|
||||
payload: {
|
||||
name: values.name,
|
||||
|
|
@ -114,8 +156,9 @@ function projectSettingsEqual(
|
|||
current.description === target.description &&
|
||||
current.isPublicBuilding === target.isPublicBuilding &&
|
||||
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
||||
current.threePhaseVoltageV === target.threePhaseVoltageV
|
||||
&& sameSupplyTypes(
|
||||
current.threePhaseVoltageV === target.threePhaseVoltageV &&
|
||||
projectCoverFields.every((field) => current[field] === target[field]) &&
|
||||
sameSupplyTypes(
|
||||
current.enabledDistributionBoardSupplyTypes,
|
||||
target.enabledDistributionBoardSupplyTypes
|
||||
)
|
||||
|
|
|
|||
|
|
@ -233,6 +233,10 @@ function replaceProjectState(
|
|||
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
state.project.enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: state.project.siteAddress,
|
||||
workStage: state.project.workStage,
|
||||
projectLead: state.project.projectLead,
|
||||
projectEditor: state.project.projectEditor,
|
||||
})
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.run();
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ export function readProjectStateSnapshot(
|
|||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: projects.siteAddress,
|
||||
workStage: projects.workStage,
|
||||
projectLead: projects.projectLead,
|
||||
projectEditor: projects.projectEditor,
|
||||
currentRevision: projects.currentRevision,
|
||||
})
|
||||
.from(projects)
|
||||
|
|
@ -249,6 +253,10 @@ export function readProjectStateSnapshot(
|
|||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
project.enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: project.siteAddress,
|
||||
workStage: project.workStage,
|
||||
projectLead: project.projectLead,
|
||||
projectEditor: project.projectEditor,
|
||||
},
|
||||
distributionBoards: boardRows,
|
||||
externalCsvConfiguration,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export class ProjectRepository {
|
|||
enabledDistributionBoardSupplyTypes: [
|
||||
...defaultDistributionBoardSupplyTypes,
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
currentRevision: 0,
|
||||
};
|
||||
await db.insert(projects).values(project);
|
||||
|
|
|
|||
30
src/db/schema/cable-sizing-calculations.ts
Normal file
30
src/db/schema/cable-sizing-calculations.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { circuits } from "./circuits.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
// Audit log for the cable-sizing module (see docs/cable-sizing-module.md).
|
||||
// Deliberately separate from the revision/command system: a calculation is
|
||||
// not itself a project mutation, only applying its result via the existing
|
||||
// circuit.update command is. circuitId/projectId are nullable and
|
||||
// onDelete "set null" so a deleted circuit or project never blocks or
|
||||
// cascades into losing the audit trail.
|
||||
export const cableSizingCalculations = sqliteTable(
|
||||
"cable_sizing_calculations",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
circuitId: text("circuit_id").references(() => circuits.id, { onDelete: "set null" }),
|
||||
equipmentIdentifier: text("equipment_identifier"),
|
||||
input: text("input", { mode: "json" }).notNull(),
|
||||
result: text("result", { mode: "json" }).notNull(),
|
||||
appliedToCircuit: integer("applied_to_circuit").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => [index("cable_sizing_calculations_circuit_id_idx").on(table.circuitId)]
|
||||
);
|
||||
|
||||
export type CableSizingCalculation = typeof cableSizingCalculations.$inferSelect;
|
||||
export type NewCableSizingCalculation = typeof cableSizingCalculations.$inferInsert;
|
||||
|
|
@ -16,5 +16,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
|||
simultaneityFactor: real("simultaneity_factor")
|
||||
.notNull()
|
||||
.default(1),
|
||||
boardNumber: text("board_number"),
|
||||
location: text("location"),
|
||||
fedFrom: text("fed_from"),
|
||||
remark: text("remark"),
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/db/schema/document-branding-assets.ts
Normal file
18
src/db/schema/document-branding-assets.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Office-wide branding for printed documents (currently only the logo shown
|
||||
// on the cover page and plan title blocks). Global application data, not part
|
||||
// of any project: it is deliberately outside project revisions, logical
|
||||
// project snapshots and project transfer.
|
||||
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
|
||||
key: text("key").primaryKey(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
fileName: text("file_name"),
|
||||
data: blob("data", { mode: "buffer" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;
|
||||
13
src/db/schema/document-branding-settings.ts
Normal file
13
src/db/schema/document-branding-settings.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Office-wide text settings for printed documents (e.g. office name and
|
||||
// address on the cover page). Global application data like
|
||||
// document_branding_assets: outside project revisions, snapshots and transfer.
|
||||
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
|
@ -21,5 +21,9 @@ export const projects = sqliteTable("projects", {
|
|||
.$type<DistributionBoardSupplyType[]>()
|
||||
.notNull()
|
||||
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||
siteAddress: text("site_address"),
|
||||
workStage: text("work_stage"),
|
||||
projectLead: text("project_lead"),
|
||||
projectEditor: text("project_editor"),
|
||||
currentRevision: integer("current_revision").notNull().default(0),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import {
|
||||
distributionBoardInfoFieldMaxLengths,
|
||||
distributionBoardInfoFields,
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const distributionBoardUpdateCommandType =
|
||||
"distribution-board.update" as const;
|
||||
// The info-card fields were added additively to schema version 1: every
|
||||
// previously stored command remains valid, and the inverse is still derived
|
||||
// from the persisted row for exactly the changed fields.
|
||||
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface DistributionBoardUpdateValues {
|
||||
export interface DistributionBoardUpdateValues
|
||||
extends Record<DistributionBoardInfoField, string | null> {
|
||||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
simultaneityFactor: number;
|
||||
|
|
@ -82,16 +89,20 @@ export function assertDistributionBoardUpdateProjectCommand(
|
|||
for (const change of changes) {
|
||||
if (
|
||||
!isPlainObject(change) ||
|
||||
typeof change.field !== "string" ||
|
||||
(change.field !== "floorId" &&
|
||||
change.field !== "supplyType" &&
|
||||
change.field !== "simultaneityFactor") ||
|
||||
change.field !== "simultaneityFactor" &&
|
||||
!isInfoField(change.field)) ||
|
||||
seen.has(change.field)
|
||||
) {
|
||||
throw new Error(
|
||||
"Distribution-board update contains an invalid or duplicate field."
|
||||
);
|
||||
}
|
||||
if (change.field === "floorId") {
|
||||
if (isInfoField(change.field)) {
|
||||
assertInfoFieldValue(change.field, change.value);
|
||||
} else if (change.field === "floorId") {
|
||||
if (
|
||||
change.value !== null &&
|
||||
(typeof change.value !== "string" || !change.value.trim())
|
||||
|
|
@ -121,6 +132,29 @@ export function assertDistributionBoardUpdateProjectCommand(
|
|||
}
|
||||
}
|
||||
|
||||
function isInfoField(field: string): field is DistributionBoardInfoField {
|
||||
return (distributionBoardInfoFields as readonly string[]).includes(field);
|
||||
}
|
||||
|
||||
function assertInfoFieldValue(
|
||||
field: DistributionBoardInfoField,
|
||||
value: unknown
|
||||
) {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value.trim() ||
|
||||
value !== value.trim() ||
|
||||
value.length > distributionBoardInfoFieldMaxLengths[field]
|
||||
) {
|
||||
throw new Error(
|
||||
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
type DistributionBoardComponentRole,
|
||||
} from "../../shared/constants/distribution-board-component.js";
|
||||
import {
|
||||
distributionBoardInfoFields,
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import type {
|
||||
|
|
@ -72,7 +74,7 @@ export interface DistributionBoardSubtreeSnapshot {
|
|||
floorId: string | null;
|
||||
supplyType: DistributionBoardSupplyType | null;
|
||||
simultaneityFactor: number;
|
||||
};
|
||||
} & Record<DistributionBoardInfoField, string | null>;
|
||||
circuitList: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
|
@ -169,6 +171,22 @@ export function cloneDistributionBoardSubtree(
|
|||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtree commands stored before the board info fields existed carry no
|
||||
* info values. They are equivalent to a board whose info fields are empty,
|
||||
* so comparisons against a freshly captured subtree and re-inserts stay
|
||||
* exact.
|
||||
*/
|
||||
export function normalizeDistributionBoardSubtreeSnapshot(
|
||||
snapshot: DistributionBoardSubtreeSnapshot
|
||||
): DistributionBoardSubtreeSnapshot {
|
||||
const board = { ...snapshot.distributionBoard };
|
||||
for (const field of distributionBoardInfoFields) {
|
||||
board[field] = board[field] ?? null;
|
||||
}
|
||||
return { ...snapshot, distributionBoard: board };
|
||||
}
|
||||
|
||||
export function assertDistributionBoardSubtreeSnapshot(
|
||||
value: unknown
|
||||
): asserts value is DistributionBoardSubtreeSnapshot {
|
||||
|
|
@ -204,6 +222,11 @@ export function assertDistributionBoardSubtreeSnapshot(
|
|||
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
|
||||
throw new Error("Distribution-board simultaneity factor is invalid.");
|
||||
}
|
||||
for (const field of distributionBoardInfoFields) {
|
||||
if (board[field] !== undefined) {
|
||||
assertNullableString(board[field], `distributionBoard.${field}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"projectId",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,23 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
|
||||
export const projectSettingsUpdateCommandType =
|
||||
"project.update-settings" as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
|
||||
// and 1 (additionally without the public-building flag) stay executable;
|
||||
// their missing values keep the current project values.
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectSettingsValues {
|
||||
export interface ProjectSettingsValues
|
||||
extends Record<ProjectCoverField, string | null> {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
|
|
@ -21,8 +31,13 @@ export interface ProjectSettingsValues {
|
|||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
type PreviousProjectSettingsValues = Omit<
|
||||
export type WithoutCoverProjectSettingsValues = Omit<
|
||||
ProjectSettingsValues,
|
||||
ProjectCoverField
|
||||
>;
|
||||
|
||||
export type BaselineProjectSettingsValues = Omit<
|
||||
WithoutCoverProjectSettingsValues,
|
||||
"isPublicBuilding"
|
||||
>;
|
||||
|
||||
|
|
@ -32,15 +47,22 @@ export interface CurrentProjectSettingsUpdateProjectCommand
|
|||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
interface PreviousProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
export interface WithoutCoverProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
|
||||
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface BaselineProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
|
||||
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export type ProjectSettingsUpdateProjectCommand =
|
||||
| CurrentProjectSettingsUpdateProjectCommand
|
||||
| PreviousProjectSettingsUpdateProjectCommand;
|
||||
| WithoutCoverProjectSettingsUpdateProjectCommand
|
||||
| BaselineProjectSettingsUpdateProjectCommand;
|
||||
|
||||
export function createProjectSettingsUpdateProjectCommand(
|
||||
values: ProjectSettingsValues
|
||||
|
|
@ -60,7 +82,10 @@ export function assertProjectSettingsUpdateProjectCommand(
|
|||
if (
|
||||
command.type !== projectSettingsUpdateCommandType ||
|
||||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
|
||||
command.schemaVersion !==
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !==
|
||||
baselineProjectSettingsUpdateCommandSchemaVersion)
|
||||
) {
|
||||
throw new Error("Unsupported project settings update command.");
|
||||
}
|
||||
|
|
@ -91,13 +116,23 @@ export function assertProjectSettingsUpdateProjectCommand(
|
|||
"Project settings update command contains invalid supply types."
|
||||
);
|
||||
}
|
||||
const isPreviousSchema =
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
const payload = command.payload;
|
||||
const expectedKeyCount =
|
||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
||||
? 8
|
||||
: command.schemaVersion ===
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||
? 9
|
||||
: 9 + projectCoverFields.length;
|
||||
if (
|
||||
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
|
||||
(!isPreviousSchema &&
|
||||
(typeof command.payload.isPublicBuilding !== "boolean" ||
|
||||
Object.keys(command.payload).length !== 9))
|
||||
Object.keys(payload).length !== expectedKeyCount ||
|
||||
(command.schemaVersion !==
|
||||
baselineProjectSettingsUpdateCommandSchemaVersion &&
|
||||
typeof payload.isPublicBuilding !== "boolean") ||
|
||||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
||||
!projectCoverFields.every((field) =>
|
||||
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { z } from "zod";
|
||||
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
distributionBoardInfoFieldMaxLengths,
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
|
|
@ -27,7 +35,8 @@ import {
|
|||
type ExternalModelStateSnapshot,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 5 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
|
|
@ -58,11 +67,31 @@ const previousProjectSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const projectSchema = previousProjectSchema
|
||||
const publicBuildingProjectSchema = previousProjectSchema
|
||||
.extend({ isPublicBuilding: z.boolean() })
|
||||
.strict();
|
||||
|
||||
const distributionBoardSchema = z
|
||||
const projectCoverValueSchema = (field: ProjectCoverField) =>
|
||||
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
|
||||
|
||||
// Schema version 6 adds the print cover fields.
|
||||
const projectSchema = publicBuildingProjectSchema
|
||||
.extend({
|
||||
siteAddress: projectCoverValueSchema("siteAddress"),
|
||||
workStage: projectCoverValueSchema("workStage"),
|
||||
projectLead: projectCoverValueSchema("projectLead"),
|
||||
projectEditor: projectCoverValueSchema("projectEditor"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const emptyProjectCoverValues = {
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
};
|
||||
|
||||
const legacyDistributionBoardSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
|
|
@ -73,6 +102,35 @@ const distributionBoardSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(distributionBoardInfoFieldMaxLengths[field])
|
||||
.nullable();
|
||||
|
||||
const distributionBoardSchema = legacyDistributionBoardSchema
|
||||
.extend({
|
||||
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
|
||||
location: distributionBoardInfoValueSchema("location"),
|
||||
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
|
||||
remark: distributionBoardInfoValueSchema("remark"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function withEmptyDistributionBoardInfo(
|
||||
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
|
||||
) {
|
||||
return boards.map((board) => ({
|
||||
...board,
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const circuitListSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
|
|
@ -457,11 +515,22 @@ export const projectStateSnapshotSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const boardInfoProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
|
|
@ -471,8 +540,8 @@ const previousProjectStateSnapshotSchema = z
|
|||
const externalModelProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
|
|
@ -481,8 +550,8 @@ const externalModelProjectStateSnapshotSchema = z
|
|||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -491,7 +560,7 @@ const baselineProjectStateSnapshotSchema = z
|
|||
.object({
|
||||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||
project: previousProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -521,11 +590,29 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return value;
|
||||
}
|
||||
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
|
||||
// Every supported version before 6 lacks the distribution-board info
|
||||
// fields and the project cover fields; they are upgraded as empty (null)
|
||||
// values.
|
||||
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
|
||||
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
};
|
||||
}
|
||||
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
|
||||
const previous = previousProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
};
|
||||
}
|
||||
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
||||
|
|
@ -533,6 +620,10 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
}
|
||||
|
|
@ -541,6 +632,10 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
legacy.distributionBoards
|
||||
),
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
|
|
@ -550,7 +645,14 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...baseline.project, isPublicBuilding: false },
|
||||
project: {
|
||||
...baseline.project,
|
||||
isPublicBuilding: false,
|
||||
...emptyProjectCoverValues,
|
||||
},
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
baseline.distributionBoards
|
||||
),
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
|
|
|
|||
71
src/frontend/components/cable-sizing-api.ts
Normal file
71
src/frontend/components/cable-sizing-api.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Thin, self-contained fetch client for the cable-sizing module. Deliberately
|
||||
// not merged into ../utils/api.ts to keep this module's frontend footprint a
|
||||
// single new file rather than growing the existing shared API file - see
|
||||
// docs/cable-sizing-module.md.
|
||||
import type {
|
||||
CableSizingAlert,
|
||||
CableSizingInput,
|
||||
CableSizingResult,
|
||||
InsulationMaterial,
|
||||
LayingMethod,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
let details = await response.text();
|
||||
try {
|
||||
const parsed = JSON.parse(details) as { error?: unknown };
|
||||
if (typeof parsed.error === "string") details = parsed.error;
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface CalculateCableSizingResponse {
|
||||
calculationId: string;
|
||||
result: CableSizingResult;
|
||||
alerts: CableSizingAlert[];
|
||||
}
|
||||
|
||||
export function calculateCableSizing(
|
||||
input: CableSizingInput,
|
||||
context?: { projectId?: string; circuitId?: string; equipmentIdentifier?: string }
|
||||
): Promise<CalculateCableSizingResponse> {
|
||||
return request("/api/cable-sizing/calculate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, context }),
|
||||
});
|
||||
}
|
||||
|
||||
export function markCableSizingCalculationApplied(calculationId: string): Promise<unknown> {
|
||||
return request(`/api/cable-sizing/calculations/${calculationId}/applied`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface LayingMethodOption {
|
||||
method: LayingMethod;
|
||||
label: string;
|
||||
dataVerified: boolean;
|
||||
}
|
||||
|
||||
export function listLayingMethods(): Promise<LayingMethodOption[]> {
|
||||
return request("/api/cable-sizing/laying-methods");
|
||||
}
|
||||
|
||||
export interface InsulationMaterialOption {
|
||||
insulation: InsulationMaterial;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function listInsulationMaterials(): Promise<InsulationMaterialOption[]> {
|
||||
return request("/api/cable-sizing/insulation-materials");
|
||||
}
|
||||
523
src/frontend/components/cable-sizing-modal.tsx
Normal file
523
src/frontend/components/cable-sizing-modal.tsx
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import {
|
||||
CONDUCTOR_MATERIALS,
|
||||
CROSS_SECTIONS_MM2,
|
||||
GROUPING_LABELS,
|
||||
GROUPING_OPTIONS,
|
||||
HARMONIC_NEUTRAL_LOAD_LABELS,
|
||||
HARMONIC_NEUTRAL_LOAD_OPTIONS,
|
||||
INSULATION_MATERIALS,
|
||||
INSULATION_MATERIAL_LABELS,
|
||||
LAYING_METHODS,
|
||||
LAYING_METHOD_GROUP,
|
||||
LAYING_METHOD_LABELS,
|
||||
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
|
||||
TEMPERATURE_FACTOR_AIR,
|
||||
TEMPERATURE_FACTOR_GROUND,
|
||||
type CableSizingAlert,
|
||||
type CableSizingInput,
|
||||
type CableSizingResult,
|
||||
type CircuitCategory,
|
||||
type ConductorMaterial,
|
||||
type HarmonicNeutralLoad,
|
||||
type InsulationMaterial,
|
||||
type LayingMethod,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation";
|
||||
import { calculateCableSizing } from "./cable-sizing-api";
|
||||
import type { CircuitTreeCircuitDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CableSizingModalProps {
|
||||
circuit: CircuitTreeCircuitDto;
|
||||
circuitCategory?: CircuitCategory;
|
||||
isSaving: boolean;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onApply: (patch: {
|
||||
cableCrossSection: string;
|
||||
cableType?: string;
|
||||
cableLength?: number;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
function temperatureOptions(method: LayingMethod) {
|
||||
const table =
|
||||
LAYING_METHOD_GROUP[method] === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND;
|
||||
return Object.keys(table).map(Number).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Extracts the first decimal number from a free-text cross-section entry
|
||||
// like "2.5 mm²" or "2,5". Returns null if nothing parseable is found -
|
||||
// used only for the manual-entry sanity check below, never persisted.
|
||||
function parseCrossSectionMm2(text: string): number | null {
|
||||
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1].replace(",", "."));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function CableSizingModal({
|
||||
circuit,
|
||||
circuitCategory,
|
||||
isSaving,
|
||||
projectId,
|
||||
onClose,
|
||||
onApply,
|
||||
}: CableSizingModalProps) {
|
||||
const [layingMethod, setLayingMethod] = useState<LayingMethod>("C");
|
||||
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
|
||||
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("copper");
|
||||
const [ambientTemperatureC, setAmbientTemperatureC] = useState(30);
|
||||
const [groupingCircuits, setGroupingCircuits] = useState(1);
|
||||
const [cosPhi, setCosPhi] = useState(1);
|
||||
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
|
||||
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
|
||||
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
|
||||
const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1);
|
||||
|
||||
// Manual override: applying this never requires a calculation. Prefilled
|
||||
// from the circuit's current values so opening the modal on an already
|
||||
// specified cable doesn't lose that data.
|
||||
const [manualCableType, setManualCableType] = useState(circuit.cableType ?? "");
|
||||
const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? "");
|
||||
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
const [calculationId, setCalculationId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CableSizingResult | null>(null);
|
||||
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const voltage = circuit.voltage ?? (phase === 1 ? 230 : 400);
|
||||
|
||||
const input: CableSizingInput = useMemo(
|
||||
() => ({
|
||||
phase,
|
||||
mode: "power",
|
||||
powerKw: circuit.circuitTotalPower,
|
||||
cosPhi,
|
||||
voltage,
|
||||
lengthM,
|
||||
layingMethod,
|
||||
conductorMaterial,
|
||||
insulation,
|
||||
ambientTemperatureC,
|
||||
groupingCircuits,
|
||||
maxVoltageDropPercent,
|
||||
harmonicNeutralLoad,
|
||||
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
|
||||
circuitCategory,
|
||||
}),
|
||||
[
|
||||
phase,
|
||||
circuit.circuitTotalPower,
|
||||
cosPhi,
|
||||
voltage,
|
||||
lengthM,
|
||||
layingMethod,
|
||||
conductorMaterial,
|
||||
insulation,
|
||||
ambientTemperatureC,
|
||||
groupingCircuits,
|
||||
maxVoltageDropPercent,
|
||||
harmonicNeutralLoad,
|
||||
circuit.protectionDevice?.ratedCurrentA,
|
||||
circuitCategory,
|
||||
]
|
||||
);
|
||||
|
||||
const manualCrossSectionWarning = useMemo(() => {
|
||||
const trimmed = manualCrossSection.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = parseCrossSectionMm2(trimmed);
|
||||
if (parsed == null) return null;
|
||||
if (!(CROSS_SECTIONS_MM2 as readonly number[]).includes(parsed)) {
|
||||
return `${parsed} mm² ist kein Standard-Querschnitt (${CROSS_SECTIONS_MM2.join(", ")}).`;
|
||||
}
|
||||
if (
|
||||
result?.dataVerified &&
|
||||
result.recommendedCrossSectionMm2 != null &&
|
||||
parsed < result.recommendedCrossSectionMm2
|
||||
) {
|
||||
return `${parsed} mm² ist kleiner als die zuletzt berechnete Empfehlung (${result.recommendedCrossSectionMm2} mm²) - passt nicht zu Last${circuit.protectionDevice ? "/Sicherung" : ""} und Verlegeart.`;
|
||||
}
|
||||
const practicalMinimum = circuitCategory
|
||||
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory]
|
||||
: undefined;
|
||||
if (practicalMinimum != null && parsed < practicalMinimum) {
|
||||
return `${parsed} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${practicalMinimum} mm²).`;
|
||||
}
|
||||
return null;
|
||||
}, [manualCrossSection, result, circuit.protectionDevice, circuitCategory]);
|
||||
|
||||
function handleLayingMethodChange(method: LayingMethod) {
|
||||
setLayingMethod(method);
|
||||
const options = temperatureOptions(method);
|
||||
const reference = LAYING_METHOD_GROUP[method] === "air" ? 30 : 20;
|
||||
setAmbientTemperatureC(options.includes(reference) ? reference : options[0]);
|
||||
setResult(null);
|
||||
setCalculationId(null);
|
||||
}
|
||||
|
||||
async function handleCalculate() {
|
||||
setCalculating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await calculateCableSizing(input, {
|
||||
projectId,
|
||||
circuitId: circuit.id,
|
||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||
});
|
||||
setResult(response.result);
|
||||
setAlerts(response.alerts);
|
||||
setCalculationId(response.calculationId);
|
||||
// Calculating fills the manual field as a suggestion, but that field
|
||||
// stays the single source of truth for what gets applied - the user
|
||||
// can still edit it by hand before submitting.
|
||||
if (response.result.dataVerified && response.result.recommendedCrossSectionMm2) {
|
||||
setManualCrossSection(`${response.result.recommendedCrossSectionMm2} mm²`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
|
||||
} finally {
|
||||
setCalculating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!manualCrossSection.trim()) return;
|
||||
await onApply({
|
||||
cableCrossSection: manualCrossSection.trim(),
|
||||
cableType: manualCableType.trim() || undefined,
|
||||
cableLength: lengthM,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4, oder Kabeltyp/Querschnitt direkt manuell eintragen. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis."
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!manualCrossSection.trim()}
|
||||
submitLabel="Übernehmen"
|
||||
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
|
||||
circuit.displayName ? ` (${circuit.displayName})` : ""
|
||||
}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-4">
|
||||
<label className="form-label" htmlFor="cable-sizing-length">
|
||||
Leitungslänge (m)
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="cable-sizing-length"
|
||||
min={0}
|
||||
onChange={(event) => setLengthM(Number(event.target.value))}
|
||||
type="number"
|
||||
value={lengthM}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<label className="form-label" htmlFor="cable-sizing-phase">
|
||||
Phasen
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-phase"
|
||||
onChange={(event) => setPhase(Number(event.target.value) as 1 | 3)}
|
||||
value={phase}
|
||||
>
|
||||
<option value={1}>1~ ({circuit.voltage ?? 230} V)</option>
|
||||
<option value={3}>3~ ({circuit.voltage ?? 400} V)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-4">
|
||||
<label className="form-label">Leistung (aus Stromkreis)</label>
|
||||
<input
|
||||
className="form-control"
|
||||
disabled
|
||||
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-method">
|
||||
Verlegeart (DIN VDE 0298-4)
|
||||
</label>
|
||||
<select
|
||||
autoFocus
|
||||
className="form-select"
|
||||
id="cable-sizing-method"
|
||||
onChange={(event) => handleLayingMethodChange(event.target.value as LayingMethod)}
|
||||
value={layingMethod}
|
||||
>
|
||||
{LAYING_METHODS.map((method) => (
|
||||
<option key={method} value={method}>
|
||||
{LAYING_METHOD_LABELS[method]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-insulation">
|
||||
Isolierstoff
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-insulation"
|
||||
onChange={(event) => {
|
||||
setInsulation(event.target.value as InsulationMaterial);
|
||||
setResult(null);
|
||||
}}
|
||||
value={insulation}
|
||||
>
|
||||
{INSULATION_MATERIALS.map((material) => (
|
||||
<option key={material} value={material}>
|
||||
{INSULATION_MATERIAL_LABELS[material]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-material">
|
||||
Leitermaterial
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-material"
|
||||
onChange={(event) => setConductorMaterial(event.target.value as ConductorMaterial)}
|
||||
value={conductorMaterial}
|
||||
>
|
||||
{CONDUCTOR_MATERIALS.map((material) => (
|
||||
<option key={material} value={material}>
|
||||
{material === "copper" ? "Kupfer" : "Aluminium"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-temperature">
|
||||
{LAYING_METHOD_GROUP[layingMethod] === "air"
|
||||
? "Umgebungstemperatur"
|
||||
: "Bodentemperatur"}
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-temperature"
|
||||
onChange={(event) => setAmbientTemperatureC(Number(event.target.value))}
|
||||
value={ambientTemperatureC}
|
||||
>
|
||||
{temperatureOptions(layingMethod).map((temp) => (
|
||||
<option key={temp} value={temp}>
|
||||
{temp}°C
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-grouping">
|
||||
Häufung (Stromkreise nebeneinander)
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-grouping"
|
||||
onChange={(event) => setGroupingCircuits(Number(event.target.value))}
|
||||
value={groupingCircuits}
|
||||
>
|
||||
{GROUPING_OPTIONS.map((grouping) => (
|
||||
<option key={grouping} value={grouping}>
|
||||
{GROUPING_LABELS[grouping]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{phase === 3 && (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-harmonics">
|
||||
Oberschwingungsanteil Neutralleiter
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="cable-sizing-harmonics"
|
||||
onChange={(event) =>
|
||||
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad)
|
||||
}
|
||||
value={harmonicNeutralLoad}
|
||||
>
|
||||
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-cosphi">
|
||||
cos φ
|
||||
</label>
|
||||
<input
|
||||
className="form-range"
|
||||
id="cable-sizing-cosphi"
|
||||
max={1}
|
||||
min={0.6}
|
||||
onChange={(event) => setCosPhi(Number(event.target.value))}
|
||||
step={0.05}
|
||||
type="range"
|
||||
value={cosPhi}
|
||||
/>
|
||||
<span>{cosPhi.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
|
||||
Max. zulässiger Spannungsfall
|
||||
</label>
|
||||
<input
|
||||
className="form-range"
|
||||
id="cable-sizing-voltage-drop"
|
||||
max={5}
|
||||
min={1}
|
||||
onChange={(event) => setMaxVoltageDropPercent(Number(event.target.value))}
|
||||
step={0.5}
|
||||
type="range"
|
||||
value={maxVoltageDropPercent}
|
||||
/>
|
||||
<span>{maxVoltageDropPercent.toFixed(1)} %</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary mt-3"
|
||||
disabled={calculating}
|
||||
onClick={handleCalculate}
|
||||
type="button"
|
||||
>
|
||||
{calculating ? "Berechnet…" : "Berechnen"}
|
||||
</button>
|
||||
{error && <p className="text-danger mt-2">{error}</p>}
|
||||
|
||||
{result && !result.dataVerified && (
|
||||
<div className="alert alert-danger mt-3">
|
||||
Für Verlegeart {layingMethod} mit Isolierstoff {insulation.toUpperCase()} liegen keine
|
||||
geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich. Bitte
|
||||
eine andere Verlegeart/Isolierstoff-Kombination wählen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.dataVerified && (
|
||||
<div className="mt-3">
|
||||
<div className="row g-2">
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">Betriebsstrom Ib</div>
|
||||
<div>{result.operatingCurrentA.toFixed(1)} A</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">
|
||||
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{result.designCurrentA.toFixed(1)} A</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">Empfohlener Querschnitt</div>
|
||||
<div>
|
||||
<strong>
|
||||
{result.recommendedCrossSectionMm2
|
||||
? `${result.recommendedCrossSectionMm2} mm²`
|
||||
: "-"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">ΔU bei Empfehlung</div>
|
||||
<div>
|
||||
{result.voltageDropAtRecommendedPercent != null
|
||||
? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %`
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">Korrekturfaktor</div>
|
||||
<div>{result.combinedDerationFactor.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<div className="text-muted small">Max. Länge (ΔU-Grenze)</div>
|
||||
<div>
|
||||
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null
|
||||
? `${result.rows
|
||||
.find((row) => row.recommended)!
|
||||
.maxLengthForVoltageDropM!.toFixed(0)} m`
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{alerts.map((alert, index) => (
|
||||
<div
|
||||
className={`alert mt-2 alert-${
|
||||
alert.kind === "critical"
|
||||
? "danger"
|
||||
: alert.kind === "warn"
|
||||
? "warning"
|
||||
: alert.kind === "ok"
|
||||
? "success"
|
||||
: "info"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
{alert.text}
|
||||
</div>
|
||||
))}
|
||||
{circuit.protectionDevice && (
|
||||
<p className="text-muted small mt-2 mb-0">
|
||||
* Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung
|
||||
({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem
|
||||
eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur
|
||||
für die tatsächliche Last.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="my-3" />
|
||||
<p className="text-muted small mb-2">
|
||||
Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in
|
||||
dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.
|
||||
</p>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-manual-type">
|
||||
Kabeltyp
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="cable-sizing-manual-type"
|
||||
onChange={(event) => setManualCableType(event.target.value)}
|
||||
placeholder="z.B. NYM-J 3x1.5"
|
||||
value={manualCableType}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
|
||||
Querschnitt
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="cable-sizing-manual-cross-section"
|
||||
onChange={(event) => setManualCrossSection(event.target.value)}
|
||||
placeholder="z.B. 4 mm²"
|
||||
value={manualCrossSection}
|
||||
/>
|
||||
</div>
|
||||
{manualCrossSectionWarning && (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,6 +137,7 @@ import type {
|
|||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||
import { CableSizingModal } from "./cable-sizing-modal";
|
||||
import {
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
|
|
@ -278,6 +279,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
useState<CircuitGroupEditorIntent | null>(null);
|
||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||
useState<CircuitTreeCircuitDto | null>(null);
|
||||
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
||||
useState<CircuitTreeCircuitDto | null>(null);
|
||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||
useState(false);
|
||||
|
|
@ -1398,6 +1401,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
});
|
||||
}
|
||||
|
||||
async function handleApplyCableSizing(patch: {
|
||||
cableCrossSection: string;
|
||||
cableType?: string;
|
||||
cableLength?: number;
|
||||
}) {
|
||||
const circuit = cableSizingEditorCircuit;
|
||||
if (!circuit) {
|
||||
return;
|
||||
}
|
||||
await runCommand({
|
||||
label: "Kabel dimensionieren",
|
||||
redo: async () => {
|
||||
const result = await updateCircuitById(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
circuit.id,
|
||||
patch
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
setCableSizingEditorCircuit(null);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRedo() {
|
||||
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
|
||||
return;
|
||||
|
|
@ -3139,6 +3167,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
onSave={handleSaveCircuitProtection}
|
||||
/>
|
||||
) : null}
|
||||
{cableSizingEditorCircuit ? (
|
||||
<CableSizingModal
|
||||
circuit={cableSizingEditorCircuit}
|
||||
circuitCategory={
|
||||
data.sections.find(
|
||||
(section) => section.id === cableSizingEditorCircuit.sectionId
|
||||
)?.category
|
||||
}
|
||||
isSaving={isSaving}
|
||||
projectId={projectId}
|
||||
onClose={() => setCableSizingEditorCircuit(null)}
|
||||
onApply={handleApplyCableSizing}
|
||||
/>
|
||||
) : null}
|
||||
<div className="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -4240,10 +4282,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
row.circuit &&
|
||||
row.rowType !== "deviceRow"
|
||||
);
|
||||
const isCableSizingTrigger = Boolean(
|
||||
(column.key === "cableSummary" || column.key === "cableCrossSection") &&
|
||||
row.circuit &&
|
||||
row.rowType !== "deviceRow"
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
|
||||
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isCableSizingTrigger ? "cell-cable-sizing-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
|
||||
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||
? "device-drag-handle"
|
||||
: ""
|
||||
|
|
@ -4263,7 +4310,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
title={
|
||||
isProtectionTrigger
|
||||
? "Schutzgerät bearbeiten"
|
||||
: column.key === "equipmentIdentifier" &&
|
||||
: isCableSizingTrigger
|
||||
? "Kabel dimensionieren"
|
||||
: column.key === "equipmentIdentifier" &&
|
||||
row.circuit &&
|
||||
(row.rowType === "circuitCompact" ||
|
||||
row.rowType === "circuitSummary" ||
|
||||
|
|
@ -4342,6 +4391,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
setProtectionEditorCircuit(row.circuit!);
|
||||
return;
|
||||
}
|
||||
if (isCableSizingTrigger) {
|
||||
setCableSizingEditorCircuit(row.circuit!);
|
||||
return;
|
||||
}
|
||||
if (cell.editable) {
|
||||
handleRowSelectionClick(row, column.key, {
|
||||
ctrlKey: event.ctrlKey,
|
||||
|
|
|
|||
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
distributionBoardInfoFieldLabels,
|
||||
distributionBoardInfoFieldMaxLengths,
|
||||
distributionBoardInfoFields,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import type { DistributionBoardInfoDto } from "../types";
|
||||
|
||||
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
|
||||
|
||||
const placeholders: Record<DistributionBoardInfoField, string> = {
|
||||
boardNumber: "z. B. UV 1.1",
|
||||
location: "z. B. Raum 003 Technik",
|
||||
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
export function toDistributionBoardInfoDraft(
|
||||
info: DistributionBoardInfoDto
|
||||
): DistributionBoardInfoDraft {
|
||||
return {
|
||||
boardNumber: info.boardNumber ?? "",
|
||||
location: info.location ?? "",
|
||||
fedFrom: info.fedFrom ?? "",
|
||||
remark: info.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function fromDistributionBoardInfoDraft(
|
||||
draft: DistributionBoardInfoDraft
|
||||
): DistributionBoardInfoDto {
|
||||
const value = (field: DistributionBoardInfoField) =>
|
||||
draft[field].trim() ? draft[field].trim() : null;
|
||||
return {
|
||||
boardNumber: value("boardNumber"),
|
||||
location: value("location"),
|
||||
fedFrom: value("fedFrom"),
|
||||
remark: value("remark"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptive board data used by the print view title block. The values are
|
||||
* local planning values; a later Revit import may prefill them only through
|
||||
* an explicit, previewed decision.
|
||||
*/
|
||||
export function DistributionBoardInfoCard({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: DistributionBoardInfoDraft;
|
||||
onChange: (draft: DistributionBoardInfoDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="distribution-board-info-heading"
|
||||
className="card"
|
||||
>
|
||||
<div className="card-header" id="distribution-board-info-heading">
|
||||
Infokarte
|
||||
</div>
|
||||
<div className="card-body row g-3">
|
||||
{distributionBoardInfoFields.map((field) => (
|
||||
<div
|
||||
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
|
||||
key={field}
|
||||
>
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor={`distribution-board-info-${field}`}
|
||||
>
|
||||
{distributionBoardInfoFieldLabels[field]}
|
||||
</label>
|
||||
{field === "remark" ? (
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={`distribution-board-info-${field}`}
|
||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, [field]: event.target.value })
|
||||
}
|
||||
rows={2}
|
||||
value={draft[field]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control"
|
||||
id={`distribution-board-info-${field}`}
|
||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, [field]: event.target.value })
|
||||
}
|
||||
placeholder={placeholders[field]}
|
||||
value={draft[field]}
|
||||
/>
|
||||
)}
|
||||
{field === "fedFrom" ? (
|
||||
<div className="form-text">
|
||||
Vorerst Freitext. Die logische Verknüpfung mit dem
|
||||
speisenden Verteiler folgt.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,9 +7,16 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import {
|
||||
projectCoverFieldLabels,
|
||||
projectCoverFieldMaxLengths,
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
export interface ProjectSettingsInput {
|
||||
export interface ProjectSettingsInput
|
||||
extends Record<ProjectCoverField, string | null> {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
|
|
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
|
|||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
|
||||
siteAddress: "Straße Nr.\nPLZ Ort",
|
||||
workStage: "z. B. LPH 5 – Ausführungsplanung",
|
||||
projectLead: "",
|
||||
projectEditor: "",
|
||||
};
|
||||
|
||||
interface ProjectSettingsModalProps {
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
|
|||
] = useState<DistributionBoardSupplyType[]>(
|
||||
project.enabledDistributionBoardSupplyTypes
|
||||
);
|
||||
const [coverValues, setCoverValues] = useState<
|
||||
Record<ProjectCoverField, string>
|
||||
>({
|
||||
siteAddress: project.siteAddress ?? "",
|
||||
workStage: project.workStage ?? "",
|
||||
projectLead: project.projectLead ?? "",
|
||||
projectEditor: project.projectEditor ?? "",
|
||||
});
|
||||
const [transfer, setTransfer] = useState<unknown>(null);
|
||||
const [transferFilename, setTransferFilename] = useState("");
|
||||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||
|
|
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
|
|||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: toNullableString(coverValues.siteAddress),
|
||||
workStage: toNullableString(coverValues.workStage),
|
||||
projectLead: toNullableString(coverValues.projectLead),
|
||||
projectEditor: toNullableString(coverValues.projectEditor),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
|
|||
value={threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Deckblatt</h3>
|
||||
<p className="text-secondary small">
|
||||
Angaben für das Deckblatt der Seitenansicht. Büroname und
|
||||
Büroanschrift werden projektübergreifend in der Seitenansicht
|
||||
gepflegt.
|
||||
</p>
|
||||
<div className="row g-3">
|
||||
{projectCoverFields.map((field) => (
|
||||
<div
|
||||
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
|
||||
key={field}
|
||||
>
|
||||
<label className="form-label" htmlFor={`project-cover-${field}`}>
|
||||
{projectCoverFieldLabels[field]}
|
||||
</label>
|
||||
{field === "siteAddress" ? (
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
rows={2}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||
|
|
|
|||
180
src/frontend/print/print-document-model.ts
Normal file
180
src/frontend/print/print-document-model.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeProtectionDeviceDto,
|
||||
CircuitTreeResponseDto,
|
||||
DistributionBoardDto,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Pure read model for the print view (cover page, board overview and board
|
||||
* plans). It only derives display values from the existing circuit-tree
|
||||
* responses and never changes project data.
|
||||
*/
|
||||
export interface PrintBoardSummary {
|
||||
board: DistributionBoardDto;
|
||||
circuitListId: string;
|
||||
tree: CircuitTreeResponseDto;
|
||||
floorName: string | null;
|
||||
circuitCount: number;
|
||||
/** Σ quantity × power per unit, without any simultaneity factor. */
|
||||
installedPowerKw: number;
|
||||
/** Σ row totals, i.e. with the device-row simultaneity factors. */
|
||||
devicePowerKw: number;
|
||||
simultaneityFactor: number;
|
||||
/** Device power with the board simultaneity factor applied. */
|
||||
demandPowerKw: number;
|
||||
/**
|
||||
* Symmetrical three-phase design current from the apparent power
|
||||
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
|
||||
*/
|
||||
designCurrentA: number;
|
||||
}
|
||||
|
||||
export function summarizePrintBoard(input: {
|
||||
board: DistributionBoardDto;
|
||||
circuitListId: string;
|
||||
tree: CircuitTreeResponseDto;
|
||||
floorName: string | null;
|
||||
}): PrintBoardSummary {
|
||||
const { tree } = input;
|
||||
let circuitCount = 0;
|
||||
let installedPowerKw = 0;
|
||||
let apparentPowerKva = 0;
|
||||
for (const section of tree.sections) {
|
||||
for (const circuit of section.circuits) {
|
||||
circuitCount += 1;
|
||||
for (const row of circuit.deviceRows) {
|
||||
installedPowerKw += row.quantity * row.powerPerUnit;
|
||||
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
|
||||
return {
|
||||
...input,
|
||||
circuitCount,
|
||||
installedPowerKw,
|
||||
devicePowerKw: tree.distributionBoardTotalPower,
|
||||
simultaneityFactor,
|
||||
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
|
||||
designCurrentA:
|
||||
(apparentPowerKva * simultaneityFactor * 1000) /
|
||||
(Math.sqrt(3) * tree.threePhaseVoltageV),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrintBoardHierarchyEntry {
|
||||
summary: PrintBoardSummary;
|
||||
depth: number;
|
||||
parentBoardId: string | null;
|
||||
/** Own demand plus the plain sum of all resolved downstream boards. */
|
||||
cumulatedDemandPowerKw: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
|
||||
* decided. A board is shown below another board only when its `fedFrom`
|
||||
* text equals that board's number or name (case-insensitive). The
|
||||
* cumulated value is a plain sum without an upstream simultaneity factor.
|
||||
* Unresolved texts and cycles keep a board at the top level.
|
||||
*/
|
||||
export function buildPreliminaryBoardHierarchy(
|
||||
summaries: PrintBoardSummary[]
|
||||
): PrintBoardHierarchyEntry[] {
|
||||
const normalize = (value: string | null) =>
|
||||
value?.trim().toLocaleLowerCase("de") || null;
|
||||
const parentById = new Map<string, string | null>();
|
||||
for (const summary of summaries) {
|
||||
const fedFrom = normalize(summary.board.fedFrom);
|
||||
const parent = fedFrom
|
||||
? summaries.find(
|
||||
(candidate) =>
|
||||
candidate !== summary &&
|
||||
(normalize(candidate.board.boardNumber) === fedFrom ||
|
||||
normalize(candidate.board.name) === fedFrom)
|
||||
)
|
||||
: undefined;
|
||||
parentById.set(summary.board.id, parent?.board.id ?? null);
|
||||
}
|
||||
for (const summary of summaries) {
|
||||
const visited = new Set<string>([summary.board.id]);
|
||||
let current = parentById.get(summary.board.id) ?? null;
|
||||
while (current !== null) {
|
||||
if (visited.has(current)) {
|
||||
parentById.set(summary.board.id, null);
|
||||
break;
|
||||
}
|
||||
visited.add(current);
|
||||
current = parentById.get(current) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const childrenOf = (boardId: string | null) =>
|
||||
summaries.filter(
|
||||
(summary) => parentById.get(summary.board.id) === boardId
|
||||
);
|
||||
const cumulated = (summary: PrintBoardSummary): number =>
|
||||
summary.demandPowerKw +
|
||||
childrenOf(summary.board.id).reduce(
|
||||
(sum, child) => sum + cumulated(child),
|
||||
0
|
||||
);
|
||||
|
||||
const entries: PrintBoardHierarchyEntry[] = [];
|
||||
const walk = (summary: PrintBoardSummary, depth: number) => {
|
||||
entries.push({
|
||||
summary,
|
||||
depth,
|
||||
parentBoardId: parentById.get(summary.board.id) ?? null,
|
||||
cumulatedDemandPowerKw: cumulated(summary),
|
||||
});
|
||||
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
|
||||
};
|
||||
childrenOf(null).forEach((root) => walk(root, 0));
|
||||
return entries;
|
||||
}
|
||||
|
||||
const kilowattFormat = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
const unitPowerFormat = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
const currentFormat = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export const formatKilowatt = (value: number) => kilowattFormat.format(value);
|
||||
export const formatUnitPower = (value: number) => unitPowerFormat.format(value);
|
||||
export const formatFactor = (value: number) => kilowattFormat.format(value);
|
||||
export const formatCurrent = (value: number) => currentFormat.format(value);
|
||||
|
||||
export function formatProtectionDevice(
|
||||
protection: CircuitTreeProtectionDeviceDto | undefined
|
||||
): string | null {
|
||||
if (!protection) {
|
||||
return null;
|
||||
}
|
||||
const parts = [
|
||||
protectionDeviceTypeLabels[protection.type],
|
||||
protection.tripCharacteristic,
|
||||
`${protection.ratedCurrentA} A`,
|
||||
protection.fuseUtilizationCategory,
|
||||
].filter(Boolean);
|
||||
let text = parts.join(" ");
|
||||
if (protection.ratedResidualCurrentMa !== undefined) {
|
||||
text += `, ${protection.ratedResidualCurrentMa} mA`;
|
||||
if (protection.rcdType) {
|
||||
text += ` Typ ${protection.rcdType}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatCircuitCable(circuit: CircuitTreeCircuitDto): string | null {
|
||||
return circuit.cableType || circuit.cableCrossSection || null;
|
||||
}
|
||||
476
src/frontend/print/print-document.css
Normal file
476
src/frontend/print/print-document.css
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/* Print view (Seitenansicht) in the provisional kbp corporate design.
|
||||
Colors are derived from the kbp logo and presentation material and will be
|
||||
replaced by the official CD specification; change them only here.
|
||||
All selectors are scoped to .print-view. The @page rules (format, margins,
|
||||
page numbers) are injected by the print page because they need project
|
||||
data. */
|
||||
.print-view {
|
||||
--pd-blue: #476088;
|
||||
--pd-lilac: #9198c4;
|
||||
--pd-line: #d6dae5;
|
||||
--pd-tint: #f5f6fa;
|
||||
--pd-text: #2b2b2b;
|
||||
--pd-muted: #6b7280;
|
||||
--pd-accent: #c9782a;
|
||||
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
--pd-landscape-width: 297mm;
|
||||
--pd-landscape-height: 210mm;
|
||||
min-height: 100vh;
|
||||
background: #e7e9ef;
|
||||
color: var(--pd-text);
|
||||
font-family: var(--pd-font);
|
||||
color-scheme: light;
|
||||
}
|
||||
.print-view.pd-a3 {
|
||||
--pd-landscape-width: 420mm;
|
||||
--pd-landscape-height: 297mm;
|
||||
}
|
||||
|
||||
.pd-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--pd-line);
|
||||
font-size: 14px;
|
||||
}
|
||||
.pd-toolbar-title {
|
||||
margin-right: auto;
|
||||
color: var(--pd-blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-toolbar .pd-toolbar-message {
|
||||
color: var(--pd-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.pd-toolbar .pd-toolbar-error {
|
||||
color: #b04035;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pd-pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 16px 48px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pd-sheet {
|
||||
position: relative;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
font-size: 9pt;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.pd-portrait {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 18mm 18mm 22mm;
|
||||
page: lb-portrait;
|
||||
}
|
||||
.pd-landscape {
|
||||
width: var(--pd-landscape-width);
|
||||
min-height: var(--pd-landscape-height);
|
||||
padding: 12mm 12mm 18mm;
|
||||
page: lb-landscape;
|
||||
}
|
||||
.pd-a3 .pd-landscape {
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.print-view table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.print-view caption {
|
||||
caption-side: top;
|
||||
padding: 0 0 1.5mm;
|
||||
color: var(--pd-blue);
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.print-view sub {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.pd-r {
|
||||
text-align: right !important;
|
||||
}
|
||||
.pd-placeholder {
|
||||
color: var(--pd-accent);
|
||||
font-style: italic;
|
||||
}
|
||||
.pd-open {
|
||||
color: var(--pd-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-multiline {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.pd-logo {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.pd-logo-missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--pd-line);
|
||||
color: var(--pd-muted);
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.pd-cover-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 2px solid var(--pd-blue);
|
||||
padding-bottom: 6mm;
|
||||
}
|
||||
.pd-cover-kicker {
|
||||
margin-top: 3mm;
|
||||
color: var(--pd-muted);
|
||||
font-size: 10pt;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pd-cover-logo {
|
||||
height: 15mm;
|
||||
width: auto;
|
||||
max-width: 60mm;
|
||||
}
|
||||
span.pd-cover-logo {
|
||||
width: 40mm;
|
||||
}
|
||||
.pd-cover-title {
|
||||
margin: 14mm 0 10mm;
|
||||
}
|
||||
.pd-cover-title h1 {
|
||||
margin: 0;
|
||||
color: var(--pd-blue);
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-cover-title p {
|
||||
margin: 2mm 0 0;
|
||||
color: var(--pd-muted);
|
||||
font-size: 13pt;
|
||||
}
|
||||
.pd-kv {
|
||||
margin-bottom: 7mm;
|
||||
}
|
||||
.pd-kv th,
|
||||
.pd-kv td {
|
||||
border: 1px solid var(--pd-line);
|
||||
padding: 2mm 3mm;
|
||||
font-size: 9.5pt;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.pd-kv th {
|
||||
width: 42mm;
|
||||
background: var(--pd-tint);
|
||||
color: var(--pd-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
.pd-cover-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6mm;
|
||||
}
|
||||
.pd-cover-cols .pd-kv th {
|
||||
width: 30mm;
|
||||
}
|
||||
.pd-block {
|
||||
margin-bottom: 7mm;
|
||||
}
|
||||
.pd-col-index {
|
||||
width: 14mm;
|
||||
}
|
||||
|
||||
/* Generic grid tables */
|
||||
.pd-grid th,
|
||||
.pd-grid td {
|
||||
border: 1px solid var(--pd-line);
|
||||
padding: 1.3mm 2mm;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
|
||||
background: var(--pd-blue);
|
||||
border-color: var(--pd-blue);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* Footer (screen only; printed pages use @page margin boxes) */
|
||||
.pd-sheet-footer {
|
||||
position: absolute;
|
||||
left: 12mm;
|
||||
right: 12mm;
|
||||
bottom: 7mm;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--pd-line);
|
||||
padding-top: 2mm;
|
||||
color: var(--pd-muted);
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
.pd-portrait .pd-sheet-footer {
|
||||
left: 18mm;
|
||||
right: 18mm;
|
||||
bottom: 10mm;
|
||||
}
|
||||
|
||||
/* Title block */
|
||||
.pd-grid th.pd-tb-cell {
|
||||
border: none;
|
||||
padding: 0 0 4mm;
|
||||
background: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
.pd-titleblock {
|
||||
display: grid;
|
||||
grid-template-columns: 28mm 1fr auto auto;
|
||||
border: 1.5px solid var(--pd-blue);
|
||||
margin-bottom: 4mm;
|
||||
background: #fff;
|
||||
font-size: 9pt;
|
||||
}
|
||||
.pd-grid .pd-titleblock {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.pd-titleblock > * {
|
||||
padding: 2.5mm 3.5mm;
|
||||
border-left: 1px solid var(--pd-line);
|
||||
}
|
||||
.pd-titleblock > :first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.pd-tb-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 17mm;
|
||||
}
|
||||
.pd-tb-logo-image {
|
||||
max-height: 10mm;
|
||||
}
|
||||
span.pd-tb-logo-image {
|
||||
width: 100%;
|
||||
height: 10mm;
|
||||
}
|
||||
.pd-tb-kicker {
|
||||
color: var(--pd-muted);
|
||||
font-size: 7.5pt;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pd-tb-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4mm;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.pd-tb-number {
|
||||
color: var(--pd-blue);
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-tb-name {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-tb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 0.8mm 4mm;
|
||||
align-content: center;
|
||||
margin: 0;
|
||||
font-size: 8pt;
|
||||
}
|
||||
.pd-tb-grid div {
|
||||
display: contents;
|
||||
}
|
||||
.pd-tb-grid dt {
|
||||
color: var(--pd-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
.pd-tb-grid dd {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-tb-meta {
|
||||
background: var(--pd-tint);
|
||||
}
|
||||
|
||||
/* Board plan table */
|
||||
.pd-plan {
|
||||
font-size: 7.6pt;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.pd-a3 .pd-plan {
|
||||
font-size: 9pt;
|
||||
}
|
||||
.pd-plan th,
|
||||
.pd-plan td {
|
||||
padding: 0.75mm 1.6mm;
|
||||
}
|
||||
.pd-plan td.pd-bmk {
|
||||
color: var(--pd-blue);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pd-plan tr.pd-group td {
|
||||
background: var(--pd-tint);
|
||||
border-top: 1.5px solid var(--pd-lilac);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-plan tr.pd-component td,
|
||||
.pd-plan tr.pd-component td.pd-bmk,
|
||||
.pd-plan tr.pd-device td {
|
||||
color: var(--pd-muted);
|
||||
}
|
||||
.pd-plan tr.pd-device td:nth-child(2) {
|
||||
padding-left: 6mm;
|
||||
}
|
||||
.pd-plan tr.pd-reserve td {
|
||||
color: var(--pd-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.pd-plan tr.pd-subtotal td {
|
||||
border-bottom: 1.5px solid var(--pd-line);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-plan .pd-sub {
|
||||
color: var(--pd-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.pd-plan tfoot td {
|
||||
background: var(--pd-tint);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-plan tfoot tr.pd-grand td {
|
||||
background: var(--pd-blue);
|
||||
border-color: var(--pd-blue);
|
||||
color: #fff;
|
||||
}
|
||||
.pd-plan tr {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Overview */
|
||||
.pd-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 62mm 1fr;
|
||||
gap: 6mm;
|
||||
}
|
||||
.pd-a3 .pd-overview {
|
||||
grid-template-columns: 90mm 1fr;
|
||||
}
|
||||
.pd-structure h2 {
|
||||
margin: 0 0 3mm;
|
||||
color: var(--pd-blue);
|
||||
font-size: 10pt;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pd-tree,
|
||||
.pd-tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pd-tree ul {
|
||||
margin-left: 5mm;
|
||||
padding-left: 4mm;
|
||||
border-left: 1.5px solid var(--pd-lilac);
|
||||
}
|
||||
.pd-tree li {
|
||||
position: relative;
|
||||
margin: 2.5mm 0;
|
||||
}
|
||||
.pd-tree ul > li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -4mm;
|
||||
top: 5mm;
|
||||
width: 4mm;
|
||||
border-top: 1.5px solid var(--pd-lilac);
|
||||
}
|
||||
.pd-node {
|
||||
display: grid;
|
||||
gap: 0.3mm;
|
||||
padding: 1.5mm 2.5mm;
|
||||
border: 1px solid var(--pd-line);
|
||||
border-left: 3px solid var(--pd-blue);
|
||||
background: #fff;
|
||||
}
|
||||
.pd-node.pd-feed {
|
||||
border-left-color: var(--pd-accent);
|
||||
}
|
||||
.pd-node span {
|
||||
color: var(--pd-muted);
|
||||
font-size: 8pt;
|
||||
}
|
||||
.pd-node em {
|
||||
color: var(--pd-blue);
|
||||
font-size: 8.5pt;
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-note {
|
||||
margin: 4mm 0 1mm;
|
||||
padding: 2.5mm 3mm;
|
||||
border-left: 3px solid var(--pd-accent);
|
||||
background: #fbf4ec;
|
||||
font-size: 8pt;
|
||||
}
|
||||
.pd-legend {
|
||||
margin: 1mm 0 0;
|
||||
color: var(--pd-muted);
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html,
|
||||
body,
|
||||
.print-view {
|
||||
background: #fff !important;
|
||||
}
|
||||
.pd-toolbar,
|
||||
.pd-sheet-footer {
|
||||
display: none !important;
|
||||
}
|
||||
.pd-pages {
|
||||
display: block;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.pd-sheet {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
break-after: page;
|
||||
}
|
||||
.pd-sheet:last-child {
|
||||
break-after: auto;
|
||||
}
|
||||
.print-view * {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
624
src/frontend/print/print-document.tsx
Normal file
624
src/frontend/print/print-document.tsx
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
OfficeProfileDto,
|
||||
ProjectDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../types";
|
||||
import {
|
||||
formatCircuitCable,
|
||||
formatCurrent,
|
||||
formatFactor,
|
||||
formatKilowatt,
|
||||
formatProtectionDevice,
|
||||
formatUnitPower,
|
||||
type PrintBoardHierarchyEntry,
|
||||
} from "./print-document-model";
|
||||
|
||||
export interface PrintDocumentProps {
|
||||
project: ProjectDto;
|
||||
office: OfficeProfileDto;
|
||||
entries: PrintBoardHierarchyEntry[];
|
||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
||||
logoSrc: string | null;
|
||||
printedAt: Date;
|
||||
}
|
||||
|
||||
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
|
||||
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
|
||||
if (!value) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
|
||||
}
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function Placeholder({ label = "ergänzen" }: { label?: string }) {
|
||||
return <span className="pd-placeholder">[{label}]</span>;
|
||||
}
|
||||
|
||||
function Open() {
|
||||
return <span className="pd-open">offen</span>;
|
||||
}
|
||||
|
||||
function Logo({ src, className }: { src: string | null; className: string }) {
|
||||
return src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
|
||||
) : (
|
||||
<span className={`pd-logo-missing ${className}`}>Logo</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
|
||||
return (
|
||||
<div className="pd-sheet-footer" aria-hidden="true">
|
||||
<span>{project.name} · Leistungsbilanz</span>
|
||||
<span>
|
||||
Stand {stand} · Datenstand Rev. {project.currentRevision}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleBlock({
|
||||
kicker,
|
||||
number,
|
||||
name,
|
||||
rows,
|
||||
project,
|
||||
stand,
|
||||
logoSrc,
|
||||
}: {
|
||||
kicker: string;
|
||||
number?: string | null;
|
||||
name: string;
|
||||
rows: Array<[ReactNode, ReactNode]>;
|
||||
project: ProjectDto;
|
||||
stand: string;
|
||||
logoSrc: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="pd-titleblock">
|
||||
<div className="pd-tb-logo">
|
||||
<Logo className="pd-tb-logo-image" src={logoSrc} />
|
||||
</div>
|
||||
<div className="pd-tb-main">
|
||||
<div className="pd-tb-kicker">{kicker}</div>
|
||||
<div className="pd-tb-title">
|
||||
{number ? <span className="pd-tb-number">{number}</span> : null}
|
||||
<span className="pd-tb-name">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="pd-tb-grid">
|
||||
{rows.map(([label, value], index) => (
|
||||
<div key={index}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<dl className="pd-tb-grid pd-tb-meta">
|
||||
<div>
|
||||
<dt>Projekt</dt>
|
||||
<dd>{project.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Projekt-Nr.</dt>
|
||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stand</dt>
|
||||
<dd>{stand}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datenstand</dt>
|
||||
<dd>Rev. {project.currentRevision}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function boardLabel(entry: PrintBoardHierarchyEntry) {
|
||||
const { board } = entry.summary;
|
||||
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
|
||||
}
|
||||
|
||||
function CoverSheet({
|
||||
project,
|
||||
office,
|
||||
entries,
|
||||
namedSnapshots,
|
||||
logoSrc,
|
||||
stand,
|
||||
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
|
||||
return (
|
||||
<section className="pd-sheet pd-portrait pd-cover">
|
||||
<div className="pd-cover-head">
|
||||
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
|
||||
<Logo className="pd-cover-logo" src={logoSrc} />
|
||||
</div>
|
||||
<div className="pd-cover-title">
|
||||
<h1>Leistungsbilanz</h1>
|
||||
<p>Verteilerübersicht und Verteilerpläne</p>
|
||||
</div>
|
||||
|
||||
<table className="pd-kv">
|
||||
<caption>Projekt</caption>
|
||||
<tbody>
|
||||
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
|
||||
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
|
||||
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
|
||||
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
|
||||
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
|
||||
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
|
||||
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
|
||||
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pd-cover-cols">
|
||||
<table className="pd-kv">
|
||||
<caption>Planverfasser</caption>
|
||||
<tbody>
|
||||
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
|
||||
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
|
||||
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
|
||||
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="pd-kv">
|
||||
<caption>Dokument</caption>
|
||||
<tbody>
|
||||
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
|
||||
<tr><th>Stand</th><td>{stand}</td></tr>
|
||||
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
|
||||
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table className="pd-grid pd-block">
|
||||
<caption>Planstände</caption>
|
||||
<thead>
|
||||
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{namedSnapshots.length ? (
|
||||
namedSnapshots.map((snapshot, index) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td>{String.fromCharCode(97 + (index % 26))}</td>
|
||||
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
|
||||
<td>{snapshot.name}</td>
|
||||
{/* Snapshots do not record an editor yet; the project's
|
||||
current editor is shown for every entry. */}
|
||||
<td><Value value={project.projectEditor} /></td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={4}>–</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="pd-grid pd-block">
|
||||
<caption>Inhalt</caption>
|
||||
<tbody>
|
||||
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
|
||||
{entries.map((entry, index) => (
|
||||
<tr key={entry.summary.board.id}>
|
||||
<td className="pd-col-index">2.{index + 1}</td>
|
||||
<td>Verteilerplan {boardLabel(entry)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<SheetFooter project={project} stand={stand} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StructureNode({
|
||||
entry,
|
||||
entries,
|
||||
}: {
|
||||
entry: PrintBoardHierarchyEntry;
|
||||
entries: PrintBoardHierarchyEntry[];
|
||||
}) {
|
||||
const children = entries.filter(
|
||||
(candidate) => candidate.parentBoardId === entry.summary.board.id
|
||||
);
|
||||
const { board } = entry.summary;
|
||||
return (
|
||||
<li>
|
||||
<div className="pd-node">
|
||||
<strong>{board.boardNumber ?? board.name}</strong>
|
||||
{board.boardNumber ? <span>{board.name}</span> : null}
|
||||
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
|
||||
</div>
|
||||
{children.length ? (
|
||||
<ul>
|
||||
{children.map((child) => (
|
||||
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSheet({
|
||||
project,
|
||||
entries,
|
||||
logoSrc,
|
||||
stand,
|
||||
}: {
|
||||
project: ProjectDto;
|
||||
entries: PrintBoardHierarchyEntry[];
|
||||
logoSrc: string | null;
|
||||
stand: string;
|
||||
}) {
|
||||
const roots = entries.filter((entry) => entry.parentBoardId === null);
|
||||
// Top-level boards whose "fed from" text names no other board are grouped
|
||||
// below that text as an external feed (e.g. "HAK / Zähleranlage").
|
||||
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
|
||||
for (const root of roots) {
|
||||
const feed = root.summary.board.fedFrom ?? "";
|
||||
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
|
||||
}
|
||||
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
|
||||
entries.reduce((total, entry) => total + pick(entry), 0);
|
||||
return (
|
||||
<section className="pd-sheet pd-landscape">
|
||||
<TitleBlock
|
||||
kicker="Übersicht"
|
||||
logoSrc={logoSrc}
|
||||
name="Verteilerübersicht und Leistungssummen"
|
||||
project={project}
|
||||
rows={[
|
||||
["Verteiler", String(entries.length)],
|
||||
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
|
||||
]}
|
||||
stand={stand}
|
||||
/>
|
||||
<div className="pd-overview">
|
||||
<div className="pd-structure">
|
||||
<h2>Versorgungsstruktur</h2>
|
||||
<ul className="pd-tree">
|
||||
{[...feedGroups].map(([feed, feedRoots]) => {
|
||||
const nodes = feedRoots.map((root) => (
|
||||
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
|
||||
));
|
||||
return feed ? (
|
||||
<li key={`feed:${feed}`}>
|
||||
<div className="pd-node pd-feed">
|
||||
<strong>Einspeisung</strong>
|
||||
<span>{feed}</span>
|
||||
</div>
|
||||
<ul>{nodes}</ul>
|
||||
</li>
|
||||
) : (
|
||||
nodes
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<table className="pd-grid pd-plan">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
|
||||
<th className="pd-r">SK</th>
|
||||
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
|
||||
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
|
||||
<th className="pd-r">GZF<br />Vert.</th>
|
||||
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
|
||||
<th className="pd-r">I<sub>B</sub><br />[A]</th>
|
||||
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const { board } = entry.summary;
|
||||
return (
|
||||
<tr key={board.id}>
|
||||
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
|
||||
{entry.depth ? "└ " : ""}
|
||||
<strong>{board.boardNumber ?? "–"}</strong>
|
||||
</td>
|
||||
<td>{board.name}</td>
|
||||
<td>{board.location ?? "–"}</td>
|
||||
<td>{entry.summary.floorName ?? "–"}</td>
|
||||
<td>{board.supplyType ?? "–"}</td>
|
||||
<td>{board.fedFrom ?? "–"}</td>
|
||||
<td className="pd-r">{entry.summary.circuitCount}</td>
|
||||
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
|
||||
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
|
||||
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
|
||||
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
|
||||
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
|
||||
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
|
||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
|
||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
|
||||
<td />
|
||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
|
||||
<td />
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p className="pd-note">
|
||||
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
|
||||
„Σ inkl. unterlagert“ werden nur über gleichlautende Verteilernummern bzw.
|
||||
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF). Die fachliche
|
||||
Verknüpfung und Summierung folgt.
|
||||
</p>
|
||||
<p className="pd-legend">
|
||||
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
|
||||
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
|
||||
S aus cos φ je Gerätezeile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter project={project} stand={stand} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const planColumnCount = 11;
|
||||
|
||||
function roomLabel(row: CircuitTreeDeviceRowDto) {
|
||||
return row.roomNumberSnapshot
|
||||
? `${row.roomNumberSnapshot} ${row.roomNameSnapshot ?? ""}`.trim()
|
||||
: "–";
|
||||
}
|
||||
|
||||
function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
|
||||
return (
|
||||
<tr className="pd-component">
|
||||
<td className="pd-bmk">{component.equipmentIdentifier}</td>
|
||||
<td colSpan={6}>{component.name}</td>
|
||||
<td>{formatProtectionDevice(component.protectionDevice) ?? "–"}</td>
|
||||
<td colSpan={3} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
||||
const rows = circuit.deviceRows;
|
||||
const circuitCells = (
|
||||
<>
|
||||
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
|
||||
<td>{formatCircuitCable(circuit) ?? (circuit.isReserve ? "–" : <Open />)}</td>
|
||||
<td className="pd-r">
|
||||
{circuit.cableLength ?? (circuit.isReserve ? "–" : <Open />)}
|
||||
</td>
|
||||
<td>{circuit.remark ?? ""}</td>
|
||||
</>
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<tr className="pd-circuit pd-reserve">
|
||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
||||
<td>{circuit.displayName ?? "Reserve"}</td>
|
||||
<td>–</td>
|
||||
<td /><td /><td />
|
||||
<td className="pd-r">–</td>
|
||||
{circuitCells}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (rows.length === 1) {
|
||||
const row = rows[0];
|
||||
const title = circuit.displayName ?? row.displayName;
|
||||
return (
|
||||
<tr className="pd-circuit">
|
||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
||||
<td>
|
||||
{title}
|
||||
{title !== row.displayName ? (
|
||||
<span className="pd-sub"> · {row.displayName}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{roomLabel(row)}</td>
|
||||
<td className="pd-r">{row.quantity}</td>
|
||||
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
|
||||
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
|
||||
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
|
||||
{circuitCells}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<tr className="pd-circuit">
|
||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
||||
<td>{circuit.displayName ?? ""}</td>
|
||||
<td /><td /><td /><td />
|
||||
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
|
||||
{circuitCells}
|
||||
</tr>
|
||||
{rows.map((row) => (
|
||||
<tr className="pd-device" key={row.id}>
|
||||
<td />
|
||||
<td>{row.displayName}</td>
|
||||
<td>{roomLabel(row)}</td>
|
||||
<td className="pd-r">{row.quantity}</td>
|
||||
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
|
||||
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
|
||||
<td className="pd-r">{formatKilowatt(row.rowTotalPower)}</td>
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardPlanSheet({
|
||||
entry,
|
||||
project,
|
||||
logoSrc,
|
||||
stand,
|
||||
}: {
|
||||
entry: PrintBoardHierarchyEntry;
|
||||
project: ProjectDto;
|
||||
logoSrc: string | null;
|
||||
stand: string;
|
||||
}) {
|
||||
const { summary } = entry;
|
||||
const { board, tree } = summary;
|
||||
return (
|
||||
<section className="pd-sheet pd-landscape">
|
||||
<table className="pd-grid pd-plan">
|
||||
{/* The title block lives in <thead> so it repeats on every printed page. */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="pd-tb-cell" colSpan={planColumnCount}>
|
||||
<TitleBlock
|
||||
kicker="Verteilerplan"
|
||||
logoSrc={logoSrc}
|
||||
name={board.name}
|
||||
number={board.boardNumber}
|
||||
project={project}
|
||||
rows={[
|
||||
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
|
||||
["Netzart", board.supplyType ?? "–"],
|
||||
["Gespeist von", board.fedFrom ?? "–"],
|
||||
[
|
||||
<>P<sub>gl</sub></>,
|
||||
<>
|
||||
{formatKilowatt(summary.demandPowerKw)} kW · I<sub>B</sub>{" "}
|
||||
{formatCurrent(summary.designCurrentA)} A
|
||||
</>,
|
||||
],
|
||||
]}
|
||||
stand={stand}
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="pd-head">
|
||||
<th>SK-Nr.</th><th>Bezeichnung</th><th>Raum</th>
|
||||
<th className="pd-r">Anz.</th>
|
||||
<th className="pd-r">P/Stk.<br />[kW]</th>
|
||||
<th className="pd-r">GZF</th>
|
||||
<th className="pd-r">P<br />[kW]</th>
|
||||
<th>Schutz</th><th>Kabel</th>
|
||||
<th className="pd-r">Länge<br />[m]</th>
|
||||
<th>Bemerkung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tree.headerComponents.map((component) => (
|
||||
<ComponentRow component={component} key={component.id} />
|
||||
))}
|
||||
{tree.sections.map((section) => (
|
||||
<SectionRows key={section.id} section={section} />
|
||||
))}
|
||||
{tree.footerComponents.map((component) => (
|
||||
<ComponentRow component={component} key={component.id} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={5}>
|
||||
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
|
||||
</td>
|
||||
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={5}>Gleichzeitigkeitsfaktor Verteiler</td>
|
||||
<td className="pd-r">{formatFactor(summary.simultaneityFactor)}</td>
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
<tr className="pd-grand">
|
||||
<td />
|
||||
<td colSpan={5}>Gleichzeitige Leistung P<sub>gl</sub></td>
|
||||
<td className="pd-r">{formatKilowatt(summary.demandPowerKw)}</td>
|
||||
<td colSpan={4}>I<sub>B</sub> ≈ {formatCurrent(summary.designCurrentA)} A</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<SheetFooter project={project} stand={stand} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRows({
|
||||
section,
|
||||
}: {
|
||||
section: PrintBoardHierarchyEntry["summary"]["tree"]["sections"][number];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className="pd-group">
|
||||
<td className="pd-bmk">{section.prefix}</td>
|
||||
<td colSpan={planColumnCount - 1}>{section.displayName}</td>
|
||||
</tr>
|
||||
{section.components.map((component) => (
|
||||
<ComponentRow component={component} key={component.id} />
|
||||
))}
|
||||
{section.circuits.map((circuit) => (
|
||||
<CircuitRows circuit={circuit} key={circuit.id} />
|
||||
))}
|
||||
<tr className="pd-subtotal">
|
||||
<td />
|
||||
<td colSpan={5}>Summe {section.displayName}</td>
|
||||
<td className="pd-r">{formatKilowatt(section.sectionTotalPower)}</td>
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrintDocument({
|
||||
project,
|
||||
office,
|
||||
entries,
|
||||
namedSnapshots,
|
||||
logoSrc,
|
||||
printedAt,
|
||||
}: PrintDocumentProps) {
|
||||
const stand = dateFormat.format(printedAt);
|
||||
return (
|
||||
<div className="pd-pages">
|
||||
<CoverSheet
|
||||
entries={entries}
|
||||
logoSrc={logoSrc}
|
||||
namedSnapshots={namedSnapshots}
|
||||
office={office}
|
||||
project={project}
|
||||
stand={stand}
|
||||
/>
|
||||
<OverviewSheet entries={entries} logoSrc={logoSrc} project={project} stand={stand} />
|
||||
{entries.map((entry) => (
|
||||
<BoardPlanSheet
|
||||
entry={entry}
|
||||
key={entry.summary.board.id}
|
||||
logoSrc={logoSrc}
|
||||
project={project}
|
||||
stand={stand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,9 +93,18 @@ export interface ProjectDto {
|
|||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
siteAddress: string | null;
|
||||
workStage: string | null;
|
||||
projectLead: string | null;
|
||||
projectEditor: string | null;
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
export interface OfficeProfileDto {
|
||||
officeName: string | null;
|
||||
officeAddress: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHistoryStateDto {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
|
|
@ -172,7 +181,14 @@ export interface ProjectCommandResultDto {
|
|||
history: ProjectHistoryStateDto;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDto {
|
||||
export interface DistributionBoardInfoDto {
|
||||
boardNumber: string | null;
|
||||
location: string | null;
|
||||
fedFrom: string | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface DistributionBoardDto extends DistributionBoardInfoDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
|
|
@ -181,6 +197,13 @@ export interface DistributionBoardDto {
|
|||
simultaneityFactor: number;
|
||||
}
|
||||
|
||||
export interface DocumentLogoMetadataDto {
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
sizeBytes: number;
|
||||
updatedAtIso: string;
|
||||
}
|
||||
|
||||
export interface DistributionBoardCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
distributionBoard: DistributionBoardDto;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import type {
|
|||
DistributionBoardCommandResultDto,
|
||||
DistributionBoardCopyCommandResultDto,
|
||||
DistributionBoardDeleteCommandResultDto,
|
||||
DistributionBoardInfoDto,
|
||||
DocumentLogoMetadataDto,
|
||||
FloorDto,
|
||||
OfficeProfileDto,
|
||||
GlobalDeviceDto,
|
||||
ProjectCommandDto,
|
||||
ProjectCommandResultDto,
|
||||
|
|
@ -256,7 +259,7 @@ export function updateProjectSettings(
|
|||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
|
||||
}
|
||||
} & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
|
||||
) {
|
||||
return request<ProjectSettingsCommandResultDto>(
|
||||
`/api/projects/${projectId}`,
|
||||
|
|
@ -329,7 +332,7 @@ export function updateDistributionBoard(
|
|||
floorId: string | null;
|
||||
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||
simultaneityFactor: number;
|
||||
},
|
||||
} & Partial<DistributionBoardInfoDto>,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<DistributionBoardCommandResultDto>(
|
||||
|
|
@ -341,6 +344,42 @@ export function updateDistributionBoard(
|
|||
);
|
||||
}
|
||||
|
||||
export const documentLogoUrl = "/api/document-branding/logo";
|
||||
|
||||
export function getDocumentBranding() {
|
||||
return request<{
|
||||
logo: DocumentLogoMetadataDto | null;
|
||||
office: OfficeProfileDto;
|
||||
}>("/api/document-branding");
|
||||
}
|
||||
|
||||
export function updateOfficeProfile(office: OfficeProfileDto) {
|
||||
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(office),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDocumentLogo(file: File) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return request<{ logo: DocumentLogoMetadataDto }>(documentLogoUrl, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
contentBase64: btoa(binary),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocumentLogo() {
|
||||
return request<void>(documentLogoUrl, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function getExternalCsvConfiguration(projectId: string) {
|
||||
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
|
||||
`/api/projects/${projectId}/external-csv/configuration`
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
|||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||
import { CableSizingCalculationRepository } from "../../db/repositories/cable-sizing-calculation.repository.js";
|
||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
|
|
@ -33,3 +35,5 @@ export const roomRepository = new RoomRepository(db);
|
|||
export const externalCsvConfigurationRepository =
|
||||
new ExternalCsvConfigurationRepository(db);
|
||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);
|
||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||
|
|
|
|||
99
src/server/controllers/cable-sizing.controller.ts
Normal file
99
src/server/controllers/cable-sizing.controller.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
|
||||
import {
|
||||
calculateCableSizing,
|
||||
buildCableSizingAlerts,
|
||||
LAYING_METHODS,
|
||||
LAYING_METHOD_LABELS,
|
||||
LAYING_METHOD_VERIFIED,
|
||||
INSULATION_MATERIALS,
|
||||
INSULATION_MATERIAL_LABELS,
|
||||
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
|
||||
import { cableSizingCalculationRepository } from "../composition/application-repositories.js";
|
||||
|
||||
// Stateless calculation, not a project command: no expectedRevision, no
|
||||
// circuit mutation here. The caller applies the result via the existing
|
||||
// circuit.update command (POST /api/projects/:projectId/commands) if the
|
||||
// user confirms it. See docs/cable-sizing-module.md.
|
||||
export async function calculateCableSizingHandler(req: Request, res: Response) {
|
||||
const parsed = cableSizingRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const { context, ...input } = parsed.data;
|
||||
const result = calculateCableSizing(input);
|
||||
const alerts = buildCableSizingAlerts(input, result);
|
||||
|
||||
let entry;
|
||||
try {
|
||||
entry = await cableSizingCalculationRepository.create({
|
||||
id: randomUUID(),
|
||||
projectId: context?.projectId ?? null,
|
||||
circuitId: context?.circuitId ?? null,
|
||||
equipmentIdentifier: context?.equipmentIdentifier ?? null,
|
||||
input,
|
||||
result,
|
||||
appliedToCircuit: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// context.circuitId/projectId are caller-supplied and only used for the
|
||||
// audit-log entry, not the calculation itself - a stale or unknown id
|
||||
// (e.g. a circuit deleted between page load and this request) should be
|
||||
// a normal 400, not a raw 500 from the foreign-key constraint.
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === "SQLITE_CONSTRAINT_FOREIGNKEY"
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Unknown context.projectId or context.circuitId" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.status(201).json({ calculationId: entry.id, result, alerts });
|
||||
}
|
||||
|
||||
export async function listLayingMethodsHandler(_req: Request, res: Response) {
|
||||
return res.json(
|
||||
LAYING_METHODS.map((method) => ({
|
||||
method,
|
||||
label: LAYING_METHOD_LABELS[method],
|
||||
dataVerified: LAYING_METHOD_VERIFIED[method],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function listInsulationMaterialsHandler(_req: Request, res: Response) {
|
||||
return res.json(
|
||||
INSULATION_MATERIALS.map((insulation) => ({
|
||||
insulation,
|
||||
label: INSULATION_MATERIAL_LABELS[insulation],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function markCalculationAppliedHandler(req: Request, res: Response) {
|
||||
const { calculationId } = req.params;
|
||||
if (typeof calculationId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid calculationId" });
|
||||
}
|
||||
const updated = await cableSizingCalculationRepository.markApplied(calculationId);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Calculation not found" });
|
||||
}
|
||||
return res.json(updated);
|
||||
}
|
||||
|
||||
export async function listCalculationsForCircuitHandler(req: Request, res: Response) {
|
||||
const { circuitId } = req.params;
|
||||
if (typeof circuitId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid circuitId" });
|
||||
}
|
||||
const rows = await cableSizingCalculationRepository.listByCircuit(circuitId);
|
||||
return res.json(rows);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
createDistributionBoardInsertSubtreeProjectCommand,
|
||||
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
|
||||
import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js";
|
||||
import { distributionBoardInfoFields } from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
copyDistributionBoardSchema,
|
||||
createDistributionBoardSchema,
|
||||
|
|
@ -64,6 +65,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
|||
distributionBoard: {
|
||||
...structure.distributionBoard,
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -86,6 +91,13 @@ export async function updateDistributionBoard(
|
|||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
// Info fields are optional in the request; only submitted fields become
|
||||
// part of the command and therefore of its inverse.
|
||||
const infoPatch = Object.fromEntries(
|
||||
distributionBoardInfoFields
|
||||
.filter((field) => parsed.data[field] !== undefined)
|
||||
.map((field) => [field, parsed.data[field]])
|
||||
);
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
|
|
@ -97,6 +109,7 @@ export async function updateDistributionBoard(
|
|||
floorId: parsed.data.floorId,
|
||||
supplyType: parsed.data.supplyType,
|
||||
simultaneityFactor: parsed.data.simultaneityFactor,
|
||||
...infoPatch,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
|||
100
src/server/controllers/document-branding.controller.ts
Normal file
100
src/server/controllers/document-branding.controller.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { Request, Response } from "express";
|
||||
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
|
||||
import { officeProfileFields } from "../../shared/constants/project-cover.js";
|
||||
import {
|
||||
maxDocumentLogoBytes,
|
||||
updateOfficeProfileSchema,
|
||||
uploadDocumentLogoSchema,
|
||||
type DocumentLogoMimeType,
|
||||
} from "../../shared/validation/document-branding.schemas.js";
|
||||
import { documentBrandingRepository } from "../composition/application-repositories.js";
|
||||
|
||||
const logoKey = "logo";
|
||||
|
||||
export function getDocumentBrandingHandler(_req: Request, res: Response) {
|
||||
const logo = documentBrandingRepository.get(logoKey);
|
||||
return res.json({
|
||||
logo: logo ? toLogoMetadata(logo) : null,
|
||||
office: documentBrandingRepository.getSettings(officeProfileFields),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOfficeProfileHandler(req: Request, res: Response) {
|
||||
const parsed = updateOfficeProfileSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
documentBrandingRepository.setSettings(parsed.data);
|
||||
return res.json({
|
||||
office: documentBrandingRepository.getSettings(officeProfileFields),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDocumentLogoHandler(_req: Request, res: Response) {
|
||||
const logo = documentBrandingRepository.get(logoKey);
|
||||
if (!logo) {
|
||||
return res.status(404).json({ error: "No logo uploaded" });
|
||||
}
|
||||
res.setHeader("Content-Type", logo.mimeType);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
// SVG uploads are rendered through <img> only; forbid script execution if
|
||||
// the file is ever opened directly.
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
);
|
||||
return res.send(logo.data);
|
||||
}
|
||||
|
||||
export function uploadDocumentLogoHandler(req: Request, res: Response) {
|
||||
const parsed = uploadDocumentLogoSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const data = Buffer.from(parsed.data.contentBase64, "base64");
|
||||
if (data.length === 0 || data.length > maxDocumentLogoBytes) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Das Logo darf höchstens 2 MB groß sein." });
|
||||
}
|
||||
if (!matchesMimeType(data, parsed.data.mimeType)) {
|
||||
return res.status(400).json({
|
||||
error: "Der Dateiinhalt passt nicht zum angegebenen Bildformat.",
|
||||
});
|
||||
}
|
||||
const logo = documentBrandingRepository.upsert({
|
||||
key: logoKey,
|
||||
mimeType: parsed.data.mimeType,
|
||||
fileName: parsed.data.fileName,
|
||||
data,
|
||||
});
|
||||
return res.json({ logo: toLogoMetadata(logo) });
|
||||
}
|
||||
|
||||
export function deleteDocumentLogoHandler(_req: Request, res: Response) {
|
||||
documentBrandingRepository.delete(logoKey);
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
function toLogoMetadata(logo: DocumentBrandingAsset) {
|
||||
return {
|
||||
mimeType: logo.mimeType,
|
||||
fileName: logo.fileName,
|
||||
sizeBytes: logo.data.length,
|
||||
updatedAtIso: logo.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesMimeType(data: Buffer, mimeType: DocumentLogoMimeType) {
|
||||
if (mimeType === "image/png") {
|
||||
return data
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
}
|
||||
if (mimeType === "image/jpeg") {
|
||||
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
||||
}
|
||||
const head = data.subarray(0, 2048).toString("utf8").toLowerCase();
|
||||
return head.includes("<svg");
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ import {
|
|||
updateProjectSettingsSchema,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
|
||||
import {
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { projectRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
|
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
|
|||
}
|
||||
|
||||
const { expectedRevision, ...settings } = parsed.data;
|
||||
const current = await projectRepository.findById(projectId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
// Cover fields are optional in the request and keep their current value
|
||||
// when omitted. `expectedRevision` rejects a project changed meanwhile.
|
||||
const coverValues = Object.fromEntries(
|
||||
projectCoverFields.map((field) => [
|
||||
field,
|
||||
settings[field] === undefined ? current[field] : settings[field],
|
||||
])
|
||||
) as Record<ProjectCoverField, string | null>;
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision,
|
||||
description: "Projekteinstellungen bearbeiten",
|
||||
command: createProjectSettingsUpdateProjectCommand(settings),
|
||||
command: createProjectSettingsUpdateProjectCommand({
|
||||
...settings,
|
||||
...coverValues,
|
||||
}),
|
||||
});
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import express from "express";
|
||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
|
|
@ -51,6 +53,8 @@ app.get("/health", (_req, res) => {
|
|||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/document-branding", documentBrandingRouter);
|
||||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
|
|
|
|||
19
src/server/routes/cable-sizing.routes.ts
Normal file
19
src/server/routes/cable-sizing.routes.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import express from "express";
|
||||
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
|
||||
|
||||
export const cableSizingRouter = express.Router();
|
||||
|
||||
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
|
||||
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
|
||||
cableSizingRouter.get(
|
||||
"/insulation-materials",
|
||||
cableSizingController.listInsulationMaterialsHandler
|
||||
);
|
||||
cableSizingRouter.post(
|
||||
"/calculations/:calculationId/applied",
|
||||
cableSizingController.markCalculationAppliedHandler
|
||||
);
|
||||
cableSizingRouter.get(
|
||||
"/circuits/:circuitId/calculations",
|
||||
cableSizingController.listCalculationsForCircuitHandler
|
||||
);
|
||||
10
src/server/routes/document-branding.routes.ts
Normal file
10
src/server/routes/document-branding.routes.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import express from "express";
|
||||
import * as documentBrandingController from "../controllers/document-branding.controller.js";
|
||||
|
||||
export const documentBrandingRouter = express.Router();
|
||||
|
||||
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
|
||||
documentBrandingRouter.put("/office", documentBrandingController.updateOfficeProfileHandler);
|
||||
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
|
||||
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
|
||||
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);
|
||||
|
|
@ -24,3 +24,44 @@ export const distributionBoardSupplyTypeLabels: Record<
|
|||
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
|
||||
SiBe: "SiBe – Sicherheitsbeleuchtung",
|
||||
};
|
||||
|
||||
/**
|
||||
* Planner-owned descriptive distribution-board data shown on the board info
|
||||
* card and in the print view. The values are local planning values. A later
|
||||
* Revit/CSV round-trip may prefill them from mapped model parameters, but only
|
||||
* through a previewed, explicitly confirmed project command (see
|
||||
* docs/spec/revit-csv-integration-requirements.md); imports never overwrite
|
||||
* them silently.
|
||||
*
|
||||
* `fedFrom` is intentionally free text for now. How a board is logically
|
||||
* linked to its upstream board or feeding circuit is not decided yet.
|
||||
*/
|
||||
export const distributionBoardInfoFields = [
|
||||
"boardNumber",
|
||||
"location",
|
||||
"fedFrom",
|
||||
"remark",
|
||||
] as const;
|
||||
|
||||
export type DistributionBoardInfoField =
|
||||
(typeof distributionBoardInfoFields)[number];
|
||||
|
||||
export const distributionBoardInfoFieldLabels: Record<
|
||||
DistributionBoardInfoField,
|
||||
string
|
||||
> = {
|
||||
boardNumber: "Verteilernummer",
|
||||
location: "Örtlichkeit",
|
||||
fedFrom: "Gespeist von",
|
||||
remark: "Bemerkung",
|
||||
};
|
||||
|
||||
export const distributionBoardInfoFieldMaxLengths: Record<
|
||||
DistributionBoardInfoField,
|
||||
number
|
||||
> = {
|
||||
boardNumber: 50,
|
||||
location: 200,
|
||||
fedFrom: 200,
|
||||
remark: 2000,
|
||||
};
|
||||
|
|
|
|||
9
src/shared/constants/document-branding.ts
Normal file
9
src/shared/constants/document-branding.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const documentLogoMimeTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/svg+xml",
|
||||
] as const;
|
||||
|
||||
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
|
||||
|
||||
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
|
||||
45
src/shared/constants/project-cover.ts
Normal file
45
src/shared/constants/project-cover.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Project data shown on the print view cover page. They are ordinary project
|
||||
* settings (persisted through `project.update-settings`, part of logical
|
||||
* snapshots and project transfer).
|
||||
*/
|
||||
export const projectCoverFields = [
|
||||
"siteAddress",
|
||||
"workStage",
|
||||
"projectLead",
|
||||
"projectEditor",
|
||||
] as const;
|
||||
|
||||
export type ProjectCoverField = (typeof projectCoverFields)[number];
|
||||
|
||||
export const projectCoverFieldLabels: Record<ProjectCoverField, string> = {
|
||||
siteAddress: "Anschrift Bauvorhaben",
|
||||
workStage: "Leistungsphase",
|
||||
projectLead: "Projektleitung",
|
||||
projectEditor: "Bearbeitung",
|
||||
};
|
||||
|
||||
export const projectCoverFieldMaxLengths: Record<ProjectCoverField, number> = {
|
||||
siteAddress: 500,
|
||||
workStage: 100,
|
||||
projectLead: 200,
|
||||
projectEditor: 200,
|
||||
};
|
||||
|
||||
/**
|
||||
* Office-wide data for the cover page ("Planverfasser"). Global branding like
|
||||
* the document logo, outside project revisions and snapshots.
|
||||
*/
|
||||
export const officeProfileFields = ["officeName", "officeAddress"] as const;
|
||||
|
||||
export type OfficeProfileField = (typeof officeProfileFields)[number];
|
||||
|
||||
export const officeProfileFieldLabels: Record<OfficeProfileField, string> = {
|
||||
officeName: "Büro",
|
||||
officeAddress: "Anschrift Büro",
|
||||
};
|
||||
|
||||
export const officeProfileFieldMaxLengths: Record<OfficeProfileField, number> = {
|
||||
officeName: 200,
|
||||
officeAddress: 500,
|
||||
};
|
||||
42
src/shared/validation/document-branding.schemas.ts
Normal file
42
src/shared/validation/document-branding.schemas.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
} from "../constants/document-branding.js";
|
||||
import { officeProfileFieldMaxLengths } from "../constants/project-cover.js";
|
||||
|
||||
export {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
type DocumentLogoMimeType,
|
||||
} from "../constants/document-branding.js";
|
||||
|
||||
export const uploadDocumentLogoSchema = z
|
||||
.object({
|
||||
fileName: z.string().trim().min(1).max(200),
|
||||
mimeType: z.enum(documentLogoMimeTypes),
|
||||
contentBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
// Base64 expands by 4/3; the decoded size is checked again server-side.
|
||||
.max(Math.ceil((maxDocumentLogoBytes * 4) / 3) + 4),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;
|
||||
|
||||
// Empty text is stored as "not set" (null).
|
||||
const officeValueSchema = (maximumLength: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(maximumLength)
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null));
|
||||
|
||||
export const updateOfficeProfileSchema = z
|
||||
.object({
|
||||
officeName: officeValueSchema(officeProfileFieldMaxLengths.officeName),
|
||||
officeAddress: officeValueSchema(officeProfileFieldMaxLengths.officeAddress),
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -1,7 +1,25 @@
|
|||
import { z } from "zod";
|
||||
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
|
||||
import {
|
||||
distributionBoardInfoFieldMaxLengths,
|
||||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
type ProjectCoverField,
|
||||
} from "../constants/project-cover.js";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
// Empty text is stored as null so "not entered" has one representation.
|
||||
const optionalCoverValueSchema = (field: ProjectCoverField) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(projectCoverFieldMaxLengths[field])
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null))
|
||||
.optional();
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
internalProjectNumber: z.string().trim().max(100).optional(),
|
||||
|
|
@ -30,6 +48,11 @@ export const updateProjectSettingsSchema = z
|
|||
.refine((values) => new Set(values).size === values.length, {
|
||||
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
|
||||
}),
|
||||
// Print cover fields; omitted fields keep their current value.
|
||||
siteAddress: optionalCoverValueSchema("siteAddress"),
|
||||
workStage: optionalCoverValueSchema("workStage"),
|
||||
projectLead: optionalCoverValueSchema("projectLead"),
|
||||
projectEditor: optionalCoverValueSchema("projectEditor"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -42,12 +65,27 @@ export const createDistributionBoardSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
// Empty info text is stored as null so that "not entered" has one
|
||||
// representation in the database, snapshots and print view.
|
||||
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(distributionBoardInfoFieldMaxLengths[field])
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null))
|
||||
.optional();
|
||||
|
||||
export const updateDistributionBoardSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
floorId: z.string().trim().min(1).nullable(),
|
||||
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||
simultaneityFactor: z.number().finite().min(0).max(1),
|
||||
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
|
||||
location: distributionBoardInfoValueSchema("location"),
|
||||
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
|
||||
remark: distributionBoardInfoValueSchema("remark"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
152
tests/cable-sizing-calculation.repository.test.ts
Normal file
152
tests/cable-sizing-calculation.repository.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
|
||||
import { CableSizingCalculationRepository } from "../src/db/repositories/cable-sizing-calculation.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createRepository() {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
return new CableSizingCalculationRepository(context.db);
|
||||
}
|
||||
|
||||
function createRepositoryWithCircuits(): {
|
||||
repository: CableSizingCalculationRepository;
|
||||
context: DatabaseContext;
|
||||
circuitOneId: string;
|
||||
circuitTwoId: string;
|
||||
} {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||
const board = new DistributionBoardFixtureRepository(context.db).createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
);
|
||||
const circuitList = context.db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.distributionBoardId, board.id))
|
||||
.get();
|
||||
if (!circuitList) {
|
||||
throw new Error("fixture did not create a circuit list");
|
||||
}
|
||||
const section = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, circuitList.id))
|
||||
.get();
|
||||
if (!section) {
|
||||
throw new Error("fixture did not create a section");
|
||||
}
|
||||
const circuitOneId = "circuit-1";
|
||||
const circuitTwoId = "circuit-2";
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: circuitOneId,
|
||||
circuitListId: section.circuitListId,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
},
|
||||
{
|
||||
id: circuitTwoId,
|
||||
circuitListId: section.circuitListId,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1F1.2",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
return {
|
||||
repository: new CableSizingCalculationRepository(context.db),
|
||||
context,
|
||||
circuitOneId,
|
||||
circuitTwoId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CableSizingCalculationRepository", () => {
|
||||
it("creates an entry with input/result JSON and defaults appliedToCircuit to false", () => {
|
||||
const repository = createRepository();
|
||||
const entry = repository.create({
|
||||
id: "calc-1",
|
||||
projectId: null,
|
||||
circuitId: null,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
input: { layingMethod: "C" },
|
||||
result: { recommendedCrossSectionMm2: 4 },
|
||||
appliedToCircuit: 0,
|
||||
});
|
||||
assert.equal(entry.id, "calc-1");
|
||||
assert.equal(entry.appliedToCircuit, 0);
|
||||
assert.deepEqual(entry.input, { layingMethod: "C" });
|
||||
assert.deepEqual(entry.result, { recommendedCrossSectionMm2: 4 });
|
||||
});
|
||||
|
||||
it("lists entries by circuitId, most recent first", () => {
|
||||
// createdAt defaults to SQLite's unixepoch() (second resolution), so two
|
||||
// inserts in the same test can tie - pass explicit, distinct timestamps
|
||||
// instead of racing the clock. circuitId has a real foreign key into
|
||||
// circuits (foreign_keys = ON), so this needs actual circuit rows, not
|
||||
// arbitrary strings.
|
||||
const { repository, circuitOneId, circuitTwoId } = createRepositoryWithCircuits();
|
||||
repository.create({
|
||||
id: "calc-a",
|
||||
projectId: null,
|
||||
circuitId: circuitOneId,
|
||||
equipmentIdentifier: null,
|
||||
input: {},
|
||||
result: {},
|
||||
appliedToCircuit: 0,
|
||||
createdAt: new Date(1000),
|
||||
});
|
||||
repository.create({
|
||||
id: "calc-b",
|
||||
projectId: null,
|
||||
circuitId: circuitOneId,
|
||||
equipmentIdentifier: null,
|
||||
input: {},
|
||||
result: {},
|
||||
appliedToCircuit: 0,
|
||||
createdAt: new Date(2000),
|
||||
});
|
||||
repository.create({
|
||||
id: "calc-other-circuit",
|
||||
projectId: null,
|
||||
circuitId: circuitTwoId,
|
||||
equipmentIdentifier: null,
|
||||
input: {},
|
||||
result: {},
|
||||
appliedToCircuit: 0,
|
||||
createdAt: new Date(3000),
|
||||
});
|
||||
|
||||
const rows = repository.listByCircuit(circuitOneId);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].id, "calc-b");
|
||||
assert.equal(rows[1].id, "calc-a");
|
||||
});
|
||||
|
||||
it("marks an entry as applied", () => {
|
||||
const repository = createRepository();
|
||||
repository.create({
|
||||
id: "calc-1",
|
||||
projectId: null,
|
||||
circuitId: null,
|
||||
equipmentIdentifier: null,
|
||||
input: {},
|
||||
result: {},
|
||||
appliedToCircuit: 0,
|
||||
});
|
||||
const updated = repository.markApplied("calc-1");
|
||||
assert.equal(updated?.appliedToCircuit, 1);
|
||||
});
|
||||
});
|
||||
209
tests/cable-sizing-calculation.test.ts
Normal file
209
tests/cable-sizing-calculation.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildCableSizingAlerts,
|
||||
calculateCableSizing,
|
||||
isCableSizingDataVerified,
|
||||
type CableSizingInput,
|
||||
} from "../src/cable-sizing/domain/cable-sizing-calculation.js";
|
||||
|
||||
const BASE_INPUT: CableSizingInput = {
|
||||
phase: 1,
|
||||
mode: "power",
|
||||
powerKw: 5,
|
||||
cosPhi: 1,
|
||||
voltage: 230,
|
||||
lengthM: 30,
|
||||
layingMethod: "C",
|
||||
conductorMaterial: "copper",
|
||||
insulation: "pvc",
|
||||
ambientTemperatureC: 30,
|
||||
groupingCircuits: 1,
|
||||
maxVoltageDropPercent: 3,
|
||||
harmonicNeutralLoad: "none",
|
||||
};
|
||||
|
||||
describe("calculateCableSizing", () => {
|
||||
it("matches the verified reference case (1~, 5 kW, 230 V, 30 m, method C, copper)", () => {
|
||||
const result = calculateCableSizing(BASE_INPUT);
|
||||
assert.equal(result.dataVerified, true);
|
||||
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
||||
assert.equal(result.crossSectionByCapacityMm2, 2.5);
|
||||
assert.equal(result.crossSectionByVoltageDropMm2, 4);
|
||||
assert.equal(result.recommendedCrossSectionMm2, 4);
|
||||
});
|
||||
|
||||
it("returns dataVerified: false and no numeric result for an unverified laying method", () => {
|
||||
const result = calculateCableSizing({ ...BASE_INPUT, layingMethod: "A2" });
|
||||
assert.equal(result.dataVerified, false);
|
||||
assert.equal(result.recommendedCrossSectionMm2, null);
|
||||
assert.equal(result.rows.length, 0);
|
||||
// The operating current itself does not depend on the capacity table
|
||||
// and is still reported so the UI can show at least that much.
|
||||
assert.ok(Math.abs(result.operatingCurrentA - 21.739) < 0.01);
|
||||
});
|
||||
|
||||
it("returns dataVerified: false for xlpe regardless of method", () => {
|
||||
const result = calculateCableSizing({ ...BASE_INPUT, insulation: "xlpe" });
|
||||
assert.equal(result.dataVerified, false);
|
||||
});
|
||||
|
||||
it("isCableSizingDataVerified matches the six ported, verified methods only", () => {
|
||||
for (const method of ["A1", "B2", "C", "E", "D1", "D2"] as const) {
|
||||
assert.equal(isCableSizingDataVerified(method, "pvc"), true, method);
|
||||
}
|
||||
for (const method of ["A2", "B1", "F", "G"] as const) {
|
||||
assert.equal(isCableSizingDataVerified(method, "pvc"), false, method);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the 0.86 harmonic reduction factor only for three-phase + 15to33Percent", () => {
|
||||
const threePhase: CableSizingInput = {
|
||||
...BASE_INPUT,
|
||||
phase: 3,
|
||||
mode: "current",
|
||||
currentA: 10,
|
||||
powerKw: undefined,
|
||||
voltage: 400,
|
||||
};
|
||||
const base = calculateCableSizing({ ...threePhase, harmonicNeutralLoad: "none" });
|
||||
const derated = calculateCableSizing({
|
||||
...threePhase,
|
||||
harmonicNeutralLoad: "15to33Percent",
|
||||
});
|
||||
assert.equal(base.harmonicReductionApplied, false);
|
||||
assert.equal(derated.harmonicReductionApplied, true);
|
||||
assert.ok(
|
||||
Math.abs(derated.combinedDerationFactor - base.combinedDerationFactor * 0.86) < 1e-9
|
||||
);
|
||||
|
||||
const singlePhaseWithHarmonics = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
harmonicNeutralLoad: "15to33Percent",
|
||||
});
|
||||
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
|
||||
});
|
||||
|
||||
it("uses max(operatingCurrentA, existingProtectionRatedCurrentA) as the design current for cross-section selection", () => {
|
||||
// Load alone (21.7 A) would recommend 4 mm² (see the reference case
|
||||
// above); a 32 A breaker on the same circuit must still be covered by
|
||||
// the cable (In <= Iz), so the recommendation should grow accordingly.
|
||||
const withoutBreaker = calculateCableSizing(BASE_INPUT);
|
||||
const withBreaker = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
existingProtectionRatedCurrentA: 32,
|
||||
});
|
||||
assert.equal(withoutBreaker.designCurrentA, withoutBreaker.operatingCurrentA);
|
||||
assert.equal(withBreaker.designCurrentA, 32);
|
||||
assert.ok(
|
||||
(withBreaker.recommendedCrossSectionMm2 ?? 0) >=
|
||||
(withoutBreaker.recommendedCrossSectionMm2 ?? 0)
|
||||
);
|
||||
assert.ok(withBreaker.protectionCoordination?.coordinated);
|
||||
});
|
||||
|
||||
it("reports an oversized breaker as the limiting factor when no cross-section can cover it", () => {
|
||||
const result = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
existingProtectionRatedCurrentA: 1000,
|
||||
});
|
||||
assert.equal(result.designCurrentA, 1000);
|
||||
assert.equal(result.recommendedCrossSectionMm2, null);
|
||||
// No cross-section satisfies the design current at all, so there is no
|
||||
// "recommended but under-protected" case to flag - protectionCoordination
|
||||
// is only meaningful once a recommendation exists.
|
||||
assert.equal(result.protectionCoordination, null);
|
||||
|
||||
const alerts = buildCableSizingAlerts(
|
||||
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
|
||||
result
|
||||
);
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.equal(alerts[0].kind, "critical");
|
||||
assert.ok(alerts[0].text.includes("Vorhandene Sicherung"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCableSizingAlerts", () => {
|
||||
it("returns a single critical alert for an unverified combination, no numeric claims", () => {
|
||||
const input: CableSizingInput = { ...BASE_INPUT, layingMethod: "G" };
|
||||
const result = calculateCableSizing(input);
|
||||
const alerts = buildCableSizingAlerts(input, result);
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.equal(alerts[0].kind, "critical");
|
||||
assert.ok(alerts[0].text.includes("keine geprüften"));
|
||||
});
|
||||
|
||||
it("returns an ok alert plus a max-length info alert for a clean, unremarkable case", () => {
|
||||
const input: CableSizingInput = {
|
||||
...BASE_INPUT,
|
||||
mode: "current",
|
||||
currentA: 15,
|
||||
powerKw: undefined,
|
||||
lengthM: 3,
|
||||
};
|
||||
const result = calculateCableSizing(input);
|
||||
const alerts = buildCableSizingAlerts(input, result);
|
||||
assert.equal(alerts.length, 2);
|
||||
assert.equal(alerts[0].kind, "ok");
|
||||
assert.equal(alerts[1].kind, "info");
|
||||
assert.ok(alerts[1].text.includes("Maximale Länge"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxLengthForVoltageDropM", () => {
|
||||
it("is the inverse of the voltage-drop formula: recalculating at that length gives back the limit", () => {
|
||||
const result = calculateCableSizing(BASE_INPUT);
|
||||
const recommendedRow = result.rows.find((row) => row.recommended);
|
||||
assert.ok(recommendedRow?.maxLengthForVoltageDropM != null);
|
||||
|
||||
const atMaxLength = calculateCableSizing({
|
||||
...BASE_INPUT,
|
||||
lengthM: recommendedRow!.maxLengthForVoltageDropM!,
|
||||
});
|
||||
const rowAtSameCrossSection = atMaxLength.rows.find(
|
||||
(row) => row.crossSectionMm2 === recommendedRow!.crossSectionMm2
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(rowAtSameCrossSection!.voltageDropPercent! - BASE_INPUT.maxVoltageDropPercent) <
|
||||
0.01
|
||||
);
|
||||
});
|
||||
|
||||
it("is null when there is no current flowing (division by zero guard)", () => {
|
||||
const result = calculateCableSizing({ ...BASE_INPUT, mode: "current", currentA: 0, powerKw: undefined });
|
||||
assert.ok(result.rows.every((row) => row.maxLengthForVoltageDropM === null));
|
||||
});
|
||||
});
|
||||
|
||||
describe("practical minimum cross-section for single_phase circuits", () => {
|
||||
it("raises a smaller calculated recommendation to 2.5 mm² for single_phase circuits", () => {
|
||||
// 1 A load at 30 m would normally recommend 1.5 mm² by calculation alone.
|
||||
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
|
||||
const withoutCategory = calculateCableSizing(smallLoad);
|
||||
const withCategory = calculateCableSizing({ ...smallLoad, circuitCategory: "single_phase" });
|
||||
|
||||
assert.equal(withoutCategory.recommendedCrossSectionMm2, 1.5);
|
||||
assert.equal(withoutCategory.practicalMinimumApplied, false);
|
||||
assert.equal(withCategory.recommendedCrossSectionMm2, 2.5);
|
||||
assert.equal(withCategory.practicalMinimumApplied, true);
|
||||
});
|
||||
|
||||
it("never lowers a recommendation that already needs more than the practical minimum", () => {
|
||||
const result = calculateCableSizing({ ...BASE_INPUT, circuitCategory: "single_phase" });
|
||||
assert.equal(result.recommendedCrossSectionMm2, 4);
|
||||
assert.equal(result.practicalMinimumApplied, false);
|
||||
});
|
||||
|
||||
it("does not apply to lighting or three_phase categories", () => {
|
||||
const smallLoad: CableSizingInput = { ...BASE_INPUT, mode: "current", currentA: 1, powerKw: undefined };
|
||||
assert.equal(
|
||||
calculateCableSizing({ ...smallLoad, circuitCategory: "lighting" }).recommendedCrossSectionMm2,
|
||||
1.5
|
||||
);
|
||||
assert.equal(
|
||||
calculateCableSizing({ ...smallLoad, circuitCategory: "three_phase" }).recommendedCrossSectionMm2,
|
||||
1.5
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -387,6 +387,110 @@ describe("distribution-board structure project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("updates the info card fields with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV-EG",
|
||||
{ floorId: null, supplyType: "AV" }
|
||||
);
|
||||
const insert = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
const readInfo = () =>
|
||||
context.db
|
||||
.select({
|
||||
boardNumber: distributionBoards.boardNumber,
|
||||
location: distributionBoards.location,
|
||||
fedFrom: distributionBoards.fedFrom,
|
||||
remark: distributionBoards.remark,
|
||||
})
|
||||
.from(distributionBoards)
|
||||
.get();
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
}
|
||||
),
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
remark: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
forward.inverse.payload.changes.map((change) => change.field),
|
||||
["boardNumber", "location", "fedFrom"]
|
||||
);
|
||||
|
||||
// A board with info values is no longer the unchanged empty setup.
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "user",
|
||||
command: insert.inverse,
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
for (const value of ["", " UV 1", "x".repeat(51)]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{ boardNumber: value }
|
||||
),
|
||||
/boardNumber/
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends distribution-board writes to the revision-safe API", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,67 @@ describe("distribution-board subtree project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("copies board info values and accepts snapshots stored before they existed", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const board = createPopulatedBoard(context);
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: "UV 1.1", location: "Technik" })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const repository =
|
||||
new DistributionBoardSubtreeProjectCommandRepository(context.db);
|
||||
const withInfo = repository.capture("project-1", board.id);
|
||||
const clone = cloneDistributionBoardSubtree(withInfo, "UV 1 Kopie");
|
||||
assert.equal(clone.distributionBoard.boardNumber, "UV 1.1");
|
||||
assert.equal(clone.distributionBoard.location, "Technik");
|
||||
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: null, location: null })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const legacySnapshot = structuredClone(
|
||||
repository.capture("project-1", board.id)
|
||||
);
|
||||
const legacyBoard = legacySnapshot.distributionBoard as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete legacyBoard.boardNumber;
|
||||
delete legacyBoard.location;
|
||||
delete legacyBoard.fedFrom;
|
||||
delete legacyBoard.remark;
|
||||
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
description: "Verteilung löschen",
|
||||
command: createDistributionBoardDeleteSubtreeProjectCommand(
|
||||
legacySnapshot
|
||||
),
|
||||
});
|
||||
assert.throws(
|
||||
() => repository.capture("project-1", board.id),
|
||||
/not found/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
repository.capture("project-1", board.id).distributionBoard.fedFrom,
|
||||
null
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed deletion snapshots without partial writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
|
|
|||
77
tests/document-branding.repository.test.ts
Normal file
77
tests/document-branding.repository.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
import { DocumentBrandingRepository } from "../src/db/repositories/document-branding.repository.js";
|
||||
import { officeProfileFields } from "../src/shared/constants/project-cover.js";
|
||||
import { updateOfficeProfileSchema } from "../src/shared/validation/document-branding.schemas.js";
|
||||
|
||||
function createRepository() {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
return { context, repository: new DocumentBrandingRepository(context.db) };
|
||||
}
|
||||
|
||||
describe("document branding repository", () => {
|
||||
it("stores, replaces and removes the office profile", () => {
|
||||
const { context, repository } = createRepository();
|
||||
try {
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: null,
|
||||
officeAddress: null,
|
||||
});
|
||||
repository.setSettings(
|
||||
updateOfficeProfileSchema.parse({
|
||||
officeName: " kbp köhler beraten + planen GmbH ",
|
||||
officeAddress: "Straße 1\n12345 Ort",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: "kbp köhler beraten + planen GmbH",
|
||||
officeAddress: "Straße 1\n12345 Ort",
|
||||
});
|
||||
repository.setSettings(
|
||||
updateOfficeProfileSchema.parse({ officeName: "Büro", officeAddress: "" })
|
||||
);
|
||||
assert.deepEqual(repository.getSettings(officeProfileFields), {
|
||||
officeName: "Büro",
|
||||
officeAddress: null,
|
||||
});
|
||||
assert.equal(
|
||||
updateOfficeProfileSchema.safeParse({ officeName: "x".repeat(201), officeAddress: null })
|
||||
.success,
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces and deletes the logo", () => {
|
||||
const { context, repository } = createRepository();
|
||||
try {
|
||||
repository.upsert({
|
||||
key: "logo",
|
||||
mimeType: "image/png",
|
||||
fileName: "a.png",
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
repository.upsert({
|
||||
key: "logo",
|
||||
mimeType: "image/svg+xml",
|
||||
fileName: "b.svg",
|
||||
data: Buffer.from("<svg/>"),
|
||||
});
|
||||
const logo = repository.get("logo");
|
||||
assert.equal(logo?.mimeType, "image/svg+xml");
|
||||
assert.equal(logo?.data.toString(), "<svg/>");
|
||||
assert.equal(repository.delete("logo"), true);
|
||||
assert.equal(repository.get("logo"), undefined);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
176
tests/print-document-model.test.ts
Normal file
176
tests/print-document-model.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildPreliminaryBoardHierarchy,
|
||||
formatProtectionDevice,
|
||||
summarizePrintBoard,
|
||||
} from "../src/frontend/print/print-document-model.js";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
DistributionBoardDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
function board(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto> = {}
|
||||
): DistributionBoardDto {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
name: id,
|
||||
floorId: null,
|
||||
supplyType: "AV",
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(
|
||||
rows: Array<{
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
}>,
|
||||
boardSimultaneityFactor = 1
|
||||
): CircuitTreeResponseDto {
|
||||
const deviceRows = rows.map((row, index) => ({
|
||||
id: `row-${index}`,
|
||||
sortOrder: index,
|
||||
name: "Gerät",
|
||||
displayName: "Gerät",
|
||||
manualQuantity: row.quantity,
|
||||
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
|
||||
...row,
|
||||
}));
|
||||
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
||||
return {
|
||||
circuitListId: "list",
|
||||
currentRevision: 1,
|
||||
singlePhaseVoltageV: 230,
|
||||
threePhaseVoltageV: 400,
|
||||
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
|
||||
distributionBoardTotalPower: total,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor:
|
||||
total * boardSimultaneityFactor,
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
sections: [
|
||||
{
|
||||
id: "section",
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 1,
|
||||
sectionTotalPower: total,
|
||||
components: [],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit",
|
||||
circuitListId: "list",
|
||||
sectionId: "section",
|
||||
equipmentIdentifier: "-2F1.1",
|
||||
sortOrder: 1,
|
||||
isReserve: false,
|
||||
circuitTotalPower: total,
|
||||
deviceRows,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto>,
|
||||
demandKw: number
|
||||
) {
|
||||
return summarizePrintBoard({
|
||||
board: board(id, info),
|
||||
circuitListId: id,
|
||||
floorName: null,
|
||||
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("print document model", () => {
|
||||
it("derives installed, device and demand power and the design current", () => {
|
||||
const result = summarizePrintBoard({
|
||||
board: board("uv"),
|
||||
circuitListId: "uv",
|
||||
floorName: "EG",
|
||||
tree: tree(
|
||||
[
|
||||
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
|
||||
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
|
||||
],
|
||||
0.5
|
||||
),
|
||||
});
|
||||
assert.equal(result.circuitCount, 1);
|
||||
assert.equal(result.installedPowerKw, 14);
|
||||
assert.equal(result.devicePowerKw, 10);
|
||||
assert.equal(result.demandPowerKw, 5);
|
||||
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
|
||||
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
|
||||
});
|
||||
|
||||
it("nests boards by matching free-text feed and sums downstream demand", () => {
|
||||
const entries = buildPreliminaryBoardHierarchy([
|
||||
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
|
||||
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
|
||||
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
|
||||
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => [
|
||||
entry.summary.board.id,
|
||||
entry.depth,
|
||||
entry.parentBoardId,
|
||||
entry.cumulatedDemandPowerKw,
|
||||
]),
|
||||
[
|
||||
["hv", 0, null, 16],
|
||||
["uv-a", 1, "hv", 6],
|
||||
["uv-b", 2, "uv-a", 2],
|
||||
["uv-c", 0, null, 1],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps boards with cyclic feed texts at the top level", () => {
|
||||
const entries = buildPreliminaryBoardHierarchy([
|
||||
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
|
||||
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
|
||||
]);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.ok(entries.some((entry) => entry.parentBoardId === null));
|
||||
assert.equal(
|
||||
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it("formats protection devices for the plan table", () => {
|
||||
assert.equal(
|
||||
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
|
||||
"LS B 16 A"
|
||||
);
|
||||
assert.equal(
|
||||
formatProtectionDevice({
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}),
|
||||
"FI/LS B 16 A, 30 mA Typ A"
|
||||
);
|
||||
assert.equal(formatProtectionDevice(undefined), null);
|
||||
});
|
||||
});
|
||||
|
|
@ -1074,6 +1074,10 @@ describe("project command service", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 1);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ function getSettings(context: DatabaseContext) {
|
|||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||
enabledDistributionBoardSupplyTypes:
|
||||
projects.enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: projects.siteAddress,
|
||||
workStage: projects.workStage,
|
||||
projectLead: projects.projectLead,
|
||||
projectEditor: projects.projectEditor,
|
||||
currentRevision: projects.currentRevision,
|
||||
})
|
||||
.from(projects)
|
||||
|
|
@ -81,6 +85,10 @@ function settings(
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -217,6 +225,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -289,6 +301,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
});
|
||||
|
||||
const undoStep = history.getNextCommand("project-1", "undo");
|
||||
|
|
@ -317,6 +333,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
currentRevision: 2,
|
||||
});
|
||||
assert.deepEqual(getDerivedVoltages(context), {
|
||||
|
|
@ -356,6 +376,10 @@ describe("project settings project-command repository", () => {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
currentRevision: 3,
|
||||
});
|
||||
assert.deepEqual(getDerivedVoltages(context), {
|
||||
|
|
@ -433,6 +457,117 @@ describe("project settings project-command repository", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("persists the print cover fields with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectSettingsProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const readCover = () =>
|
||||
context.db
|
||||
.select({
|
||||
siteAddress: projects.siteAddress,
|
||||
workStage: projects.workStage,
|
||||
projectLead: projects.projectLead,
|
||||
projectEditor: projects.projectEditor,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.get();
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectSettingsUpdateProjectCommand(
|
||||
settings({
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt",
|
||||
workStage: "LPH 5",
|
||||
projectLead: "P. Leitung",
|
||||
projectEditor: "B. Arbeiter",
|
||||
})
|
||||
),
|
||||
});
|
||||
assert.deepEqual(readCover(), {
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt",
|
||||
workStage: "LPH 5",
|
||||
projectLead: "P. Leitung",
|
||||
projectEditor: "B. Arbeiter",
|
||||
});
|
||||
assert.equal(forward.inverse.schemaVersion, 3);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(readCover(), {
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createProjectSettingsUpdateProjectCommand(
|
||||
settings({ workStage: " LPH 5" })
|
||||
),
|
||||
/invalid values/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the cover fields when applying a version 2 command", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.update(projects)
|
||||
.set({ workStage: "LPH 3", projectLead: "P. Leitung" })
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.run();
|
||||
const repository = new ProjectSettingsProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const {
|
||||
siteAddress: _siteAddress,
|
||||
workStage: _workStage,
|
||||
projectLead: _projectLead,
|
||||
projectEditor: _projectEditor,
|
||||
...versionTwoPayload
|
||||
} = settings({ name: "Stand aus Version 2" });
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: {
|
||||
schemaVersion: 2,
|
||||
type: "project.update-settings",
|
||||
payload: versionTwoPayload,
|
||||
},
|
||||
});
|
||||
const project = context.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.get();
|
||||
assert.equal(project?.name, "Stand aus Version 2");
|
||||
assert.equal(project?.workStage, "LPH 3");
|
||||
assert.equal(project?.projectLead, "P. Leitung");
|
||||
assert.equal(forward.inverse.schemaVersion, 2);
|
||||
assert.equal(Object.keys(forward.inverse.payload).length, 9);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the project supply-type selection and rejects disabling an in-use type", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ import {
|
|||
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||
|
||||
/** Removes the fields introduced by snapshot schema version 6. */
|
||||
function withoutVersionSixFields<
|
||||
T extends { project: object; distributionBoards: object[] },
|
||||
>(snapshot: T): T {
|
||||
const project = snapshot.project as Record<string, unknown>;
|
||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||
delete project[field];
|
||||
}
|
||||
for (const board of snapshot.distributionBoards) {
|
||||
const record = board as Record<string, unknown>;
|
||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
|
||||
delete record[field];
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function minimalSnapshot() {
|
||||
return {
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
|
|
@ -29,6 +46,10 @@ function minimalSnapshot() {
|
|||
"MSR",
|
||||
"SiBe",
|
||||
],
|
||||
siteAddress: "Musterstraße 1\n12345 Musterstadt" as string | null,
|
||||
workStage: "LPH 5" as string | null,
|
||||
projectLead: "P. Leitung" as string | null,
|
||||
projectEditor: null as string | null,
|
||||
},
|
||||
distributionBoards: [],
|
||||
externalCsvConfiguration: null,
|
||||
|
|
@ -61,6 +82,10 @@ function protectedSnapshot() {
|
|||
floorId: null,
|
||||
supplyType: null,
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: "UV 1.1" as string | null,
|
||||
location: "Raum 003 Technik" as string | null,
|
||||
fedFrom: "HV 0.1" as string | null,
|
||||
remark: null as string | null,
|
||||
},
|
||||
],
|
||||
circuitLists: [
|
||||
|
|
@ -151,7 +176,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades the supported baseline snapshot with a safe building default", () => {
|
||||
const baseline = structuredClone(minimalSnapshot());
|
||||
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||
|
|
@ -165,7 +190,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
previous.schemaVersion = 2 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalCsvConfiguration;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
|
@ -183,7 +208,7 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
|
||||
it("upgrades snapshot version 3 with an empty external model", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||
previous.schemaVersion = 3 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
||||
|
|
@ -199,8 +224,52 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.deepEqual(
|
||||
{
|
||||
siteAddress: upgraded.project.siteAddress,
|
||||
workStage: upgraded.project.workStage,
|
||||
projectLead: upgraded.project.projectLead,
|
||||
projectEditor: upgraded.project.projectEditor,
|
||||
},
|
||||
{ siteAddress: null, workStage: null, projectLead: null, projectEditor: null }
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
|
||||
location: upgraded.distributionBoards[0]?.location,
|
||||
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
|
||||
remark: upgraded.distributionBoards[0]?.remark,
|
||||
},
|
||||
{ boardNumber: null, location: null, fedFrom: null, remark: null }
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps board info and cover values and rejects them in older versions", () => {
|
||||
const parsed = parseProjectStateSnapshot(protectedSnapshot());
|
||||
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
|
||||
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
|
||||
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
|
||||
assert.equal(parsed.project.siteAddress, "Musterstraße 1\n12345 Musterstadt");
|
||||
assert.equal(parsed.project.workStage, "LPH 5");
|
||||
|
||||
const mislabeled = protectedSnapshot();
|
||||
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
|
||||
assert.throws(() => parseProjectStateSnapshot(mislabeled));
|
||||
|
||||
const missingCover = structuredClone(protectedSnapshot());
|
||||
delete (missingCover.project as Record<string, unknown>).workStage;
|
||||
assert.throws(() => parseProjectStateSnapshot(missingCover));
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
|
||||
const previous = protectedSnapshot();
|
||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||
previous.schemaVersion = 4 as typeof previous.schemaVersion;
|
||||
previous.circuits[0]!.isReserve = false;
|
||||
previous.circuits[0]!.deviceRows = [{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue