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
@@ -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,7 +58,23 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
},
}
: createProjectSettingsUpdateProjectCommand({
: 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,
externalProjectNumber: current.externalProjectNumber,
@@ -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),
});