Add component structure commands

This commit is contained in:
2026-07-30 19:38:48 +02:00
parent 9d4d4082fe
commit 604604f056
15 changed files with 1058 additions and 5 deletions
@@ -0,0 +1,193 @@
import { and, eq } from "drizzle-orm";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
createDistributionBoardComponentDeleteProjectCommand,
createDistributionBoardComponentInsertProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
type DistributionBoardComponentSnapshot,
type DistributionBoardComponentStructureProjectCommand,
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
import type {
DistributionBoardComponentStructureProjectCommandStore,
ExecuteDistributionBoardComponentStructureCommandInput,
} from "../../domain/ports/distribution-board-component-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardComponentStructureProjectCommand
): DistributionBoardComponentStructureProjectCommand {
if (command.type === distributionBoardComponentInsertCommandType) {
assertDistributionBoardComponentInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentDeleteCommandType) {
assertDistributionBoardComponentDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentInsertProjectCommand(
command.payload.snapshot
);
}
throw new Error(
"Unsupported distribution-board component structure command."
);
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const existing = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
database
.insert(distributionBoardComponents)
.values(snapshot.component)
.run();
if (snapshot.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
snapshot.component.id
)
)
.get();
if (
!component ||
!sameRecord(snapshot.component, component) ||
!sameNullableRecord(snapshot.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before deletion."
);
}
const deleted = database
.delete(distributionBoardComponents)
.where(
and(
eq(distributionBoardComponents.id, component.id),
eq(
distributionBoardComponents.circuitListId,
snapshot.component.circuitListId
)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Distribution-board component could not be deleted."
);
}
}
private assertOwnership(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, snapshot.component.circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error(
"Distribution-board component circuit list does not belong to project."
);
}
if (snapshot.component.sectionId !== null) {
const section = database
.select({ circuitListId: circuitSections.circuitListId })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.component.sectionId))
.get();
if (
!section ||
section.circuitListId !== snapshot.component.circuitListId
) {
throw new Error(
"Distribution-board component section does not belong to circuit list."
);
}
}
}
}
function sameNullableRecord(
expected: object | null,
actual: object | undefined
) {
return expected === null
? actual === undefined
: actual !== undefined && sameRecord(expected, actual);
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,254 @@
import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js";
import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardComponentInsertCommandType =
"distribution-board-component.insert" as const;
export const distributionBoardComponentDeleteCommandType =
"distribution-board-component.delete" as const;
export const distributionBoardComponentStructureCommandSchemaVersion =
1 as const;
type MutableDistributionBoardComponentRole =
| "group_upstream_protection"
| "group_residual_current_protection"
| "auxiliary";
export interface DistributionBoardComponentSnapshot {
component: {
id: string;
circuitListId: string;
sectionId: string | null;
equipmentIdentifier: string;
name: string;
role: MutableDistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
};
protectionDevice: {
componentId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
} | null;
}
interface DistributionBoardComponentStructurePayload {
snapshot: DistributionBoardComponentSnapshot;
}
export interface DistributionBoardComponentInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentInsertCommandType;
}
export interface DistributionBoardComponentDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentDeleteCommandType;
}
export type DistributionBoardComponentStructureProjectCommand =
| DistributionBoardComponentInsertProjectCommand
| DistributionBoardComponentDeleteProjectCommand;
export function createDistributionBoardComponentInsertProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentInsertProjectCommand {
const command: DistributionBoardComponentInsertProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentInsertCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentInsertProjectCommand(command);
return command;
}
export function createDistributionBoardComponentDeleteProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentDeleteProjectCommand {
const command: DistributionBoardComponentDeleteProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentDeleteCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentDeleteProjectCommand(command);
return command;
}
export function assertDistributionBoardComponentInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentInsertProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentInsertCommandType
);
}
export function assertDistributionBoardComponentDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentDeleteProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentDeleteCommandType
);
}
function assertStructureCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof distributionBoardComponentInsertCommandType
| typeof distributionBoardComponentDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardComponentStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error(
"Unsupported distribution-board component structure command."
);
}
assertDistributionBoardComponentSnapshot(command.payload.snapshot);
}
export function assertDistributionBoardComponentSnapshot(
value: unknown
): asserts value is DistributionBoardComponentSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 2 ||
!isPlainObject(value.component)
) {
throw new Error(
"Distribution-board component snapshot is invalid."
);
}
const component = value.component;
if (Object.keys(component).length !== 8) {
throw new Error(
"Distribution-board component snapshot is incomplete."
);
}
for (const field of [
"id",
"circuitListId",
"equipmentIdentifier",
"name",
] as const) {
assertNonEmptyString(component[field], `component.${field}`);
}
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection" &&
component.role !== "auxiliary"
) {
throw new Error(
"Fixed distribution-board components cannot use component CRUD commands."
);
}
if (
!distributionBoardComponentPlacements.includes(
component.placement as DistributionBoardComponentPlacement
) ||
!Number.isFinite(component.sortOrder)
) {
throw new Error(
"Distribution-board component placement or sort order is invalid."
);
}
const groupComponent = component.role !== "auxiliary";
if (
groupComponent !==
(component.placement === "group" &&
typeof component.sectionId === "string" &&
Boolean(component.sectionId.trim()))
) {
throw new Error(
"Group protection components require group placement and a section."
);
}
if (
!groupComponent &&
(component.placement !== "footer" ||
component.sectionId !== null)
) {
throw new Error(
"Auxiliary components require footer placement without a section."
);
}
if (groupComponent) {
if (!isPlainObject(value.protectionDevice)) {
throw new Error(
"Group protection components require protection data."
);
}
assertProtectionDevice(
value.protectionDevice,
component.id as string
);
} else if (value.protectionDevice !== null) {
throw new Error(
"Auxiliary components cannot contain protection data."
);
}
}
function assertProtectionDevice(
value: Record<string, unknown>,
componentId: string
) {
if (
Object.keys(value).length !== 7 ||
value.componentId !== componentId
) {
throw new Error(
"Distribution-board component protection snapshot is invalid."
);
}
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
throw new Error(
"Distribution-board component protection configuration is invalid."
);
}
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,24 @@
import type { DistributionBoardComponentStructureProjectCommand } from "../models/distribution-board-component-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteDistributionBoardComponentStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: DistributionBoardComponentStructureProjectCommand;
}
export interface DistributionBoardComponentStructureProjectCommandStore {
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
): {
revision: AppendedProjectRevision;
inverse: DistributionBoardComponentStructureProjectCommand;
};
}
@@ -41,6 +41,12 @@ import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
} from "../models/distribution-board-component-structure-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -91,6 +97,7 @@ import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
@@ -135,6 +142,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -296,6 +304,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardComponentInsertCommandType: {
assertDistributionBoardComponentInsertProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case distributionBoardComponentDeleteCommandType: {
assertDistributionBoardComponentDeleteProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -33,6 +33,8 @@ const commandTypeLabels: Record<string, string> = {
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
"distribution-board.delete": "Verteilung entfernt",
"distribution-board-component.insert": "Verteilergerät angelegt",
"distribution-board-component.delete": "Verteilergerät entfernt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
@@ -27,6 +28,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore =
new DistributionBoardComponentStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
@@ -59,5 +62,6 @@ export const projectCommandService = new ProjectCommandService(
projectDeviceRowSyncProjectCommandStore,
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore,
projectHistoryStore
);