Merge branch 'main' into feature/user-management

This commit is contained in:
Julian Appel 2026-09-28 22:32:48 +02:00
commit 73579d1e79
22 changed files with 1141 additions and 54 deletions

View file

@ -483,6 +483,17 @@ downstream board's demand after its own board simultaneity factor to the chosen
feeding circuit as a read-only virtual row. Recursive chains and several boards
on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected.
The distribution-board dialog can create one empty feeder circuit and link it
in the same `distribution-board.create-feeder-circuit` project command. Its
history inverse restores the complete previous board update before removing
only the unchanged generated circuit; Redo reuses its UUID and BMK. No
CircuitDeviceRow is created. The three-phase group is suggested, the optional
circuit display name stays empty by default, and protection remains open for
planner sizing. A circuit with exactly one downstream board and no device rows
is rendered as one compact row using the live board number/name; several
downstream boards or additional devices retain summary and child rows. Editing
the name or remark of an empty reserve circuit changes the circuit itself and
does not implicitly create a device row.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.

View file

@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes
- The project-command endpoint additionally accepts
`distribution-board.create-feeder-circuit` with an empty complete circuit
snapshot and an embedded version-2 `distribution-board.update`. Both writes
are one transaction, revision and Undo step. The generated circuit has no
device row; Redo keeps its UUID and BMK. The history-only inverse
`distribution-board.remove-feeder-circuit` restores the previous board values
and removes only the unchanged circuit.
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs
@ -346,7 +353,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included. Circuit totals contain the demand of linked downstream
boards. `downstreamBoardLoads` exposes those contributions as read-only rows;
boards. `downstreamBoardLoads` exposes those contributions as read-only
values; the grid combines exactly one downstream board without device rows
into the circuit row and keeps child rows for mixed or multiple loads.
`feedingReference` resolves the current board/circuit labels by id.
Response sketch:

View file

@ -477,6 +477,21 @@ Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören
Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt.
Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein
neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige
Gruppe und das nächste BMK vor. Der Befehl
`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und
wendet die vollständige Verteilungsänderung in derselben Transaktion und
Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst
offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo
stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach
nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut.
Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das
Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten
Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen
Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren
Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine
Gerätezeile.
Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten

View file

@ -38,8 +38,9 @@ automatisch bei späteren Änderungen in einem anderen Tab.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und
schreibgeschützten Unterverteilungszeilen),
(reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit
Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten
Unterverteilungszeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten.

View file

@ -1551,6 +1551,10 @@ a.kpi:hover {
background: var(--panel-bg-subtle);
}
.tree-grid .feeder-circuit-row td:nth-child(2) {
font-weight: 600;
}
.tree-grid .device-row td:first-child {
color: var(--text-faint);
}

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,
{
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo),
},
project.currentRevision
);
setBoards((current) =>
current.map((board) =>
board.id === result.distributionBoard.id
? result.distributionBoard
: board
)
);
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
);
} 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

@ -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}
/>
<IconButton
disabled={isSaving}
disabled={
isSaving ||
(row.circuit.downstreamBoardLoads?.length ?? 0) > 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"
/>
</>

View file

@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr>
);
}
if (rows.length === 0 && downstreamBoards.length === 1) {
const boardLabel =
downstreamBoards[0].boardNumber ?? downstreamBoards[0].name;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{boardLabel}
{circuit.displayName ? (
<span className="pd-sub"> · {circuit.displayName}</span>
) : null}
</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;

View file

@ -366,20 +366,22 @@ export function createDistributionBoard(
);
}
export type UpdateDistributionBoardInput = {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
} & Partial<DistributionBoardInfoDto> &
Partial<
Pick<
DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId"
>
>;
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
} & Partial<DistributionBoardInfoDto> &
Partial<
Pick<
DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId"
>
>,
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

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

View file

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

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

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

View file

@ -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", () => {

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