Merge origin/main into feature/user-management

Renumber the users migration to 0013_users so it follows the upstream
0012_good_gateway (logical board feeds); AGENTS.md deferred-work list keeps
both the new feed items and the auth items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-28 21:46:21 +02:00
commit 95bfba2f67
63 changed files with 5546 additions and 443 deletions

View file

@ -1555,6 +1555,16 @@ a.kpi:hover {
color: var(--text-faint);
}
.tree-grid .downstream-board-load-row td {
background: var(--panel-bg-subtle);
color: var(--text-muted);
font-style: italic;
}
.tree-grid .downstream-board-load-row td:nth-child(2) {
padding-left: 1.5rem;
}
.tree-grid .empty-circuit-row td {
background: var(--panel-bg-subtle);
}

View file

@ -56,6 +56,7 @@ import {
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectCircuitTrees,
getProjectDeviceSyncPreview,
listBuildings,
listCircuitLists,
@ -73,6 +74,7 @@ import {
import type {
BuildingDto,
CircuitListDto,
CircuitTreeResponseDto,
CreateRoomInput,
CreateProjectDeviceInput,
DistributionBoardDto,
@ -151,6 +153,7 @@ export default function ProjectDetailPage() {
const [project, setProject] = useState<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
const [circuitTrees, setCircuitTrees] = useState<CircuitTreeResponseDto[]>([]);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
@ -170,6 +173,8 @@ export default function ProjectDetailPage() {
setEditingBoardSimultaneityFactor,
] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({
@ -209,6 +214,7 @@ export default function ProjectDetailPage() {
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
getProjectCircuitTrees(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
@ -219,6 +225,7 @@ export default function ProjectDetailPage() {
currentProject,
distributionBoards,
loadedCircuitLists,
loadedCircuitTrees,
loadedBuildings,
loadedFloors,
loadedRooms,
@ -228,6 +235,7 @@ export default function ProjectDetailPage() {
setProject(currentProject);
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);
setCircuitTrees(loadedCircuitTrees.trees);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
@ -310,6 +318,48 @@ export default function ProjectDetailPage() {
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
);
const circuitTreeByBoardId = useMemo(
() => new Map(circuitTrees.map((tree) => [tree.distributionBoardId, tree])),
[circuitTrees]
);
const feedingBoardOptions = useMemo(() => {
if (!editingBoard) return [];
const boardById = new Map(boards.map((board) => [board.id, board]));
return boards.filter((candidate) => {
if (candidate.id === editingBoard.id) return false;
const visited = new Set<string>();
let current: DistributionBoardDto | undefined = candidate;
while (current && current.feedingDistributionBoardId && !visited.has(current.id)) {
if (current.feedingDistributionBoardId === editingBoard.id) return false;
visited.add(current.id);
current = boardById.get(current.feedingDistributionBoardId);
}
return true;
});
}, [boards, editingBoard]);
const selectedFeedingTree = editingFeedingBoardId
? circuitTreeByBoardId.get(editingFeedingBoardId)
: undefined;
const selectedFeedingCircuits = useMemo(
() =>
selectedFeedingTree?.sections.flatMap((section) =>
section.circuits.map((circuit) => ({ circuit, section }))
) ?? [],
[selectedFeedingTree]
);
const selectedFeedingCircuit = selectedFeedingCircuits.find(
({ circuit }) => circuit.id === editingFeedingCircuitId
);
const selectedFeedingBoard = boards.find(
(board) => board.id === editingFeedingBoardId
);
const selectedFeedingCircuitUseCount = editingFeedingCircuitId
? boards.filter(
(board) =>
board.id !== editingBoard?.id &&
board.feedingCircuitId === editingFeedingCircuitId
).length
: 0;
const enabledBoardSupplyTypes =
project?.enabledDistributionBoardSupplyTypes ?? [];
const usedBoardSupplyTypes = useMemo(
@ -329,6 +379,21 @@ export default function ProjectDetailPage() {
[boards]
);
function formatBoardFeedingReference(board: DistributionBoardDto) {
if (!board.feedingDistributionBoardId) return board.fedFrom ?? "–";
const feedingBoard = boards.find(
(candidate) => candidate.id === board.feedingDistributionBoardId
);
if (!feedingBoard) return "Unbekannte Verteilung";
const boardLabel = feedingBoard.boardNumber ?? feedingBoard.name;
if (!board.feedingCircuitId) return boardLabel;
const circuit = circuitTreeByBoardId
.get(feedingBoard.id)
?.sections.flatMap((section) => section.circuits)
.find((candidate) => candidate.id === board.feedingCircuitId);
return `${boardLabel}${circuit?.equipmentIdentifier ?? ""}`;
}
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
current ? { ...current, currentRevision } : current
@ -358,6 +423,7 @@ export default function ProjectDetailPage() {
]);
setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setBoardName("");
setBoardFloorId("");
setBoardSupplyType(
@ -488,6 +554,8 @@ export default function ProjectDetailPage() {
);
setEditingBoardCopyName(`${board.name} Kopie`);
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
}
function openBoardCreator() {
@ -523,6 +591,8 @@ export default function ProjectDetailPage() {
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo),
},
project.currentRevision
@ -535,6 +605,7 @@ export default function ProjectDetailPage() {
)
);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null);
} catch (err) {
setError(
@ -590,6 +661,7 @@ export default function ProjectDetailPage() {
result.circuitList,
]);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null);
} catch (err) {
setError(
@ -633,6 +705,7 @@ export default function ProjectDetailPage() {
)
);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null);
} catch (err) {
setError(
@ -989,6 +1062,7 @@ export default function ProjectDetailPage() {
<th>Örtlichkeit</th>
<th>Etage</th>
<th>Netzart</th>
<th>Gespeist von</th>
<th>GZF</th>
<th className="text-end">Aktionen</th>
</tr>
@ -1015,6 +1089,7 @@ export default function ProjectDetailPage() {
]
: "Nicht festgelegt"}
</td>
<td>{formatBoardFeedingReference(board)}</td>
<td>
{board.simultaneityFactor.toLocaleString(
"de-DE",
@ -1392,7 +1467,7 @@ export default function ProjectDetailPage() {
{editingBoard ? (
<FormModal
description={`${editingBoard.name}: Etagenzuordnung, Netzart und Gleichzeitigkeitsfaktor ändern.`}
description={`${editingBoard.name}: Etagenzuordnung, Netzart, Einspeisung und Gleichzeitigkeitsfaktor ändern.`}
isSaving={isSaving}
onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard}
@ -1479,6 +1554,91 @@ export default function ProjectDetailPage() {
</div>
</div>
</div>
<section className="card mt-4" aria-labelledby="feeding-reference-heading">
<div className="card-header" id="feeding-reference-heading">
Logische Einspeisung
</div>
<div className="card-body row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-distribution-board">
Speisende Verteilung
</label>
<select
className="form-select"
id="feeding-distribution-board"
onChange={(event) => {
setEditingFeedingBoardId(event.target.value);
setEditingFeedingCircuitId("");
}}
value={editingFeedingBoardId}
>
<option value="">Keine Projektverteilung</option>
{feedingBoardOptions.map((board) => (
<option key={board.id} value={board.id}>
{board.boardNumber ?? board.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-circuit">
Speisender Abgang
</label>
<select
className="form-select"
disabled={!editingFeedingBoardId}
id="feeding-circuit"
onChange={(event) => setEditingFeedingCircuitId(event.target.value)}
value={editingFeedingCircuitId}
>
<option value="">Nur Verteilung bekannt</option>
{selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier}
{circuit.displayName ? ` · ${circuit.displayName}` : ""}
</option>
))}
</select>
</div>
{editingFeedingBoardId && !editingFeedingCircuitId ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
Leistung dieser Verteilung aber keinem Stromkreis zugerechnet.
</div>
</div>
) : null}
{selectedFeedingBoard?.supplyType &&
editingBoardSupplyType !== selectedFeedingBoard.supplyType ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Die Netzarten weichen ab: {selectedFeedingBoard.supplyType}
{" → "}{editingBoardSupplyType}. Bitte Umschaltung und
Versorgungskonzept prüfen.
</div>
</div>
) : null}
{selectedFeedingCircuit &&
selectedFeedingCircuit.section.category !== "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
Einspeisung für die Unterverteilung geeignet ist.
</div>
</div>
) : null}
{selectedFeedingCircuitUseCount > 0 ? (
<div className="col-12">
<div className="alert alert-info mb-0 py-2">
Dieser Abgang speist bereits {selectedFeedingCircuitUseCount}
{selectedFeedingCircuitUseCount === 1
? " weitere Verteilung."
: " weitere Verteilungen."}
</div>
</div>
) : null}
</div>
</section>
<div className="mt-4">
<DistributionBoardInfoCard
draft={editingBoardInfo}

View file

@ -7,10 +7,9 @@ import "../../../../frontend/print/print-document.css";
import {
deleteDocumentLogo,
documentLogoUrl,
getCircuitTree,
getProjectCircuitTrees,
getDocumentBranding,
getProject,
listCircuitLists,
listDistributionBoards,
listFloors,
listProjectSnapshots,
@ -31,7 +30,7 @@ import {
import { PrintDocument } from "../../../../frontend/print/print-document";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
buildBoardHierarchy,
summarizePrintBoard,
type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model";
@ -89,17 +88,20 @@ export default function ProjectPrintPage() {
}
let cancelled = false;
async function load() {
const [project, boards, circuitLists, floors, snapshots, branding] =
const [project, boards, projectTrees, floors, snapshots, branding] =
await Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
getProjectCircuitTrees(projectId),
listFloors(projectId),
listProjectSnapshots(projectId),
getDocumentBranding(),
]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const summaries = await Promise.all(
const treeByBoardId = new Map(
projectTrees.trees.map((tree) => [tree.distributionBoardId, tree])
);
const summaries =
[...boards]
.sort(
(left, right) =>
@ -107,20 +109,17 @@ export default function ProjectPrintPage() {
boardSortCollator.compare(left.name, right.name)
)
.flatMap((board) => {
const circuitList = circuitLists.find(
(list) => list.distributionBoardId === board.id
);
return circuitList ? [{ board, circuitList }] : [];
const tree = treeByBoardId.get(board.id);
return tree ? [{ board, tree }] : [];
})
.map(async ({ board, circuitList }) =>
.map(({ board, tree }) =>
summarizePrintBoard({
board,
circuitListId: circuitList.id,
tree: await getCircuitTree(projectId, circuitList.id),
circuitListId: tree.circuitListId,
tree,
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
})
)
);
);
const finalProject = await getProject(projectId);
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
if (cancelled) {
@ -148,7 +147,7 @@ export default function ProjectPrintPage() {
}, [projectId]);
const entries = useMemo(
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
() => (data ? buildBoardHierarchy(data.summaries) : []),
[data]
);
const stand = useMemo(

View file

@ -0,0 +1,4 @@
ALTER TABLE `distribution_boards` ADD `feeding_distribution_board_id` text REFERENCES distribution_boards(id);--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `feeding_circuit_id` text REFERENCES circuits(id);--> statement-breakpoint
CREATE INDEX `distribution_boards_feeding_board_idx` ON `distribution_boards` (`feeding_distribution_board_id`);--> statement-breakpoint
CREATE INDEX `distribution_boards_feeding_circuit_idx` ON `distribution_boards` (`feeding_circuit_id`);

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7c6307b6-2516-445c-a088-c8ad746b9fde",
"id": "8029e6ab-56de-4345-8d38-0ca7462640de",
"prevId": "63f27b3a-7378-462d-8119-b67d67903dbb",
"tables": {
"buildings": {
@ -1010,9 +1010,38 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feeding_distribution_board_id": {
"name": "feeding_distribution_board_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feeding_circuit_id": {
"name": "feeding_circuit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"distribution_boards_feeding_board_idx": {
"name": "distribution_boards_feeding_board_idx",
"columns": [
"feeding_distribution_board_id"
],
"isUnique": false
},
"distribution_boards_feeding_circuit_idx": {
"name": "distribution_boards_feeding_circuit_idx",
"columns": [
"feeding_circuit_id"
],
"isUnique": false
}
},
"indexes": {},
"foreignKeys": {
"distribution_boards_project_id_projects_id_fk": {
"name": "distribution_boards_project_id_projects_id_fk",
@ -1039,6 +1068,32 @@
],
"onDelete": "set null",
"onUpdate": "no action"
},
"distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk": {
"name": "distribution_boards_feeding_distribution_board_id_distribution_boards_id_fk",
"tableFrom": "distribution_boards",
"tableTo": "distribution_boards",
"columnsFrom": [
"feeding_distribution_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"distribution_boards_feeding_circuit_id_circuits_id_fk": {
"name": "distribution_boards_feeding_circuit_id_circuits_id_fk",
"tableFrom": "distribution_boards",
"tableTo": "circuits",
"columnsFrom": [
"feeding_circuit_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@ -2916,92 +2971,6 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"must_change_password": {
"name": "must_change_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},

File diff suppressed because it is too large Load diff

View file

@ -89,8 +89,15 @@
{
"idx": 12,
"version": "6",
"when": 1790617581434,
"tag": "0012_lively_turbo",
"when": 1790540142918,
"tag": "0012_good_gateway",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1790624623732,
"tag": "0013_users",
"breakpoints": true
}
]

View file

@ -23,6 +23,7 @@ import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
interface PersistedMoveRow {
id: string;
@ -171,6 +172,9 @@ export class CircuitDeviceRowMoveProjectCommandRepository
projectId,
destinationCircuitIds
);
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
targetCircuit.id,
]);
const deleted = database
.delete(circuits)
.where(

View file

@ -26,6 +26,7 @@ import { distributionBoardComponentProtectionDevices } from "../schema/distribut
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
@ -65,6 +66,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
"Circuit-group subtree changed before deletion."
);
}
assertCircuitsAreNotReferencedAsFeeders(
database,
projectId,
current.circuits.map((entry) => entry.circuit.id)
);
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))

View file

@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { defaultCircuitSectionDefinitions } from "../../domain/models/distribution-board-structure-project-command.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitSections } from "../schema/circuit-sections.js";
@ -30,6 +30,15 @@ export class CircuitSectionRepository {
.orderBy(asc(circuitSections.sortOrder));
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuitSections)
.where(inArray(circuitSections.circuitListId, circuitListIds))
.orderBy(asc(circuitSections.sortOrder), asc(circuitSections.id));
}
async createDefaults(circuitListId: string) {
const db = this.database;
for (const entry of createDefaultCircuitSectionValues(circuitListId)) {

View file

@ -26,6 +26,7 @@ import {
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
@ -236,6 +237,8 @@ export class CircuitStructureProjectCommandRepository
protectionDevice: protectionDevice ?? null,
});
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
const result = database
.delete(circuits)
.where(

View file

@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm";
import { asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { circuits } from "../schema/circuits.js";
@ -14,6 +14,15 @@ export class CircuitRepository {
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(circuits)
.where(inArray(circuits.circuitListId, circuitListIds))
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
}
async listBySection(sectionId: string) {
const db = this.database;
return db

View file

@ -19,6 +19,18 @@ export class DistributionBoardComponentRepository {
);
}
async listByCircuitLists(circuitListIds: string[]) {
if (circuitListIds.length === 0) return [];
return this.database
.select()
.from(distributionBoardComponents)
.where(inArray(distributionBoardComponents.circuitListId, circuitListIds))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
);
}
async listProtectionByComponentIds(componentIds: string[]) {
if (componentIds.length === 0) {
return [];

View file

@ -0,0 +1,65 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
export function assertBoardsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
distributionBoardIds: string[]
) {
if (distributionBoardIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingDistributionBoardId,
[...new Set(distributionBoardIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
export function assertCircuitsAreNotReferencedAsFeeders(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
if (circuitIds.length === 0) return;
const dependent = database
.select({
name: distributionBoards.name,
boardNumber: distributionBoards.boardNumber,
})
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
inArray(
distributionBoards.feedingCircuitId,
[...new Set(circuitIds)]
)
)
)
.orderBy(asc(distributionBoards.boardNumber), asc(distributionBoards.name))
.limit(1)
.get();
if (dependent) throwFeedingReferenceError(dependent);
}
function throwFeedingReferenceError(dependent: {
name: string;
boardNumber: string | null;
}): never {
const label = dependent.boardNumber ?? dependent.name;
throw new Error(`Speist ${label} – erst Einspeisung ändern.`);
}

View file

@ -30,6 +30,7 @@ import { distributionBoards } from "../schema/distribution-boards.js";
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";
export class DistributionBoardStructureProjectCommandRepository
implements DistributionBoardStructureProjectCommandStore
@ -248,6 +249,8 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
const deleted = database
.delete(distributionBoards)
.where(
@ -325,6 +328,16 @@ export class DistributionBoardStructureProjectCommandRepository
patch.supplyType
);
}
this.assertFeedingReference(database, input.projectId, current.id, {
feedingDistributionBoardId:
patch.feedingDistributionBoardId === undefined
? current.feedingDistributionBoardId
: patch.feedingDistributionBoardId,
feedingCircuitId:
patch.feedingCircuitId === undefined
? current.feedingCircuitId
: patch.feedingCircuitId,
});
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
@ -333,7 +346,8 @@ export class DistributionBoardStructureProjectCommandRepository
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
inversePatch,
input.command.schemaVersion
);
const updated = database
.update(distributionBoards)
@ -352,6 +366,77 @@ export class DistributionBoardStructureProjectCommandRepository
}
return inverse;
}
private assertFeedingReference(
database: AppDatabase,
projectId: string,
distributionBoardId: string,
reference: {
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
) {
const { feedingDistributionBoardId, feedingCircuitId } = reference;
if (feedingDistributionBoardId === null) {
if (feedingCircuitId !== null) {
throw new Error(
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
);
}
return;
}
if (feedingDistributionBoardId === distributionBoardId) {
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
}
const projectBoards = database
.select({
id: distributionBoards.id,
feedingDistributionBoardId:
distributionBoards.feedingDistributionBoardId,
})
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.all();
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
if (!boardById.has(feedingDistributionBoardId)) {
throw new Error(
"Die speisende Verteilung gehört nicht zum Projekt."
);
}
const visited = new Set<string>([distributionBoardId]);
let currentId: string | null = feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen.");
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
if (feedingCircuitId === null) {
return;
}
const feedingCircuit = database
.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuits.id, feedingCircuitId))
.get();
if (
!feedingCircuit ||
feedingCircuit.projectId !== projectId ||
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
) {
throw new Error(
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
);
}
}
}
function assertSupplyTypeEnabled(
@ -392,11 +477,20 @@ function structureMatches(
location,
fedFrom,
remark,
feedingDistributionBoardId,
feedingCircuitId,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
[
boardNumber,
location,
fedFrom,
remark,
feedingDistributionBoardId,
feedingCircuitId,
].some((value) => value !== null) ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard

View file

@ -32,6 +32,7 @@ import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore
@ -82,6 +83,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree changed before deletion."
);
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id,
]);
const deleted = database
.delete(distributionBoards)
.where(
@ -300,6 +304,69 @@ export class DistributionBoardSubtreeProjectCommandRepository
);
}
}
const feedingDistributionBoardId =
snapshot.distributionBoard.feedingDistributionBoardId;
const feedingCircuitId = snapshot.distributionBoard.feedingCircuitId;
if (feedingDistributionBoardId === null) {
if (feedingCircuitId !== null) {
throw new Error(
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
);
}
} else {
if (feedingDistributionBoardId === snapshot.distributionBoard.id) {
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
}
const projectBoards = database
.select({
id: distributionBoards.id,
feedingDistributionBoardId:
distributionBoards.feedingDistributionBoardId,
})
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.all();
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
if (!boardById.has(feedingDistributionBoardId)) {
throw new Error(
"Die speisende Verteilung gehört nicht zum Projekt."
);
}
const visited = new Set<string>([snapshot.distributionBoard.id]);
let currentId: string | null = feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error(
"Die Einspeisungsverknüpfung würde einen Zyklus erzeugen."
);
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
if (feedingCircuitId !== null) {
const feedingCircuit = database
.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuits.id, feedingCircuitId))
.get();
if (
!feedingCircuit ||
feedingCircuit.projectId !== projectId ||
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
) {
throw new Error(
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
);
}
}
}
const linkedProjectDeviceIds = uniqueStrings(
snapshot.circuits.flatMap((circuit) =>
circuit.deviceRows.flatMap((row) =>

View file

@ -33,6 +33,7 @@ import {
snapshotsEqual,
} from "./external-object-assignment.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
export class ExternalObjectNewCircuitProjectCommandRepository
implements ExternalObjectNewCircuitProjectCommandStore
@ -162,6 +163,9 @@ export class ExternalObjectNewCircuitProjectCommandRepository
objects,
"External object changed during new-circuit assignment."
);
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [
expectedCircuit.id,
]);
const deleted = database.delete(circuits).where(and(
eq(circuits.id, expectedCircuit.id),
eq(circuits.circuitListId, expectedCircuit.circuitListId)

View file

@ -270,6 +270,14 @@ function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
) {
database
.update(distributionBoards)
.set({
feedingDistributionBoardId: null,
feedingCircuitId: null,
})
.where(eq(distributionBoards.projectId, state.project.id))
.run();
database
.delete(externalModelSources)
.where(eq(externalModelSources.projectId, state.project.id))
@ -330,7 +338,15 @@ function replaceProjectState(
}
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);
insertMany(database, distributionBoards, state.distributionBoards);
insertMany(
database,
distributionBoards,
state.distributionBoards.map((board) => ({
...board,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}))
);
insertMany(database, circuitLists, state.circuitLists);
insertMany(database, circuitSections, state.circuitSections);
insertMany(
@ -341,6 +357,7 @@ function replaceProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
restoreDistributionBoardFeedingReferences(database, state);
insertMany(
database,
distributionBoardComponents,
@ -378,6 +395,31 @@ function replaceProjectState(
insertMany(database, externalModelObjects, state.externalModel.objects);
}
function restoreDistributionBoardFeedingReferences(
database: AppDatabase,
state: ProjectStateSnapshot
) {
for (const board of state.distributionBoards) {
if (
board.feedingDistributionBoardId === null &&
board.feedingCircuitId === null
) {
continue;
}
const updated = database
.update(distributionBoards)
.set({
feedingDistributionBoardId: board.feedingDistributionBoardId,
feedingCircuitId: board.feedingCircuitId,
})
.where(eq(distributionBoards.id, board.id))
.run();
if (updated.changes !== 1) {
throw new Error("Distribution-board feeding reference could not be restored.");
}
}
}
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,

View file

@ -173,7 +173,15 @@ function insertProjectState(
insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);
insertMany(database, distributionBoards, state.distributionBoards);
insertMany(
database,
distributionBoards,
state.distributionBoards.map((board) => ({
...board,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}))
);
insertMany(database, circuitLists, state.circuitLists);
insertMany(database, circuitSections, state.circuitSections);
insertMany(
@ -184,6 +192,25 @@ function insertProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
for (const board of state.distributionBoards) {
if (
board.feedingDistributionBoardId === null &&
board.feedingCircuitId === null
) {
continue;
}
const updated = database
.update(distributionBoards)
.set({
feedingDistributionBoardId: board.feedingDistributionBoardId,
feedingCircuitId: board.feedingCircuitId,
})
.where(eq(distributionBoards.id, board.id))
.run();
if (updated.changes !== 1) {
throw new Error("Distribution-board feeding reference could not be imported.");
}
}
insertMany(
database,
distributionBoardComponents,

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { distributionBoards } from "./distribution-boards.js";
import { projects } from "./projects.js";
@ -11,7 +11,7 @@ export const circuitLists = sqliteTable(
.references(() => projects.id, { onDelete: "cascade" }),
distributionBoardId: text("distribution_board_id")
.notNull()
.references(() => distributionBoards.id, { onDelete: "cascade" }),
.references((): AnySQLiteColumn => distributionBoards.id, { onDelete: "cascade" }),
name: text("name").notNull(),
},
(table) => [unique("circuit_lists_distribution_board_id_unique").on(table.distributionBoardId)]

View file

@ -1,4 +1,4 @@
import { index, integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { index, integer, real, sqliteTable, text, unique, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js";
@ -8,7 +8,7 @@ export const circuits = sqliteTable(
id: text("id").primaryKey(),
circuitListId: text("circuit_list_id")
.notNull()
.references(() => circuitLists.id, { onDelete: "cascade" }),
.references((): AnySQLiteColumn => circuitLists.id, { onDelete: "cascade" }),
sectionId: text("section_id")
.notNull()
.references(() => circuitSections.id, { onDelete: "cascade" }),

View file

@ -1,24 +1,44 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, real, sqliteTable, text, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { circuits } from "./circuits.js";
import { floors } from "./floors.js";
import { projects } from "./projects.js";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
export const distributionBoards = sqliteTable("distribution_boards", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
floorId: text("floor_id").references(() => floors.id, {
onDelete: "set null",
}),
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
});
export const distributionBoards = sqliteTable(
"distribution_boards",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
floorId: text("floor_id").references(() => floors.id, {
onDelete: "set null",
}),
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
feedingDistributionBoardId: text("feeding_distribution_board_id").references(
(): AnySQLiteColumn => distributionBoards.id,
{ onDelete: "restrict" }
),
feedingCircuitId: text("feeding_circuit_id").references(
(): AnySQLiteColumn => circuits.id,
{ onDelete: "restrict" }
),
},
(table) => [
index("distribution_boards_feeding_board_idx").on(
table.feedingDistributionBoardId
),
index("distribution_boards_feeding_circuit_idx").on(
table.feedingCircuitId
),
]
);

View file

@ -0,0 +1,217 @@
export interface DistributionBoardLoadBoardInput {
id: string;
name: string;
boardNumber: string | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export interface DistributionBoardLoadCircuitInput {
id: string;
distributionBoardId: string;
rows: Array<{
activePowerKw: number;
cosPhi: number | null;
}>;
}
export interface DownstreamDistributionBoardLoad {
distributionBoardId: string;
name: string;
boardNumber: string | null;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CalculatedCircuitLoad {
circuitId: string;
deviceActivePowerKw: number;
deviceApparentPowerKva: number;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
downstreamBoards: DownstreamDistributionBoardLoad[];
}
export interface CalculatedDistributionBoardLoad {
distributionBoardId: string;
activePowerBeforeSimultaneityKw: number;
apparentPowerBeforeSimultaneityKva: number;
demandActivePowerKw: number;
demandApparentPowerKva: number;
cosPhi: number;
}
export interface DistributionBoardLoadCalculation {
boards: Map<string, CalculatedDistributionBoardLoad>;
circuits: Map<string, CalculatedCircuitLoad>;
}
/**
* Calculates one project graph. A child board enters exactly one feeder
* circuit with its demand after the child's own board simultaneity factor.
* Board-only relations form a hierarchy but intentionally add no load.
*/
export function calculateDistributionBoardLoads(input: {
boards: DistributionBoardLoadBoardInput[];
circuits: DistributionBoardLoadCircuitInput[];
}): DistributionBoardLoadCalculation {
const boardById = new Map(input.boards.map((board) => [board.id, board]));
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
const circuitsByBoardId = groupBy(
input.circuits,
(circuit) => circuit.distributionBoardId
);
const childrenByCircuitId = new Map<string, DistributionBoardLoadBoardInput[]>();
for (const board of input.boards) {
assertBoardInput(board, boardById, circuitById);
if (board.feedingCircuitId === null) continue;
const children = childrenByCircuitId.get(board.feedingCircuitId) ?? [];
children.push(board);
childrenByCircuitId.set(board.feedingCircuitId, children);
}
const boards = new Map<string, CalculatedDistributionBoardLoad>();
const circuits = new Map<string, CalculatedCircuitLoad>();
const activePath = new Set<string>();
const calculateBoard = (boardId: string): CalculatedDistributionBoardLoad => {
const cached = boards.get(boardId);
if (cached) return cached;
if (activePath.has(boardId)) {
throw new Error("Distribution-board feeding graph contains a cycle.");
}
const board = boardById.get(boardId);
if (!board) throw new Error("Distribution board is missing from load graph.");
activePath.add(boardId);
let activePowerBeforeSimultaneityKw = 0;
let apparentPowerBeforeSimultaneityKva = 0;
for (const circuit of circuitsByBoardId.get(boardId) ?? []) {
let deviceActivePowerKw = 0;
let deviceApparentPowerKva = 0;
for (const row of circuit.rows) {
assertNonNegativeFinite(row.activePowerKw, "row active power");
const cosPhi = row.cosPhi ?? 1;
if (!Number.isFinite(cosPhi) || cosPhi <= 0 || cosPhi > 1) {
throw new Error("Row cos phi must be greater than zero and at most one.");
}
deviceActivePowerKw += row.activePowerKw;
deviceApparentPowerKva += row.activePowerKw / cosPhi;
}
const downstreamBoards = (childrenByCircuitId.get(circuit.id) ?? [])
.map((child) => {
const childLoad = calculateBoard(child.id);
return {
distributionBoardId: child.id,
name: child.name,
boardNumber: child.boardNumber,
activePowerKw: childLoad.demandActivePowerKw,
apparentPowerKva: childLoad.demandApparentPowerKva,
cosPhi: effectiveCosPhi(
childLoad.demandActivePowerKw,
childLoad.demandApparentPowerKva
),
};
});
const activePowerKw =
deviceActivePowerKw +
downstreamBoards.reduce((sum, child) => sum + child.activePowerKw, 0);
const apparentPowerKva =
deviceApparentPowerKva +
downstreamBoards.reduce((sum, child) => sum + child.apparentPowerKva, 0);
circuits.set(circuit.id, {
circuitId: circuit.id,
deviceActivePowerKw,
deviceApparentPowerKva,
activePowerKw,
apparentPowerKva,
cosPhi: effectiveCosPhi(activePowerKw, apparentPowerKva),
downstreamBoards,
});
activePowerBeforeSimultaneityKw += activePowerKw;
apparentPowerBeforeSimultaneityKva += apparentPowerKva;
}
activePath.delete(boardId);
const result = {
distributionBoardId: boardId,
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva,
demandActivePowerKw:
activePowerBeforeSimultaneityKw * board.simultaneityFactor,
demandApparentPowerKva:
apparentPowerBeforeSimultaneityKva * board.simultaneityFactor,
cosPhi: effectiveCosPhi(
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva
),
};
boards.set(boardId, result);
return result;
};
for (const board of input.boards) calculateBoard(board.id);
return { boards, circuits };
}
function assertBoardInput(
board: DistributionBoardLoadBoardInput,
boardById: Map<string, DistributionBoardLoadBoardInput>,
circuitById: Map<string, DistributionBoardLoadCircuitInput>
) {
if (
!Number.isFinite(board.simultaneityFactor) ||
board.simultaneityFactor < 0 ||
board.simultaneityFactor > 1
) {
throw new Error("Board simultaneity factor must be between zero and one.");
}
if (board.feedingDistributionBoardId === board.id) {
throw new Error("Distribution board cannot feed itself.");
}
if (
board.feedingDistributionBoardId !== null &&
!boardById.has(board.feedingDistributionBoardId)
) {
throw new Error("Feeding distribution board is missing from load graph.");
}
if (
board.feedingCircuitId !== null &&
board.feedingDistributionBoardId === null
) {
throw new Error("Feeding circuit requires a feeding distribution board.");
}
if (
board.feedingCircuitId !== null &&
circuitById.get(board.feedingCircuitId)?.distributionBoardId !==
board.feedingDistributionBoardId
) {
throw new Error("Feeding circuit belongs to another distribution board.");
}
}
function effectiveCosPhi(activePowerKw: number, apparentPowerKva: number) {
return activePowerKw > 0 && apparentPowerKva > 0
? activePowerKw / apparentPowerKva
: 1;
}
function assertNonNegativeFinite(value: number, label: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a non-negative finite number.`);
}
}
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
const groups = new Map<string, T[]>();
for (const value of values) {
const key = keyOf(value);
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}

View file

@ -40,7 +40,10 @@ export interface CircuitTreeCircuit {
isReserve: boolean;
remark?: string;
circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRow[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
protectionDevice?: CircuitTreeProtectionDevice;
}
@ -59,6 +62,11 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse {
circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReference;
currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
@ -70,7 +78,36 @@ export interface CircuitTreeResponse {
footerComponents: CircuitTreeComponent[];
}
export interface CircuitTreeDownstreamBoardLoad {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReference {
distributionBoardId: string;
circuitListId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponse {
currentRevision: number;
trees: CircuitTreeResponse[];
}
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,

View file

@ -9,16 +9,18 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
// The info-card fields were added additively to schema version 1: every
// previously stored command remains valid, and the inverse is still derived
// from the persisted row for exactly the changed fields.
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
// Version 2 adds logical feeding references. Version-1 commands remain
// executable and can only change the fields they originally knew.
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export type DistributionBoardUpdateField =
@ -41,16 +43,20 @@ export interface DistributionBoardUpdateCommandPayload {
export interface DistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
schemaVersion:
| typeof legacyDistributionBoardUpdateCommandSchemaVersion
| typeof distributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType;
}
export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string,
patch: DistributionBoardUpdatePatch
patch: DistributionBoardUpdatePatch,
schemaVersion: DistributionBoardUpdateProjectCommand["schemaVersion"] =
distributionBoardUpdateCommandSchemaVersion
): DistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = {
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
schemaVersion,
type: distributionBoardUpdateCommandType,
payload: {
distributionBoardId,
@ -68,7 +74,8 @@ export function assertDistributionBoardUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardUpdateProjectCommand {
if (
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion ||
(command.schemaVersion !== legacyDistributionBoardUpdateCommandSchemaVersion &&
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
command.type !== distributionBoardUpdateCommandType ||
!isPlainObject(command.payload)
) {
@ -93,7 +100,12 @@ export function assertDistributionBoardUpdateProjectCommand(
(change.field !== "floorId" &&
change.field !== "supplyType" &&
change.field !== "simultaneityFactor" &&
change.field !== "feedingDistributionBoardId" &&
change.field !== "feedingCircuitId" &&
!isInfoField(change.field)) ||
(command.schemaVersion === legacyDistributionBoardUpdateCommandSchemaVersion &&
(change.field === "feedingDistributionBoardId" ||
change.field === "feedingCircuitId")) ||
seen.has(change.field)
) {
throw new Error(
@ -105,7 +117,9 @@ export function assertDistributionBoardUpdateProjectCommand(
} else if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
(typeof change.value !== "string" ||
!change.value.trim() ||
change.value !== change.value.trim())
) {
throw new Error("floorId must be a non-empty string or null.");
}
@ -118,6 +132,16 @@ export function assertDistributionBoardUpdateProjectCommand(
) {
throw new Error("supplyType is invalid.");
}
} else if (
change.field === "feedingDistributionBoardId" ||
change.field === "feedingCircuitId"
) {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
) {
throw new Error(`${change.field} must be a non-empty string or null.`);
}
} else if (
typeof change.value !== "number" ||
!Number.isFinite(change.value) ||

View file

@ -74,6 +74,8 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
} & Record<DistributionBoardInfoField, string | null>;
circuitList: {
id: string;
@ -184,6 +186,9 @@ export function normalizeDistributionBoardSubtreeSnapshot(
for (const field of distributionBoardInfoFields) {
board[field] = board[field] ?? null;
}
board.feedingDistributionBoardId =
board.feedingDistributionBoardId ?? null;
board.feedingCircuitId = board.feedingCircuitId ?? null;
return { ...snapshot, distributionBoard: board };
}
@ -227,6 +232,18 @@ export function assertDistributionBoardSubtreeSnapshot(
assertNullableString(board[field], `distributionBoard.${field}`);
}
}
if (board.feedingDistributionBoardId !== undefined) {
assertNullableString(
board.feedingDistributionBoardId,
"distributionBoard.feedingDistributionBoardId"
);
}
if (board.feedingCircuitId !== undefined) {
assertNullableString(
board.feedingCircuitId,
"distributionBoard.feedingCircuitId"
);
}
for (const field of [
"id",
"projectId",

View file

@ -35,7 +35,8 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 8 as const;
export const projectStateSnapshotSchemaVersion = 9 as const;
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
@ -112,7 +113,7 @@ const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable();
const distributionBoardSchema = legacyDistributionBoardSchema
const distributionBoardWithoutFeedingSchema = legacyDistributionBoardSchema
.extend({
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
@ -121,6 +122,13 @@ const distributionBoardSchema = legacyDistributionBoardSchema
})
.strict();
const distributionBoardSchema = distributionBoardWithoutFeedingSchema
.extend({
feedingDistributionBoardId: idSchema.nullable(),
feedingCircuitId: idSchema.nullable(),
})
.strict();
function withEmptyDistributionBoardInfo(
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
) {
@ -133,6 +141,16 @@ function withEmptyDistributionBoardInfo(
}));
}
function withEmptyDistributionBoardFeeding(
boards: Array<z.infer<typeof distributionBoardWithoutFeedingSchema>>
) {
return boards.map((board) => ({
...board,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}));
}
const circuitListSchema = z
.object({
id: idSchema,
@ -553,11 +571,22 @@ export const projectStateSnapshotSchema = z
})
.strict();
const roomAreaProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const buildingProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...buildingProjectStateSnapshotContents,
@ -568,7 +597,7 @@ const coverProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
@ -655,6 +684,27 @@ export function parseProjectStateSnapshot(
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
if (
roomArea !== null &&
typeof roomArea === "object" &&
!Array.isArray(roomArea) &&
(roomArea as { schemaVersion?: unknown }).schemaVersion ===
roomAreaProjectStateSnapshotSchemaVersion
) {
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardFeeding(
previous.distributionBoards
),
};
}
return roomArea;
}
function upgradeToRoomAreaProjectStateSnapshot(value: unknown): unknown {
const cover = upgradeToCoverProjectStateSnapshot(value);
if (
cover !== null &&
@ -666,7 +716,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const previous = buildingProjectStateSnapshotSchema.parse(cover);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: roomAreaProjectStateSnapshotSchemaVersion,
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
};
}
@ -683,7 +733,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
// building. Build version 7 first so the room-area upgrade above remains a
// single exact transition.
const previous = coverProjectStateSnapshotSchema.parse(cover);
return upgradePreviousProjectStateSnapshot({
return upgradeToRoomAreaProjectStateSnapshot({
...previous,
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
buildings: [],
@ -873,6 +923,15 @@ function assertProjectStateSnapshotRelations(
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
const boardById = new Map(
snapshot.distributionBoards.map((entry) => [entry.id, entry])
);
const circuitListById = new Map(
snapshot.circuitLists.map((entry) => [entry.id, entry])
);
const circuitById = new Map(
snapshot.circuits.map((entry) => [entry.id, entry])
);
const componentById = new Map(
snapshot.distributionBoardComponents.map((entry) => [
entry.id,
@ -901,6 +960,47 @@ function assertProjectStateSnapshotRelations(
"Snapshot distribution board uses a disabled supply type."
);
}
if (board.feedingDistributionBoardId === board.id) {
throw new Error("Snapshot distribution board cannot feed itself.");
}
if (board.feedingDistributionBoardId !== null) {
assertReference(
boardIds,
board.feedingDistributionBoardId,
"feeding distribution board"
);
}
if (board.feedingCircuitId !== null) {
if (board.feedingDistributionBoardId === null) {
throw new Error(
"Snapshot feeding circuit requires a feeding distribution board."
);
}
assertReference(circuitIds, board.feedingCircuitId, "feeding circuit");
const feedingCircuit = circuitById.get(board.feedingCircuitId);
const feedingList = feedingCircuit
? circuitListById.get(feedingCircuit.circuitListId)
: undefined;
if (
!feedingList ||
feedingList.distributionBoardId !== board.feedingDistributionBoardId
) {
throw new Error(
"Snapshot feeding circuit belongs to another distribution board."
);
}
}
}
for (const board of snapshot.distributionBoards) {
const visited = new Set<string>([board.id]);
let currentId = board.feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error("Snapshot distribution-board feeding graph contains a cycle.");
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");

View file

@ -223,6 +223,14 @@ export function remapProjectState(
...board,
id: requiredId(boardIds, board.id),
projectId: targetProjectId,
feedingDistributionBoardId:
board.feedingDistributionBoardId === null
? null
: requiredId(boardIds, board.feedingDistributionBoardId),
feedingCircuitId:
board.feedingCircuitId === null
? null
: requiredId(circuitIds, board.feedingCircuitId),
floorId:
board.floorId === null
? null

View file

@ -22,6 +22,9 @@ export function deriveCircuitPhase(
* Rows without cos φ count as 1; a circuit without load yields 1.
*/
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
if (Number.isFinite(circuit.circuitCosPhi) && circuit.circuitCosPhi > 0) {
return Math.round(circuit.circuitCosPhi * 100) / 100;
}
let activePowerKw = 0;
let apparentPowerKva = 0;
for (const row of circuit.deviceRows) {

View file

@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
@ -3293,6 +3294,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
threePhaseVoltageV={data.threePhaseVoltageV}
/>
) : null}
{data.feedingReference ? (
<div className="alert alert-info py-2" role="status">
Gespeist von{" "}
<Link
href={`/projects/${projectId}/circuit-lists/${
data.feedingReference.circuitListId
}/tree-edit`}
>
{data.feedingReference.distributionBoardNumber ??
data.feedingReference.distributionBoardName}
{data.feedingReference.circuitEquipmentIdentifier ?? ""}
</Link>
{data.feedingReference.circuitId
? null
: " (speisender Stromkreis noch nicht festgelegt)"}
</div>
) : null}
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4062,6 +4080,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "summary-row"
: row.rowType === "deviceRow"
? "device-row"
: row.rowType === "downstreamBoardLoad"
? "downstream-board-load-row"
: row.rowType === "reserveCircuit"
? "empty-circuit-row"
: row.rowType === "placeholder"
@ -4667,7 +4687,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: ""
}`}
>
{row.circuit && row.rowType !== "deviceRow" ? (
{row.circuit &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit") ? (
<>
<IconButton
disabled={isSaving}

View file

@ -13,7 +13,7 @@ export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, stri
const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik",
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
fedFrom: "z. B. HAK, Zähleranlage, Trafo oder NEA",
remark: "",
};
@ -98,8 +98,8 @@ export function DistributionBoardInfoCard({
)}
{field === "fedFrom" ? (
<div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem
speisenden Verteiler folgt.
Für externe Einspeisungen oder ergänzende Hinweise. Eine
speisende Projektverteilung wird oben logisch verknüpft.
</div>
) : null}
</div>

View file

@ -19,14 +19,14 @@ export interface PrintBoardSummary {
circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */
devicePowerKw: number;
/** Device-row demand plus downstream-board demand before this board's GZF. */
powerBeforeBoardSimultaneityKw: number;
simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */
/** Total active power with this board's simultaneity factor applied. */
demandPowerKw: number;
/**
* Symmetrical three-phase design current from the apparent power
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
* Design current from the apparent power including downstream boards.
* The selected feeder circuit determines whether one or three phases apply.
*/
designCurrentA: number;
}
@ -46,8 +46,13 @@ export function summarizePrintBoard(input: {
circuitCount += 1;
for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
apparentPowerKva +=
circuit.circuitApparentPower ??
circuit.deviceRows.reduce(
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
0
);
}
}
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
@ -55,12 +60,13 @@ export function summarizePrintBoard(input: {
...input,
circuitCount,
installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower,
powerBeforeBoardSimultaneityKw: tree.distributionBoardTotalPower,
simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA:
(apparentPowerKva * simultaneityFactor * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV),
designCurrentA: calculateDesignCurrent(
apparentPowerKva * simultaneityFactor,
tree
),
};
}
@ -68,8 +74,6 @@ export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary;
depth: number;
parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
}
export function assertPrintRevisionConsistency(
@ -82,31 +86,21 @@ export function assertPrintRevisionConsistency(
}
}
/**
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
export function buildBoardHierarchy(
summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) =>
value?.trim().toLocaleLowerCase("de") || null;
const summaryById = new Map(
summaries.map((summary) => [summary.board.id, summary])
);
const parentById = new Map<string, string | null>();
for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom);
const candidates = fedFrom
? summaries.filter(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
const parentId = summary.board.feedingDistributionBoardId;
parentById.set(
summary.board.id,
parentId && parentId !== summary.board.id && summaryById.has(parentId)
? parentId
: null
);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
@ -125,20 +119,12 @@ export function buildPreliminaryBoardHierarchy(
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth,
parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
});
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
@ -146,6 +132,25 @@ export function buildPreliminaryBoardHierarchy(
return entries;
}
export function formatFeedingReference(summary: PrintBoardSummary): string {
const reference = summary.tree.feedingReference;
if (!reference) return summary.board.fedFrom ?? "–";
return `${
reference.distributionBoardNumber ?? reference.distributionBoardName
}${reference.circuitEquipmentIdentifier ?? ""}`;
}
function calculateDesignCurrent(
apparentPowerKva: number,
tree: CircuitTreeResponseDto
) {
const category = tree.feedingReference?.circuitCategory;
return category === "lighting" || category === "single_phase"
? (apparentPowerKva * 1000) / tree.singlePhaseVoltageV
: (apparentPowerKva * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV);
}
const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,

View file

@ -13,6 +13,7 @@ import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatFeedingReference,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
@ -269,8 +270,8 @@ function OverviewSheet({
stand: string;
}) {
const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped
// below that text as an external feed (e.g. "HAK / Zähleranlage").
// Root boards may still carry an external feed description such as
// "HAK / Zähleranlage"; logical board links determine the actual tree.
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) {
const feed = root.summary.board.fedFrom ?? "";
@ -320,11 +321,10 @@ function OverviewSheet({
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
<th className="pd-r">P vor Vert.-GZF<br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
<th className="pd-r">P<sub>gl</sub> inkl. UV<br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr>
</thead>
<tbody>
@ -340,41 +340,45 @@ function OverviewSheet({
<td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td>
<td>{formatFeedingReference(entry.summary)}</td>
<td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.powerBeforeBoardSimultaneityKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
<td colSpan={7}>Summe der Einspeisungen</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) =>
entry.parentBoardId === null
? entry.summary.powerBeforeBoardSimultaneityKw
: 0
))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) =>
entry.parentBoardId === null ? entry.summary.demandPowerKw : 0
))}</td>
<td />
</tr>
</tfoot>
</table>
<p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
Verknüpfung und Summierung folgt.
Die Struktur folgt den logischen Verknüpfungen. Eine Unterverteilung
wird im gewählten Abgang mit ihrer Leistung nach eigenem
Verteiler-GZF berücksichtigt. Ohne gewählten Abgang erscheint sie
nur in der Hierarchie.
</p>
<p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
S aus cos φ je Gerätezeile
P<sub>inst</sub> = lokale Geräte ohne GZF · P vor Vert.-GZF =
Geräte-GZF einschließlich gespeister Unterverteilungen · P<sub>gl</sub> =
zusätzlich mit Verteiler-GZF · I<sub>B</sub> aus S und der Phasenart
des speisenden Abgangs
</p>
</div>
</div>
@ -404,6 +408,7 @@ function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows;
const downstreamBoards = circuit.downstreamBoardLoads ?? [];
const circuitCells = (
<>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
@ -414,7 +419,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td>{circuit.remark ?? ""}</td>
</>
);
if (rows.length === 0) {
if (rows.length === 0 && downstreamBoards.length === 0) {
return (
<tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
@ -426,7 +431,7 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr>
);
}
if (rows.length === 1) {
if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
return (
@ -468,6 +473,16 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
<td colSpan={4} />
</tr>
))}
{downstreamBoards.map((load) => (
<tr className="pd-device" key={`downstream:${load.distributionBoardId}`}>
<td />
<td>Unterverteilung {load.boardNumber ?? load.name}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">{formatKilowatt(load.activePowerKw)}</td>
<td colSpan={4}>Automatisch aus der verknüpften Verteilung</td>
</tr>
))}
</>
);
}
@ -501,7 +516,7 @@ function BoardPlanSheet({
rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"],
["Gespeist von", formatFeedingReference(summary)],
[
<>P<sub>gl</sub></>,
<>
@ -542,7 +557,7 @@ function BoardPlanSheet({
<td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
<td className="pd-r">{formatKilowatt(summary.powerBeforeBoardSimultaneityKw)}</td>
<td colSpan={4} />
</tr>
<tr>

View file

@ -195,6 +195,8 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export interface DocumentLogoMetadataDto {
@ -442,7 +444,10 @@ export interface CircuitTreeCircuitDto {
isReserve: boolean;
remark?: string;
circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRowDto[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoadDto[];
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
@ -461,6 +466,11 @@ export interface CircuitTreeSectionDto {
export interface CircuitTreeResponseDto {
circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReferenceDto;
currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
@ -472,6 +482,32 @@ export interface CircuitTreeResponseDto {
footerComponents: CircuitTreeComponentDto[];
}
export interface CircuitTreeDownstreamBoardLoadDto {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReferenceDto {
distributionBoardId: string;
circuitListId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponseDto {
currentRevision: number;
trees: CircuitTreeResponseDto[];
}
export interface ExternalCircuitListObjectDto {
id: string;
ifcGuid: string;

View file

@ -33,6 +33,7 @@ import type {
ProjectDto,
RoomDto,
CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto,
ExternalCsvConfigurationSnapshotDto,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
@ -372,7 +373,13 @@ export function updateDistributionBoard(
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
} & Partial<DistributionBoardInfoDto>,
} & Partial<DistributionBoardInfoDto> &
Partial<
Pick<
DistributionBoardDto,
"feedingDistributionBoardId" | "feedingCircuitId"
>
>,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -523,6 +530,12 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function getProjectCircuitTrees(projectId: string) {
return request<ProjectCircuitTreesResponseDto>(
`/api/projects/${projectId}/circuit-trees`
);
}
export function insertDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,

View file

@ -44,6 +44,7 @@ export type RowType =
| "circuitCompact"
| "circuitSummary"
| "deviceRow"
| "downstreamBoardLoad"
| "reserveCircuit"
| "placeholder"
| "footerComponent";
@ -614,6 +615,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
return "readonly";
}
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 (deviceFieldKeys.has(key)) return "deviceField";

View file

@ -2,6 +2,7 @@ import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
CircuitTreeDownstreamBoardLoadDto,
CircuitTreeSectionDto,
} from "../types.js";
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
@ -45,6 +46,7 @@ export interface VisibleGridRow {
circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto;
component?: CircuitTreeComponentDto;
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto;
cells: VisibleGridCell[];
}
@ -126,13 +128,16 @@ function makeVisibleGridRow(
rowType: RowType,
sectionId: string,
circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto
device?: CircuitTreeDeviceRowDto,
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto
): VisibleGridRow {
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: rowType === "downstreamBoardLoad" && downstreamBoardLoad
? `downstream-board:${circuit?.id}:${downstreamBoardLoad.distributionBoardId}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((column): VisibleGridCell => {
@ -140,7 +145,17 @@ function makeVisibleGridRow(
const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue;
if (rowType === "placeholder") {
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
value = column.key === "displayName"
? `Unterverteilung ${downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name}`
: column.key === "rowTotalPower"
? downstreamBoardLoad.activePowerKw
: column.key === "cosPhi"
? downstreamBoardLoad.cosPhi
: column.key === "remark"
? "Automatisch aus der verknüpften Verteilung"
: undefined;
} else if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) {
value = getDeviceValue(device, column.key);
@ -166,7 +181,15 @@ function makeVisibleGridRow(
return { cellKey: column.key, editable, kind, value };
});
return { rowKey, rowType, sectionId, circuit, device, cells };
return {
rowKey,
rowType,
sectionId,
circuit,
device,
downstreamBoardLoad,
cells,
};
}
export function buildVisibleGridRowsWithStructure(
@ -221,15 +244,27 @@ export function buildVisibleGridRowsWithStructure(
}
if (row.rowType === "circuitBlock") {
const circuit = row.circuit;
if (circuit.deviceRows.length === 0) {
const downstreamLoads = circuit.downstreamBoardLoads ?? [];
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
} else if (circuit.deviceRows.length === 1) {
} else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else {
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) {
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
}
for (const load of downstreamLoads) {
rows.push(
makeVisibleGridRow(
"downstreamBoardLoad",
section.id,
circuit,
undefined,
load
)
);
}
}
continue;
}

View file

@ -1,13 +1,11 @@
import type { Request, Response } from "express";
import {
applyDistributionBoardSimultaneityFactor,
calculateCircuitTotalPower,
calculateDistributionBoardTotalPower,
calculateRowTotalPower,
calculateSectionTotalPower,
} from "../../domain/calculations/circuit-power-calculation.js";
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
import type { CircuitTreeProtectionDevice } from "../../domain/models/circuit-tree.model.js";
import { calculateDistributionBoardLoads } from "../../domain/calculations/distribution-board-load-calculation.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CircuitTreeProtectionDevice,
CircuitTreeResponse,
ProjectCircuitTreesResponse,
} from "../../domain/models/circuit-tree.model.js";
import {
circuitDeviceRowRepository,
circuitListRepository,
@ -20,9 +18,7 @@ import {
} from "../composition/application-repositories.js";
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
if (!(error instanceof Error)) return false;
return (
error.message.includes("no such table: circuit_sections") ||
error.message.includes("no such table: circuits") ||
@ -37,70 +33,147 @@ export async function getCircuitTree(req: Request, res: Response) {
if (typeof projectId !== "string" || typeof circuitListId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const list = await circuitListRepository.findById(projectId, circuitListId);
if (!list) {
return res.status(404).json({ error: "Circuit list not found" });
}
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
const distributionBoard =
await distributionBoardRepository.findById(
projectId,
list.distributionBoardId
);
if (!distributionBoard) {
return res.status(404).json({
error: "Distribution board not found",
});
}
if (!list) return res.status(404).json({ error: "Circuit list not found" });
try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
const circuits = await circuitRepository.listByCircuitList(circuitListId);
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
const components =
await distributionBoardComponentRepository.listByCircuitList(
circuitListId
);
const [circuitProtections, componentProtections] =
await Promise.all([
circuitProtectionDeviceRepository.listByCircuitIds(
circuits.map((entry) => entry.id)
),
distributionBoardComponentRepository.listProtectionByComponentIds(
components.map((entry) => entry.id)
),
]);
const sectionById = new Map(sections.map((section) => [section.id, section]));
const rowsByCircuitId = new Map<string, typeof rows>();
const circuitProtectionById = new Map(
circuitProtections.map((entry) => [entry.circuitId, entry])
);
const componentProtectionById = new Map(
componentProtections.map((entry) => [entry.componentId, entry])
);
for (const row of rows) {
if (!rowsByCircuitId.has(row.circuitId)) {
rowsByCircuitId.set(row.circuitId, []);
}
rowsByCircuitId.get(row.circuitId)!.push(row);
const result = await buildProjectCircuitTrees(projectId);
if (!result) return res.status(404).json({ error: "Project not found" });
const tree = result.trees.find((entry) => entry.circuitListId === circuitListId);
if (!tree) {
return res.status(404).json({ error: "Distribution board not found" });
}
return res.json(tree);
} catch (error) {
if (isMissingCircuitTreeSchemaError(error)) {
const project = await projectRepository.findById(projectId);
const board = await distributionBoardRepository.findById(
projectId,
list.distributionBoardId
);
if (!project || !board) {
return res.status(404).json({ error: "Project structure not found" });
}
return res.json(emptyCircuitTree(project, board, circuitListId));
}
throw error;
}
}
export async function getProjectCircuitTrees(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
try {
const result = await buildProjectCircuitTrees(projectId);
if (!result) return res.status(404).json({ error: "Project not found" });
return res.json(result);
} catch (error) {
if (isMissingCircuitTreeSchemaError(error)) {
const project = await projectRepository.findById(projectId);
if (!project) return res.status(404).json({ error: "Project not found" });
return res.json({ currentRevision: project.currentRevision, trees: [] });
}
throw error;
}
}
export async function buildProjectCircuitTrees(
projectId: string
): Promise<ProjectCircuitTreesResponse | null> {
const project = await projectRepository.findById(projectId);
if (!project) return null;
const [lists, boards] = await Promise.all([
circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
]);
const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds),
circuitRepository.listByCircuitLists(listIds),
distributionBoardComponentRepository.listByCircuitLists(listIds),
]);
const [rows, circuitProtections, componentProtections] = await Promise.all([
circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)),
circuitProtectionDeviceRepository.listByCircuitIds(circuits.map((entry) => entry.id)),
distributionBoardComponentRepository.listProtectionByComponentIds(
components.map((entry) => entry.id)
),
]);
const boardById = new Map(boards.map((board) => [board.id, board]));
const listById = new Map(lists.map((list) => [list.id, list]));
const listByBoardId = new Map(
lists.map((list) => [list.distributionBoardId, list])
);
const sectionById = new Map(sections.map((section) => [section.id, section]));
const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit]));
const rowsByCircuitId = groupBy(rows, (row) => row.circuitId);
const sectionsByListId = groupBy(sections, (section) => section.circuitListId);
const circuitsByListId = groupBy(circuits, (circuit) => circuit.circuitListId);
const componentsByListId = groupBy(components, (component) => component.circuitListId);
const circuitProtectionById = new Map(
circuitProtections.map((entry) => [entry.circuitId, entry])
);
const componentProtectionById = new Map(
componentProtections.map((entry) => [entry.componentId, entry])
);
const loads = calculateDistributionBoardLoads({
boards: boards.map((board) => ({
id: board.id,
name: board.name,
boardNumber: board.boardNumber,
simultaneityFactor: board.simultaneityFactor,
feedingDistributionBoardId: board.feedingDistributionBoardId,
feedingCircuitId: board.feedingCircuitId,
})),
circuits: circuits.map((circuit) => {
const list = listById.get(circuit.circuitListId);
if (!list) throw new Error("Circuit list is missing from project tree.");
return {
id: circuit.id,
distributionBoardId: list.distributionBoardId,
rows: (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
activePowerKw: calculateRowTotalPower(
row.quantity,
row.powerPerUnit,
row.simultaneityFactor
),
cosPhi: row.cosPhi,
})),
};
}),
});
const trees = lists.map((list): CircuitTreeResponse => {
const board = boardById.get(list.distributionBoardId);
if (!board) throw new Error("Distribution board is missing from project tree.");
const boardLoad = loads.boards.get(board.id);
if (!boardLoad) throw new Error("Distribution-board load is missing.");
const tree: CircuitTreeResponse = {
circuitListId,
circuitListId: list.id,
distributionBoardId: board.id,
distributionBoardName: board.name,
distributionBoardNumber: board.boardNumber ?? undefined,
distributionBoardSupplyType: board.supplyType ?? undefined,
feedingReference: resolveFeedingReference(
board,
boardById,
listByBoardId,
circuitById,
sectionById
),
currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor:
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
distributionBoardSimultaneityFactor: board.simultaneityFactor,
distributionBoardTotalPower: boardLoad.activePowerBeforeSimultaneityKw,
distributionBoardTotalPowerWithSimultaneityFactor:
boardLoad.demandActivePowerKw,
headerComponents: [],
sections: sections.map((section) => ({
sections: (sectionsByListId.get(list.id) ?? []).map((section) => ({
id: section.id,
key: section.key,
displayName: section.displayName,
@ -115,9 +188,8 @@ export async function getCircuitTree(req: Request, res: Response) {
footerComponents: [],
};
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
for (const component of components) {
const protection =
componentProtectionById.get(component.id);
for (const component of componentsByListId.get(list.id) ?? []) {
const protection = componentProtectionById.get(component.id);
const dto = {
id: component.id,
circuitListId: component.circuitListId,
@ -127,24 +199,18 @@ export async function getCircuitTree(req: Request, res: Response) {
role: component.role,
placement: component.placement,
sortOrder: component.sortOrder,
protectionDevice: protection
? toProtectionDeviceDto(protection)
: undefined,
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
};
if (component.placement === "header") {
tree.headerComponents.push(dto);
} else if (component.placement === "footer") {
tree.footerComponents.push(dto);
} else if (component.sectionId !== null) {
if (component.placement === "header") tree.headerComponents.push(dto);
else if (component.placement === "footer") tree.footerComponents.push(dto);
else if (component.sectionId !== null) {
sectionBlocks.get(component.sectionId)?.components.push(dto);
}
}
for (const circuit of circuits) {
const section = sectionById.get(circuit.sectionId);
if (!section || section.circuitListId !== circuit.circuitListId) {
continue;
}
for (const circuit of circuitsByListId.get(list.id) ?? []) {
const block = sectionBlocks.get(circuit.sectionId);
if (!block) continue;
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
id: row.id,
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
@ -166,12 +232,16 @@ export async function getCircuitTree(req: Request, res: Response) {
cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined,
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
rowTotalPower: calculateRowTotalPower(
row.quantity,
row.powerPerUnit,
row.simultaneityFactor
),
}));
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing.");
const protection = circuitProtectionById.get(circuit.id);
sectionBlocks.get(section.id)?.circuits.push({
block.circuits.push({
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
@ -186,50 +256,97 @@ export async function getCircuitTree(req: Request, res: Response) {
voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve),
isReserve:
Boolean(circuit.isReserve) &&
circuitLoad.downstreamBoards.length === 0,
remark: circuit.remark ?? undefined,
circuitTotalPower,
circuitTotalPower: circuitLoad.activePowerKw,
circuitApparentPower: circuitLoad.apparentPowerKva,
circuitCosPhi: circuitLoad.cosPhi,
deviceRows,
protectionDevice: protection
? toProtectionDeviceDto(protection)
: undefined,
downstreamBoardLoads: circuitLoad.downstreamBoards
.map((load) => ({
distributionBoardId: load.distributionBoardId,
name: load.name,
boardNumber: load.boardNumber ?? undefined,
supplyType: boardById.get(load.distributionBoardId)?.supplyType ?? undefined,
activePowerKw: load.activePowerKw,
apparentPowerKva: load.apparentPowerKva,
cosPhi: load.cosPhi,
}))
.sort((left, right) =>
(left.boardNumber ?? left.name).localeCompare(
right.boardNumber ?? right.name,
"de"
)
),
protectionDevice: protection ? toProtectionDeviceDto(protection) : undefined,
});
}
for (const section of tree.sections) {
section.sectionTotalPower = calculateSectionTotalPower(
section.circuits
section.sectionTotalPower = section.circuits.reduce(
(sum, circuit) => sum + circuit.circuitTotalPower,
0
);
}
tree.distributionBoardTotalPower =
calculateDistributionBoardTotalPower(tree.sections);
tree.distributionBoardTotalPowerWithSimultaneityFactor =
applyDistributionBoardSimultaneityFactor(
tree.distributionBoardTotalPower,
tree.distributionBoardSimultaneityFactor
);
return tree;
});
return res.json(tree);
} catch (error) {
if (isMissingCircuitTreeSchemaError(error)) {
return res.json({
circuitListId,
currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor:
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
sections: [],
footerComponents: [],
warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
});
}
throw error;
}
return { currentRevision: project.currentRevision, trees };
}
function resolveFeedingReference(
board: Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number],
boardById: Map<string, Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number]>,
listByBoardId: Map<string, Awaited<ReturnType<typeof circuitListRepository.listByProject>>[number]>,
circuitById: Map<string, Awaited<ReturnType<typeof circuitRepository.listByCircuitLists>>[number]>,
sectionById: Map<string, Awaited<ReturnType<typeof circuitSectionRepository.listByCircuitLists>>[number]>
) {
if (board.feedingDistributionBoardId === null) return undefined;
const feedingBoard = boardById.get(board.feedingDistributionBoardId);
if (!feedingBoard) throw new Error("Feeding distribution board is missing.");
const feedingList = listByBoardId.get(feedingBoard.id);
if (!feedingList) throw new Error("Feeding circuit list is missing.");
const feedingCircuit = board.feedingCircuitId === null
? undefined
: circuitById.get(board.feedingCircuitId);
return {
distributionBoardId: feedingBoard.id,
circuitListId: feedingList.id,
distributionBoardName: feedingBoard.name,
distributionBoardNumber: feedingBoard.boardNumber ?? undefined,
circuitId: feedingCircuit?.id,
circuitEquipmentIdentifier: feedingCircuit?.equipmentIdentifier,
circuitDisplayName: feedingCircuit?.displayName ?? undefined,
circuitCategory: feedingCircuit
? sectionById.get(feedingCircuit.sectionId)?.category ?? undefined
: undefined,
};
}
function emptyCircuitTree(
project: NonNullable<Awaited<ReturnType<typeof projectRepository.findById>>>,
board: NonNullable<Awaited<ReturnType<typeof distributionBoardRepository.findById>>>,
circuitListId: string
): CircuitTreeResponse & { warning: string } {
return {
circuitListId,
distributionBoardId: board.id,
distributionBoardName: board.name,
distributionBoardNumber: board.boardNumber ?? undefined,
distributionBoardSupplyType: board.supplyType ?? undefined,
currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor: board.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
headerComponents: [],
sections: [],
footerComponents: [],
warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
};
}
function toProtectionDeviceDto(value: {
@ -243,11 +360,20 @@ function toProtectionDeviceDto(value: {
return {
type: value.type,
ratedCurrentA: value.ratedCurrentA,
fuseUtilizationCategory:
value.fuseUtilizationCategory ?? undefined,
fuseUtilizationCategory: value.fuseUtilizationCategory ?? undefined,
tripCharacteristic: value.tripCharacteristic ?? undefined,
rcdType: value.rcdType ?? undefined,
ratedResidualCurrentMa:
value.ratedResidualCurrentMa ?? undefined,
ratedResidualCurrentMa: value.ratedResidualCurrentMa ?? undefined,
};
}
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
const groups = new Map<string, T[]>();
for (const value of values) {
const key = keyOf(value);
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}

View file

@ -69,6 +69,8 @@ export async function createDistributionBoard(req: Request, res: Response) {
location: null,
fedFrom: null,
remark: null,
feedingDistributionBoardId: null,
feedingCircuitId: null,
},
});
} catch (error) {
@ -98,6 +100,17 @@ export async function updateDistributionBoard(
.filter((field) => parsed.data[field] !== undefined)
.map((field) => [field, parsed.data[field]])
);
const feedingPatch = {
...(parsed.data.feedingDistributionBoardId === undefined
? {}
: {
feedingDistributionBoardId:
parsed.data.feedingDistributionBoardId,
}),
...(parsed.data.feedingCircuitId === undefined
? {}
: { feedingCircuitId: parsed.data.feedingCircuitId }),
};
try {
const result = projectCommandService.executeUser({
projectId,
@ -110,6 +123,7 @@ export async function updateDistributionBoard(
supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor,
...infoPatch,
...feedingPatch,
}
),
});

View file

@ -29,7 +29,10 @@ import {
listEstimateBalances,
updateEstimateBalance,
} from "../controllers/estimate-balance.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import {
getCircuitTree,
getProjectCircuitTrees,
} from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import {
getProjectHistory,
@ -109,6 +112,7 @@ projectRouter.delete(
deleteDistributionBoard
);
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-trees", getProjectCircuitTrees);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",

View file

@ -86,6 +86,8 @@ export const updateDistributionBoardSchema = z
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
feedingDistributionBoardId: z.string().trim().min(1).nullable().optional(),
feedingCircuitId: z.string().trim().min(1).nullable().optional(),
})
.strict();