Add distribution power summaries

This commit is contained in:
2026-07-29 19:02:18 +02:00
parent dcb303284c
commit 9ea081179d
28 changed files with 2668 additions and 77 deletions
+19 -6
View File
@@ -184,6 +184,15 @@ Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`. verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert; Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit. gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
und Filter verändern diese fachlichen Summen nicht.
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
`circuit.reorder-section` speichert die erwartete und neue Sortierposition `circuit.reorder-section` speichert die erwartete und neue Sortierposition
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
@@ -271,12 +280,16 @@ Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen. Freigabe der Netzart in den Projekteinstellungen.
`distribution-board.update` versioniert Etage und Netzart gemeinsam und stellt `distribution-board.update` versioniert Etage, Netzart und den
beide Werte über dauerhaftes Undo/Redo wieder her. Snapshot-Schema 4 und der verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
portable Projekttransfer enthalten diese Felder; Schema 1/2 sowie gespeicherte dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
Version-1-Strukturcommands werden ohne erfundene Zuordnung hochgestuft. ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 3 wird mit allen sechs Netzarten als Projektauswahl Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
hochgestuft. Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
Projektauswahl hochgestuft.
`project-floor.insert` und `project-room.insert` versionieren die Anlage von `project-floor.insert` und `project-room.insert` versionieren die Anlage von
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
+13
View File
@@ -52,11 +52,24 @@ requirements and intended sequencing, not proof of implementation.
stored values. Version-four and older imports remain supported and are stored values. Version-four and older imports remain supported and are
normalized while being upgraded. normalized while being upgraded.
## Circuit List Power Summary
- [x] Show the total power of every circuit section.
- [x] Show the unadjusted total power of the complete distribution board.
- [x] Store a distribution-board simultaneity factor between zero and one in
the distribution-board settings.
- [x] Show the distribution-board total after applying that factor.
- [x] Preserve the factor in project commands, snapshots and portable project
transfers.
- [x] Keep column visibility and order as a project-specific UI preference so
all distribution boards in the same project open with the same columns.
## Recommended Sequence ## Recommended Sequence
1. [x] Distribution-board floor and supply fields. 1. [x] Distribution-board floor and supply fields.
2. [x] Device voltage derivation without overrides. 2. [x] Device voltage derivation without overrides.
3. [x] Snapshot-to-revision descriptions and history presentation. 3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks. must not be inferred from these tasks.
+45
View File
@@ -116,6 +116,39 @@ body {
font-weight: 600; font-weight: 600;
} }
.distribution-power-summary {
display: grid;
grid-template-columns: repeat(3, minmax(12rem, 1fr));
gap: 0.5rem;
padding: 0.55rem;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #f8fafc;
}
.distribution-power-summary > div {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.distribution-power-summary span {
color: #475569;
font-size: 0.74rem;
line-height: 1.2;
}
.distribution-power-summary strong {
color: #172033;
font-size: 0.95rem;
}
@media (max-width: 900px) {
.distribution-power-summary {
grid-template-columns: 1fr;
}
}
.column-settings-menu { .column-settings-menu {
position: absolute; position: absolute;
z-index: 9; z-index: 9;
@@ -695,6 +728,18 @@ body {
outline-offset: -2px; outline-offset: -2px;
} }
.tree-grid .section-title {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.tree-grid .section-title span {
color: #475569;
font-size: 0.78rem;
font-weight: 500;
}
.tree-grid .cell-invalid { .tree-grid .cell-invalid {
outline: 2px solid #c2410c; outline: 2px solid #c2410c;
outline-offset: -2px; outline-offset: -2px;
+65 -2
View File
@@ -87,6 +87,10 @@ export default function ProjectDetailPage() {
const [editingBoardFloorId, setEditingBoardFloorId] = useState(""); const [editingBoardFloorId, setEditingBoardFloorId] = useState("");
const [editingBoardSupplyType, setEditingBoardSupplyType] = const [editingBoardSupplyType, setEditingBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV"); useState<DistributionBoardSupplyType>("AV");
const [
editingBoardSimultaneityFactor,
setEditingBoardSimultaneityFactor,
] = useState("1");
const [floorName, setFloorName] = useState(""); const [floorName, setFloorName] = useState("");
const [roomNumber, setRoomNumber] = useState(""); const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState(""); const [roomName, setRoomName] = useState("");
@@ -398,6 +402,9 @@ export default function ProjectDetailPage() {
project?.enabledDistributionBoardSupplyTypes[0] ?? project?.enabledDistributionBoardSupplyTypes[0] ??
"AV" "AV"
); );
setEditingBoardSimultaneityFactor(
String(board.simultaneityFactor)
);
} }
function openBoardCreator() { function openBoardCreator() {
@@ -412,6 +419,17 @@ export default function ProjectDetailPage() {
if (!projectId || !project || !editingBoard) { if (!projectId || !project || !editingBoard) {
return; return;
} }
const simultaneityFactor = Number(
editingBoardSimultaneityFactor
);
if (
!editingBoardSimultaneityFactor.trim() ||
!Number.isFinite(simultaneityFactor) ||
simultaneityFactor < 0 ||
simultaneityFactor > 1
) {
return;
}
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
@@ -421,6 +439,7 @@ export default function ProjectDetailPage() {
{ {
floorId: editingBoardFloorId || null, floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType, supplyType: editingBoardSupplyType,
simultaneityFactor,
}, },
project.currentRevision project.currentRevision
); );
@@ -702,6 +721,7 @@ export default function ProjectDetailPage() {
<th>Verteilung</th> <th>Verteilung</th>
<th>Etage</th> <th>Etage</th>
<th>Netzart</th> <th>Netzart</th>
<th>GZF</th>
<th className="text-end">Aktionen</th> <th className="text-end">Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -723,6 +743,15 @@ export default function ProjectDetailPage() {
] ]
: "Nicht festgelegt"} : "Nicht festgelegt"}
</td> </td>
<td>
{board.simultaneityFactor.toLocaleString(
"de-DE",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}
</td>
<td className="text-end"> <td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-2"> <div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button <button
@@ -752,7 +781,7 @@ export default function ProjectDetailPage() {
})} })}
{!boards.length ? ( {!boards.length ? (
<tr> <tr>
<td colSpan={4} className="text-center text-secondary py-4"> <td colSpan={5} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden. Noch keine Verteilungen vorhanden.
</td> </td>
</tr> </tr>
@@ -1131,10 +1160,18 @@ export default function ProjectDetailPage() {
{editingBoard ? ( {editingBoard ? (
<FormModal <FormModal
description={`${editingBoard.name}: Etagenzuordnung und Netzart ändern.`} description={`${editingBoard.name}: Etagenzuordnung, Netzart und Gleichzeitigkeitsfaktor ändern.`}
isSaving={isSaving} isSaving={isSaving}
onClose={() => setEditingBoard(null)} onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard} onSubmit={handleUpdateBoard}
submitDisabled={
!editingBoardSimultaneityFactor.trim() ||
!Number.isFinite(
Number(editingBoardSimultaneityFactor)
) ||
Number(editingBoardSimultaneityFactor) < 0 ||
Number(editingBoardSimultaneityFactor) > 1
}
submitLabel="Änderungen speichern" submitLabel="Änderungen speichern"
title="Verteilung bearbeiten" title="Verteilung bearbeiten"
> >
@@ -1187,6 +1224,32 @@ export default function ProjectDetailPage() {
))} ))}
</select> </select>
</div> </div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-simultaneity-factor"
>
Verteilerweiter Gleichzeitigkeitsfaktor
</label>
<input
className="form-control"
id="edit-distribution-board-simultaneity-factor"
max="1"
min="0"
onChange={(event) =>
setEditingBoardSimultaneityFactor(
event.target.value
)
}
required
step="0.01"
type="number"
value={editingBoardSimultaneityFactor}
/>
<div className="form-text">
Wird auf die Gesamtleistung des Verteilers angewendet.
</div>
</div>
</div> </div>
</FormModal> </FormModal>
) : null} ) : null}
@@ -0,0 +1 @@
ALTER TABLE `distribution_boards` ADD `simultaneity_factor` real DEFAULT 1 NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -162,6 +162,13 @@
"when": 1785314626476, "when": 1785314626476,
"tag": "0022_default_remaining_phase_types", "tag": "0022_default_remaining_phase_types",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1785343645615,
"tag": "0023_cheerful_night_thrasher",
"breakpoints": true
} }
] ]
} }
@@ -14,6 +14,7 @@ import {
import { import {
assertDistributionBoardUpdateProjectCommand, assertDistributionBoardUpdateProjectCommand,
createDistributionBoardUpdateProjectCommand, createDistributionBoardUpdateProjectCommand,
legacyDistributionBoardUpdateCommandSchemaVersion,
type DistributionBoardUpdateField, type DistributionBoardUpdateField,
type DistributionBoardUpdatePatch, type DistributionBoardUpdatePatch,
type DistributionBoardUpdateProjectCommand, type DistributionBoardUpdateProjectCommand,
@@ -303,10 +304,19 @@ export class DistributionBoardStructureProjectCommandRepository
getDistributionBoardFieldValue(current, change.field), getDistributionBoardFieldValue(current, change.field),
]) ])
) as DistributionBoardUpdatePatch; ) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand( const currentInverse = createDistributionBoardUpdateProjectCommand(
current.id, current.id,
inversePatch inversePatch
); );
const inverse =
input.command.schemaVersion ===
legacyDistributionBoardUpdateCommandSchemaVersion
? ({
...currentInverse,
schemaVersion:
legacyDistributionBoardUpdateCommandSchemaVersion,
} as DistributionBoardUpdateProjectCommand)
: currentInverse;
const updated = database const updated = database
.update(distributionBoards) .update(distributionBoards)
.set(patch) .set(patch)
@@ -379,8 +389,16 @@ function structureMatches(
sections: Array<typeof circuitSections.$inferSelect>; sections: Array<typeof circuitSections.$inferSelect>;
} }
) { ) {
const {
simultaneityFactor,
...actualDistributionBoard
} = actual.board;
if ( if (
!sameRecord(expected.distributionBoard, actual.board) || simultaneityFactor !== 1 ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard
) ||
!sameRecord(expected.circuitList, actual.list) || !sameRecord(expected.circuitList, actual.list) ||
expected.sections.length !== actual.sections.length expected.sections.length !== actual.sections.length
) { ) {
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
@@ -12,4 +12,19 @@ export class DistributionBoardRepository {
.where(eq(distributionBoards.projectId, projectId)); .where(eq(distributionBoards.projectId, projectId));
} }
async findById(projectId: string, distributionBoardId: string) {
return (
this.database
.select()
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
eq(distributionBoards.id, distributionBoardId)
)
)
.get() ?? null
);
}
} }
@@ -10,6 +10,7 @@ import {
previousProjectStateSnapshotSchemaVersion, previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion, projectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion, supplyTypesProjectStateSnapshotSchemaVersion,
voltageProjectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js"; } from "../../domain/models/project-state-snapshot.model.js";
import type { import type {
CreateNamedProjectSnapshotInput, CreateNamedProjectSnapshotInput,
@@ -149,6 +150,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
distributionBoardProjectStateSnapshotSchemaVersion && distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== stored.schemaVersion !==
supplyTypesProjectStateSnapshotSchemaVersion && supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
voltageProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion stored.schemaVersion !== projectStateSnapshotSchemaVersion
) { ) {
throw new Error("Project snapshot schema version is not supported."); throw new Error("Project snapshot schema version is not supported.");
+4 -1
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"; import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { floors } from "./floors.js"; import { floors } from "./floors.js";
import { projects } from "./projects.js"; import { projects } from "./projects.js";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js"; import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
@@ -13,5 +13,8 @@ export const distributionBoards = sqliteTable("distribution_boards", {
onDelete: "set null", onDelete: "set null",
}), }),
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(), supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
}); });
@@ -15,3 +15,37 @@ export function calculateCircuitTotalPower(
); );
} }
export function calculateSectionTotalPower(
circuits: Array<{ circuitTotalPower: number }>
): number {
return circuits.reduce(
(sum, circuit) => sum + circuit.circuitTotalPower,
0
);
}
export function calculateDistributionBoardTotalPower(
sections: Array<{ sectionTotalPower: number }>
): number {
return sections.reduce(
(sum, section) => sum + section.sectionTotalPower,
0
);
}
export function applyDistributionBoardSimultaneityFactor(
totalPower: number,
simultaneityFactor: number
): number {
if (
!Number.isFinite(simultaneityFactor) ||
simultaneityFactor < 0 ||
simultaneityFactor > 1
) {
throw new Error(
"Distribution-board simultaneity factor must be between zero and one."
);
}
return totalPower * simultaneityFactor;
}
+4
View File
@@ -52,6 +52,7 @@ export interface CircuitTreeSectionBlock {
displayName: string; displayName: string;
prefix: string; prefix: string;
sortOrder: number; sortOrder: number;
sectionTotalPower: number;
circuits: CircuitTreeCircuit[]; circuits: CircuitTreeCircuit[];
} }
@@ -60,6 +61,9 @@ export interface CircuitTreeResponse {
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
sections: CircuitTreeSectionBlock[]; sections: CircuitTreeSectionBlock[];
} }
@@ -6,11 +6,13 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType = export const distributionBoardUpdateCommandType =
"distribution-board.update" as const; "distribution-board.update" as const;
export const distributionBoardUpdateCommandSchemaVersion = 1 as const; export const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
export interface DistributionBoardUpdateValues { export interface DistributionBoardUpdateValues {
floorId: string | null; floorId: string | null;
supplyType: DistributionBoardSupplyType | null; supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
} }
export type DistributionBoardUpdateField = export type DistributionBoardUpdateField =
@@ -31,17 +33,27 @@ export interface DistributionBoardUpdateCommandPayload {
changes: DistributionBoardUpdateFieldChange[]; changes: DistributionBoardUpdateFieldChange[];
} }
export interface DistributionBoardUpdateProjectCommand interface CurrentDistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> { extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion; schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType; type: typeof distributionBoardUpdateCommandType;
} }
interface LegacyDistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof legacyDistributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType;
}
export type DistributionBoardUpdateProjectCommand =
| CurrentDistributionBoardUpdateProjectCommand
| LegacyDistributionBoardUpdateProjectCommand;
export function createDistributionBoardUpdateProjectCommand( export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string, distributionBoardId: string,
patch: DistributionBoardUpdatePatch patch: DistributionBoardUpdatePatch
): DistributionBoardUpdateProjectCommand { ): CurrentDistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = { const command: CurrentDistributionBoardUpdateProjectCommand = {
schemaVersion: distributionBoardUpdateCommandSchemaVersion, schemaVersion: distributionBoardUpdateCommandSchemaVersion,
type: distributionBoardUpdateCommandType, type: distributionBoardUpdateCommandType,
payload: { payload: {
@@ -60,7 +72,9 @@ export function assertDistributionBoardUpdateProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardUpdateProjectCommand { ): asserts command is DistributionBoardUpdateProjectCommand {
if ( if (
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion || (command.schemaVersion !==
legacyDistributionBoardUpdateCommandSchemaVersion &&
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
command.type !== distributionBoardUpdateCommandType || command.type !== distributionBoardUpdateCommandType ||
!isPlainObject(command.payload) !isPlainObject(command.payload)
) { ) {
@@ -81,7 +95,11 @@ export function assertDistributionBoardUpdateProjectCommand(
for (const change of changes) { for (const change of changes) {
if ( if (
!isPlainObject(change) || !isPlainObject(change) ||
(change.field !== "floorId" && change.field !== "supplyType") || (change.field !== "floorId" &&
change.field !== "supplyType" &&
(command.schemaVersion ===
legacyDistributionBoardUpdateCommandSchemaVersion ||
change.field !== "simultaneityFactor")) ||
seen.has(change.field) seen.has(change.field)
) { ) {
throw new Error( throw new Error(
@@ -95,13 +113,24 @@ export function assertDistributionBoardUpdateProjectCommand(
) { ) {
throw new Error("floorId must be a non-empty string or null."); throw new Error("floorId must be a non-empty string or null.");
} }
} else if (change.field === "supplyType") {
if (
change.value !== null &&
!distributionBoardSupplyTypes.includes(
change.value as DistributionBoardSupplyType
)
) {
throw new Error("supplyType is invalid.");
}
} else if ( } else if (
change.value !== null && typeof change.value !== "number" ||
!distributionBoardSupplyTypes.includes( !Number.isFinite(change.value) ||
change.value as DistributionBoardSupplyType change.value < 0 ||
) change.value > 1
) { ) {
throw new Error("supplyType is invalid."); throw new Error(
"simultaneityFactor must be between zero and one."
);
} }
seen.add(change.field); seen.add(change.field);
} }
@@ -13,7 +13,8 @@ export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const previousProjectStateSnapshotSchemaVersion = 2 as const; export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const; export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const; export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
export const projectStateSnapshotSchemaVersion = 5 as const; export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 6 as const;
const idSchema = z.string().trim().min(1); const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable(); const nullableStringSchema = z.string().nullable();
@@ -57,6 +58,12 @@ const distributionBoardSchema = legacyDistributionBoardSchema
}) })
.strict(); .strict();
const currentDistributionBoardSchema = distributionBoardSchema
.extend({
simultaneityFactor: finiteNumberSchema.min(0).max(1),
})
.strict();
const circuitListSchema = z const circuitListSchema = z
.object({ .object({
id: idSchema, id: idSchema,
@@ -213,9 +220,17 @@ const supplyTypesProjectStateSnapshotSchema = z
}) })
.strict(); .strict();
export const projectStateSnapshotSchema = const voltageProjectStateSnapshotSchema =
supplyTypesProjectStateSnapshotSchema.extend({ supplyTypesProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(
voltageProjectStateSnapshotSchemaVersion
),
});
export const projectStateSnapshotSchema =
voltageProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion), schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
distributionBoards: z.array(currentDistributionBoardSchema),
}); });
export type ProjectStateSnapshot = z.infer< export type ProjectStateSnapshot = z.infer<
@@ -228,34 +243,46 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined; const version = isPlainObject(value) ? value.schemaVersion : undefined;
const parsedSnapshot = const parsedSnapshot =
version === legacyProjectStateSnapshotSchemaVersion version === legacyProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot( ? upgradeVoltageProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot( upgradeSupplyTypesProjectStateSnapshot(
upgradePreviousProjectStateSnapshot( upgradeDistributionBoardProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot( upgradePreviousProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value) upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
) )
) )
) )
) )
: version === previousProjectStateSnapshotSchemaVersion : version === previousProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot( ? upgradeVoltageProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot( upgradeSupplyTypesProjectStateSnapshot(
upgradePreviousProjectStateSnapshot( upgradeDistributionBoardProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value) upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
)
) )
) )
) )
: version === distributionBoardProjectStateSnapshotSchemaVersion : version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot( ? upgradeVoltageProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot( upgradeSupplyTypesProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value) upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value)
)
) )
) )
: version === supplyTypesProjectStateSnapshotSchemaVersion : version === supplyTypesProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot( ? upgradeVoltageProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value) upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
)
) )
: projectStateSnapshotSchema.parse(value); : version === voltageProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
voltageProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value);
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot); const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
assertProjectStateSnapshotRelations(snapshot); assertProjectStateSnapshotRelations(snapshot);
return snapshot; return snapshot;
@@ -354,14 +381,14 @@ function upgradeDistributionBoardProjectStateSnapshot(
function upgradeSupplyTypesProjectStateSnapshot( function upgradeSupplyTypesProjectStateSnapshot(
snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema> snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema>
): ProjectStateSnapshot { ): z.infer<typeof voltageProjectStateSnapshotSchema> {
const settings = snapshot.project; const settings = snapshot.project;
const sectionById = new Map( const sectionById = new Map(
snapshot.circuitSections.map((section) => [section.id, section]) snapshot.circuitSections.map((section) => [section.id, section])
); );
return { return {
...snapshot, ...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: voltageProjectStateSnapshotSchemaVersion,
projectDevices: snapshot.projectDevices.map((device) => ({ projectDevices: snapshot.projectDevices.map((device) => ({
...device, ...device,
voltageV: resolveProjectVoltage(device.phaseType, settings), voltageV: resolveProjectVoltage(device.phaseType, settings),
@@ -383,6 +410,19 @@ function upgradeSupplyTypesProjectStateSnapshot(
}; };
} }
function upgradeVoltageProjectStateSnapshot(
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: snapshot.distributionBoards.map((board) => ({
...board,
simultaneityFactor: 1,
})),
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }
+106 -25
View File
@@ -27,7 +27,11 @@ import {
deviceFieldKeys, deviceFieldKeys,
formatPhaseTypeLabel, formatPhaseTypeLabel,
formatValue, formatValue,
getCircuitSectionLabel,
getProjectColumnLayoutStorageKey,
isGridEditorControlTarget, isGridEditorControlTarget,
normalizeColumnOrder,
parseStoredColumnLayout,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
import { import {
buildCircuitDeviceRowInsertSnapshot, buildCircuitDeviceRowInsertSnapshot,
@@ -131,7 +135,8 @@ type CircuitReorderDropIntent =
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
| { kind: "section-end"; sectionId: string; valid: boolean }; | { kind: "section-end"; sectionId: string; valid: boolean };
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1"; const LEGACY_COLUMN_LAYOUT_STORAGE_KEY =
"circuitTreeEditor.columnLayout.v1";
function normalizeUiError(err: unknown): string { function normalizeUiError(err: unknown): string {
const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen."; const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen.";
@@ -222,6 +227,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [filterValueSearch, setFilterValueSearch] = useState(""); const [filterValueSearch, setFilterValueSearch] = useState("");
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys); const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key)); const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
const [
loadedColumnLayoutProjectId,
setLoadedColumnLayoutProjectId,
] = useState<string | null>(null);
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false); const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
const [columnSearch, setColumnSearch] = useState(""); const [columnSearch, setColumnSearch] = useState("");
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null); const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
@@ -267,13 +276,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
} }
function normalizeColumnOrder(keys: CellKey[]) {
const unique = [...new Set(keys)];
const allKeys = allColumns.map((column) => column.key);
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
}
// Initial/identity-change tree load for current route context. // Initial/identity-change tree load for current route context.
useEffect(() => { useEffect(() => {
void loadTree({ showLoading: true }); void loadTree({ showLoading: true });
@@ -292,32 +294,48 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
setLoadedColumnLayoutProjectId(null);
setColumnOrder(allColumns.map((column) => column.key));
setVisibleColumnKeys(defaultVisibleColumnKeys);
try { try {
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY); const parsed = parseStoredColumnLayout(
if (!raw) { localStorage.getItem(
getProjectColumnLayoutStorageKey(projectId)
) ??
localStorage.getItem(
LEGACY_COLUMN_LAYOUT_STORAGE_KEY
)
);
if (!parsed) {
return; return;
} }
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] }; setColumnOrder(parsed.order);
const validKeys = new Set(allColumns.map((column) => column.key)); setVisibleColumnKeys(parsed.visible);
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
return;
}
setColumnOrder(normalizeColumnOrder(parsedOrder));
setVisibleColumnKeys(parsedVisible);
} catch { } catch {
// ignore invalid local storage and use defaults // ignore invalid local storage and use defaults
} finally {
setLoadedColumnLayoutProjectId(projectId);
} }
}, []); }, [projectId]);
useEffect(() => { useEffect(() => {
if (loadedColumnLayoutProjectId !== projectId) {
return;
}
const payload = { const payload = {
order: columnOrder, order: columnOrder,
visible: visibleColumnKeys, visible: visibleColumnKeys,
}; };
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload)); localStorage.setItem(
}, [columnOrder, visibleColumnKeys]); getProjectColumnLayoutStorageKey(projectId),
JSON.stringify(payload)
);
}, [
columnOrder,
loadedColumnLayoutProjectId,
projectId,
visibleColumnKeys,
]);
useEffect(() => { useEffect(() => {
const visible = new Set(visibleColumnKeys); const visible = new Set(visibleColumnKeys);
@@ -474,6 +492,51 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
); );
} }
function renderDistributionPowerSummary() {
if (!data) {
return null;
}
return (
<section
className="distribution-power-summary"
aria-label="Leistungszusammenfassung des Verteilers"
>
<div>
<span>Gesamtleistung Verteiler</span>
<strong>
{formatValue(
data.distributionBoardTotalPower,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
<div>
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span>
<strong>
{formatValue(
data.distributionBoardSimultaneityFactor,
"simultaneityFactor"
)}
</strong>
</div>
<div>
<span>
Gesamtleistung Verteiler unter Berücksichtigung des
Gleichzeitigkeitsfaktors
</span>
<strong>
{formatValue(
data.distributionBoardTotalPowerWithSimultaneityFactor,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
</section>
);
}
function closeColumnSettingsMenu() { function closeColumnSettingsMenu() {
setIsColumnMenuOpen(false); setIsColumnMenuOpen(false);
setColumnSearch(""); setColumnSearch("");
@@ -2490,6 +2553,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{renderColumnSettingsMenu("col-empty")} {renderColumnSettingsMenu("col-empty")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{error ? ( {error ? (
<div className="notice error editor-error-notice" role="alert"> <div className="notice error editor-error-notice" role="alert">
<span>{error}</span> <span>{error}</span>
@@ -2598,6 +2662,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{renderColumnSettingsMenu("col")} {renderColumnSettingsMenu("col")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{hasActiveSortOrFilter ? ( {hasActiveSortOrFilter ? (
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div> <div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
) : null} ) : null}
@@ -2701,14 +2766,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section); const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
return ( return (
<option key={section.id} value={section.id} disabled={!valid}> <option key={section.id} value={section.id} disabled={!valid}>
{section.displayName}{valid ? "" : " (nicht zulässig)"} {getCircuitSectionLabel(section)}
{valid ? "" : " (nicht zulässig)"}
</option> </option>
); );
})} })}
</select> </select>
</label> </label>
{selectedProjectDevice && suggestedSection ? ( {selectedProjectDevice && suggestedSection ? (
<p className="notice muted">Vorgeschlagener Bereich: {suggestedSection.displayName}</p> <p className="notice muted">
Vorgeschlagener Bereich:{" "}
{getCircuitSectionLabel(suggestedSection)}
</p>
) : null} ) : null}
<button <button
type="button" type="button"
@@ -2980,7 +3049,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
<td colSpan={visibleColumns.length + 1} className="section-drop-cell"> <td colSpan={visibleColumns.length + 1} className="section-drop-cell">
<div className="section-content"> <div className="section-content">
<strong>{section.displayName}</strong> <div className="section-title">
<strong>
{getCircuitSectionLabel(section)}
</strong>
<span>
Gesamtleistung Abschnitt:{" "}
{formatValue(
section.sectionTotalPower,
"rowTotalPower"
)}{" "}
kW
</span>
</div>
<div className="section-actions"> <div className="section-actions">
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}> <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Stromkreis hinzufügen Stromkreis hinzufügen
@@ -4,7 +4,10 @@ import { useEffect, useState } from "react";
import { Fragment } from "react"; import { Fragment } from "react";
import { getCircuitTree } from "../utils/api"; import { getCircuitTree } from "../utils/api";
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types"; import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
import { formatValue } from "../utils/circuit-grid-model"; import {
formatValue,
getCircuitSectionLabel,
} from "../utils/circuit-grid-model";
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) { function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
if (circuit.displayName?.trim()) { if (circuit.displayName?.trim()) {
@@ -98,7 +101,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
<> <>
<tr className="section-row"> <tr className="section-row">
<td colSpan={21}> <td colSpan={21}>
<strong>{section.displayName}</strong> <strong>{getCircuitSectionLabel(section)}</strong>
</td> </td>
</tr> </tr>
{section.circuits.map((circuit) => { {section.circuits.map((circuit) => {
+5
View File
@@ -95,6 +95,7 @@ export interface DistributionBoardDto {
name: string; name: string;
floorId: string | null; floorId: string | null;
supplyType: DistributionBoardSupplyType | null; supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
} }
export interface DistributionBoardCommandResultDto export interface DistributionBoardCommandResultDto
@@ -296,6 +297,7 @@ export interface CircuitTreeSectionDto {
displayName: string; displayName: string;
prefix: string; prefix: string;
sortOrder: number; sortOrder: number;
sectionTotalPower: number;
circuits: CircuitTreeCircuitDto[]; circuits: CircuitTreeCircuitDto[];
} }
@@ -315,6 +317,9 @@ export interface CircuitTreeResponseDto {
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
sections: CircuitTreeSectionDto[]; sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto; migrationReport?: CircuitTreeMigrationReportDto;
} }
+1
View File
@@ -299,6 +299,7 @@ export function updateDistributionBoard(
input: { input: {
floorId: string | null; floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>; supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
}, },
expectedRevision: number expectedRevision: number
) { ) {
+90
View File
@@ -124,6 +124,96 @@ export const defaultVisibleColumnKeys = allColumns
.filter((column) => column.defaultVisible) .filter((column) => column.defaultVisible)
.map((column) => column.key); .map((column) => column.key);
const projectColumnLayoutStoragePrefix =
"circuitTreeEditor.columnLayout.v2";
export interface CircuitColumnLayout {
order: CellKey[];
visible: CellKey[];
}
const circuitSectionLabels: Record<string, string> = {
lighting: "Licht",
single_phase: "Einphasig",
three_phase: "Dreiphasig",
unassigned: "Nicht zugeordnet",
};
export function getCircuitSectionLabel(
section: { key: string; displayName: string }
): string {
return circuitSectionLabels[section.key] ?? section.displayName;
}
export function getProjectColumnLayoutStorageKey(
projectId: string
): string {
return `${projectColumnLayoutStoragePrefix}.${projectId}`;
}
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
const unique = [...new Set(keys)];
const allKeys = allColumns.map((column) => column.key);
const merged = [
...unique,
...allKeys.filter((key) => !unique.includes(key)),
];
return [
"equipmentIdentifier",
...merged.filter((key) => key !== "equipmentIdentifier"),
];
}
export function parseStoredColumnLayout(
serialized: string | null
): CircuitColumnLayout | null {
if (!serialized) {
return null;
}
try {
const parsed = JSON.parse(serialized) as {
order?: unknown;
visible?: unknown;
};
if (
!Array.isArray(parsed.order) ||
!Array.isArray(parsed.visible)
) {
return null;
}
const validKeys = new Set(
allColumns.map((column) => column.key)
);
const order = parsed.order.filter(
(key): key is CellKey =>
typeof key === "string" &&
validKeys.has(key as CellKey)
);
const visible = [
...new Set(
parsed.visible.filter(
(key): key is CellKey =>
typeof key === "string" &&
validKeys.has(key as CellKey)
)
),
];
if (
!order.length ||
!visible.length ||
!visible.includes("equipmentIdentifier")
) {
return null;
}
return {
order: normalizeColumnOrder(order),
visible,
};
} catch {
return null;
}
}
const deviceOnlyColumns = new Set<CellKey>([ const deviceOnlyColumns = new Set<CellKey>([
"quantity", "quantity",
"powerPerUnit", "powerPerUnit",
@@ -1,7 +1,10 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { import {
applyDistributionBoardSimultaneityFactor,
calculateCircuitTotalPower, calculateCircuitTotalPower,
calculateDistributionBoardTotalPower,
calculateRowTotalPower, calculateRowTotalPower,
calculateSectionTotalPower,
} from "../../domain/calculations/circuit-power-calculation.js"; } from "../../domain/calculations/circuit-power-calculation.js";
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js"; import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
import { import {
@@ -9,6 +12,7 @@ import {
circuitListRepository, circuitListRepository,
circuitRepository, circuitRepository,
circuitSectionRepository, circuitSectionRepository,
distributionBoardRepository,
projectRepository, projectRepository,
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
@@ -37,6 +41,16 @@ export async function getCircuitTree(req: Request, res: Response) {
if (!project) { if (!project) {
return res.status(404).json({ error: "Project not found" }); 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",
});
}
try { try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId); const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
@@ -57,12 +71,17 @@ export async function getCircuitTree(req: Request, res: Response) {
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV, singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor:
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
sections: sections.map((section) => ({ sections: sections.map((section) => ({
id: section.id, id: section.id,
key: section.key, key: section.key,
displayName: section.displayName, displayName: section.displayName,
prefix: section.prefix, prefix: section.prefix,
sortOrder: section.sortOrder, sortOrder: section.sortOrder,
sectionTotalPower: 0,
circuits: [], circuits: [],
})), })),
}; };
@@ -123,6 +142,19 @@ export async function getCircuitTree(req: Request, res: Response) {
}); });
} }
for (const section of tree.sections) {
section.sectionTotalPower = calculateSectionTotalPower(
section.circuits
);
}
tree.distributionBoardTotalPower =
calculateDistributionBoardTotalPower(tree.sections);
tree.distributionBoardTotalPowerWithSimultaneityFactor =
applyDistributionBoardSimultaneityFactor(
tree.distributionBoardTotalPower,
tree.distributionBoardSimultaneityFactor
);
return res.json(tree); return res.json(tree);
} catch (error) { } catch (error) {
if (isMissingCircuitTreeSchemaError(error)) { if (isMissingCircuitTreeSchemaError(error)) {
@@ -131,6 +163,10 @@ export async function getCircuitTree(req: Request, res: Response) {
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV, singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV,
distributionBoardSimultaneityFactor:
distributionBoard.simultaneityFactor,
distributionBoardTotalPower: 0,
distributionBoardTotalPowerWithSimultaneityFactor: 0,
sections: [], sections: [],
warning: warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
@@ -51,7 +51,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
}); });
return res.status(201).json({ return res.status(201).json({
...result, ...result,
distributionBoard: structure.distributionBoard, distributionBoard: {
...structure.distributionBoard,
simultaneityFactor: 1,
},
}); });
} catch (error) { } catch (error) {
return respondWithProjectCommandError(error, res); return respondWithProjectCommandError(error, res);
@@ -83,6 +86,7 @@ export async function updateDistributionBoard(
{ {
floorId: parsed.data.floorId, floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType, supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor,
} }
), ),
}); });
@@ -45,6 +45,7 @@ export const updateDistributionBoardSchema = z
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(), floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes), supplyType: z.enum(distributionBoardSupplyTypes),
simultaneityFactor: z.number().finite().min(0).max(1),
}) })
.strict(); .strict();
+44
View File
@@ -5,11 +5,14 @@ import {
buildCircuitEditPatch, buildCircuitEditPatch,
buildDeviceRowEditPatch, buildDeviceRowEditPatch,
formatValue, formatValue,
getCircuitSectionLabel,
getProjectColumnLayoutStorageKey,
isGridEditorControlTarget, isGridEditorControlTarget,
getBlockSortValue, getBlockSortValue,
getCellKind, getCellKind,
getCircuitValue, getCircuitValue,
getDeviceValue, getDeviceValue,
parseStoredColumnLayout,
parseNumeric, parseNumeric,
} from "../src/frontend/utils/circuit-grid-model.js"; } from "../src/frontend/utils/circuit-grid-model.js";
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js"; import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
@@ -73,6 +76,47 @@ describe("circuit grid model", () => {
assert.equal(allColumns[0].locked, true); assert.equal(allColumns[0].locked, true);
}); });
it("stores valid column layouts under a project-specific key", () => {
assert.equal(
getProjectColumnLayoutStorageKey("project-1"),
"circuitTreeEditor.columnLayout.v2.project-1"
);
const layout = parseStoredColumnLayout(
JSON.stringify({
order: ["displayName", "equipmentIdentifier"],
visible: [
"displayName",
"equipmentIdentifier",
"unknown",
],
})
);
assert.ok(layout);
assert.equal(layout.order[0], "equipmentIdentifier");
assert.deepEqual(layout.visible, [
"displayName",
"equipmentIdentifier",
]);
assert.equal(parseStoredColumnLayout("{invalid"), null);
});
it("shows stable circuit sections with German labels", () => {
assert.equal(
getCircuitSectionLabel({
key: "lighting",
displayName: "Lighting",
}),
"Licht"
);
assert.equal(
getCircuitSectionLabel({
key: "custom",
displayName: "Sonderbereich",
}),
"Sonderbereich"
);
});
it("maps shared fields to the correct level for every row shape", () => { it("maps shared fields to the correct level for every row shape", () => {
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField"); assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
+29
View File
@@ -1,8 +1,11 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
applyDistributionBoardSimultaneityFactor,
calculateCircuitTotalPower, calculateCircuitTotalPower,
calculateDistributionBoardTotalPower,
calculateRowTotalPower, calculateRowTotalPower,
calculateSectionTotalPower,
} from "../src/domain/calculations/circuit-power-calculation.js"; } from "../src/domain/calculations/circuit-power-calculation.js";
import { import {
resolveCircuitPhaseType, resolveCircuitPhaseType,
@@ -19,6 +22,32 @@ describe("circuit power calculation", () => {
]); ]);
assert.equal(total, 4.5); assert.equal(total, 4.5);
}); });
it("calculates section and distribution-board totals", () => {
const lighting = calculateSectionTotalPower([
{ circuitTotalPower: 1.5 },
{ circuitTotalPower: 2.25 },
]);
const singlePhase = calculateSectionTotalPower([
{ circuitTotalPower: 3 },
]);
const total = calculateDistributionBoardTotalPower([
{ sectionTotalPower: lighting },
{ sectionTotalPower: singlePhase },
{ sectionTotalPower: 0 },
]);
assert.equal(lighting, 3.75);
assert.equal(total, 6.75);
assert.equal(
applyDistributionBoardSimultaneityFactor(total, 0.8),
5.4
);
assert.throws(
() => applyDistributionBoardSimultaneityFactor(total, 1.1),
/between zero and one/
);
});
}); });
describe("project voltage derivation", () => { describe("project voltage derivation", () => {
@@ -22,7 +22,10 @@ import {
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
type DistributionBoardStructureProjectCommand, type DistributionBoardStructureProjectCommand,
} from "../src/domain/models/distribution-board-structure-project-command.model.js"; } from "../src/domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js"; import {
createDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateProjectCommand,
} from "../src/domain/models/distribution-board-project-command.model.js";
import { import {
createDistributionBoard, createDistributionBoard,
updateDistributionBoard, updateDistributionBoard,
@@ -293,7 +296,7 @@ describe("distribution-board structure project command", () => {
} }
}); });
it("updates floor and supply type with persisted undo and validates project ownership", () => { it("updates floor, supply type and simultaneity factor with persisted undo", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
context.db context.db
@@ -335,7 +338,11 @@ describe("distribution-board structure project command", () => {
source: "user", source: "user",
command: createDistributionBoardUpdateProjectCommand( command: createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id, structure.distributionBoard.id,
{ floorId: "floor-1", supplyType: "SV" } {
floorId: "floor-1",
supplyType: "SV",
simultaneityFactor: 0.75,
}
), ),
}); });
assert.deepEqual( assert.deepEqual(
@@ -343,10 +350,16 @@ describe("distribution-board structure project command", () => {
.select({ .select({
floorId: distributionBoards.floorId, floorId: distributionBoards.floorId,
supplyType: distributionBoards.supplyType, supplyType: distributionBoards.supplyType,
simultaneityFactor:
distributionBoards.simultaneityFactor,
}) })
.from(distributionBoards) .from(distributionBoards)
.get(), .get(),
{ floorId: "floor-1", supplyType: "SV" } {
floorId: "floor-1",
supplyType: "SV",
simultaneityFactor: 0.75,
}
); );
const undoStep = new ProjectHistoryRepository( const undoStep = new ProjectHistoryRepository(
@@ -365,10 +378,16 @@ describe("distribution-board structure project command", () => {
.select({ .select({
floorId: distributionBoards.floorId, floorId: distributionBoards.floorId,
supplyType: distributionBoards.supplyType, supplyType: distributionBoards.supplyType,
simultaneityFactor:
distributionBoards.simultaneityFactor,
}) })
.from(distributionBoards) .from(distributionBoards)
.get(), .get(),
{ floorId: null, supplyType: "AV" } {
floorId: null,
supplyType: "AV",
simultaneityFactor: 1,
}
); );
assert.throws( assert.throws(
() => () =>
@@ -395,6 +414,52 @@ describe("distribution-board structure project command", () => {
} }
}); });
it("keeps stored version-one board updates executable", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV Alt",
{ supplyType: "AV" }
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardInsertProjectCommand(structure),
});
const legacyUpdate = {
schemaVersion: 1,
type: "distribution-board.update",
payload: {
distributionBoardId: structure.distributionBoard.id,
changes: [{ field: "supplyType", value: "SV" }],
},
} as DistributionBoardUpdateProjectCommand;
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: legacyUpdate,
});
const board = context.db
.select()
.from(distributionBoards)
.get();
assert.equal(board?.supplyType, "SV");
assert.equal(board?.simultaneityFactor, 1);
} finally {
context.close();
}
});
it("sends distribution-board updates to the revision-safe API", async () => { it("sends distribution-board updates to the revision-safe API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = []; const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -413,7 +478,11 @@ describe("distribution-board structure project command", () => {
await updateDistributionBoard( await updateDistributionBoard(
"project-1", "project-1",
"board-1", "board-1",
{ floorId: "floor-1", supplyType: "USV" }, {
floorId: "floor-1",
supplyType: "USV",
simultaneityFactor: 0.8,
},
9 9
); );
} finally { } finally {
@@ -426,6 +495,7 @@ describe("distribution-board structure project command", () => {
body: { body: {
floorId: "floor-1", floorId: "floor-1",
supplyType: "USV", supplyType: "USV",
simultaneityFactor: 0.8,
expectedRevision: 9, expectedRevision: 9,
}, },
}, },
+19 -1
View File
@@ -67,7 +67,11 @@ function createTestDatabase(): DatabaseContext {
.run(); .run();
context.db context.db
.update(distributionBoards) .update(distributionBoards)
.set({ floorId: "floor-1", supplyType: "SV" }) .set({
floorId: "floor-1",
supplyType: "SV",
simultaneityFactor: 0.65,
})
.where(eq(distributionBoards.id, board.id)) .where(eq(distributionBoards.id, board.id))
.run(); .run();
context.db context.db
@@ -176,6 +180,10 @@ describe("project snapshot repository", () => {
assert.equal(state.distributionBoards.length, 1); assert.equal(state.distributionBoards.length, 1);
assert.equal(state.distributionBoards[0].floorId, "floor-1"); assert.equal(state.distributionBoards[0].floorId, "floor-1");
assert.equal(state.distributionBoards[0].supplyType, "SV"); assert.equal(state.distributionBoards[0].supplyType, "SV");
assert.equal(
state.distributionBoards[0].simultaneityFactor,
0.65
);
assert.equal(state.circuitLists.length, 1); assert.equal(state.circuitLists.length, 1);
assert.equal( assert.equal(
state.circuitSections.length, state.circuitSections.length,
@@ -293,6 +301,7 @@ describe("project snapshot repository", () => {
>) { >) {
delete board.floorId; delete board.floorId;
delete board.supplyType; delete board.supplyType;
delete board.simultaneityFactor;
} }
const legacyPayloadJson = JSON.stringify(currentPayload); const legacyPayloadJson = JSON.stringify(currentPayload);
context.db context.db
@@ -375,6 +384,10 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].supplyType, copied.state.distributionBoards[0].supplyType,
"SV" "SV"
); );
assert.equal(
copied.state.distributionBoards[0].simultaneityFactor,
0.65
);
assert.equal( assert.equal(
copied.state.circuits[0].deviceRows[0].legacyConsumerId, copied.state.circuits[0].deviceRows[0].legacyConsumerId,
null null
@@ -431,6 +444,7 @@ describe("portable project transfer", () => {
for (const board of legacyState.distributionBoards) { for (const board of legacyState.distributionBoards) {
delete board.floorId; delete board.floorId;
delete board.supplyType; delete board.supplyType;
delete board.simultaneityFactor;
} }
const legacyPayloadJson = JSON.stringify(legacyState); const legacyPayloadJson = JSON.stringify(legacyState);
const duplicate = repository.importDuplicate({ const duplicate = repository.importDuplicate({
@@ -448,6 +462,10 @@ describe("portable project transfer", () => {
assert.ok(copied); assert.ok(copied);
assert.equal(copied.state.distributionBoards[0].floorId, null); assert.equal(copied.state.distributionBoards[0].floorId, null);
assert.equal(copied.state.distributionBoards[0].supplyType, null); assert.equal(copied.state.distributionBoards[0].supplyType, null);
assert.equal(
copied.state.distributionBoards[0].simultaneityFactor,
1
);
} finally { } finally {
context.close(); context.close();
} }
+28
View File
@@ -103,6 +103,7 @@ describe("project state snapshot model", () => {
...previous.distributionBoards[0], ...previous.distributionBoards[0],
floorId: null, floorId: null,
supplyType: null, supplyType: null,
simultaneityFactor: 1,
}, },
]); ]);
}); });
@@ -158,6 +159,30 @@ describe("project state snapshot model", () => {
assert.equal(snapshot.projectDevices[0]?.voltageV, 400); assert.equal(snapshot.projectDevices[0]?.voltageV, 400);
}); });
it("upgrades version-five distribution boards with a neutral factor", () => {
const previous = {
...minimalSnapshot(),
schemaVersion: 5 as const,
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: "AV" as const,
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(
snapshot.distributionBoards[0]?.simultaneityFactor,
1
);
});
it("rejects duplicate ids and cross-project ownership", () => { it("rejects duplicate ids and cross-project ownership", () => {
assert.throws( assert.throws(
() => () =>
@@ -170,6 +195,7 @@ describe("project state snapshot model", () => {
name: "UV 1", name: "UV 1",
floorId: null, floorId: null,
supplyType: null, supplyType: null,
simultaneityFactor: 1,
}, },
{ {
id: "board-1", id: "board-1",
@@ -177,6 +203,7 @@ describe("project state snapshot model", () => {
name: "UV 2", name: "UV 2",
floorId: null, floorId: null,
supplyType: null, supplyType: null,
simultaneityFactor: 1,
}, },
], ],
}), }),
@@ -211,6 +238,7 @@ describe("project state snapshot model", () => {
name: "UV 1", name: "UV 1",
floorId: "missing", floorId: "missing",
supplyType: "AV", supplyType: "AV",
simultaneityFactor: 1,
}, },
], ],
}), }),