Add configurable distribution supply types

This commit is contained in:
2026-07-29 09:31:20 +02:00
parent 194bc9c0b1
commit b1a11397b3
43 changed files with 5697 additions and 126 deletions
+11 -5
View File
@@ -283,17 +283,23 @@ that have remained unchanged. ProjectDevice create, update, delete and
global-to-project copy API/UI paths use these persistent commands and track the
returned project revision. ProjectDevice synchronization/disconnect API and UI
paths do the same; their undo action uses the project-wide history endpoint.
Project voltage settings use the persistent `project.update-settings` command.
Both voltage defaults change in one revision and Undo/Redo restores them
together; the project PUT route requires `expectedRevision`.
Project settings use the persistent `project.update-settings` command. Project
metadata, both voltage defaults and the enabled distribution-board supply types
change in one revision and Undo/Redo restores them together; the project PUT
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
cannot be disabled.
Distribution-board setup uses `distribution-board.insert` with a complete
stable snapshot of the board, circuit list and four default sections. Its
inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
Floor and room setup use `project-floor.insert` and `project-room.insert` with
complete stable snapshots. Their inverses remove only unchanged records and
reject floors with assigned rooms or rooms referenced by device rows or
retained upgrade data. Both POST routes require `expectedRevision` and return
reject floors with assigned rooms/distribution boards or rooms referenced by
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
the updated history state.
Required operations:
+18 -5
View File
@@ -145,11 +145,14 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId`
- request:
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo/Redo restores both voltage values together
- persistent Undo/Redo restores metadata, voltage values and the enabled
distribution-board supply types together
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
a supply type currently used by a distribution board cannot be disabled
- unchanged values are rejected without creating a revision; a stale
revision returns `409 PROJECT_REVISION_CONFLICT`
@@ -181,7 +184,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
### Distribution Board Setup
- `POST /projects/:projectId/distribution-boards`
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
- body:
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
- `floorId` may be `null`; a non-null floor must belong to the project
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
be enabled in the project settings
- executes `distribution-board.insert` with stable ids for the distribution
board, its circuit list and all four default circuit sections
- response:
@@ -189,6 +196,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
- persistent Undo removes only the unchanged and still-empty generated
structure; Redo restores the same ids
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored
together by persistent Undo/Redo
### Project Floors and Rooms
@@ -203,8 +215,9 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
and must belong to the project when present
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
- Persistent Undo removes only unchanged, unreferenced records. A floor with
assigned rooms and a room referenced by device rows or retained upgrade data
are rejected instead of silently clearing foreign keys.
assigned rooms or distribution boards and a room referenced by device rows
or retained upgrade data are rejected instead of silently clearing foreign
keys.
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
### Tree Endpoint
+17 -4
View File
@@ -221,8 +221,11 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung sowie beide Standardspannungen als eine
atomare Änderung. Die Projektseite bearbeitet diese Angaben in einem
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
@@ -258,11 +261,21 @@ Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
Controller-Schreibweg ist entfernt.
Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen.
`distribution-board.update` versioniert Etage und Netzart gemeinsam und stellt
beide Werte über dauerhaftes Undo/Redo wieder her. Snapshot-Schema 4 und der
portable Projekttransfer enthalten diese Felder; 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
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
entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt,
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
Raum wird nur entfernt,
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
+5
View File
@@ -25,6 +25,11 @@ requirements and intended sequencing, not proof of implementation.
- `SV` (Sicherheitsstromversorgung)
- `EV` (Ersatzstromversorgung)
- `USV` (Unterbrechungsfreie Stromversorgung)
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
- `SiBe` (Sicherheitsbeleuchtung)
- Let project settings select which catalog supply types are used in a
project. Distribution-board dialogs offer only that selection; a type in
use cannot be disabled.
- Include both fields in project snapshots and portable project
export/import.
- Persist changes through project commands so they remain undoable after a
+33
View File
@@ -53,6 +53,7 @@ const requiredProjectColumns = [
"external_project_number",
"building_owner",
"description",
"enabled_distribution_board_supply_types",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
@@ -60,6 +61,19 @@ const projectColumns = new Set(
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
const distributionBoardColumns = new Set(
db
.prepare("PRAGMA table_info(distribution_boards)")
.all()
.map((column) => column.name)
);
const missingDistributionBoardColumns =
requiredDistributionBoardColumns.filter(
(name) => !distributionBoardColumns.has(name)
);
const integrityCheck = db.pragma("integrity_check", { simple: true });
const foreignKeyViolations = db.pragma("foreign_key_check");
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
@@ -67,6 +81,12 @@ console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
);
console.log("Integrity:", integrityCheck);
console.log("Foreign-key violations:", foreignKeyViolations.length);
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
@@ -96,4 +116,17 @@ if (missingProjectColumns.length > 0) {
process.exit(1);
}
if (missingDistributionBoardColumns.length > 0) {
console.error(
"Missing distribution-board columns:",
missingDistributionBoardColumns.join(", ")
);
process.exit(1);
}
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
console.error("Database integrity or foreign-key verification failed.");
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
+226 -4
View File
@@ -23,6 +23,7 @@ import {
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -41,6 +42,10 @@ import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
import {
distributionBoardSupplyTypeLabels,
type DistributionBoardSupplyType,
} from "../../../shared/constants/distribution-board";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
ProjectSettingsModal,
@@ -74,6 +79,14 @@ export default function ProjectDetailPage() {
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [boardName, setBoardName] = useState("");
const [boardFloorId, setBoardFloorId] = useState("");
const [boardSupplyType, setBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [editingBoard, setEditingBoard] =
useState<DistributionBoardDto | null>(null);
const [editingBoardFloorId, setEditingBoardFloorId] = useState("");
const [editingBoardSupplyType, setEditingBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [floorName, setFloorName] = useState("");
const [roomNumber, setRoomNumber] = useState("");
const [roomName, setRoomName] = useState("");
@@ -158,6 +171,24 @@ export default function ProjectDetailPage() {
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
);
const enabledBoardSupplyTypes =
project?.enabledDistributionBoardSupplyTypes ?? [];
const usedBoardSupplyTypes = useMemo(
() =>
[
...new Set(
boards
.map((board) => board.supplyType)
.filter(
(
supplyType
): supplyType is DistributionBoardSupplyType =>
supplyType !== null
)
),
],
[boards]
);
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
@@ -175,7 +206,11 @@ export default function ProjectDetailPage() {
try {
const result = await createDistributionBoard(
projectId,
boardName.trim(),
{
name: boardName.trim(),
floorId: boardFloorId || null,
supplyType: boardSupplyType,
},
project.currentRevision
);
setBoards((current) => [
@@ -185,6 +220,10 @@ export default function ProjectDetailPage() {
setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision);
setBoardName("");
setBoardFloorId("");
setBoardSupplyType(
project.enabledDistributionBoardSupplyTypes[0]
);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
@@ -351,6 +390,60 @@ export default function ProjectDetailPage() {
}
}
function openBoardEditor(board: DistributionBoardDto) {
setEditingBoard(board);
setEditingBoardFloorId(board.floorId ?? "");
setEditingBoardSupplyType(
board.supplyType ??
project?.enabledDistributionBoardSupplyTypes[0] ??
"AV"
);
}
function openBoardCreator() {
setBoardSupplyType(
project?.enabledDistributionBoardSupplyTypes[0] ?? "AV"
);
setStructureModal("board");
}
async function handleUpdateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !editingBoard) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await updateDistributionBoard(
projectId,
editingBoard.id,
{
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
},
project.currentRevision
);
setBoards((current) =>
current.map((board) =>
board.id === result.distributionBoard.id
? result.distributionBoard
: board
)
);
applyProjectRevision(result.history.currentRevision);
setEditingBoard(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht bearbeitet werden."
);
} finally {
setIsSaving(false);
}
}
async function handleExportProject() {
if (!project) {
return;
@@ -596,7 +689,7 @@ export default function ProjectDetailPage() {
</div>
<button
className="btn btn-sm btn-primary"
onClick={() => setStructureModal("board")}
onClick={openBoardCreator}
type="button"
>
Verteilung hinzufügen
@@ -607,6 +700,8 @@ export default function ProjectDetailPage() {
<thead>
<tr>
<th>Verteilung</th>
<th>Etage</th>
<th>Netzart</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
@@ -616,8 +711,28 @@ export default function ProjectDetailPage() {
return (
<tr key={board.id}>
<td>{board.name}</td>
<td>
{board.floorId
? floorById.get(board.floorId)?.name ?? "Unbekannt"
: "Ohne Etage"}
</td>
<td>
{board.supplyType
? distributionBoardSupplyTypeLabels[
board.supplyType
]
: "Nicht festgelegt"}
</td>
<td className="text-end">
<div className="d-inline-flex gap-2">
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
<button
className="btn btn-sm btn-outline-secondary"
disabled={isSaving}
onClick={() => openBoardEditor(board)}
type="button"
>
Bearbeiten
</button>
{circuitList ? (
<Link
className="btn btn-sm btn-primary"
@@ -637,7 +752,7 @@ export default function ProjectDetailPage() {
})}
{!boards.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
<td colSpan={4} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden.
</td>
</tr>
@@ -967,6 +1082,112 @@ export default function ProjectDetailPage() {
required
value={boardName}
/>
<div className="row g-3 mt-0">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="distribution-board-floor">
Etage
</label>
<select
className="form-select"
id="distribution-board-floor"
onChange={(event) => setBoardFloorId(event.target.value)}
value={boardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="distribution-board-supply-type"
onChange={(event) =>
setBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={boardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{editingBoard ? (
<FormModal
description={`${editingBoard.name}: Etagenzuordnung und Netzart ändern.`}
isSaving={isSaving}
onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard}
submitLabel="Änderungen speichern"
title="Verteilung bearbeiten"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-floor"
>
Etage
</label>
<select
autoFocus
className="form-select"
id="edit-distribution-board-floor"
onChange={(event) =>
setEditingBoardFloorId(event.target.value)
}
value={editingBoardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="edit-distribution-board-supply-type"
onChange={(event) =>
setEditingBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={editingBoardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
@@ -1082,6 +1303,7 @@ export default function ProjectDetailPage() {
onImport={handleImportProject}
onSave={handleSaveProjectSettings}
project={project}
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/>
) : null}
</main>
@@ -0,0 +1,2 @@
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `supply_type` text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -120,6 +120,20 @@
"when": 1785254079435,
"tag": "0016_dashing_darkstar",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1785307189539,
"tag": "0017_vengeful_romulus",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1785308458789,
"tag": "0018_fancy_argent",
"breakpoints": true
}
]
}
@@ -6,9 +6,19 @@ import {
createDistributionBoardInsertProjectCommand,
distributionBoardDeleteCommandType,
distributionBoardInsertCommandType,
normalizeDistributionBoardStructureProjectCommand,
type DistributionBoardStructureProjectCommand,
type DistributionBoardStructureSnapshot,
legacyDistributionBoardStructureCommandSchemaVersion,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
createDistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateField,
type DistributionBoardUpdatePatch,
type DistributionBoardUpdateProjectCommand,
type DistributionBoardUpdateValues,
} from "../../domain/models/distribution-board-project-command.model.js";
import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
@@ -18,6 +28,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
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";
@@ -35,26 +46,52 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
assertDistributionBoardUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.update(tx, input)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardStructureProjectCommand
): DistributionBoardStructureProjectCommand {
if (command.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(command);
return this.insert(
const normalized =
normalizeDistributionBoardStructureProjectCommand(command);
if (normalized.type === distributionBoardInsertCommandType) {
assertDistributionBoardInsertProjectCommand(normalized);
const inverse = this.insert(
database,
projectId,
command.payload.structure
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
if (command.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(command);
return this.delete(
if (normalized.type === distributionBoardDeleteCommandType) {
assertDistributionBoardDeleteProjectCommand(normalized);
const inverse = this.delete(
database,
projectId,
command.payload.structure
normalized.payload.structure
);
return command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
? toLegacyStructureCommand(inverse)
: inverse;
}
throw new Error("Unsupported distribution-board structure command.");
}
@@ -73,13 +110,33 @@ export class DistributionBoardStructureProjectCommandRepository
);
}
const project = database
.select({ id: projects.id })
.select({
id: projects.id,
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
structure.distributionBoard.supplyType
);
if (structure.distributionBoard.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, structure.distributionBoard.floorId))
.get();
if (!floor || floor.projectId !== projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
const existingBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
@@ -179,6 +236,139 @@ export class DistributionBoardStructureProjectCommandRepository
}
return createDistributionBoardInsertProjectCommand(structure);
}
private update(
database: AppDatabase,
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
) {
const current = database
.select()
.from(distributionBoards)
.where(
and(
eq(
distributionBoards.id,
input.command.payload.distributionBoardId
),
eq(distributionBoards.projectId, input.projectId)
)
)
.get();
if (!current) {
throw new Error("Distribution board does not belong to project.");
}
const project = database
.select({
enabledSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const patch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
change.value,
])
) as DistributionBoardUpdatePatch;
if (patch.floorId !== undefined && patch.floorId !== null) {
const floor = database
.select({ projectId: floors.projectId })
.from(floors)
.where(eq(floors.id, patch.floorId))
.get();
if (!floor || floor.projectId !== input.projectId) {
throw new Error(
"Distribution-board floor does not belong to project."
);
}
}
if (patch.supplyType !== undefined) {
assertSupplyTypeEnabled(
project.enabledSupplyTypes,
patch.supplyType
);
}
const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [
change.field,
getDistributionBoardFieldValue(current, change.field),
])
) as DistributionBoardUpdatePatch;
const inverse = createDistributionBoardUpdateProjectCommand(
current.id,
inversePatch
);
const updated = database
.update(distributionBoards)
.set(patch)
.where(
and(
eq(distributionBoards.id, current.id),
eq(distributionBoards.projectId, input.projectId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Distribution board changed before command execution."
);
}
return inverse;
}
}
function assertSupplyTypeEnabled(
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
) {
if (
supplyType !== null &&
!enabledSupplyTypes.includes(supplyType)
) {
throw new Error(
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
);
}
}
function toLegacyStructureCommand(
command:
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
): DistributionBoardStructureProjectCommand {
const {
floorId: _floorId,
supplyType: _supplyType,
...distributionBoard
} = command.payload.structure.distributionBoard;
return {
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
type: command.type,
payload: {
structure: {
...command.payload.structure,
distributionBoard,
},
},
} as DistributionBoardStructureProjectCommand;
}
function getDistributionBoardFieldValue<
TField extends DistributionBoardUpdateField,
>(
distributionBoard: typeof distributionBoards.$inferSelect,
field: TField
): DistributionBoardUpdateValues[TField] {
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
}
function structureMatches(
@@ -24,6 +24,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { consumers } from "../schema/consumers.js";
import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
@@ -129,6 +130,17 @@ export class ProjectLocationStructureProjectCommandRepository
"A project floor with assigned rooms cannot be removed by history."
);
}
const referencedDistributionBoard = database
.select({ id: distributionBoards.id })
.from(distributionBoards)
.where(eq(distributionBoards.floorId, expected.id))
.limit(1)
.get();
if (referencedDistributionBoard) {
throw new Error(
"A project floor assigned to a distribution board cannot be removed by history."
);
}
const deleted = database
.delete(floors)
.where(
@@ -1,8 +1,10 @@
import { eq } from "drizzle-orm";
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
createProjectSettingsUpdateProjectCommand,
legacyProjectSettingsUpdateCommandSchemaVersion,
normalizeProjectSettingsValues,
previousProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import type {
@@ -11,6 +13,7 @@ import type {
} from "../../domain/ports/project-settings-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ProjectSettingsProjectCommandRepository
@@ -39,18 +42,7 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const target: ProjectSettingsValues =
input.command.schemaVersion ===
legacyProjectSettingsUpdateCommandSchemaVersion
? {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
...input.command.payload,
}
: input.command.payload;
const target = normalizeProjectSettingsValues(input.command, current);
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
@@ -66,6 +58,22 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: input.command.schemaVersion ===
previousProjectSettingsUpdateCommandSchemaVersion
? {
schemaVersion:
previousProjectSettingsUpdateCommandSchemaVersion,
type: input.command.type,
payload: {
name: current.name,
internalProjectNumber: current.internalProjectNumber,
externalProjectNumber: current.externalProjectNumber,
buildingOwner: current.buildingOwner,
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
name: current.name,
internalProjectNumber: current.internalProjectNumber,
@@ -74,7 +82,10 @@ export class ProjectSettingsProjectCommandRepository
description: current.description,
singlePhaseVoltageV: current.singlePhaseVoltageV,
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
});
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
.update(projects)
.set(target)
@@ -100,5 +111,54 @@ function projectSettingsEqual(
current.description === target.description &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)
);
}
function sameSupplyTypes(
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function assertDisabledSupplyTypesAreUnused(
database: AppDatabase,
projectId: string,
target: ProjectSettingsValues
) {
const used = database
.select({ supplyType: distributionBoards.supplyType })
.from(distributionBoards)
.where(
and(
eq(distributionBoards.projectId, projectId),
isNotNull(distributionBoards.supplyType)
)
)
.all();
const disabledUsedTypes = [
...new Set(
used
.map((entry) => entry.supplyType)
.filter(
(supplyType) =>
supplyType !== null &&
!target.enabledDistributionBoardSupplyTypes.some(
(enabled) => enabled === supplyType
)
)
),
];
if (disabledUsedTypes.length > 0) {
throw new Error(
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
);
}
}
@@ -4,8 +4,10 @@ import { ProjectRevisionConflictError } from "../../domain/errors/project-revisi
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
distributionBoardProjectStateSnapshotSchemaVersion,
deserializeProjectStateSnapshot,
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@@ -141,6 +143,9 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
}
if (
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
@@ -194,6 +194,8 @@ function replaceProjectState(
description: state.project.description,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
})
.where(eq(projects.id, state.project.id))
.run();
@@ -38,6 +38,8 @@ export function readProjectStateSnapshot(
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
currentRevision: projects.currentRevision,
})
.from(projects)
@@ -182,6 +184,8 @@ export function readProjectStateSnapshot(
description: project.description,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
},
distributionBoards: boardRows,
circuitLists: listRows,
@@ -74,12 +74,18 @@ export class ProjectTransferRepository implements ProjectTransferStore {
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256
) {
throw new Error("Project transfer checksum verification failed.");
}
@@ -87,6 +93,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function insertProjectState(
database: AppDatabase,
state: ReturnType<typeof remapProjectState>
@@ -5,6 +5,7 @@ import { projects } from "../schema/projects.js";
import type {
CreateProjectInput,
} from "../../shared/validation/project-structure.schemas.js";
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
export class ProjectRepository {
constructor(private readonly database: AppDatabase) {}
@@ -26,6 +27,9 @@ export class ProjectRepository {
description: input.description ?? null,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
currentRevision: 0,
};
await db.insert(projects).values(project);
+6
View File
@@ -1,5 +1,7 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
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(),
@@ -7,5 +9,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
.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>(),
});
+9
View File
@@ -1,4 +1,6 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
@@ -9,5 +11,12 @@ export const projects = sqliteTable("projects", {
description: text("description"),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
enabledDistributionBoardSupplyTypes: text(
"enabled_distribution_board_supply_types",
{ mode: "json" }
)
.$type<DistributionBoardSupplyType[]>()
.notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
currentRevision: integer("current_revision").notNull().default(0),
});
@@ -0,0 +1,112 @@
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
}
export type DistributionBoardUpdateField =
keyof DistributionBoardUpdateValues;
export type DistributionBoardUpdatePatch =
Partial<DistributionBoardUpdateValues>;
export interface DistributionBoardUpdateFieldChange<
TField extends DistributionBoardUpdateField =
DistributionBoardUpdateField,
> {
field: TField;
value: DistributionBoardUpdateValues[TField];
}
export interface DistributionBoardUpdateCommandPayload {
distributionBoardId: string;
changes: DistributionBoardUpdateFieldChange[];
}
export interface DistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType;
}
export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string,
patch: DistributionBoardUpdatePatch
): DistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = {
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
type: distributionBoardUpdateCommandType,
payload: {
distributionBoardId,
changes: Object.entries(patch).map(([field, value]) => ({
field: field as DistributionBoardUpdateField,
value,
})) as DistributionBoardUpdateFieldChange[],
},
};
assertDistributionBoardUpdateProjectCommand(command);
return command;
}
export function assertDistributionBoardUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardUpdateProjectCommand {
if (
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion ||
command.type !== distributionBoardUpdateCommandType ||
!isPlainObject(command.payload)
) {
throw new Error("Unsupported distribution-board update command.");
}
const { distributionBoardId, changes } = command.payload;
if (
typeof distributionBoardId !== "string" ||
!distributionBoardId.trim() ||
!Array.isArray(changes) ||
changes.length === 0
) {
throw new Error(
"Distribution-board update command requires an id and changes."
);
}
const seen = new Set<string>();
for (const change of changes) {
if (
!isPlainObject(change) ||
(change.field !== "floorId" && change.field !== "supplyType") ||
seen.has(change.field)
) {
throw new Error(
"Distribution-board update contains an invalid or duplicate field."
);
}
if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
) {
throw new Error("floorId must be a non-empty string or null.");
}
} else if (
change.value !== null &&
!distributionBoardSupplyTypes.includes(
change.value as DistributionBoardSupplyType
)
) {
throw new Error("supplyType is invalid.");
}
seen.add(change.field);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1,11 +1,16 @@
import crypto from "node:crypto";
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardInsertCommandType =
"distribution-board.insert" as const;
export const distributionBoardDeleteCommandType =
"distribution-board.delete" as const;
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
export const defaultCircuitSectionDefinitions = [
{
@@ -34,11 +39,23 @@ export const defaultCircuitSectionDefinitions = [
},
] as const;
interface LegacyDistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
};
circuitList: DistributionBoardStructureSnapshot["circuitList"];
sections: DistributionBoardStructureSnapshot["sections"];
}
export interface DistributionBoardStructureSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
};
circuitList: {
id: string;
@@ -56,29 +73,52 @@ export interface DistributionBoardStructureSnapshot {
}>;
}
interface DistributionBoardStructureCommandPayload {
structure: DistributionBoardStructureSnapshot;
interface DistributionBoardStructureCommandPayload<
TStructure = DistributionBoardStructureSnapshot,
> {
structure: TStructure;
}
interface CurrentDistributionBoardStructureProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
type:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType;
}
interface LegacyDistributionBoardStructureProjectCommand
extends SerializedProjectCommand<
DistributionBoardStructureCommandPayload<LegacyDistributionBoardStructureSnapshot>
> {
schemaVersion: typeof legacyDistributionBoardStructureCommandSchemaVersion;
type:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType;
}
export interface DistributionBoardInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
extends CurrentDistributionBoardStructureProjectCommand {
type: typeof distributionBoardInsertCommandType;
}
export interface DistributionBoardDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
extends CurrentDistributionBoardStructureProjectCommand {
type: typeof distributionBoardDeleteCommandType;
}
export type DistributionBoardStructureProjectCommand =
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
| DistributionBoardDeleteProjectCommand
| LegacyDistributionBoardStructureProjectCommand;
export function createDistributionBoardStructureSnapshot(
projectId: string,
name: string
name: string,
input: {
floorId?: string | null;
supplyType?: DistributionBoardSupplyType | null;
} = {}
): DistributionBoardStructureSnapshot {
const normalizedName = name.trim();
assertNonEmptyString(projectId, "projectId");
@@ -89,6 +129,8 @@ export function createDistributionBoardStructureSnapshot(
id: structureId,
projectId,
name: normalizedName,
floorId: input.floorId ?? null,
supplyType: input.supplyType ?? null,
},
circuitList: {
id: structureId,
@@ -132,7 +174,7 @@ export function createDistributionBoardDeleteProjectCommand(
export function assertDistributionBoardInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardInsertProjectCommand {
): asserts command is DistributionBoardStructureProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardInsertCommandType
@@ -141,23 +183,88 @@ export function assertDistributionBoardInsertProjectCommand(
export function assertDistributionBoardDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardDeleteProjectCommand {
): asserts command is DistributionBoardStructureProjectCommand {
assertDistributionBoardStructureProjectCommand(
command,
distributionBoardDeleteCommandType
);
}
export function normalizeDistributionBoardStructureProjectCommand(
command: DistributionBoardStructureProjectCommand
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
if (
command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
) {
const structure: DistributionBoardStructureSnapshot = {
...command.payload.structure,
distributionBoard: {
...command.payload.structure.distributionBoard,
floorId: null,
supplyType: null,
},
};
return command.type === distributionBoardInsertCommandType
? createDistributionBoardInsertProjectCommand(structure)
: createDistributionBoardDeleteProjectCommand(structure);
}
return command as
| DistributionBoardInsertProjectCommand
| DistributionBoardDeleteProjectCommand;
}
export function assertDistributionBoardStructureSnapshot(
structure: unknown
): asserts structure is DistributionBoardStructureSnapshot {
assertDistributionBoardStructureSnapshotVersion(structure, false);
}
function assertDistributionBoardStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType
) {
if (
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board structure command.");
}
if (
command.schemaVersion ===
legacyDistributionBoardStructureCommandSchemaVersion
) {
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
true
);
return;
}
if (
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
) {
throw new Error("Unsupported distribution-board structure command.");
}
assertDistributionBoardStructureSnapshotVersion(
command.payload.structure,
false
);
}
function assertDistributionBoardStructureSnapshotVersion(
structure: unknown,
legacy: boolean
) {
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
throw new Error("Distribution-board structure is invalid.");
}
const { distributionBoard, circuitList, sections } = structure;
if (
!isPlainObject(distributionBoard) ||
Object.keys(distributionBoard).length !== 3 ||
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
!isPlainObject(circuitList) ||
Object.keys(circuitList).length !== 4 ||
!Array.isArray(sections) ||
@@ -165,8 +272,15 @@ export function assertDistributionBoardStructureSnapshot(
) {
throw new Error("Distribution-board structure is incomplete.");
}
for (const [field, value] of Object.entries(distributionBoard)) {
assertNonEmptyString(value, `distributionBoard.${field}`);
for (const field of ["id", "projectId", "name"] as const) {
assertNonEmptyString(
distributionBoard[field],
`distributionBoard.${field}`
);
}
if (!legacy) {
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
assertNullableSupplyType(distributionBoard.supplyType);
}
for (const [field, value] of Object.entries(circuitList)) {
assertNonEmptyString(value, `circuitList.${field}`);
@@ -174,22 +288,16 @@ export function assertDistributionBoardStructureSnapshot(
if (
circuitList.projectId !== distributionBoard.projectId ||
circuitList.distributionBoardId !== distributionBoard.id ||
circuitList.name !==
`${distributionBoard.name} Stromkreisliste`
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
) {
throw new Error(
"Distribution board and circuit list are inconsistent."
);
throw new Error("Distribution board and circuit list are inconsistent.");
}
const sectionIds = new Set<string>();
for (let index = 0; index < sections.length; index += 1) {
const section = sections[index];
const expected = defaultCircuitSectionDefinitions[index];
if (
!isPlainObject(section) ||
Object.keys(section).length !== 6
) {
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
throw new Error("Default circuit section is invalid.");
}
assertNonEmptyString(section.id, "section.id");
@@ -211,22 +319,23 @@ export function assertDistributionBoardStructureSnapshot(
}
}
function assertDistributionBoardStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof distributionBoardInsertCommandType
| typeof distributionBoardDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board structure command.");
function assertNullableId(value: unknown, field: string) {
if (value !== null) {
assertNonEmptyString(value, field);
}
}
function assertNullableSupplyType(
value: unknown
): asserts value is DistributionBoardSupplyType | null {
if (
value !== null &&
!distributionBoardSupplyTypes.includes(
value as DistributionBoardSupplyType
)
) {
throw new Error("distributionBoard.supplyType is invalid.");
}
assertDistributionBoardStructureSnapshot(command.payload.structure);
}
function assertNonEmptyString(
@@ -1,16 +1,21 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
export interface LegacyProjectSettingsValues {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
export interface PreviousProjectSettingsValues extends LegacyProjectSettingsValues {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@@ -18,6 +23,10 @@ export interface ProjectSettingsValues extends LegacyProjectSettingsValues {
description: string | null;
}
export interface ProjectSettingsValues extends PreviousProjectSettingsValues {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
export interface LegacyProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
@@ -30,8 +39,15 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType;
}
export interface PreviousProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export type ProjectSettingsUpdateProjectCommand =
| LegacyProjectSettingsUpdateProjectCommand
| PreviousProjectSettingsUpdateProjectCommand
| CurrentProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand(
@@ -53,6 +69,8 @@ export function assertProjectSettingsUpdateProjectCommand(
command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !==
legacyProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
previousProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
) {
throw new Error("Unsupported project settings update command.");
@@ -66,6 +84,10 @@ export function assertProjectSettingsUpdateProjectCommand(
assertLegacyValues(command.payload);
return;
}
const expectedKeyCount =
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
? 7
: 8;
if (
!isTrimmedString(command.payload.name, 1, 200) ||
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
@@ -74,12 +96,61 @@ export function assertProjectSettingsUpdateProjectCommand(
!isNullableTrimmedString(command.payload.description, 2000) ||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
Object.keys(command.payload).length !== 7
Object.keys(command.payload).length !== expectedKeyCount
) {
throw new Error(
"Project settings update command contains invalid values."
);
}
if (
command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!isValidSupplyTypes(
command.payload.enabledDistributionBoardSupplyTypes
)
) {
throw new Error(
"Project settings update command contains invalid supply types."
);
}
}
export function normalizeProjectSettingsValues(
command: ProjectSettingsUpdateProjectCommand,
current: ProjectSettingsValues
): ProjectSettingsValues {
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
return {
...current,
...command.payload,
};
}
if (
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
) {
return {
...command.payload,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
};
}
return command.payload;
}
function isValidSupplyTypes(
value: unknown
): value is DistributionBoardSupplyType[] {
return (
Array.isArray(value) &&
value.length > 0 &&
new Set(value).size === value.length &&
value.every(
(entry) =>
typeof entry === "string" &&
distributionBoardSupplyTypes.includes(
entry as DistributionBoardSupplyType
)
)
);
}
function assertLegacyValues(
+111 -10
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import {
defaultDistributionBoardSupplyTypes,
distributionBoardSupplyTypes,
} from "../../shared/constants/distribution-board.js";
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const projectStateSnapshotSchemaVersion = 2 as const;
export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const projectStateSnapshotSchemaVersion = 4 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
@@ -23,7 +29,14 @@ const projectSchema = legacyProjectSchema.extend({
description: z.string().trim().min(1).max(2000).nullable(),
});
const distributionBoardSchema = z
const currentProjectSchema = projectSchema.extend({
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length),
});
const legacyDistributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
@@ -31,6 +44,13 @@ const distributionBoardSchema = z
})
.strict();
const distributionBoardSchema = legacyDistributionBoardSchema
.extend({
floorId: idSchema.nullable(),
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
@@ -140,8 +160,7 @@ const roomSchema = z
})
.strict();
const projectStateSnapshotContents = {
distributionBoards: z.array(distributionBoardSchema),
const commonProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
@@ -154,15 +173,37 @@ const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
...projectStateSnapshotContents,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
const distributionBoardProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(
distributionBoardProjectStateSnapshotSchemaVersion
),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
...projectStateSnapshotContents,
project: currentProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
})
.strict();
@@ -176,9 +217,23 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeLegacyProjectStateSnapshot(
? upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
)
)
: version === previousProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
)
)
: version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
@@ -186,10 +241,10 @@ export function parseProjectStateSnapshot(
function upgradeLegacyProjectStateSnapshot(
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
): ProjectStateSnapshot {
): z.infer<typeof previousProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: previousProjectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
internalProjectNumber: null,
@@ -200,6 +255,35 @@ function upgradeLegacyProjectStateSnapshot(
};
}
function upgradePreviousProjectStateSnapshot(
snapshot: z.infer<typeof previousProjectStateSnapshotSchema>
): z.infer<typeof distributionBoardProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: distributionBoardProjectStateSnapshotSchemaVersion,
distributionBoards: snapshot.distributionBoards.map((board) => ({
...board,
floorId: null,
supplyType: null,
})),
};
}
function upgradeDistributionBoardProjectStateSnapshot(
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
...snapshot.project,
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
},
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -245,6 +329,23 @@ function assertProjectStateSnapshotRelations(
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
if (board.floorId !== null) {
assertReference(
floorIds,
board.floorId,
"distribution board floor"
);
}
if (
board.supplyType !== null &&
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
board.supplyType
)
) {
throw new Error(
"Snapshot distribution board uses a disabled supply type."
);
}
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
@@ -94,6 +94,10 @@ export function remapProjectState(
...board,
id: requiredId(boardIds, board.id),
projectId: targetProjectId,
floorId:
board.floorId === null
? null
: requiredId(floorIds, board.floorId),
})),
circuitLists: source.circuitLists.map((list) => ({
...list,
@@ -1,4 +1,5 @@
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
@@ -23,4 +24,15 @@ export interface DistributionBoardStructureProjectCommandStore {
execute(
input: ExecuteDistributionBoardStructureCommandInput
): ExecutedDistributionBoardStructureCommand;
executeUpdate(
input: Omit<
ExecuteDistributionBoardStructureCommandInput,
"command"
> & {
command: DistributionBoardUpdateProjectCommand;
}
): {
revision: AppendedProjectRevision;
inverse: DistributionBoardUpdateProjectCommand;
};
}
@@ -37,6 +37,10 @@ import {
circuitDeleteCommandType,
circuitInsertCommandType,
} from "../models/circuit-structure-project-command.model.js";
import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -285,6 +289,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardUpdateCommandType: {
assertDistributionBoardUpdateProjectCommand(input.command);
return this.distributionBoardStructureStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -1,7 +1,12 @@
"use client";
import { FormEvent, useState } from "react";
import React, { FormEvent, useState } from "react";
import type { ProjectDto } from "../types";
import {
distributionBoardSupplyTypeLabels,
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
export interface ProjectSettingsInput {
name: string;
@@ -11,6 +16,7 @@ export interface ProjectSettingsInput {
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
interface ProjectSettingsModalProps {
@@ -23,6 +29,7 @@ interface ProjectSettingsModalProps {
) => Promise<void>;
onSave: (input: ProjectSettingsInput) => Promise<void>;
project: ProjectDto;
usedDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
export function ProjectSettingsModal({
@@ -32,6 +39,7 @@ export function ProjectSettingsModal({
onImport,
onSave,
project,
usedDistributionBoardSupplyTypes,
}: ProjectSettingsModalProps) {
const [name, setName] = useState(project.name);
const [internalProjectNumber, setInternalProjectNumber] = useState(
@@ -52,6 +60,12 @@ export function ProjectSettingsModal({
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
String(project.threePhaseVoltageV)
);
const [
enabledDistributionBoardSupplyTypes,
setEnabledDistributionBoardSupplyTypes,
] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes
);
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@@ -70,13 +84,31 @@ export function ProjectSettingsModal({
description: toNullableString(description),
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes,
});
}
const isValid =
name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0;
Number(threePhaseVoltageV) > 0 &&
enabledDistributionBoardSupplyTypes.length > 0;
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
if (
enabledDistributionBoardSupplyTypes.includes(supplyType) &&
usedDistributionBoardSupplyTypes.includes(supplyType)
) {
return;
}
setEnabledDistributionBoardSupplyTypes((current) =>
current.includes(supplyType)
? current.filter((entry) => entry !== supplyType)
: distributionBoardSupplyTypes.filter(
(entry) => entry === supplyType || current.includes(entry)
)
);
}
async function handleTransferFile(file: File | undefined) {
setTransfer(null);
@@ -165,6 +197,53 @@ export function ProjectSettingsModal({
value={externalProjectNumber}
/>
</div>
<div className="col-12">
<fieldset>
<legend className="form-label mb-1">
Verwendete Netzarten
</legend>
<p className="form-text mt-0">
Nur ausgewählte Netzarten stehen bei Verteilungen zur
Auswahl. Bereits verwendete Netzarten können nicht
deaktiviert werden.
</p>
<div className="row g-2">
{distributionBoardSupplyTypes.map((supplyType) => (
<div
className="col-12 col-md-6"
key={supplyType}
>
<label className="form-check">
<input
checked={enabledDistributionBoardSupplyTypes.includes(
supplyType
)}
className="form-check-input"
disabled={usedDistributionBoardSupplyTypes.includes(
supplyType
)}
onChange={() => toggleSupplyType(supplyType)}
type="checkbox"
/>
<span className="form-check-label">
{distributionBoardSupplyTypeLabels[supplyType]}
{usedDistributionBoardSupplyTypes.includes(
supplyType
)
? " (in Verwendung)"
: ""}
</span>
</label>
</div>
))}
</div>
{enabledDistributionBoardSupplyTypes.length === 0 ? (
<div className="text-danger small mt-2">
Mindestens eine Netzart muss aktiviert sein.
</div>
) : null}
</fieldset>
</div>
<div className="col-12">
<label className="form-label" htmlFor="building-owner">
Bauherr
+5
View File
@@ -1,3 +1,5 @@
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
export interface ProjectDto {
id: string;
name: string;
@@ -7,6 +9,7 @@ export interface ProjectDto {
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
currentRevision: number;
}
@@ -89,6 +92,8 @@ export interface DistributionBoardDto {
id: string;
projectId: string;
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
}
export interface DistributionBoardCommandResultDto
+25 -2
View File
@@ -226,6 +226,7 @@ export function updateProjectSettings(
description: string | null;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
}
) {
return request<ProjectSettingsCommandResultDto>(
@@ -276,14 +277,36 @@ export function listDistributionBoards(projectId: string) {
export function createDistributionBoard(
projectId: string,
name: string,
input: {
name: string;
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
},
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
`/api/projects/${projectId}/distribution-boards`,
{
method: "POST",
body: JSON.stringify({ name, expectedRevision }),
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
export function updateDistributionBoard(
projectId: string,
distributionBoardId: string,
input: {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
},
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}`,
{
method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }),
}
);
}
@@ -31,6 +31,7 @@ const commandTypeLabels: Record<string, string> = {
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
"project.update-settings": "Projekteinstellungen bearbeitet",
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
"distribution-board.delete": "Verteilung entfernt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
@@ -1,9 +1,13 @@
import type { Request, Response } from "express";
import { createDistributionBoardUpdateProjectCommand } from "../../domain/models/distribution-board-project-command.model.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../../domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
import {
createDistributionBoardSchema,
updateDistributionBoardSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { distributionBoardRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
@@ -31,7 +35,11 @@ export async function createDistributionBoard(req: Request, res: Response) {
const structure = createDistributionBoardStructureSnapshot(
projectId,
parsed.data.name
parsed.data.name,
{
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
}
);
try {
const result = projectCommandService.executeUser({
@@ -49,3 +57,43 @@ export async function createDistributionBoard(req: Request, res: Response) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateDistributionBoard(
req: Request,
res: Response
) {
const { projectId, distributionBoardId } = req.params;
if (
typeof projectId !== "string" ||
typeof distributionBoardId !== "string"
) {
return res.status(400).json({ error: "Invalid distribution board id" });
}
const parsed = updateDistributionBoardSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Verteilung bearbeiten",
command: createDistributionBoardUpdateProjectCommand(
distributionBoardId,
{
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
}
),
});
const distributionBoard = (
await distributionBoardRepository.listByProject(projectId)
).find((board) => board.id === distributionBoardId);
if (!distributionBoard) {
return res.status(404).json({ error: "Distribution board not found" });
}
return res.json({ ...result, distributionBoard });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
+5
View File
@@ -8,6 +8,7 @@ import {
import {
createDistributionBoard,
listDistributionBoardsByProject,
updateDistributionBoard,
} from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
@@ -55,6 +56,10 @@ projectRouter.post(
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.put(
"/:projectId/distribution-boards/:distributionBoardId",
updateDistributionBoard
);
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
projectRouter.get("/:projectId/floors", listFloorsByProject);
@@ -0,0 +1,26 @@
export const distributionBoardSupplyTypes = [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
] as const;
export type DistributionBoardSupplyType =
(typeof distributionBoardSupplyTypes)[number];
export const defaultDistributionBoardSupplyTypes: DistributionBoardSupplyType[] =
[...distributionBoardSupplyTypes];
export const distributionBoardSupplyTypeLabels: Record<
DistributionBoardSupplyType,
string
> = {
AV: "AV Allgemeine Stromversorgung",
SV: "SV Sicherheitsstromversorgung",
EV: "EV Ersatzstromversorgung",
USV: "USV Unterbrechungsfreie Stromversorgung",
MSR: "MSR Mess-, Steuerungs- und Regelungstechnik",
SiBe: "SiBe Sicherheitsbeleuchtung",
};
@@ -1,4 +1,5 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createProjectSchema = z.object({
@@ -21,6 +22,12 @@ export const updateProjectSettingsSchema = z
description: z.string().trim().max(2000).nullable(),
singlePhaseVoltageV: z.number().positive(),
threePhaseVoltageV: z.number().positive(),
enabledDistributionBoardSupplyTypes: z
.array(z.enum(distributionBoardSupplyTypes))
.min(1)
.refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}),
})
.strict();
@@ -28,6 +35,16 @@ export const createDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1),
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
.strict();
export const updateDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
})
.strict();
@@ -13,14 +13,20 @@ import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
type DistributionBoardStructureProjectCommand,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoard } from "../src/frontend/utils/api.js";
import { createDistributionBoardUpdateProjectCommand } from "../src/domain/models/distribution-board-project-command.model.js";
import {
createDistributionBoard,
updateDistributionBoard,
} from "../src/frontend/utils/api.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
@@ -50,9 +56,11 @@ describe("distribution-board structure project command", () => {
it("builds the fixed setup and sends the expected frontend revision", async () => {
const structure = createDistributionBoardStructureSnapshot(
"project-1",
" UV-02 "
" UV-02 ",
{ floorId: null, supplyType: "SV" }
);
assert.equal(structure.distributionBoard.name, "UV-02");
assert.equal(structure.distributionBoard.supplyType, "SV");
assert.equal(
structure.circuitList.name,
"UV-02 Stromkreisliste"
@@ -91,14 +99,27 @@ describe("distribution-board structure project command", () => {
});
};
try {
await createDistributionBoard("project-1", "UV-02", 7);
await createDistributionBoard(
"project-1",
{
name: "UV-02",
floorId: null,
supplyType: "SV",
},
7
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/distribution-boards",
body: { name: "UV-02", expectedRevision: 7 },
body: {
name: "UV-02",
floorId: null,
supplyType: "SV",
expectedRevision: 7,
},
},
]);
});
@@ -178,6 +199,239 @@ describe("distribution-board structure project command", () => {
}
});
it("allows only supply types enabled in the project", () => {
const context = createTestDatabase();
try {
context.db
.update(projects)
.set({
enabledDistributionBoardSupplyTypes: ["MSR", "SiBe"],
})
.where(eq(projects.id, "project-1"))
.run();
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(
createDistributionBoardStructureSnapshot(
"project-1",
"UV AV",
{ floorId: null, supplyType: "AV" }
)
),
}),
/nicht aktiviert/
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV SiBe",
{ floorId: null, supplyType: "SiBe" }
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(structure),
});
assert.equal(
context.db.select().from(distributionBoards).get()?.supplyType,
"SiBe"
);
} finally {
context.close();
}
});
it("applies stored version-one setup commands with unassigned new fields", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const current = createDistributionBoardStructureSnapshot(
"project-1",
"UV Alt",
{ floorId: null, supplyType: null }
);
const {
floorId: _floorId,
supplyType: _supplyType,
...legacyBoard
} = current.distributionBoard;
const legacyCommand = {
schemaVersion: 1,
type: "distribution-board.insert",
payload: {
structure: {
...current,
distributionBoard: legacyBoard,
},
},
} as unknown as DistributionBoardStructureProjectCommand;
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: legacyCommand,
});
const inserted = context.db
.select()
.from(distributionBoards)
.get();
assert.equal(inserted?.floorId, null);
assert.equal(inserted?.supplyType, null);
} finally {
context.close();
}
});
it("updates floor and supply type with persisted undo and validates project ownership", () => {
const context = createTestDatabase();
try {
context.db
.insert(floors)
.values([
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 10,
},
{
id: "floor-foreign",
projectId: "project-2",
name: "Fremd",
sortOrder: 10,
},
])
.run();
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-02",
{ floorId: null, supplyType: "AV" }
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardInsertProjectCommand(structure),
});
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{ floorId: "floor-1", supplyType: "SV" }
),
});
assert.deepEqual(
context.db
.select({
floorId: distributionBoards.floorId,
supplyType: distributionBoards.supplyType,
})
.from(distributionBoards)
.get(),
{ floorId: "floor-1", supplyType: "SV" }
);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(
context.db
.select({
floorId: distributionBoards.floorId,
supplyType: distributionBoards.supplyType,
})
.from(distributionBoards)
.get(),
{ floorId: null, supplyType: "AV" }
);
assert.throws(
() =>
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 3,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{ floorId: "floor-foreign" }
),
}),
/does not belong/
);
assert.equal(
context.db
.select()
.from(projectRevisions)
.all().length,
3
);
} finally {
context.close();
}
});
it("sends distribution-board updates to the revision-safe API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: JSON.parse(String(init?.body)),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await updateDistributionBoard(
"project-1",
"board-1",
{ floorId: "floor-1", supplyType: "USV" },
9
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/distribution-boards/board-1",
method: "PUT",
body: {
floorId: "floor-1",
supplyType: "USV",
expectedRevision: 9,
},
},
]);
});
it("rejects foreign, stale and changed structures without partial writes", () => {
const context = createTestDatabase();
try {
+8
View File
@@ -958,6 +958,14 @@ describe("project command service", () => {
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
}),
});
assert.equal(updated.history.currentRevision, 1);
@@ -10,6 +10,7 @@ import {
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { consumers } from "../src/db/schema/consumers.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
@@ -183,6 +184,52 @@ describe("project-location structure project command", () => {
}
});
it("does not undo a floor assigned to a distribution board", () => {
const context = createTestDatabase();
try {
const repository =
new ProjectLocationStructureProjectCommandRepository(context.db);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectFloorInsertProjectCommand(floor),
});
context.db
.insert(distributionBoards)
.values({
id: "board-1",
projectId: "project-1",
name: "UV-01",
floorId: floor.id,
supplyType: "AV",
})
.run();
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: inserted.inverse,
}),
/assigned to a distribution board/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("creates a stable room and supports persisted undo and redo", () => {
const context = createTestDatabase();
try {
@@ -11,6 +11,7 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { updateProjectSettings } from "../src/frontend/utils/api.js";
@@ -43,6 +44,8 @@ function getSettings(context: DatabaseContext) {
description: projects.description,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
currentRevision: projects.currentRevision,
})
.from(projects)
@@ -63,6 +66,14 @@ function settings(
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
...overrides,
};
}
@@ -110,6 +121,14 @@ describe("project settings project-command repository", () => {
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
},
},
]);
@@ -128,6 +147,14 @@ describe("project settings project-command repository", () => {
buildingOwner: "Beispiel Bau GmbH",
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
});
const forward = repository.executeUpdate({
projectId: "project-1",
@@ -136,13 +163,12 @@ describe("project settings project-command repository", () => {
command,
});
assert.deepEqual(getSettings(context), {
...settings({
name: "Neuer Projektname",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: "Beispiel Bau GmbH",
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
}),
currentRevision: 1,
});
assert.deepEqual(forward.inverse.payload, {
@@ -153,6 +179,14 @@ describe("project settings project-command repository", () => {
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
});
const undoStep = history.getNextCommand("project-1", "undo");
@@ -172,6 +206,14 @@ describe("project settings project-command repository", () => {
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
currentRevision: 2,
});
@@ -192,6 +234,14 @@ describe("project settings project-command repository", () => {
description: null,
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
currentRevision: 3,
});
} finally {
@@ -230,6 +280,51 @@ describe("project settings project-command repository", () => {
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
enabledDistributionBoardSupplyTypes: ["AV", "MSR", "SiBe"],
}),
});
assert.deepEqual(
getSettings(context)?.enabledDistributionBoardSupplyTypes,
["AV", "MSR", "SiBe"]
);
context.db.insert(distributionBoards).values({
id: "board-1",
projectId: "project-1",
name: "UV SiBe",
supplyType: "SiBe",
}).run();
assert.throws(
() =>
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createProjectSettingsUpdateProjectCommand({
...settings(),
enabledDistributionBoardSupplyTypes: ["AV", "MSR"],
}),
}),
/SiBe/
);
assert.equal(getSettings(context)?.currentRevision, 1);
} finally {
context.close();
}
});
it("rejects no-op, invalid project and stale revision without partial writes", () => {
const context = createTestDatabase();
try {
@@ -277,13 +372,7 @@ describe("project settings project-command repository", () => {
ProjectRevisionConflictError
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...settings(),
currentRevision: 0,
});
assert.equal(
@@ -323,13 +412,7 @@ describe("project settings project-command repository", () => {
/forced project settings history failure/
);
assert.deepEqual(getSettings(context), {
name: "Testprojekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
...settings(),
currentRevision: 0,
});
assert.equal(
+63
View File
@@ -16,6 +16,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
@@ -64,6 +65,11 @@ function createTestDatabase(): DatabaseContext {
sortOrder: 10,
})
.run();
context.db
.update(distributionBoards)
.set({ floorId: "floor-1", supplyType: "SV" })
.where(eq(distributionBoards.id, board.id))
.run();
context.db
.insert(rooms)
.values({
@@ -168,6 +174,8 @@ describe("project snapshot repository", () => {
);
assert.equal(state.project.id, "project-1");
assert.equal(state.distributionBoards.length, 1);
assert.equal(state.distributionBoards[0].floorId, "floor-1");
assert.equal(state.distributionBoards[0].supplyType, "SV");
assert.equal(state.circuitLists.length, 1);
assert.equal(
state.circuitSections.length,
@@ -279,6 +287,13 @@ describe("project snapshot repository", () => {
delete currentPayload.project.externalProjectNumber;
delete currentPayload.project.buildingOwner;
delete currentPayload.project.description;
delete currentPayload.project.enabledDistributionBoardSupplyTypes;
for (const board of currentPayload.distributionBoards as Array<
Record<string, unknown>
>) {
delete board.floorId;
delete board.supplyType;
}
const legacyPayloadJson = JSON.stringify(currentPayload);
context.db
.update(projectSnapshots)
@@ -352,6 +367,14 @@ describe("portable project transfer", () => {
copied.state.circuits[0].deviceRows[0].roomId,
copied.state.rooms[0].id
);
assert.equal(
copied.state.distributionBoards[0].floorId,
copied.state.floors[0].id
);
assert.equal(
copied.state.distributionBoards[0].supplyType,
"SV"
);
assert.equal(
copied.state.circuits[0].deviceRows[0].legacyConsumerId,
null
@@ -390,6 +413,46 @@ describe("portable project transfer", () => {
}
});
it("imports a checksum-valid version-two project transfer", () => {
const context = createTestDatabase();
try {
const repository = new ProjectTransferRepository(context.db);
const current = repository.exportProject("project-1");
assert.ok(current);
const legacyState = structuredClone(
current.projectState
) as unknown as Record<string, unknown> & {
distributionBoards: Array<Record<string, unknown>>;
};
legacyState.schemaVersion = 2;
delete (
legacyState.project as Record<string, unknown>
).enabledDistributionBoardSupplyTypes;
for (const board of legacyState.distributionBoards) {
delete board.floorId;
delete board.supplyType;
}
const legacyPayloadJson = JSON.stringify(legacyState);
const duplicate = repository.importDuplicate({
...current,
projectState: legacyState,
payloadSha256: crypto
.createHash("sha256")
.update(legacyPayloadJson)
.digest("hex"),
});
const copied = readProjectStateSnapshot(
context.db,
duplicate.projectId
);
assert.ok(copied);
assert.equal(copied.state.distributionBoards[0].floorId, null);
assert.equal(copied.state.distributionBoards[0].supplyType, null);
} finally {
context.close();
}
});
it("rolls back a duplicate when a late project-state insert fails", () => {
const context = createTestDatabase();
try {
+84
View File
@@ -20,6 +20,14 @@ function minimalSnapshot() {
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
},
distributionBoards: [],
circuitLists: [],
@@ -61,9 +69,65 @@ describe("project state snapshot model", () => {
externalProjectNumber: null,
buildingOwner: null,
description: null,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
});
});
it("upgrades version-two distribution boards without inferred assignments", () => {
const previous = {
...minimalSnapshot(),
schemaVersion: 2 as const,
project: {
...minimalSnapshot().project,
enabledDistributionBoardSupplyTypes: undefined,
},
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
},
],
};
delete previous.project.enabledDistributionBoardSupplyTypes;
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(snapshot.distributionBoards, [
{
...previous.distributionBoards[0],
floorId: null,
supplyType: null,
},
]);
});
it("upgrades version-three snapshots with all project supply types", () => {
const previous = {
...minimalSnapshot(),
schemaVersion: 3 as const,
project: {
...minimalSnapshot().project,
enabledDistributionBoardSupplyTypes: undefined,
},
};
delete previous.project.enabledDistributionBoardSupplyTypes;
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(snapshot.project.enabledDistributionBoardSupplyTypes, [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
]);
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>
@@ -74,11 +138,15 @@ describe("project state snapshot model", () => {
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
},
{
id: "board-1",
projectId: "project-1",
name: "UV 2",
floorId: null,
supplyType: null,
},
],
}),
@@ -102,6 +170,22 @@ describe("project state snapshot model", () => {
});
it("rejects invalid references and inconsistent reserve circuits", () => {
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: "missing",
supplyType: "AV",
},
],
}),
/distribution board floor reference is invalid/
);
assert.throws(
() =>
parseProjectStateSnapshot({
+35
View File
@@ -4,6 +4,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
import {
createNamedProjectSnapshot,
@@ -99,6 +100,40 @@ describe("project version history presentation", () => {
});
});
describe("project settings presentation", () => {
it("shows all supply types and marks types already in use", () => {
const markup = renderToStaticMarkup(
createElement(ProjectSettingsModal, {
isSaving: false,
onClose: () => undefined,
onExport: async () => undefined,
onImport: async () => undefined,
onSave: async () => undefined,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"MSR",
"SiBe",
],
currentRevision: 0,
},
usedDistributionBoardSupplyTypes: ["SiBe"],
})
);
assert.match(markup, /MSR/);
assert.match(markup, /SiBe/);
assert.match(markup, /in Verwendung/);
});
});
describe("project device modal presentation", () => {
it("renders explicit German labels for every editable device field", () => {
const markup = renderToStaticMarkup(