Model complete distribution board subtrees

This commit is contained in:
2026-07-31 11:51:46 +02:00
parent bd8dd09f79
commit 13693f64b1
4 changed files with 753 additions and 0 deletions
@@ -0,0 +1,86 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "./distribution-board-subtree-snapshot.model.js";
export const distributionBoardInsertSubtreeCommandType =
"distribution-board.insert-subtree" as const;
export const distributionBoardDeleteSubtreeCommandType =
"distribution-board.delete-subtree" as const;
export const distributionBoardSubtreeCommandSchemaVersion = 1 as const;
interface Payload {
snapshot: DistributionBoardSubtreeSnapshot;
}
export interface DistributionBoardInsertSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof distributionBoardSubtreeCommandSchemaVersion;
type: typeof distributionBoardInsertSubtreeCommandType;
}
export interface DistributionBoardDeleteSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof distributionBoardSubtreeCommandSchemaVersion;
type: typeof distributionBoardDeleteSubtreeCommandType;
}
export type DistributionBoardSubtreeProjectCommand =
| DistributionBoardInsertSubtreeProjectCommand
| DistributionBoardDeleteSubtreeProjectCommand;
export function createDistributionBoardInsertSubtreeProjectCommand(
snapshot: DistributionBoardSubtreeSnapshot
): DistributionBoardInsertSubtreeProjectCommand {
const command: DistributionBoardInsertSubtreeProjectCommand = {
schemaVersion: distributionBoardSubtreeCommandSchemaVersion,
type: distributionBoardInsertSubtreeCommandType,
payload: { snapshot },
};
assertDistributionBoardInsertSubtreeProjectCommand(command);
return command;
}
export function createDistributionBoardDeleteSubtreeProjectCommand(
snapshot: DistributionBoardSubtreeSnapshot
): DistributionBoardDeleteSubtreeProjectCommand {
const command: DistributionBoardDeleteSubtreeProjectCommand = {
schemaVersion: distributionBoardSubtreeCommandSchemaVersion,
type: distributionBoardDeleteSubtreeCommandType,
payload: { snapshot },
};
assertDistributionBoardDeleteSubtreeProjectCommand(command);
return command;
}
export function assertDistributionBoardInsertSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardInsertSubtreeProjectCommand {
assertCommand(command, distributionBoardInsertSubtreeCommandType);
}
export function assertDistributionBoardDeleteSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardDeleteSubtreeProjectCommand {
assertCommand(command, distributionBoardDeleteSubtreeCommandType);
}
function assertCommand(
command: SerializedProjectCommand<unknown>,
type: DistributionBoardSubtreeProjectCommand["type"]
) {
if (
command.schemaVersion !== distributionBoardSubtreeCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported distribution-board subtree command.");
}
assertDistributionBoardSubtreeSnapshot(command.payload.snapshot);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,466 @@
import crypto from "node:crypto";
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import {
distributionBoardComponentPlacements,
distributionBoardComponentRoles,
type DistributionBoardComponentPlacement,
type DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
assertCircuitInsertProjectCommand,
circuitInsertCommandType,
circuitStructureCommandSchemaVersion,
type CircuitSnapshot,
} from "./circuit-structure-project-command.model.js";
export interface DistributionBoardSubtreeSectionSnapshot {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
category: CircuitGroupCategory | null;
groupNumber: number | null;
}
export interface DistributionBoardSubtreeComponentProtectionSnapshot {
componentId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
}
export interface DistributionBoardSubtreeComponentSnapshot {
component: {
id: string;
circuitListId: string;
sectionId: string | null;
equipmentIdentifier: string;
name: string;
role: DistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
};
protectionDevice:
| DistributionBoardSubtreeComponentProtectionSnapshot
| null;
}
export interface DistributionBoardSubtreeSnapshot {
distributionBoard: {
id: string;
projectId: string;
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
};
circuitList: {
id: string;
projectId: string;
distributionBoardId: string;
name: string;
};
sections: DistributionBoardSubtreeSectionSnapshot[];
components: DistributionBoardSubtreeComponentSnapshot[];
circuits: CircuitSnapshot[];
}
export function cloneDistributionBoardSubtree(
source: DistributionBoardSubtreeSnapshot,
name: string,
createId: () => string = () => crypto.randomUUID()
): DistributionBoardSubtreeSnapshot {
assertDistributionBoardSubtreeSnapshot(source);
const normalizedName = name.trim();
assertNonEmptyString(normalizedName, "name");
const distributionBoardId = createId();
const circuitListId = distributionBoardId;
const sectionIds = new Map(
source.sections.map((section) => [section.id, createId()])
);
const circuitIds = new Map(
source.circuits.map((circuit) => [circuit.id, createId()])
);
const componentIds = new Map(
source.components.map(({ component }) => [component.id, createId()])
);
const clone: DistributionBoardSubtreeSnapshot = {
distributionBoard: {
...source.distributionBoard,
id: distributionBoardId,
name: normalizedName,
},
circuitList: {
...source.circuitList,
id: circuitListId,
distributionBoardId,
name: `${normalizedName} Stromkreisliste`,
},
sections: source.sections.map((section) => ({
...section,
id: requiredMappedId(sectionIds, section.id),
circuitListId,
})),
components: source.components.map(
({ component, protectionDevice }) => {
const componentId = requiredMappedId(
componentIds,
component.id
);
return {
component: {
...component,
id: componentId,
circuitListId,
sectionId:
component.sectionId === null
? null
: requiredMappedId(sectionIds, component.sectionId),
},
protectionDevice:
protectionDevice === null
? null
: { ...protectionDevice, componentId },
};
}
),
circuits: source.circuits.map((circuit) => {
const circuitId = requiredMappedId(circuitIds, circuit.id);
return {
...circuit,
id: circuitId,
circuitListId,
sectionId: requiredMappedId(sectionIds, circuit.sectionId),
deviceRows: circuit.deviceRows.map((row) => ({
...row,
id: createId(),
circuitId,
legacyConsumerId: null,
})),
protectionDevice:
circuit.protectionDevice === null ||
circuit.protectionDevice === undefined
? null
: { ...circuit.protectionDevice, circuitId },
};
}),
};
assertDistributionBoardSubtreeSnapshot(clone);
return clone;
}
export function assertDistributionBoardSubtreeSnapshot(
value: unknown
): asserts value is DistributionBoardSubtreeSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 5 ||
!isPlainObject(value.distributionBoard) ||
!isPlainObject(value.circuitList) ||
!Array.isArray(value.sections) ||
!Array.isArray(value.components) ||
!Array.isArray(value.circuits)
) {
throw new Error("Distribution-board subtree snapshot is invalid.");
}
const board = value.distributionBoard;
const list = value.circuitList;
for (const field of ["id", "projectId", "name"] as const) {
assertNonEmptyString(board[field], `distributionBoard.${field}`);
}
assertNullableString(board.floorId, "distributionBoard.floorId");
if (
board.supplyType !== null &&
!distributionBoardSupplyTypes.includes(
board.supplyType as DistributionBoardSupplyType
)
) {
throw new Error("Distribution-board supply type is invalid.");
}
assertFiniteNumber(
board.simultaneityFactor,
"distributionBoard.simultaneityFactor"
);
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
throw new Error("Distribution-board simultaneity factor is invalid.");
}
for (const field of [
"id",
"projectId",
"distributionBoardId",
"name",
] as const) {
assertNonEmptyString(list[field], `circuitList.${field}`);
}
const circuitListId = list.id;
assertNonEmptyString(circuitListId, "circuitList.id");
if (
list.projectId !== board.projectId ||
list.distributionBoardId !== board.id
) {
throw new Error("Distribution-board circuit list ownership is invalid.");
}
const sectionIds = new Set<string>();
const sectionKeys = new Set<string>();
const sectionPrefixes = new Set<string>();
const categoryGroups = new Set<string>();
for (const section of value.sections) {
assertSection(section, circuitListId);
registerUnique(sectionIds, section.id, "circuit-section id");
registerUnique(sectionKeys, section.key, "circuit-section key");
registerUnique(sectionPrefixes, section.prefix, "circuit-section prefix");
if (section.category !== null && section.groupNumber !== null) {
registerUnique(
categoryGroups,
`${section.category}:${section.groupNumber}`,
"circuit-group number"
);
}
}
const equipmentIdentifiers = new Set<string>();
const componentIds = new Set<string>();
const fixedRoles = new Set<string>();
const groupRoles = new Set<string>();
for (const entry of value.components) {
assertComponent(entry, circuitListId, sectionIds);
const component = entry.component;
registerUnique(componentIds, component.id, "component id");
registerEquipmentIdentifier(
equipmentIdentifiers,
component.equipmentIdentifier
);
if (
component.role === "main_switch" ||
component.role === "surge_protective_device"
) {
registerUnique(fixedRoles, component.role, "fixed component role");
}
if (
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection"
) {
registerUnique(
groupRoles,
`${component.sectionId}:${component.role}`,
"group component role"
);
}
}
const circuitIds = new Set<string>();
const deviceRowIds = new Set<string>();
for (const circuit of value.circuits) {
assertCircuitInsertProjectCommand({
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit },
});
if (
circuit.circuitListId !== circuitListId ||
!sectionIds.has(circuit.sectionId) ||
!("protectionDevice" in circuit)
) {
throw new Error("Distribution-board circuit ownership is invalid.");
}
registerUnique(circuitIds, circuit.id, "circuit id");
registerEquipmentIdentifier(
equipmentIdentifiers,
circuit.equipmentIdentifier
);
for (const row of circuit.deviceRows) {
registerUnique(deviceRowIds, row.id, "device-row id");
}
}
}
function assertSection(
value: unknown,
circuitListId: string
): asserts value is DistributionBoardSubtreeSectionSnapshot {
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
throw new Error("Distribution-board circuit section is invalid.");
}
for (const field of ["id", "circuitListId", "key", "displayName", "prefix"] as const) {
assertNonEmptyString(value[field], `section.${field}`);
}
assertFiniteNumber(value.sortOrder, "section.sortOrder");
if (value.circuitListId !== circuitListId) {
throw new Error("Distribution-board section belongs to another list.");
}
if (
value.category === null
? value.groupNumber !== null
: !circuitGroupCategories.includes(
value.category as CircuitGroupCategory
) ||
!Number.isInteger(value.groupNumber) ||
(value.groupNumber as number) <= 0
) {
throw new Error("Distribution-board circuit group is invalid.");
}
}
function assertComponent(
value: unknown,
circuitListId: string,
sectionIds: ReadonlySet<string>
): asserts value is DistributionBoardSubtreeComponentSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 2 ||
!isPlainObject(value.component)
) {
throw new Error("Distribution-board component snapshot is invalid.");
}
const component = value.component;
for (const field of ["id", "circuitListId", "equipmentIdentifier", "name"] as const) {
assertNonEmptyString(component[field], `component.${field}`);
}
if (
component.circuitListId !== circuitListId ||
!distributionBoardComponentRoles.includes(
component.role as DistributionBoardComponentRole
) ||
!distributionBoardComponentPlacements.includes(
component.placement as DistributionBoardComponentPlacement
)
) {
throw new Error("Distribution-board component values are invalid.");
}
assertFiniteNumber(component.sortOrder, "component.sortOrder");
assertNullableString(component.sectionId, "component.sectionId");
const fixed =
component.role === "main_switch" ||
component.role === "surge_protective_device";
const grouped =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection";
if (
(fixed &&
(component.placement !== "header" || component.sectionId !== null)) ||
(grouped &&
(component.placement !== "group" ||
typeof component.sectionId !== "string" ||
!sectionIds.has(component.sectionId))) ||
(component.role === "auxiliary" &&
(component.placement !== "footer" || component.sectionId !== null))
) {
throw new Error("Distribution-board component placement is invalid.");
}
if (value.protectionDevice === null) {
if (grouped) {
throw new Error("Group protection component lacks protection data.");
}
return;
}
if (!grouped || !isPlainObject(value.protectionDevice)) {
throw new Error("Component protection data is invalid.");
}
const protection = value.protectionDevice;
if (
protection.componentId !== component.id ||
!protectionDeviceConfigurationSchema.safeParse({
type: protection.type,
ratedCurrentA: protection.ratedCurrentA,
...(protection.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: protection.fuseUtilizationCategory }),
...(protection.tripCharacteristic === null
? {}
: { tripCharacteristic: protection.tripCharacteristic }),
...(protection.rcdType === null ? {} : { rcdType: protection.rcdType }),
...(protection.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: protection.ratedResidualCurrentMa }),
}).success
) {
throw new Error("Component protection configuration is invalid.");
}
}
function requiredMappedId(
ids: ReadonlyMap<string, string>,
sourceId: string
) {
const id = ids.get(sourceId);
if (!id) {
throw new Error("Distribution-board copy reference is invalid.");
}
return id;
}
function registerEquipmentIdentifier(
identifiers: Set<string>,
value: string
) {
registerUnique(
identifiers,
value.trim().toLocaleLowerCase("de"),
"equipment identifier"
);
}
function registerUnique(
values: Set<string>,
value: string,
label: string
) {
if (values.has(value)) {
throw new Error(`Distribution-board subtree contains duplicate ${label}.`);
}
values.add(value);
}
function assertNonEmptyString(
value: unknown,
field: string
): asserts value is string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function assertNullableString(
value: unknown,
field: string
): asserts value is string | null {
if (value !== null) {
assertNonEmptyString(value, field);
}
}
function assertFiniteNumber(
value: unknown,
field: string
): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be finite.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -15,6 +15,7 @@ import {
updateDistributionBoardComponentSnapshot,
} from "../src/frontend/utils/distribution-board-component-editing.js";
import "./distribution-board-component-structure-project-command.repository.test.js";
import "./distribution-board-subtree.test.js";
describe("distribution board component catalog", () => {
it("defines the agreed component roles and placement zones", () => {
+200
View File
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertDistributionBoardSubtreeSnapshot,
cloneDistributionBoardSubtree,
type DistributionBoardSubtreeSnapshot,
} from "../src/domain/models/distribution-board-subtree-snapshot.model.js";
import {
createDistributionBoardDeleteSubtreeProjectCommand,
createDistributionBoardInsertSubtreeProjectCommand,
} from "../src/domain/models/distribution-board-subtree-project-command.model.js";
function createSnapshot(): DistributionBoardSubtreeSnapshot {
return {
distributionBoard: {
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: "floor-1",
supplyType: "AV",
simultaneityFactor: 0.8,
},
circuitList: {
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
sections: [
{
id: "section-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting",
groupNumber: 1,
},
],
components: [
{
component: {
id: "main-1",
circuitListId: "list-1",
sectionId: null,
equipmentIdentifier: "-Q0",
name: "Hauptschalter",
role: "main_switch",
placement: "header",
sortOrder: 10,
},
protectionDevice: null,
},
{
component: {
id: "rcd-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 20,
},
protectionDevice: {
componentId: "rcd-1",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1.1",
displayName: "Licht",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: "NYM-J",
cableCrossSection: "3x1,5 mm²",
cableLength: 10,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: false,
remark: null,
deviceRows: [
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
legacyConsumerId: "legacy-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: "room-1",
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Büro",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: null,
},
],
protectionDevice: {
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
},
],
};
}
describe("distribution-board subtree snapshot", () => {
it("validates complete populated board structures and commands", () => {
const snapshot = createSnapshot();
assert.doesNotThrow(() =>
assertDistributionBoardSubtreeSnapshot(snapshot)
);
assert.equal(
createDistributionBoardInsertSubtreeProjectCommand(snapshot).type,
"distribution-board.insert-subtree"
);
assert.equal(
createDistributionBoardDeleteSubtreeProjectCommand(snapshot).type,
"distribution-board.delete-subtree"
);
});
it("clones every owned id while preserving project links and values", () => {
const ids = Array.from({ length: 6 }, (_, index) => `new-${index + 1}`);
const clone = cloneDistributionBoardSubtree(
createSnapshot(),
"UV 1 Kopie",
() => ids.shift()!
);
assert.equal(clone.distributionBoard.id, "new-1");
assert.equal(clone.circuitList.id, "new-1");
assert.equal(clone.distributionBoard.name, "UV 1 Kopie");
assert.equal(clone.circuitList.name, "UV 1 Kopie Stromkreisliste");
assert.equal(clone.sections[0].id, "new-2");
assert.equal(clone.circuits[0].id, "new-3");
assert.equal(clone.components[0].component.id, "new-4");
assert.equal(clone.components[1].component.id, "new-5");
assert.equal(clone.components[1].component.sectionId, "new-2");
assert.equal(clone.components[1].protectionDevice?.componentId, "new-5");
assert.equal(clone.circuits[0].sectionId, "new-2");
assert.equal(clone.circuits[0].deviceRows[0].id, "new-6");
assert.equal(clone.circuits[0].deviceRows[0].circuitId, "new-3");
assert.equal(
clone.circuits[0].deviceRows[0].linkedProjectDeviceId,
"device-1"
);
assert.equal(clone.circuits[0].deviceRows[0].roomId, "room-1");
assert.equal(clone.circuits[0].deviceRows[0].legacyConsumerId, null);
assert.equal(clone.circuits[0].protectionDevice?.circuitId, "new-3");
assert.doesNotThrow(() => assertDistributionBoardSubtreeSnapshot(clone));
});
it("rejects broken ownership and duplicate equipment identifiers", () => {
const brokenOwnership = createSnapshot();
brokenOwnership.circuits[0].sectionId = "missing";
assert.throws(
() => assertDistributionBoardSubtreeSnapshot(brokenOwnership),
/ownership/
);
const duplicateIdentifier = createSnapshot();
duplicateIdentifier.components[0].component.equipmentIdentifier =
" -1f1.1 ";
assert.throws(
() => assertDistributionBoardSubtreeSnapshot(duplicateIdentifier),
/duplicate equipment identifier/
);
});
});