Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese
diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts
index e7cfcb2..4841616 100644
--- a/src/db/repositories/circuit-structure-project-command.repository.ts
+++ b/src/db/repositories/circuit-structure-project-command.repository.ts
@@ -40,7 +40,7 @@ export class CircuitStructureProjectCommandRepository
this.database,
input,
(tx) =>
- this.applyCommand(
+ this.applyInTransaction(
tx,
input.projectId,
input.source,
@@ -49,7 +49,11 @@ export class CircuitStructureProjectCommandRepository
);
}
- private applyCommand(
+ /**
+ * Applies the circuit write inside an already-owned project transaction.
+ * Composite project commands use this without creating a second revision.
+ */
+ applyInTransaction(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts
index 9346df7..115705b 100644
--- a/src/db/repositories/distribution-board-structure-project-command.repository.ts
+++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts
@@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm";
+import { isDeepStrictEqual } from "node:util";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -17,6 +18,20 @@ import {
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
+import {
+ assertDistributionBoardFeederCircuitProjectCommand,
+ createDistributionBoardCreateFeederCircuitProjectCommand,
+ createDistributionBoardRemoveFeederCircuitProjectCommand,
+ distributionBoardCreateFeederCircuitCommandType,
+ distributionBoardRemoveFeederCircuitCommandType,
+ type DistributionBoardFeederCircuitProjectCommand,
+} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
+import {
+ circuitDeleteCommandType,
+ circuitInsertCommandType,
+ createCircuitDeleteProjectCommand,
+ createCircuitInsertProjectCommand,
+} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@@ -31,6 +46,7 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
+import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
@@ -62,6 +78,30 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
+ executeFeederCircuit(
+ input: Omit<
+ ExecuteDistributionBoardStructureCommandInput,
+ "command"
+ > & {
+ command: DistributionBoardFeederCircuitProjectCommand;
+ }
+ ) {
+ assertDistributionBoardFeederCircuitProjectCommand(input.command);
+ if (
+ input.source === "user" &&
+ input.command.type === distributionBoardRemoveFeederCircuitCommandType
+ ) {
+ throw new Error(
+ "Generated feeder circuits may only be removed through project history."
+ );
+ }
+ return executeProjectCommandTransaction(
+ this.database,
+ input,
+ (tx) => this.applyFeederCircuitCommand(tx, input)
+ );
+ }
+
private applyCommand(
database: AppDatabase,
projectId: string,
@@ -367,6 +407,64 @@ export class DistributionBoardStructureProjectCommandRepository
return inverse;
}
+ private applyFeederCircuitCommand(
+ database: AppDatabase,
+ input: Omit<
+ ExecuteDistributionBoardStructureCommandInput,
+ "command"
+ > & {
+ command: DistributionBoardFeederCircuitProjectCommand;
+ }
+ ): DistributionBoardFeederCircuitProjectCommand {
+ const circuitRepository =
+ new CircuitStructureProjectCommandRepository(database);
+ const { circuit, boardUpdate } = input.command.payload;
+ if (
+ input.command.type === distributionBoardCreateFeederCircuitCommandType
+ ) {
+ const circuitInverse = circuitRepository.applyInTransaction(
+ database,
+ input.projectId,
+ input.source,
+ createCircuitInsertProjectCommand(circuit)
+ );
+ if (circuitInverse.type !== circuitDeleteCommandType) {
+ throw new Error("Created feeder circuit returned an invalid inverse.");
+ }
+ const boardInverse = this.update(database, {
+ ...input,
+ command: boardUpdate,
+ });
+ return createDistributionBoardRemoveFeederCircuitProjectCommand(
+ circuit,
+ boardInverse
+ );
+ }
+
+ const boardInverse = this.update(database, {
+ ...input,
+ command: boardUpdate,
+ });
+ const circuitInverse = circuitRepository.applyInTransaction(
+ database,
+ input.projectId,
+ input.source,
+ createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId)
+ );
+ if (
+ circuitInverse.type !== circuitInsertCommandType ||
+ !isDeepStrictEqual(circuitInverse.payload.circuit, circuit)
+ ) {
+ throw new Error(
+ "Generated feeder circuit changed before history removal."
+ );
+ }
+ return createDistributionBoardCreateFeederCircuitProjectCommand(
+ circuit,
+ boardInverse
+ );
+ }
+
private assertFeedingReference(
database: AppDatabase,
projectId: string,
diff --git a/src/domain/models/distribution-board-feeder-circuit-project-command.model.ts b/src/domain/models/distribution-board-feeder-circuit-project-command.model.ts
new file mode 100644
index 0000000..a1fc035
--- /dev/null
+++ b/src/domain/models/distribution-board-feeder-circuit-project-command.model.ts
@@ -0,0 +1,132 @@
+import {
+ assertCircuitInsertProjectCommand,
+ circuitInsertCommandType,
+ circuitStructureCommandSchemaVersion,
+ type CircuitSnapshot,
+} from "./circuit-structure-project-command.model.js";
+import {
+ assertDistributionBoardUpdateProjectCommand,
+ type DistributionBoardUpdateProjectCommand,
+} from "./distribution-board-project-command.model.js";
+import type { SerializedProjectCommand } from "./project-command.model.js";
+
+export const distributionBoardCreateFeederCircuitCommandType =
+ "distribution-board.create-feeder-circuit" as const;
+export const distributionBoardRemoveFeederCircuitCommandType =
+ "distribution-board.remove-feeder-circuit" as const;
+export const distributionBoardFeederCircuitCommandSchemaVersion = 1 as const;
+
+export interface DistributionBoardFeederCircuitCommandPayload {
+ circuit: CircuitSnapshot;
+ boardUpdate: DistributionBoardUpdateProjectCommand;
+}
+
+export interface DistributionBoardCreateFeederCircuitProjectCommand
+ extends SerializedProjectCommand {
+ schemaVersion: typeof distributionBoardFeederCircuitCommandSchemaVersion;
+ type: typeof distributionBoardCreateFeederCircuitCommandType;
+}
+
+export interface DistributionBoardRemoveFeederCircuitProjectCommand
+ extends SerializedProjectCommand {
+ schemaVersion: typeof distributionBoardFeederCircuitCommandSchemaVersion;
+ type: typeof distributionBoardRemoveFeederCircuitCommandType;
+}
+
+export type DistributionBoardFeederCircuitProjectCommand =
+ | DistributionBoardCreateFeederCircuitProjectCommand
+ | DistributionBoardRemoveFeederCircuitProjectCommand;
+
+export function createDistributionBoardCreateFeederCircuitProjectCommand(
+ circuit: CircuitSnapshot,
+ boardUpdate: DistributionBoardUpdateProjectCommand
+): DistributionBoardCreateFeederCircuitProjectCommand {
+ const command: DistributionBoardCreateFeederCircuitProjectCommand = {
+ schemaVersion: distributionBoardFeederCircuitCommandSchemaVersion,
+ type: distributionBoardCreateFeederCircuitCommandType,
+ payload: { circuit, boardUpdate },
+ };
+ assertDistributionBoardFeederCircuitProjectCommand(command);
+ return command;
+}
+
+export function createDistributionBoardRemoveFeederCircuitProjectCommand(
+ circuit: CircuitSnapshot,
+ boardUpdate: DistributionBoardUpdateProjectCommand
+): DistributionBoardRemoveFeederCircuitProjectCommand {
+ const command: DistributionBoardRemoveFeederCircuitProjectCommand = {
+ schemaVersion: distributionBoardFeederCircuitCommandSchemaVersion,
+ type: distributionBoardRemoveFeederCircuitCommandType,
+ payload: { circuit, boardUpdate },
+ };
+ assertDistributionBoardFeederCircuitProjectCommand(command);
+ return command;
+}
+
+export function assertDistributionBoardFeederCircuitProjectCommand(
+ command: SerializedProjectCommand
+): asserts command is DistributionBoardFeederCircuitProjectCommand {
+ if (
+ command.schemaVersion !==
+ distributionBoardFeederCircuitCommandSchemaVersion ||
+ (command.type !== distributionBoardCreateFeederCircuitCommandType &&
+ command.type !== distributionBoardRemoveFeederCircuitCommandType) ||
+ !isPlainObject(command.payload)
+ ) {
+ throw new Error("Unsupported distribution-board feeder-circuit command.");
+ }
+
+ const { circuit, boardUpdate } = command.payload;
+ if (!isPlainObject(boardUpdate)) {
+ throw new Error("Feeder-circuit command requires a board update.");
+ }
+ assertCircuitInsertProjectCommand({
+ schemaVersion: circuitStructureCommandSchemaVersion,
+ type: circuitInsertCommandType,
+ payload: { circuit },
+ });
+ const boardUpdateCommand =
+ boardUpdate as unknown as SerializedProjectCommand;
+ assertDistributionBoardUpdateProjectCommand(boardUpdateCommand);
+
+ const typedCircuit = circuit as CircuitSnapshot;
+ const typedBoardUpdate: DistributionBoardUpdateProjectCommand =
+ boardUpdateCommand;
+ if (typedCircuit.deviceRows.length !== 0 || !typedCircuit.isReserve) {
+ throw new Error(
+ "A generated feeder circuit must be empty before the board load is linked."
+ );
+ }
+ const feedingBoard = getChangeValue(
+ typedBoardUpdate,
+ "feedingDistributionBoardId"
+ );
+ const feedingCircuit = getChangeValue(
+ typedBoardUpdate,
+ "feedingCircuitId"
+ );
+ if (feedingBoard === undefined || feedingCircuit === undefined) {
+ throw new Error(
+ "Feeder-circuit commands must update both logical feeding references."
+ );
+ }
+ if (
+ command.type === distributionBoardCreateFeederCircuitCommandType &&
+ (typeof feedingBoard !== "string" || feedingCircuit !== typedCircuit.id)
+ ) {
+ throw new Error(
+ "The created feeder circuit must be linked by the distribution-board update."
+ );
+ }
+}
+
+function getChangeValue(
+ command: DistributionBoardUpdateProjectCommand,
+ field: "feedingDistributionBoardId" | "feedingCircuitId"
+) {
+ return command.payload.changes.find((change) => change.field === field)?.value;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
diff --git a/src/domain/ports/distribution-board-structure-project-command.store.ts b/src/domain/ports/distribution-board-structure-project-command.store.ts
index f0cb5c9..6dd8d8f 100644
--- a/src/domain/ports/distribution-board-structure-project-command.store.ts
+++ b/src/domain/ports/distribution-board-structure-project-command.store.ts
@@ -1,5 +1,6 @@
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
+import type { DistributionBoardFeederCircuitProjectCommand } from "../models/distribution-board-feeder-circuit-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
@@ -35,4 +36,15 @@ export interface DistributionBoardStructureProjectCommandStore {
revision: AppendedProjectRevision;
inverse: DistributionBoardUpdateProjectCommand;
};
+ executeFeederCircuit(
+ input: Omit<
+ ExecuteDistributionBoardStructureCommandInput,
+ "command"
+ > & {
+ command: DistributionBoardFeederCircuitProjectCommand;
+ }
+ ): {
+ revision: AppendedProjectRevision;
+ inverse: DistributionBoardFeederCircuitProjectCommand;
+ };
}
diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts
index cf1f740..35dd9e8 100644
--- a/src/domain/services/project-command.service.ts
+++ b/src/domain/services/project-command.service.ts
@@ -45,6 +45,11 @@ import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
+import {
+ assertDistributionBoardFeederCircuitProjectCommand,
+ distributionBoardCreateFeederCircuitCommandType,
+ distributionBoardRemoveFeederCircuitCommandType,
+} from "../models/distribution-board-feeder-circuit-project-command.model.js";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
@@ -418,6 +423,14 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
+ case distributionBoardCreateFeederCircuitCommandType:
+ case distributionBoardRemoveFeederCircuitCommandType: {
+ assertDistributionBoardFeederCircuitProjectCommand(input.command);
+ return this.distributionBoardStructureStore.executeFeederCircuit({
+ ...input,
+ command: input.command,
+ }).revision;
+ }
case distributionBoardComponentInsertCommandType: {
assertDistributionBoardComponentInsertProjectCommand(
input.command
diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx
index bac08ce..f75b64e 100644
--- a/src/frontend/components/circuit-tree-editor.tsx
+++ b/src/frontend/components/circuit-tree-editor.tsx
@@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? ""
- : String(visibleCell.value ?? "");
+ : String(visibleCell.editValue ?? visibleCell.value ?? "");
focusTokenRef.current += 1;
setEditingCell({
...cell,
@@ -4077,7 +4077,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
key={row.rowKey}
className={`${
row.rowType === "circuitSummary"
- ? "summary-row"
+ ? row.downstreamBoardLoad
+ ? "feeder-circuit-row"
+ : "summary-row"
: row.rowType === "deviceRow"
? "device-row"
: row.rowType === "downstreamBoardLoad"
@@ -4708,11 +4710,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={-1}
/>
0
+ }
icon={Trash2}
label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
tabIndex={-1}
+ title={
+ (row.circuit.downstreamBoardLoads?.length ?? 0) > 0
+ ? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern"
+ : undefined
+ }
variant="danger"
/>
>
diff --git a/src/frontend/print/print-document.tsx b/src/frontend/print/print-document.tsx
index 5a0152b..a13d7e1 100644
--- a/src/frontend/print/print-document.tsx
+++ b/src/frontend/print/print-document.tsx
@@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
);
}
+ if (rows.length === 0 && downstreamBoards.length === 1) {
+ const boardLabel =
+ downstreamBoards[0].boardNumber ?? downstreamBoards[0].name;
+ return (
+
+ | {circuit.equipmentIdentifier} |
+
+ {boardLabel}
+ {circuit.displayName ? (
+ · {circuit.displayName}
+ ) : null}
+ |
+ – |
+ | | |
+ {formatKilowatt(circuit.circuitTotalPower)} |
+ {circuitCells}
+
+ );
+ }
if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts
index 8473bfa..8f5a878 100644
--- a/src/frontend/utils/api.ts
+++ b/src/frontend/utils/api.ts
@@ -366,20 +366,22 @@ export function createDistributionBoard(
);
}
+export type UpdateDistributionBoardInput = {
+ floorId: string | null;
+ supplyType: NonNullable;
+ simultaneityFactor: number;
+} & Partial &
+ Partial<
+ Pick<
+ DistributionBoardDto,
+ "feedingDistributionBoardId" | "feedingCircuitId"
+ >
+ >;
+
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
- input: {
- floorId: string | null;
- supplyType: NonNullable;
- simultaneityFactor: number;
- } & Partial &
- Partial<
- Pick<
- DistributionBoardDto,
- "feedingDistributionBoardId" | "feedingCircuitId"
- >
- >,
+ input: UpdateDistributionBoardInput,
expectedRevision: number
) {
return request(
@@ -391,6 +393,41 @@ export function updateDistributionBoard(
);
}
+export function createFeederCircuitAndUpdateDistributionBoard(
+ projectId: string,
+ distributionBoardId: string,
+ input: UpdateDistributionBoardInput & {
+ feedingDistributionBoardId: string;
+ feedingCircuitId: string;
+ },
+ circuit: CircuitSnapshot,
+ expectedRevision: number
+) {
+ return executeProjectCommand(
+ projectId,
+ expectedRevision,
+ {
+ schemaVersion: 1,
+ type: "distribution-board.create-feeder-circuit",
+ payload: {
+ circuit,
+ boardUpdate: {
+ schemaVersion: 2,
+ type: "distribution-board.update",
+ payload: {
+ distributionBoardId,
+ changes: Object.entries(input).map(([field, value]) => ({
+ field,
+ value,
+ })),
+ },
+ },
+ },
+ },
+ "Einspeiseabgang anlegen und Verteilung verknüpfen"
+ );
+}
+
export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() {
diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts
index faa4a11..fafeca4 100644
--- a/src/frontend/utils/circuit-grid-model.ts
+++ b/src/frontend/utils/circuit-grid-model.ts
@@ -617,7 +617,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
if (rowType === "downstreamBoardLoad") return "readonly";
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
- if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
+ if (rowType === "reserveCircuit") {
+ if (circuitFieldKeys.has(key)) return "circuitField";
+ if (deviceFieldKeys.has(key)) return "deviceField";
+ }
+ if (rowType === "circuitCompact") {
if (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField";
}
diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts
index a9b11de..30e5ecf 100644
--- a/src/frontend/utils/circuit-grid-projection.ts
+++ b/src/frontend/utils/circuit-grid-projection.ts
@@ -37,6 +37,8 @@ export interface VisibleGridCell {
editable: boolean;
kind: CellKind;
value: GridValue;
+ /** Separate persisted value when the displayed value is derived. */
+ editValue?: GridValue;
}
export interface VisibleGridRow {
@@ -144,6 +146,7 @@ function makeVisibleGridRow(
const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue;
+ let editValue: GridValue | undefined;
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
value = column.key === "displayName"
@@ -155,6 +158,35 @@ function makeVisibleGridRow(
: column.key === "remark"
? "Automatisch aus der verknüpften Verteilung"
: undefined;
+ } else if (
+ rowType === "circuitSummary" &&
+ circuit &&
+ downstreamBoardLoad &&
+ circuit.deviceRows.length === 0
+ ) {
+ if (column.key === "displayName") {
+ const boardLabel =
+ downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name;
+ value = circuit.displayName
+ ? `${boardLabel} · ${circuit.displayName}`
+ : boardLabel;
+ editValue = circuit.displayName ?? "";
+ } else if (column.key === "cosPhi") {
+ value = circuit.circuitCosPhi;
+ } else if (
+ kind === "computed" &&
+ circuitOnlyColumns.has(column.key)
+ ) {
+ value = getCircuitValue(circuit, column.key);
+ } else if (kind === "circuitField") {
+ value = getCircuitValue(circuit, column.key);
+ } else if (column.key === "rowTotalPower") {
+ value = circuit.circuitTotalPower;
+ } else if (column.key === "circuitTotalPower") {
+ value = circuit.circuitTotalPower;
+ } else if (column.key === "voltage") {
+ value = circuit.voltage;
+ }
} else if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
@@ -178,7 +210,7 @@ function makeVisibleGridRow(
value = circuit.voltage;
}
- return { cellKey: column.key, editable, kind, value };
+ return { cellKey: column.key, editable, kind, value, editValue };
});
return {
@@ -247,6 +279,19 @@ export function buildVisibleGridRowsWithStructure(
const downstreamLoads = circuit.downstreamBoardLoads ?? [];
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
+ } else if (
+ circuit.deviceRows.length === 0 &&
+ downstreamLoads.length === 1
+ ) {
+ rows.push(
+ makeVisibleGridRow(
+ "circuitSummary",
+ section.id,
+ circuit,
+ undefined,
+ downstreamLoads[0]
+ )
+ );
} else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else {
diff --git a/src/frontend/utils/distribution-board-feeder-circuit.ts b/src/frontend/utils/distribution-board-feeder-circuit.ts
new file mode 100644
index 0000000..c2c7f43
--- /dev/null
+++ b/src/frontend/utils/distribution-board-feeder-circuit.ts
@@ -0,0 +1,68 @@
+import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
+import type {
+ CircuitTreeResponseDto,
+ CircuitTreeSectionDto,
+} from "../types";
+import { buildCircuitInsertSnapshot } from "./circuit-structure-command";
+
+export function getDefaultFeederCircuitSection(
+ tree: CircuitTreeResponseDto | undefined
+) {
+ if (!tree) return undefined;
+ return (
+ tree.sections.find((section) => section.category === "three_phase") ??
+ tree.sections.find((section) => section.category !== undefined)
+ );
+}
+
+export function getNextFeederCircuitIdentifier(
+ section: CircuitTreeSectionDto
+) {
+ let highestSuffix = 0;
+ for (const circuit of section.circuits) {
+ if (!circuit.equipmentIdentifier.startsWith(section.prefix)) continue;
+ const suffix = circuit.equipmentIdentifier.slice(section.prefix.length);
+ if (!/^\d+$/.test(suffix)) continue;
+ highestSuffix = Math.max(highestSuffix, Number(suffix));
+ }
+ return `${section.prefix}${highestSuffix + 1}`;
+}
+
+export function buildFeederCircuitSnapshot(input: {
+ id: string;
+ tree: CircuitTreeResponseDto;
+ sectionId: string;
+ displayName: string;
+}): CircuitSnapshot {
+ const section = input.tree.sections.find(
+ (candidate) => candidate.id === input.sectionId
+ );
+ if (!section || !section.category) {
+ throw new Error("Die gewählte Stromkreisgruppe ist nicht verfügbar.");
+ }
+ const snapshot = buildCircuitInsertSnapshot({
+ id: input.id,
+ circuitListId: input.tree.circuitListId,
+ category: section.category,
+ deviceRows: [],
+ values: {
+ sectionId: section.id,
+ equipmentIdentifier: getNextFeederCircuitIdentifier(section),
+ displayName: input.displayName.trim() || undefined,
+ sortOrder:
+ section.circuits.length === 0
+ ? 10
+ : Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) +
+ 10,
+ voltage:
+ section.category === "three_phase"
+ ? input.tree.threePhaseVoltageV
+ : input.tree.singlePhaseVoltageV,
+ isReserve: true,
+ },
+ });
+ // A distribution-board feeder is not a generic final circuit. Its protection
+ // must be selected from the calculated load and cable, so no 16 A group
+ // default is persisted here.
+ return { ...snapshot, protectionDevice: null };
+}
diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts
index 146cda8..eb195d0 100644
--- a/src/frontend/utils/project-version-history.ts
+++ b/src/frontend/utils/project-version-history.ts
@@ -32,6 +32,10 @@ const commandTypeLabels: Record = {
"project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
+ "distribution-board.create-feeder-circuit":
+ "Einspeiseabgang angelegt und verknüpft",
+ "distribution-board.remove-feeder-circuit":
+ "Einspeiseabgang und Verknüpfung entfernt",
"distribution-board.delete": "Verteilung entfernt",
"distribution-board.insert-subtree": "Verteilung vollständig eingefügt",
"distribution-board.delete-subtree": "Verteilung vollständig entfernt",
diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts
index 641a770..475d781 100644
--- a/tests/circuit-grid-model.test.ts
+++ b/tests/circuit-grid-model.test.ts
@@ -133,7 +133,9 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField");
- assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField");
+ assert.equal(getCellKind("reserveCircuit", "displayName"), "circuitField");
+ assert.equal(getCellKind("reserveCircuit", "remark"), "circuitField");
+ assert.equal(getCellKind("reserveCircuit", "powerPerUnit"), "deviceField");
assert.equal(getCellKind("placeholder", "displayName"), "deviceField");
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts
index 1a16913..6551aee 100644
--- a/tests/circuit-grid-projection.test.ts
+++ b/tests/circuit-grid-projection.test.ts
@@ -179,9 +179,10 @@ describe("circuit grid projection", () => {
);
});
- it("renders linked downstream boards as separate read-only rows", () => {
+ it("renders one linked downstream board as one compact editable circuit row", () => {
const feeder = {
...reserveCircuit,
+ displayName: undefined,
isReserve: false,
circuitTotalPower: 4,
circuitApparentPower: 5,
@@ -202,19 +203,65 @@ describe("circuit grid projection", () => {
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitSummary",
- "downstreamBoardLoad",
"placeholder",
]);
- const loadRow = rows[2];
+ const loadRow = rows[1];
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value,
- "Unterverteilung UV-02"
+ "UV-02"
+ );
+ assert.equal(
+ loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue,
+ ""
);
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
4
);
- assert.equal(loadRow.cells.every((cell) => !cell.editable), true);
+ assert.equal(
+ loadRow.cells.find((cell) => cell.cellKey === "displayName")?.kind,
+ "circuitField"
+ );
+ });
+
+ it("keeps separate read-only rows for several linked downstream boards", () => {
+ const feeder = {
+ ...reserveCircuit,
+ isReserve: false,
+ circuitTotalPower: 6,
+ circuitApparentPower: 7,
+ circuitCosPhi: 6 / 7,
+ downstreamBoardLoads: [
+ {
+ distributionBoardId: "board-a",
+ name: "Unterverteilung A",
+ boardNumber: "UV-A",
+ activePowerKw: 4,
+ apparentPowerKva: 5,
+ cosPhi: 0.8,
+ },
+ {
+ distributionBoardId: "board-b",
+ name: "Unterverteilung B",
+ boardNumber: "UV-B",
+ activePowerKw: 2,
+ apparentPowerKva: 2,
+ cosPhi: 1,
+ },
+ ],
+ };
+ const rows = buildVisibleGridRowsWithStructure(
+ [{ ...sections[0], circuits: [feeder] }],
+ { headerComponents: [], footerComponents: [] }
+ );
+ assert.deepEqual(rows.map((row) => row.rowType), [
+ "section",
+ "circuitSummary",
+ "downstreamBoardLoad",
+ "downstreamBoardLoad",
+ "placeholder",
+ ]);
+ assert.equal(rows[2].cells.every((cell) => !cell.editable), true);
});
it("shows circuit protection once on each visible circuit row", () => {
diff --git a/tests/distribution-board-feeder-circuit-project-command.repository.test.ts b/tests/distribution-board-feeder-circuit-project-command.repository.test.ts
new file mode 100644
index 0000000..d4d2c26
--- /dev/null
+++ b/tests/distribution-board-feeder-circuit-project-command.repository.test.ts
@@ -0,0 +1,240 @@
+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 { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
+import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
+import { circuits } from "../src/db/schema/circuits.js";
+import { distributionBoards } from "../src/db/schema/distribution-boards.js";
+import { projects } from "../src/db/schema/projects.js";
+import {
+ assertDistributionBoardFeederCircuitProjectCommand,
+ createDistributionBoardCreateFeederCircuitProjectCommand,
+} from "../src/domain/models/distribution-board-feeder-circuit-project-command.model.js";
+import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js";
+import {
+ createDistributionBoardInsertProjectCommand,
+ createDistributionBoardStructureSnapshot,
+} from "../src/domain/models/distribution-board-structure-project-command.model.js";
+import { buildCircuitInsertSnapshot } from "../src/frontend/utils/circuit-structure-command.js";
+
+function createTestDatabase(): DatabaseContext {
+ 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();
+ return context;
+}
+
+function setupBoards(
+ context: DatabaseContext,
+ repository: DistributionBoardStructureProjectCommandRepository
+) {
+ const source = createDistributionBoardStructureSnapshot(
+ "project-1",
+ "NSHV"
+ );
+ const downstream = createDistributionBoardStructureSnapshot(
+ "project-1",
+ "UV EG"
+ );
+ repository.execute({
+ projectId: "project-1",
+ expectedRevision: 0,
+ source: "user",
+ command: createDistributionBoardInsertProjectCommand(source),
+ });
+ repository.execute({
+ projectId: "project-1",
+ expectedRevision: 1,
+ source: "user",
+ command: createDistributionBoardInsertProjectCommand(downstream),
+ });
+ const section = source.sections.find(
+ (candidate) => candidate.key === "three_phase"
+ )!;
+ const circuit = {
+ ...buildCircuitInsertSnapshot({
+ id: "feeder-circuit",
+ circuitListId: source.circuitList.id,
+ category: "three_phase",
+ deviceRows: [],
+ values: {
+ sectionId: section.id,
+ equipmentIdentifier: `${section.prefix}1`,
+ displayName: undefined,
+ sortOrder: 10,
+ voltage: 400,
+ isReserve: true,
+ },
+ }),
+ protectionDevice: null,
+ };
+ const boardUpdate = createDistributionBoardUpdateProjectCommand(
+ downstream.distributionBoard.id,
+ {
+ feedingDistributionBoardId: source.distributionBoard.id,
+ feedingCircuitId: circuit.id,
+ }
+ );
+ const command =
+ createDistributionBoardCreateFeederCircuitProjectCommand(
+ circuit,
+ boardUpdate
+ );
+ return { source, downstream, circuit, command };
+}
+
+describe("distribution-board feeder-circuit project command", () => {
+ it("creates and links one feeder atomically and supports undo and redo", () => {
+ const context = createTestDatabase();
+ try {
+ const repository =
+ new DistributionBoardStructureProjectCommandRepository(context.db);
+ const history = new ProjectHistoryRepository(context.db);
+ const { source, downstream, circuit, command } = setupBoards(
+ context,
+ repository
+ );
+
+ repository.executeFeederCircuit({
+ projectId: "project-1",
+ expectedRevision: 2,
+ source: "user",
+ command,
+ });
+ assert.ok(
+ context.db
+ .select()
+ .from(circuits)
+ .where(eq(circuits.id, circuit.id))
+ .get()
+ );
+ assert.deepEqual(
+ context.db
+ .select({
+ boardId: distributionBoards.feedingDistributionBoardId,
+ circuitId: distributionBoards.feedingCircuitId,
+ })
+ .from(distributionBoards)
+ .where(eq(distributionBoards.id, downstream.distributionBoard.id))
+ .get(),
+ {
+ boardId: source.distributionBoard.id,
+ circuitId: circuit.id,
+ }
+ );
+
+ const undo = history.getNextCommand("project-1", "undo");
+ assert.ok(undo);
+ assertDistributionBoardFeederCircuitProjectCommand(undo.command);
+ repository.executeFeederCircuit({
+ projectId: "project-1",
+ expectedRevision: 3,
+ source: "undo",
+ historyTargetChangeSetId: undo.changeSetId,
+ command: undo.command,
+ });
+ assert.equal(
+ context.db
+ .select()
+ .from(circuits)
+ .where(eq(circuits.id, circuit.id))
+ .get(),
+ undefined
+ );
+ assert.deepEqual(
+ context.db
+ .select({
+ boardId: distributionBoards.feedingDistributionBoardId,
+ circuitId: distributionBoards.feedingCircuitId,
+ })
+ .from(distributionBoards)
+ .where(eq(distributionBoards.id, downstream.distributionBoard.id))
+ .get(),
+ { boardId: null, circuitId: null }
+ );
+
+ const redo = history.getNextCommand("project-1", "redo");
+ assert.ok(redo);
+ assertDistributionBoardFeederCircuitProjectCommand(redo.command);
+ repository.executeFeederCircuit({
+ projectId: "project-1",
+ expectedRevision: 4,
+ source: "redo",
+ historyTargetChangeSetId: redo.changeSetId,
+ command: redo.command,
+ });
+ assert.equal(
+ context.db
+ .select({ equipmentIdentifier: circuits.equipmentIdentifier })
+ .from(circuits)
+ .where(eq(circuits.id, circuit.id))
+ .get()?.equipmentIdentifier,
+ circuit.equipmentIdentifier
+ );
+ } finally {
+ context.close();
+ }
+ });
+
+ it("rolls back unlinking when the generated circuit changed before undo", () => {
+ const context = createTestDatabase();
+ try {
+ const repository =
+ new DistributionBoardStructureProjectCommandRepository(context.db);
+ const history = new ProjectHistoryRepository(context.db);
+ const { downstream, circuit, command } = setupBoards(context, repository);
+ repository.executeFeederCircuit({
+ projectId: "project-1",
+ expectedRevision: 2,
+ source: "user",
+ command,
+ });
+ context.db
+ .update(circuits)
+ .set({ displayName: "Außerhalb der Historie geändert" })
+ .where(eq(circuits.id, circuit.id))
+ .run();
+
+ const undo = history.getNextCommand("project-1", "undo");
+ assert.ok(undo);
+ assertDistributionBoardFeederCircuitProjectCommand(undo.command);
+ assert.throws(
+ () =>
+ repository.executeFeederCircuit({
+ projectId: "project-1",
+ expectedRevision: 3,
+ source: "undo",
+ historyTargetChangeSetId: undo.changeSetId,
+ command: undo.command,
+ }),
+ /changed before history removal/
+ );
+ const board = context.db
+ .select()
+ .from(distributionBoards)
+ .where(eq(distributionBoards.id, downstream.distributionBoard.id))
+ .get();
+ assert.equal(board?.feedingCircuitId, circuit.id);
+ assert.ok(
+ context.db
+ .select()
+ .from(circuits)
+ .where(eq(circuits.id, circuit.id))
+ .get()
+ );
+ } finally {
+ context.close();
+ }
+ });
+});
diff --git a/tests/distribution-board-feeder-circuit.test.ts b/tests/distribution-board-feeder-circuit.test.ts
new file mode 100644
index 0000000..fb143da
--- /dev/null
+++ b/tests/distribution-board-feeder-circuit.test.ts
@@ -0,0 +1,168 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ buildFeederCircuitSnapshot,
+ getDefaultFeederCircuitSection,
+ getNextFeederCircuitIdentifier,
+} from "../src/frontend/utils/distribution-board-feeder-circuit.js";
+import type { CircuitTreeResponseDto } from "../src/frontend/types.js";
+import { createFeederCircuitAndUpdateDistributionBoard } from "../src/frontend/utils/api.js";
+
+function tree(): CircuitTreeResponseDto {
+ return {
+ circuitListId: "list-main",
+ distributionBoardId: "board-main",
+ distributionBoardName: "NSHV",
+ currentRevision: 2,
+ singlePhaseVoltageV: 230,
+ threePhaseVoltageV: 400,
+ distributionBoardSimultaneityFactor: 1,
+ distributionBoardTotalPower: 0,
+ distributionBoardTotalPowerWithSimultaneityFactor: 0,
+ headerComponents: [],
+ footerComponents: [],
+ sections: [
+ {
+ id: "single",
+ key: "single_phase",
+ category: "single_phase",
+ groupNumber: 1,
+ displayName: "1-phasig 1",
+ prefix: "-2F1.",
+ sortOrder: 10,
+ sectionTotalPower: 0,
+ components: [],
+ circuits: [],
+ },
+ {
+ id: "three",
+ key: "three_phase",
+ category: "three_phase",
+ groupNumber: 1,
+ displayName: "3-phasig 1",
+ prefix: "-3F1.",
+ sortOrder: 20,
+ sectionTotalPower: 0,
+ components: [],
+ circuits: [
+ {
+ id: "existing-2",
+ circuitListId: "list-main",
+ sectionId: "three",
+ equipmentIdentifier: "-3F1.2",
+ sortOrder: 10,
+ isReserve: true,
+ circuitTotalPower: 0,
+ circuitApparentPower: 0,
+ circuitCosPhi: 1,
+ deviceRows: [],
+ downstreamBoardLoads: [],
+ },
+ {
+ id: "existing-5",
+ circuitListId: "list-main",
+ sectionId: "three",
+ equipmentIdentifier: "-3F1.5",
+ sortOrder: 30,
+ isReserve: true,
+ circuitTotalPower: 0,
+ circuitApparentPower: 0,
+ circuitCosPhi: 1,
+ deviceRows: [],
+ downstreamBoardLoads: [],
+ },
+ ],
+ },
+ ],
+ };
+}
+
+describe("distribution-board feeder circuit planning", () => {
+ it("prefers a three-phase section and does not fill BMK gaps", () => {
+ const input = tree();
+ const section = getDefaultFeederCircuitSection(input)!;
+ assert.equal(section.id, "three");
+ assert.equal(getNextFeederCircuitIdentifier(section), "-3F1.6");
+ });
+
+ it("builds an empty circuit with stable id, optional name and defaults", () => {
+ const input = tree();
+ const snapshot = buildFeederCircuitSnapshot({
+ id: "new-feeder",
+ tree: input,
+ sectionId: "three",
+ displayName: " ",
+ });
+ assert.equal(snapshot.id, "new-feeder");
+ assert.equal(snapshot.equipmentIdentifier, "-3F1.6");
+ assert.equal(snapshot.displayName, null);
+ assert.equal(snapshot.sortOrder, 40);
+ assert.equal(snapshot.voltage, 400);
+ assert.equal(snapshot.isReserve, true);
+ assert.deepEqual(snapshot.deviceRows, []);
+ assert.equal(snapshot.protectionDevice, null);
+ });
+
+ it("sends circuit creation and board linking as one project command", async () => {
+ const input = tree();
+ const circuit = buildFeederCircuitSnapshot({
+ id: "new-feeder",
+ tree: input,
+ sectionId: "three",
+ displayName: "",
+ });
+ const requests: Array<{ url: string; method: string; body: any }> = [];
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (request, init) => {
+ requests.push({
+ url: String(request),
+ method: init?.method ?? "GET",
+ body: JSON.parse(String(init?.body)),
+ });
+ return new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+ try {
+ await createFeederCircuitAndUpdateDistributionBoard(
+ "project-1",
+ "board-child",
+ {
+ floorId: null,
+ supplyType: "AV",
+ simultaneityFactor: 0.8,
+ feedingDistributionBoardId: "board-main",
+ feedingCircuitId: circuit.id,
+ },
+ circuit,
+ 7
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, "/api/projects/project-1/commands");
+ assert.equal(requests[0].method, "POST");
+ assert.equal(requests[0].body.expectedRevision, 7);
+ assert.equal(
+ requests[0].body.command.type,
+ "distribution-board.create-feeder-circuit"
+ );
+ assert.equal(requests[0].body.command.payload.circuit.id, "new-feeder");
+ assert.deepEqual(
+ requests[0].body.command.payload.boardUpdate.payload.changes
+ .filter((change: { field: string }) =>
+ change.field.startsWith("feeding")
+ )
+ .map((change: { field: string; value: unknown }) => [
+ change.field,
+ change.value,
+ ]),
+ [
+ ["feedingDistributionBoardId", "board-main"],
+ ["feedingCircuitId", "new-feeder"],
+ ]
+ );
+ });
+});