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:
commit
95bfba2f67
63 changed files with 5546 additions and 443 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
4
src/db/migrations/0012_good_gateway.sql
Normal file
4
src/db/migrations/0012_good_gateway.sql
Normal 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`);
|
||||
|
|
@ -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": {},
|
||||
|
|
|
|||
3072
src/db/migrations/meta/0013_snapshot.json
Normal file
3072
src/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
217
src/domain/calculations/distribution-board-load-calculation.ts
Normal file
217
src/domain/calculations/distribution-board-load-calculation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue