Configure circuit protection

This commit is contained in:
2026-07-31 07:45:48 +02:00
parent 18ca5eb3d4
commit 8898117ed4
18 changed files with 642 additions and 14 deletions
+8
View File
@@ -378,6 +378,14 @@ Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen; atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
wiederherstellen. wiederherstellen.
Der vollständige `CircuitSnapshot` kann rückwärtskompatibel einen
`protectionDevice`-Datensatz enthalten. Neue gruppierte Stromkreise und durch
Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
Im Editor sind die bisherigen Schutzspalten deshalb nur noch eine
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
`distribution-board.update` versioniert Etage, Netzart und den `distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
+1 -1
View File
@@ -37,7 +37,7 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E3a: create, edit and delete mutable group and footer components. - [x] Phase E3a: create, edit and delete mutable group and footer components.
- [x] Phase E3b1: create, rename and safely delete empty circuit groups. - [x] Phase E3b1: create, rename and safely delete empty circuit groups.
- [x] Phase E3b2a: persistent circuit-protection update command. - [x] Phase E3b2a: persistent circuit-protection update command.
- [ ] Phase E3b2b: circuit-protection defaults on insert and editor modal. - [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
- [ ] Phase E4: group reorder, renumber, circuit moves and populated-delete warning. - [ ] Phase E4: group reorder, renumber, circuit moves and populated-delete warning.
- [ ] Phase E: editor projection and editing. - [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification. - [ ] Phase F: documentation and full GUI verification.
@@ -743,8 +743,8 @@ Status: In progress. The complete tree read model E1, pure structural
projection E2a, grid rendering E2b and mutable component editing E3a are projection E2a, grid rendering E2b and mutable component editing E3a are
complete. Basic group management E3b1 is also complete. Circuit-protection complete. Basic group management E3b1 is also complete. Circuit-protection
editing now has its persistent E3b2a command boundary; insertion defaults and editing now has its persistent E3b2a command boundary; insertion defaults and
the editor modal as well as structural drag/renumber/delete workflows remain the editor modal are complete in E3b2b. Structural drag/renumber/delete
pending. workflows remain pending.
- render fixed header components - render fixed header components
- render group components and circuit blocks - render group components and circuit blocks
@@ -783,6 +783,12 @@ Implemented in E1/E2a/E2b/E3a:
snapshot, rejects stale state and preserves exact persistent Undo/Redo snapshot, rejects stale state and preserves exact persistent Undo/Redo
- user commands cannot remove circuit protection; a nullable target exists - user commands cannot remove circuit protection; a nullable target exists
only as the inverse of adding protection to a retained circuit without it only as the inverse of adding protection to a retained circuit without it
- newly inserted circuits and placeholder targets carry the category-specific
default protection in their complete atomic snapshot
- circuit deletion and restoration preserve the exact 1:1 protection row
together with the circuit and all device rows
- the editor modal exposes only fields valid for the selected protection type;
the old flat protection columns now project the new 1:1 data read-only
Acceptance: Acceptance:
@@ -17,6 +17,7 @@ import type {
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
remark: snapshot.remark, remark: snapshot.remark,
}) })
.run(); .run();
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
} }
private assertTargetCircuitUnchanged( private assertTargetCircuitUnchanged(
@@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
"Created target circuit changed before command execution." "Created target circuit changed before command execution."
); );
} }
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, snapshot.id))
.get();
if (
snapshot.protectionDevice === undefined
? protectionDevice !== undefined
: snapshot.protectionDevice === null
? protectionDevice !== undefined
: !protectionDevice ||
Object.entries(snapshot.protectionDevice).some(
([key, value]) =>
(protectionDevice as Record<string, unknown>)[key] !==
value
)
) {
throw new Error(
"Created target circuit protection changed before command execution."
);
}
const currentRowIds = database const currentRowIds = database
.select({ id: circuitDeviceRows.id }) .select({ id: circuitDeviceRows.id })
@@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { import {
@@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository
if (snapshot.deviceRows.length > 0) { if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run(); database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
} }
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
return createCircuitDeleteProjectCommand( return createCircuitDeleteProjectCommand(
snapshot.id, snapshot.id,
@@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository
asc(circuitDeviceRows.id) asc(circuitDeviceRows.id)
) )
.all(); .all();
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({ const inverse = createCircuitInsertProjectCommand({
id: circuit.id, id: circuit.id,
circuitListId: circuit.circuitListId, circuitListId: circuit.circuitListId,
@@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository
isReserve: Boolean(circuit.isReserve), isReserve: Boolean(circuit.isReserve),
remark: circuit.remark, remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot), deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
}); });
const result = database const result = database
@@ -66,17 +66,20 @@ export function assertCircuitProtectionUpdateProjectCommand(
throw new Error("Circuit protection update must change state."); throw new Error("Circuit protection update must change state.");
} }
if (expected !== null) { if (expected !== null) {
assertSnapshot(expected, circuitId); assertCircuitProtectionSnapshot(expected, circuitId);
} }
if (target !== null) { if (target !== null) {
assertSnapshot(target, circuitId); assertCircuitProtectionSnapshot(target, circuitId);
} }
if (JSON.stringify(expected) === JSON.stringify(target)) { if (JSON.stringify(expected) === JSON.stringify(target)) {
throw new Error("Circuit protection update must change state."); throw new Error("Circuit protection update must change state.");
} }
} }
function assertSnapshot(value: unknown, circuitId: string) { export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
if ( if (
!isPlainObject(value) || !isPlainObject(value) ||
Object.keys(value).length !== 7 || Object.keys(value).length !== 7 ||
@@ -5,6 +5,10 @@ import {
type CircuitDeviceRowSnapshot, type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js"; } from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertCircuitProtectionSnapshot,
type CircuitProtectionSnapshot,
} from "./circuit-protection-project-command.model.js";
export const circuitInsertCommandType = "circuit.insert" as const; export const circuitInsertCommandType = "circuit.insert" as const;
export const circuitDeleteCommandType = "circuit.delete" as const; export const circuitDeleteCommandType = "circuit.delete" as const;
@@ -31,6 +35,7 @@ export interface CircuitSnapshot {
isReserve: boolean; isReserve: boolean;
remark: string | null; remark: string | null;
deviceRows: CircuitDeviceRowSnapshot[]; deviceRows: CircuitDeviceRowSnapshot[];
protectionDevice?: CircuitProtectionSnapshot | null;
} }
export interface CircuitInsertCommandPayload { export interface CircuitInsertCommandPayload {
@@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand(
if (!Array.isArray(circuit.deviceRows)) { if (!Array.isArray(circuit.deviceRows)) {
throw new Error("circuit.deviceRows must be an array."); throw new Error("circuit.deviceRows must be an array.");
} }
if (
circuit.protectionDevice !== undefined &&
circuit.protectionDevice !== null
) {
assertCircuitProtectionSnapshot(
circuit.protectionDevice,
circuit.id as string
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) { if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error( throw new Error(
"Circuit reserve state must match whether device rows exist." "Circuit reserve state must match whether device rows exist."
@@ -0,0 +1,233 @@
"use client";
import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
protectionDeviceTypes,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeProtectionDeviceDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitProtectionModalProps {
equipmentIdentifier: string;
initialProtection: CircuitTreeProtectionDeviceDto;
isSaving: boolean;
onClose: () => void;
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
}
export function CircuitProtectionModal({
equipmentIdentifier,
initialProtection,
isSaving,
onClose,
onSave,
}: CircuitProtectionModalProps) {
const [type, setType] = useState(initialProtection.type);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection.ratedCurrentA
);
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection.fuseUtilizationCategory ?? "gG"
);
const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection.tripCharacteristic ?? "B"
);
const [rcdType, setRcdType] = useState(
initialProtection.rcdType ?? "A"
);
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection.ratedResidualCurrentMa ?? 30
);
const usesFuseCategory = (
fuseProtectionDeviceTypes as readonly string[]
).includes(type);
const usesTripCharacteristic =
type === "LS" || type === "FI_LS" || type === "AFDD";
const usesResidualCurrent = type === "FI" || type === "FI_LS";
const allowedRatedCurrents =
allowedRatedCurrentsAByProtectionDeviceType[type];
function handleTypeChange(nextType: ProtectionDeviceType) {
setType(nextType);
setRatedCurrentA(
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
setRcdType("A");
setRatedResidualCurrentMa(30);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
type,
ratedCurrentA,
...(usesFuseCategory ? { fuseUtilizationCategory } : {}),
...(usesTripCharacteristic ? { tripCharacteristic } : {}),
...(usesResidualCurrent
? { rcdType, ratedResidualCurrentMa }
: {}),
});
}
return (
<FormModal
description="Die Auswahl ist planerisch gesetzt. Eine spätere Dimensionierung wird nur Empfehlungen und Warnungen ergänzen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={
!(allowedRatedCurrents as readonly number[]).includes(
ratedCurrentA
)
}
submitLabel="Schutzgerät speichern"
title={`Schutzgerät bearbeiten ${equipmentIdentifier}`}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-type">
Schutzgerät
</label>
<select
autoFocus
className="form-select"
id="circuit-protection-type"
onChange={(event) =>
handleTypeChange(
event.target.value as ProtectionDeviceType
)
}
value={type}
>
{protectionDeviceTypes.map((entry) => (
<option key={entry} value={entry}>
{protectionDeviceTypeLabels[entry]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-current">
Bemessungsstrom
</label>
<select
className="form-select"
id="circuit-protection-current"
onChange={(event) =>
setRatedCurrentA(Number(event.target.value))
}
value={ratedCurrentA}
>
{allowedRatedCurrents.map((current) => (
<option key={current} value={current}>
{current} A
</option>
))}
</select>
</div>
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-fuse-category">
Sicherungscharakteristik
</label>
<select
className="form-select"
id="circuit-fuse-category"
onChange={(event) =>
setFuseUtilizationCategory(
event.target
.value as (typeof fuseUtilizationCategories)[number]
)
}
value={fuseUtilizationCategory}
>
{fuseUtilizationCategories.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesTripCharacteristic ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-trip-characteristic">
Auslösecharakteristik
</label>
<select
className="form-select"
id="circuit-trip-characteristic"
onChange={(event) =>
setTripCharacteristic(
event.target
.value as (typeof breakerTripCharacteristics)[number]
)
}
value={tripCharacteristic}
>
{breakerTripCharacteristics.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesResidualCurrent ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-rcd-type">
FI-Typ
</label>
<select
className="form-select"
id="circuit-rcd-type"
onChange={(event) =>
setRcdType(
event.target.value as (typeof rcdTypes)[number]
)
}
value={rcdType}
>
{rcdTypes.map((entry) => (
<option key={entry} value={entry}>
Typ {entry}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-residual-current">
Bemessungsdifferenzstrom
</label>
<select
className="form-select"
id="circuit-residual-current"
onChange={(event) =>
setRatedResidualCurrentMa(Number(event.target.value))
}
value={ratedResidualCurrentMa}
>
{ratedResidualCurrentsMa.map((entry) => (
<option key={entry} value={entry}>
{entry} mA
</option>
))}
</select>
</div>
</>
) : null}
</div>
</FormModal>
);
}
@@ -38,6 +38,10 @@ import {
buildCircuitDeviceRowInsertSnapshot, buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot, buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command"; } from "../utils/circuit-structure-command";
import {
getCircuitProtectionEditorInitialValue,
toCircuitProtectionSnapshot,
} from "../utils/circuit-protection-editing";
import { import {
buildCircuitDeviceRowMoveAssignments, buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command"; } from "../utils/circuit-device-row-move-command";
@@ -95,6 +99,7 @@ import {
redoProjectCommand, redoProjectCommand,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
updateCircuitProtectionCommand,
updateCircuitGroupCommand, updateCircuitGroupCommand,
updateDistributionBoardComponentCommand, updateDistributionBoardComponentCommand,
undoProjectCommand, undoProjectCommand,
@@ -102,6 +107,7 @@ import {
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto, CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto, CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto, CreateCircuitInputDto,
@@ -117,6 +123,7 @@ import type {
} from "../../domain/models/circuit-structure-project-command.model"; } from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group"; import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
type SaveDirection = "stay" | "next" | "prev"; type SaveDirection = "stay" | "next" | "prev";
@@ -246,6 +253,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null); useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null); useState<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false); useState(false);
@@ -962,6 +971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
circuitListId, circuitListId,
values: { ...values, voltage }, values: { ...values, voltage },
deviceRows, deviceRows,
category: section.category,
}); });
} }
@@ -1225,6 +1235,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto
) {
const circuit = protectionEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Stromkreisschutz bearbeiten",
redo: async () => {
const result = await updateCircuitProtectionCommand(
projectId,
getExpectedProjectRevision(),
circuit.id,
circuit.protectionDevice
? toCircuitProtectionSnapshot(
circuit.id,
circuit.protectionDevice
)
: null,
toCircuitProtectionSnapshot(circuit.id, protection)
);
applyProjectCommandResult(result);
setProtectionEditorCircuit(null);
return null;
},
});
}
async function handleRedo() { async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return; return;
@@ -2834,6 +2873,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup} onSave={handleSaveCircuitGroup}
/> />
) : null} ) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
protectionEditorCircuit.equipmentIdentifier
}
initialProtection={getCircuitProtectionEditorInitialValue(
data.sections.find(
(section) =>
section.id === protectionEditorCircuit.sectionId
)?.category,
protectionEditorCircuit.protectionDevice
)}
isSaving={isSaving}
onClose={() => setProtectionEditorCircuit(null)}
onSave={handleSaveCircuitProtection}
/>
) : null}
<div className="editor-toolbar"> <div className="editor-toolbar">
<button <button
type="button" type="button"
@@ -4033,6 +4089,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
<> <>
<button
type="button"
tabIndex={-1}
onClick={() =>
setProtectionEditorCircuit(row.circuit!)
}
>
Schutzgerät
</button>
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
+22
View File
@@ -58,6 +58,9 @@ import type {
import type { import type {
CircuitGroupSnapshot, CircuitGroupSnapshot,
} from "../../domain/models/circuit-group-structure-project-command.model"; } from "../../domain/models/circuit-group-structure-project-command.model";
import type {
CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -430,6 +433,25 @@ export function deleteCircuitGroupCommand(
); );
} }
export function updateCircuitProtectionCommand(
projectId: string,
expectedRevision: number,
circuitId: string,
expected: CircuitProtectionSnapshot | null,
target: CircuitProtectionSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-protection.update",
payload: { circuitId, expected, target },
},
"Stromkreisschutz bearbeiten"
);
}
export function updateCircuitById( export function updateCircuitById(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
+51 -7
View File
@@ -1,6 +1,7 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js"; import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js"; import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js"; import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
export type CellKey = export type CellKey =
| "equipmentIdentifier" | "equipmentIdentifier"
@@ -472,18 +473,54 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
} }
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue { export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
const protection = circuit.protectionDevice;
const protectionCharacteristic = protection
? [
protection.fuseUtilizationCategory,
protection.tripCharacteristic,
protection.rcdType ? `Typ ${protection.rcdType}` : undefined,
protection.ratedResidualCurrentMa !== undefined
? `${protection.ratedResidualCurrentMa} mA`
: undefined,
]
.filter(Boolean)
.join(" · ")
: undefined;
switch (key) { switch (key) {
case "equipmentIdentifier": return circuit.equipmentIdentifier; case "equipmentIdentifier": return circuit.equipmentIdentifier;
case "displayName": return circuit.displayName; case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower; case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType": return circuit.protectionType; case "protectionType":
case "protectionRatedCurrent": return circuit.protectionRatedCurrent; return protection
case "protectionCharacteristic": return circuit.protectionCharacteristic; ? protectionDeviceTypeLabels[protection.type]
: circuit.protectionType;
case "protectionRatedCurrent":
return protection?.ratedCurrentA ?? circuit.protectionRatedCurrent;
case "protectionCharacteristic":
return protectionCharacteristic || circuit.protectionCharacteristic;
case "protectionSummary": { case "protectionSummary": {
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null if (!protection) {
? `${circuit.protectionRatedCurrent}A` const legacyCurrent =
circuit.protectionRatedCurrent !== undefined &&
circuit.protectionRatedCurrent !== null
? `${circuit.protectionRatedCurrent}A`
: "";
return [
circuit.protectionType,
legacyCurrent,
circuit.protectionCharacteristic,
].filter(Boolean).join(" ").trim() || undefined;
}
const currentValue =
protection.ratedCurrentA;
const current = currentValue !== undefined && currentValue !== null
? `${currentValue} A`
: ""; : "";
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined; return [
protectionDeviceTypeLabels[protection.type],
current,
protectionCharacteristic,
].filter(Boolean).join(" · ").trim() || undefined;
} }
case "cableSummary": { case "cableSummary": {
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : ""; const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
@@ -539,7 +576,14 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
} }
export function getCellKind(rowType: RowType, key: CellKey): CellKind { export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed"; if (
key === "rowTotalPower" ||
key === "circuitTotalPower" ||
key === "protectionType" ||
key === "protectionRatedCurrent" ||
key === "protectionCharacteristic" ||
key === "protectionSummary"
) return "computed";
if (key === "voltage") return "readonly"; if (key === "voltage") return "readonly";
if (rowType === "section") return "readonly"; if (rowType === "section") return "readonly";
if (rowType === "placeholder") { if (rowType === "placeholder") {
@@ -0,0 +1,36 @@
import type {
CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import type {
CircuitTreeProtectionDeviceDto,
} from "../types";
export function toCircuitProtectionSnapshot(
circuitId: string,
protection: CircuitTreeProtectionDeviceDto
): CircuitProtectionSnapshot {
return {
circuitId,
type: protection.type,
ratedCurrentA: protection.ratedCurrentA,
fuseUtilizationCategory:
protection.fuseUtilizationCategory ?? null,
tripCharacteristic: protection.tripCharacteristic ?? null,
rcdType: protection.rcdType ?? null,
ratedResidualCurrentMa:
protection.ratedResidualCurrentMa ?? null,
};
}
export function getCircuitProtectionEditorInitialValue(
category: CircuitGroupCategory | undefined,
protection: CircuitTreeProtectionDeviceDto | undefined
): CircuitTreeProtectionDeviceDto {
if (protection) {
return protection;
}
const fallback = createDefaultCircuitProtection(category ?? "lighting");
return { ...fallback };
}
@@ -8,6 +8,8 @@ import type {
CreateCircuitDeviceRowInputDto, CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto, CreateCircuitInputDto,
} from "../types.js"; } from "../types.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
export function buildCircuitDeviceRowInsertSnapshot(input: { export function buildCircuitDeviceRowInsertSnapshot(input: {
id: string; id: string;
@@ -46,8 +48,12 @@ export function buildCircuitInsertSnapshot(input: {
circuitListId: string; circuitListId: string;
values: CreateCircuitInputDto; values: CreateCircuitInputDto;
deviceRows: CircuitDeviceRowSnapshot[]; deviceRows: CircuitDeviceRowSnapshot[];
category?: CircuitGroupCategory;
}): CircuitSnapshot { }): CircuitSnapshot {
const { id, circuitListId, values, deviceRows } = input; const { id, circuitListId, values, deviceRows, category } = input;
const defaultProtection = category
? createDefaultCircuitProtection(category)
: null;
return { return {
id, id,
circuitListId, circuitListId,
@@ -69,5 +75,30 @@ export function buildCircuitInsertSnapshot(input: {
isReserve: deviceRows.length === 0, isReserve: deviceRows.length === 0,
remark: values.remark ?? null, remark: values.remark ?? null,
deviceRows, deviceRows,
...(defaultProtection
? {
protectionDevice: {
circuitId: id,
type: defaultProtection.type,
ratedCurrentA: defaultProtection.ratedCurrentA,
fuseUtilizationCategory:
"fuseUtilizationCategory" in defaultProtection
? defaultProtection.fuseUtilizationCategory
: null,
tripCharacteristic:
"tripCharacteristic" in defaultProtection
? defaultProtection.tripCharacteristic
: null,
rcdType:
"rcdType" in defaultProtection
? defaultProtection.rcdType
: null,
ratedResidualCurrentMa:
"ratedResidualCurrentMa" in defaultProtection
? defaultProtection.ratedResidualCurrentMa
: null,
},
}
: {}),
}; };
} }
@@ -11,6 +11,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/reposito
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
@@ -193,6 +194,15 @@ function createMoveTargetSnapshot(
isReserve: true, isReserve: true,
remark: null, remark: null,
deviceRows: [], deviceRows: [],
protectionDevice: {
circuitId: "circuit-new",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
...overrides, ...overrides,
}; };
} }
@@ -520,6 +530,14 @@ describe("circuit device-row move project-command repository", () => {
"-1F4" "-1F4"
); );
assert.equal(isReserve(fixture.context, "circuit-new"), false); assert.equal(isReserve(fixture.context, "circuit-new"), false);
assert.equal(
fixture.context.db
.select({ ratedCurrentA: circuitProtectionDevices.ratedCurrentA })
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get()?.ratedCurrentA,
10
);
assert.equal(isReserve(fixture.context, "circuit-1"), false); assert.equal(isReserve(fixture.context, "circuit-1"), false);
assert.equal(isReserve(fixture.context, "circuit-2"), true); assert.equal(isReserve(fixture.context, "circuit-2"), true);
@@ -538,6 +556,14 @@ describe("circuit device-row move project-command repository", () => {
.get(), .get(),
undefined undefined
); );
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get(),
undefined
);
assert.deepEqual( assert.deepEqual(
[ [
getRow(fixture.context, "row-1"), getRow(fixture.context, "row-1"),
@@ -562,6 +588,14 @@ describe("circuit device-row move project-command repository", () => {
getRow(fixture.context, "row-2")?.circuitId, getRow(fixture.context, "row-2")?.circuitId,
"circuit-new" "circuit-new"
); );
assert.equal(
fixture.context.db
.select({ type: circuitProtectionDevices.type })
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get()?.type,
"LS"
);
assert.deepEqual( assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState( new ProjectHistoryRepository(fixture.context.db).getState(
"project-1" "project-1"
+17
View File
@@ -128,12 +128,29 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly"); assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed"); assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
}); });
it("projects circuit and device values without mixing ownership", () => { it("projects circuit and device values without mixing ownership", () => {
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office"); assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08); assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B"); assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
assert.equal(
getCircuitValue(
{
...circuit,
protectionDevice: {
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
},
},
"protectionSummary"
),
"FI/LS · 16 A · B · Typ A · 30 mA"
);
assert.equal(getCircuitValue(circuit, "voltage"), 230); assert.equal(getCircuitValue(circuit, "voltage"), 230);
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI"); assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m"); assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
+10
View File
@@ -65,12 +65,22 @@ describe("circuit structure command adapters", () => {
controlRequirement: "DALI", controlRequirement: "DALI",
}, },
deviceRows: [], deviceRows: [],
category: "single_phase",
}); });
assert.equal(reserve.isReserve, true); assert.equal(reserve.isReserve, true);
assert.equal(reserve.displayName, null); assert.equal(reserve.displayName, null);
assert.equal(reserve.voltage, 230); assert.equal(reserve.voltage, 230);
assert.equal(reserve.controlRequirement, "DALI"); assert.equal(reserve.controlRequirement, "DALI");
assert.deepEqual(reserve.protectionDevice, {
circuitId: "circuit-1",
type: "FI_LS",
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
});
const row = buildCircuitDeviceRowInsertSnapshot({ const row = buildCircuitDeviceRowInsertSnapshot({
id: "row-1", id: "row-1",
@@ -11,6 +11,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
@@ -165,6 +166,18 @@ function createTestDatabase(): TestFixture {
}, },
]) ])
.run(); .run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-existing",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
return { return {
context, context,
@@ -377,6 +390,11 @@ describe("circuit structure project-command repository", () => {
"circuit-existing" "circuit-existing"
); );
const beforeRows = getRows(fixture.context, "circuit-existing"); const beforeRows = getRows(fixture.context, "circuit-existing");
const beforeProtection = fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get();
assert.ok(beforeCircuit); assert.ok(beforeCircuit);
const store = new CircuitStructureProjectCommandRepository( const store = new CircuitStructureProjectCommandRepository(
fixture.context.db fixture.context.db
@@ -395,6 +413,14 @@ describe("circuit structure project-command repository", () => {
undefined undefined
); );
assert.equal(getRows(fixture.context, "circuit-existing").length, 0); assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
undefined
);
store.execute({ store.execute({
projectId: "project-1", projectId: "project-1",
@@ -411,6 +437,14 @@ describe("circuit structure project-command repository", () => {
getRows(fixture.context, "circuit-existing"), getRows(fixture.context, "circuit-existing"),
beforeRows beforeRows
); );
assert.deepEqual(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
beforeProtection
);
} finally { } finally {
fixture.context.close(); fixture.context.close();
} }
+30
View File
@@ -14,6 +14,10 @@ import {
} from "../src/domain/services/protection-device-defaults.js"; } from "../src/domain/services/protection-device-defaults.js";
import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js"; import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js";
import "./circuit-protection-project-command.repository.test.js"; import "./circuit-protection-project-command.repository.test.js";
import {
getCircuitProtectionEditorInitialValue,
toCircuitProtectionSnapshot,
} from "../src/frontend/utils/circuit-protection-editing.js";
describe("protection device catalog", () => { describe("protection device catalog", () => {
it("contains exactly the agreed protection device types", () => { it("contains exactly the agreed protection device types", () => {
@@ -175,6 +179,32 @@ describe("protection device validation", () => {
}); });
describe("protection device defaults", () => { describe("protection device defaults", () => {
it("maps editor values to complete circuit-owned snapshots", () => {
const initial = getCircuitProtectionEditorInitialValue(
"single_phase",
undefined
);
assert.deepEqual(initial, {
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
});
assert.deepEqual(
toCircuitProtectionSnapshot("circuit-1", initial),
{
circuitId: "circuit-1",
type: "FI_LS",
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
}
);
});
it("creates the agreed circuit defaults", () => { it("creates the agreed circuit defaults", () => {
assert.deepEqual(createDefaultCircuitProtection("lighting"), { assert.deepEqual(createDefaultCircuitProtection("lighting"), {
type: "LS", type: "LS",