Create feeder circuits while linking boards

This commit is contained in:
Julian Appel 2026-09-28 22:14:30 +02:00
parent 552362bad1
commit ade21fe386
11 changed files with 960 additions and 40 deletions

View file

@ -47,6 +47,7 @@ import {
import {
copyGlobalDeviceToProject,
copyDistributionBoard,
createFeederCircuitAndUpdateDistributionBoard,
copyProjectDeviceToGlobal,
createDistributionBoard,
createProjectDevice,
@ -71,6 +72,12 @@ import {
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
import { createClientId } from "../../../frontend/utils/client-id";
import {
buildFeederCircuitSnapshot,
getDefaultFeederCircuitSection,
getNextFeederCircuitIdentifier,
} from "../../../frontend/utils/distribution-board-feeder-circuit";
import type {
BuildingDto,
CircuitListDto,
@ -146,6 +153,7 @@ const boardFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices";
const createFeederCircuitSelectValue = "__create_feeder_circuit__";
export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>();
@ -175,6 +183,12 @@ export default function ProjectDetailPage() {
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [isCreatingFeederCircuit, setIsCreatingFeederCircuit] =
useState(false);
const [editingFeederCircuitSectionId, setEditingFeederCircuitSectionId] =
useState("");
const [editingFeederCircuitDisplayName, setEditingFeederCircuitDisplayName] =
useState("");
const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({
@ -350,6 +364,21 @@ export default function ProjectDetailPage() {
const selectedFeedingCircuit = selectedFeedingCircuits.find(
({ circuit }) => circuit.id === editingFeedingCircuitId
);
const feederCircuitSections =
selectedFeedingTree?.sections.filter(
(section) => section.category !== undefined
) ?? [];
const selectedNewFeederCircuitSection = feederCircuitSections.find(
(section) => section.id === editingFeederCircuitSectionId
);
const newFeederCircuitIdentifier = selectedNewFeederCircuitSection
? getNextFeederCircuitIdentifier(selectedNewFeederCircuitSection)
: null;
const selectedFeedingCircuitCategory =
selectedFeedingCircuit?.section.category ??
(isCreatingFeederCircuit
? selectedNewFeederCircuitSection?.category
: undefined);
const selectedFeedingBoard = boards.find(
(board) => board.id === editingFeedingBoardId
);
@ -556,6 +585,9 @@ export default function ProjectDetailPage() {
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId("");
setEditingFeederCircuitDisplayName("");
}
function openBoardCreator() {
@ -584,28 +616,56 @@ export default function ProjectDetailPage() {
setIsSaving(true);
setError(null);
try {
const result = await updateDistributionBoard(
projectId,
editingBoard.id,
{
const boardInput = {
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo),
};
let result;
if (isCreatingFeederCircuit) {
if (!editingFeedingBoardId || !selectedFeedingTree) {
throw new Error("Bitte eine speisende Verteilung wählen.");
}
if (!selectedNewFeederCircuitSection) {
throw new Error(
"Bitte eine Stromkreisgruppe für den neuen Abgang wählen."
);
}
const circuit = buildFeederCircuitSnapshot({
id: createClientId(),
tree: selectedFeedingTree,
sectionId: selectedNewFeederCircuitSection.id,
displayName: editingFeederCircuitDisplayName,
});
result = await createFeederCircuitAndUpdateDistributionBoard(
projectId,
editingBoard.id,
{
...boardInput,
feedingDistributionBoardId: editingFeedingBoardId,
feedingCircuitId: circuit.id,
},
circuit,
project.currentRevision
);
setBoards((current) =>
current.map((board) =>
board.id === result.distributionBoard.id
? result.distributionBoard
: board
)
} else {
result = await updateDistributionBoard(
projectId,
editingBoard.id,
boardInput,
project.currentRevision
);
}
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
const [updatedBoards, updatedTrees] = await Promise.all([
listDistributionBoards(projectId),
getProjectCircuitTrees(projectId),
]);
setBoards(updatedBoards);
setCircuitTrees(updatedTrees.trees);
setEditingBoard(null);
} catch (err) {
setError(
@ -1567,8 +1627,16 @@ export default function ProjectDetailPage() {
className="form-select"
id="feeding-distribution-board"
onChange={(event) => {
setEditingFeedingBoardId(event.target.value);
const boardId = event.target.value;
setEditingFeedingBoardId(boardId);
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId(
getDefaultFeederCircuitSection(
circuitTreeByBoardId.get(boardId)
)?.id ?? ""
);
setEditingFeederCircuitDisplayName("");
}}
value={editingFeedingBoardId}
>
@ -1588,10 +1656,31 @@ export default function ProjectDetailPage() {
className="form-select"
disabled={!editingFeedingBoardId}
id="feeding-circuit"
onChange={(event) => setEditingFeedingCircuitId(event.target.value)}
value={editingFeedingCircuitId}
onChange={(event) => {
if (event.target.value === createFeederCircuitSelectValue) {
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(true);
setEditingFeederCircuitSectionId(
(current) =>
current ||
getDefaultFeederCircuitSection(selectedFeedingTree)?.id ||
""
);
return;
}
setIsCreatingFeederCircuit(false);
setEditingFeedingCircuitId(event.target.value);
}}
value={
isCreatingFeederCircuit
? createFeederCircuitSelectValue
: editingFeedingCircuitId
}
>
<option value="">Nur Verteilung bekannt</option>
<option value={createFeederCircuitSelectValue}>
+ Neuen Einspeiseabgang anlegen …
</option>
{selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier}
@ -1600,7 +1689,62 @@ export default function ProjectDetailPage() {
))}
</select>
</div>
{editingFeedingBoardId && !editingFeedingCircuitId ? (
{isCreatingFeederCircuit ? (
<div className="col-12">
<div className="border rounded p-3">
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-section">
Stromkreisgruppe
</label>
<select
className="form-select"
id="new-feeder-circuit-section"
onChange={(event) =>
setEditingFeederCircuitSectionId(event.target.value)
}
required
value={editingFeederCircuitSectionId}
>
{feederCircuitSections.map((section) => (
<option key={section.id} value={section.id}>
{section.displayName}
</option>
))}
</select>
<div className="form-text">
Vorgesehenes BMK: {newFeederCircuitIdentifier ?? "–"}
</div>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-name">
Abgangsbezeichnung (optional)
</label>
<input
className="form-control"
id="new-feeder-circuit-name"
onChange={(event) =>
setEditingFeederCircuitDisplayName(event.target.value)
}
placeholder="z. B. Steigetrasse Ost"
value={editingFeederCircuitDisplayName}
/>
<div className="form-text">
Ohne Zusatzbezeichnung wird der Name der gespeisten
Verteilung angezeigt.
</div>
</div>
</div>
<div className="form-text mt-2">
Der Abgang wird erst beim Speichern angelegt und gemeinsam
mit dieser Verteilung verknüpft. Es entsteht keine Gerätezeile.
</div>
</div>
</div>
) : null}
{editingFeedingBoardId &&
!editingFeedingCircuitId &&
!isCreatingFeederCircuit ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
@ -1618,8 +1762,8 @@ export default function ProjectDetailPage() {
</div>
</div>
) : null}
{selectedFeedingCircuit &&
selectedFeedingCircuit.section.category !== "three_phase" ? (
{selectedFeedingCircuitCategory !== undefined &&
selectedFeedingCircuitCategory !== "three_phase" ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese

View file

@ -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"],

View file

@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { isDeepStrictEqual } from "node:util";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@ -17,6 +18,20 @@ import {
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
import {
assertDistributionBoardFeederCircuitProjectCommand,
createDistributionBoardCreateFeederCircuitProjectCommand,
createDistributionBoardRemoveFeederCircuitProjectCommand,
distributionBoardCreateFeederCircuitCommandType,
distributionBoardRemoveFeederCircuitCommandType,
type DistributionBoardFeederCircuitProjectCommand,
} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
import {
circuitDeleteCommandType,
circuitInsertCommandType,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../../domain/models/circuit-structure-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@ -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,

View file

@ -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<DistributionBoardFeederCircuitCommandPayload> {
schemaVersion: typeof distributionBoardFeederCircuitCommandSchemaVersion;
type: typeof distributionBoardCreateFeederCircuitCommandType;
}
export interface DistributionBoardRemoveFeederCircuitProjectCommand
extends SerializedProjectCommand<DistributionBoardFeederCircuitCommandPayload> {
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<unknown>
): 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<unknown>;
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -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;
};
}

View file

@ -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

View file

@ -366,10 +366,7 @@ export function createDistributionBoard(
);
}
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: {
export type UpdateDistributionBoardInput = {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
@ -379,7 +376,12 @@ export function updateDistributionBoard(
DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId"
>
>,
>;
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: UpdateDistributionBoardInput,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -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() {

View file

@ -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 };
}

View file

@ -32,6 +32,10 @@ const commandTypeLabels: Record<string, string> = {
"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",

View file

@ -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();
}
});
});

View file

@ -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"],
]
);
});
});