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;
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
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
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
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 E3b1: create, rename and safely delete empty circuit groups.
- [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 E: editor projection and editing.
- [ ] 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
complete. Basic group management E3b1 is also complete. Circuit-protection
editing now has its persistent E3b2a command boundary; insertion defaults and
the editor modal as well as structural drag/renumber/delete workflows remain
pending.
the editor modal are complete in E3b2b. Structural drag/renumber/delete
workflows remain pending.
- render fixed header components
- 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
- user commands cannot remove circuit protection; a nullable target exists
only as the inverse of adding protection to a retained circuit without it
- newly inserted circuits and placeholder targets carry the category-specific
default protection in their complete atomic snapshot
- circuit deletion and restoration preserve the exact 1:1 protection row
together with the circuit and all device rows
- the editor modal exposes only fields valid for the selected protection type;
the old flat protection columns now project the new 1:1 data read-only
Acceptance:
@@ -17,6 +17,7 @@ import type {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
remark: snapshot.remark,
})
.run();
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private assertTargetCircuitUnchanged(
@@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
"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
.select({ id: circuitDeviceRows.id })
@@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import {
@@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository
if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
}
if (snapshot.protectionDevice) {
database
.insert(circuitProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
@@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository
asc(circuitDeviceRows.id)
)
.all();
const protectionDevice = database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({
id: circuit.id,
circuitListId: circuit.circuitListId,
@@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository
isReserve: Boolean(circuit.isReserve),
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
});
const result = database
@@ -66,17 +66,20 @@ export function assertCircuitProtectionUpdateProjectCommand(
throw new Error("Circuit protection update must change state.");
}
if (expected !== null) {
assertSnapshot(expected, circuitId);
assertCircuitProtectionSnapshot(expected, circuitId);
}
if (target !== null) {
assertSnapshot(target, circuitId);
assertCircuitProtectionSnapshot(target, circuitId);
}
if (JSON.stringify(expected) === JSON.stringify(target)) {
throw new Error("Circuit protection update must change state.");
}
}
function assertSnapshot(value: unknown, circuitId: string) {
export function assertCircuitProtectionSnapshot(
value: unknown,
circuitId: string
) {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 7 ||
@@ -5,6 +5,10 @@ import {
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-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 circuitDeleteCommandType = "circuit.delete" as const;
@@ -31,6 +35,7 @@ export interface CircuitSnapshot {
isReserve: boolean;
remark: string | null;
deviceRows: CircuitDeviceRowSnapshot[];
protectionDevice?: CircuitProtectionSnapshot | null;
}
export interface CircuitInsertCommandPayload {
@@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand(
if (!Array.isArray(circuit.deviceRows)) {
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)) {
throw new Error(
"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,
buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command";
import {
getCircuitProtectionEditorInitialValue,
toCircuitProtectionSnapshot,
} from "../utils/circuit-protection-editing";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command";
@@ -95,6 +99,7 @@ import {
redoProjectCommand,
updateCircuitById,
updateCircuitDeviceRowById,
updateCircuitProtectionCommand,
updateCircuitGroupCommand,
updateDistributionBoardComponentCommand,
undoProjectCommand,
@@ -102,6 +107,7 @@ import {
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
@@ -117,6 +123,7 @@ import type {
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
type SaveDirection = "stay" | "next" | "prev";
@@ -246,6 +253,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
@@ -962,6 +971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
circuitListId,
values: { ...values, voltage },
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() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@@ -2834,6 +2873,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : 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">
<button
type="button"
@@ -4033,6 +4089,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<button
type="button"
tabIndex={-1}
onClick={() =>
setProtectionEditorCircuit(row.circuit!)
}
>
Schutzgerät
</button>
<button
type="button"
tabIndex={-1}
+22
View File
@@ -58,6 +58,9 @@ import type {
import type {
CircuitGroupSnapshot,
} 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> {
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(
projectId: string,
expectedRevision: number,
+50 -6
View File
@@ -1,6 +1,7 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.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 { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
export type CellKey =
| "equipmentIdentifier"
@@ -472,18 +473,54 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
}
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) {
case "equipmentIdentifier": return circuit.equipmentIdentifier;
case "displayName": return circuit.displayName;
case "circuitTotalPower": return circuit.circuitTotalPower;
case "protectionType": return circuit.protectionType;
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
case "protectionCharacteristic": return circuit.protectionCharacteristic;
case "protectionType":
return protection
? protectionDeviceTypeLabels[protection.type]
: circuit.protectionType;
case "protectionRatedCurrent":
return protection?.ratedCurrentA ?? circuit.protectionRatedCurrent;
case "protectionCharacteristic":
return protectionCharacteristic || circuit.protectionCharacteristic;
case "protectionSummary": {
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
if (!protection) {
const legacyCurrent =
circuit.protectionRatedCurrent !== undefined &&
circuit.protectionRatedCurrent !== null
? `${circuit.protectionRatedCurrent}A`
: "";
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
return [
circuit.protectionType,
legacyCurrent,
circuit.protectionCharacteristic,
].filter(Boolean).join(" ").trim() || undefined;
}
const currentValue =
protection.ratedCurrentA;
const current = currentValue !== undefined && currentValue !== null
? `${currentValue} A`
: "";
return [
protectionDeviceTypeLabels[protection.type],
current,
protectionCharacteristic,
].filter(Boolean).join(" · ").trim() || undefined;
}
case "cableSummary": {
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 {
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 (rowType === "section") return "readonly";
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,
CreateCircuitInputDto,
} from "../types.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
export function buildCircuitDeviceRowInsertSnapshot(input: {
id: string;
@@ -46,8 +48,12 @@ export function buildCircuitInsertSnapshot(input: {
circuitListId: string;
values: CreateCircuitInputDto;
deviceRows: CircuitDeviceRowSnapshot[];
category?: CircuitGroupCategory;
}): CircuitSnapshot {
const { id, circuitListId, values, deviceRows } = input;
const { id, circuitListId, values, deviceRows, category } = input;
const defaultProtection = category
? createDefaultCircuitProtection(category)
: null;
return {
id,
circuitListId,
@@ -69,5 +75,30 @@ export function buildCircuitInsertSnapshot(input: {
isReserve: deviceRows.length === 0,
remark: values.remark ?? null,
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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { circuits } from "../src/db/schema/circuits.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
@@ -193,6 +194,15 @@ function createMoveTargetSnapshot(
isReserve: true,
remark: null,
deviceRows: [],
protectionDevice: {
circuitId: "circuit-new",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
...overrides,
};
}
@@ -520,6 +530,14 @@ describe("circuit device-row move project-command repository", () => {
"-1F4"
);
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-2"), true);
@@ -538,6 +556,14 @@ describe("circuit device-row move project-command repository", () => {
.get(),
undefined
);
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get(),
undefined
);
assert.deepEqual(
[
getRow(fixture.context, "row-1"),
@@ -562,6 +588,14 @@ describe("circuit device-row move project-command repository", () => {
getRow(fixture.context, "row-2")?.circuitId,
"circuit-new"
);
assert.equal(
fixture.context.db
.select({ type: circuitProtectionDevices.type })
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
.get()?.type,
"LS"
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
+17
View File
@@ -128,12 +128,29 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
});
it("projects circuit and device values without mixing ownership", () => {
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
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, "controlRequirement"), "DALI");
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",
},
deviceRows: [],
category: "single_phase",
});
assert.equal(reserve.isReserve, true);
assert.equal(reserve.displayName, null);
assert.equal(reserve.voltage, 230);
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({
id: "row-1",
@@ -11,6 +11,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.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 { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
@@ -165,6 +166,18 @@ function createTestDatabase(): TestFixture {
},
])
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-existing",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
return {
context,
@@ -377,6 +390,11 @@ describe("circuit structure project-command repository", () => {
"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);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
@@ -395,6 +413,14 @@ describe("circuit structure project-command repository", () => {
undefined
);
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({
projectId: "project-1",
@@ -411,6 +437,14 @@ describe("circuit structure project-command repository", () => {
getRows(fixture.context, "circuit-existing"),
beforeRows
);
assert.deepEqual(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
beforeProtection
);
} finally {
fixture.context.close();
}
+30
View File
@@ -14,6 +14,10 @@ import {
} from "../src/domain/services/protection-device-defaults.js";
import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js";
import "./circuit-protection-project-command.repository.test.js";
import {
getCircuitProtectionEditorInitialValue,
toCircuitProtectionSnapshot,
} from "../src/frontend/utils/circuit-protection-editing.js";
describe("protection device catalog", () => {
it("contains exactly the agreed protection device types", () => {
@@ -175,6 +179,32 @@ describe("protection device validation", () => {
});
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", () => {
assert.deepEqual(createDefaultCircuitProtection("lighting"), {
type: "LS",