637 lines
19 KiB
TypeScript
637 lines
19 KiB
TypeScript
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import {
|
|
assertDistributionBoardDeleteProjectCommand,
|
|
assertDistributionBoardInsertProjectCommand,
|
|
distributionBoardDeleteCommandType,
|
|
distributionBoardInsertCommandType,
|
|
invertDistributionBoardStructureProjectCommand,
|
|
normalizeDistributionBoardStructureProjectCommand,
|
|
type DistributionBoardStructureProjectCommand,
|
|
type NormalizedDistributionBoardStructureSnapshot,
|
|
} 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 {
|
|
assertDistributionBoardFeederCircuitProjectCommand,
|
|
createDistributionBoardCreateFeederCircuitProjectCommand,
|
|
createDistributionBoardRemoveFeederCircuitProjectCommand,
|
|
distributionBoardCreateFeederCircuitCommandType,
|
|
distributionBoardRemoveFeederCircuitCommandType,
|
|
type DistributionBoardFeederCircuitProjectCommand,
|
|
} from "../../domain/models/distribution-board-feeder-circuit-project-command.model.js";
|
|
import {
|
|
circuitDeleteCommandType,
|
|
circuitInsertCommandType,
|
|
createCircuitDeleteProjectCommand,
|
|
createCircuitInsertProjectCommand,
|
|
} from "../../domain/models/circuit-structure-project-command.model.js";
|
|
import type {
|
|
DistributionBoardStructureProjectCommandStore,
|
|
ExecuteDistributionBoardStructureCommandInput,
|
|
} from "../../domain/ports/distribution-board-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 { circuits } from "../schema/circuits.js";
|
|
import { distributionBoardComponents } from "../schema/distribution-board-components.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";
|
|
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
|
|
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
|
|
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
|
|
|
|
export class DistributionBoardStructureProjectCommandRepository
|
|
implements DistributionBoardStructureProjectCommandStore
|
|
{
|
|
constructor(private readonly database: AppDatabase) {}
|
|
|
|
execute(input: ExecuteDistributionBoardStructureCommandInput) {
|
|
return executeProjectCommandTransaction(
|
|
this.database,
|
|
input,
|
|
(tx) =>
|
|
this.applyCommand(tx, input.projectId, input.command)
|
|
);
|
|
}
|
|
|
|
executeUpdate(
|
|
input: Omit<
|
|
ExecuteDistributionBoardStructureCommandInput,
|
|
"command"
|
|
> & {
|
|
command: DistributionBoardUpdateProjectCommand;
|
|
}
|
|
) {
|
|
assertDistributionBoardUpdateProjectCommand(input.command);
|
|
return executeProjectCommandTransaction(
|
|
this.database,
|
|
input,
|
|
(tx) => this.update(tx, input)
|
|
);
|
|
}
|
|
|
|
executeFeederCircuit(
|
|
input: Omit<
|
|
ExecuteDistributionBoardStructureCommandInput,
|
|
"command"
|
|
> & {
|
|
command: DistributionBoardFeederCircuitProjectCommand;
|
|
}
|
|
) {
|
|
assertDistributionBoardFeederCircuitProjectCommand(input.command);
|
|
if (
|
|
input.source === "user" &&
|
|
input.command.type === distributionBoardRemoveFeederCircuitCommandType
|
|
) {
|
|
throw new Error(
|
|
"Generated feeder circuits may only be removed through project history."
|
|
);
|
|
}
|
|
return executeProjectCommandTransaction(
|
|
this.database,
|
|
input,
|
|
(tx) => this.applyFeederCircuitCommand(tx, input)
|
|
);
|
|
}
|
|
|
|
private applyCommand(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
command: DistributionBoardStructureProjectCommand
|
|
): DistributionBoardStructureProjectCommand {
|
|
const structure =
|
|
normalizeDistributionBoardStructureProjectCommand(command);
|
|
if (command.type === distributionBoardInsertCommandType) {
|
|
this.insert(database, projectId, structure);
|
|
return invertDistributionBoardStructureProjectCommand(command);
|
|
}
|
|
if (command.type === distributionBoardDeleteCommandType) {
|
|
this.delete(database, projectId, structure);
|
|
return invertDistributionBoardStructureProjectCommand(command);
|
|
}
|
|
throw new Error("Unsupported distribution-board structure command.");
|
|
}
|
|
|
|
private insert(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
structure: NormalizedDistributionBoardStructureSnapshot
|
|
) {
|
|
if (
|
|
structure.distributionBoard.projectId !== projectId ||
|
|
structure.circuitList.projectId !== projectId
|
|
) {
|
|
throw new Error(
|
|
"Distribution-board structure does not belong to project."
|
|
);
|
|
}
|
|
const project = database
|
|
.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)
|
|
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
|
.get();
|
|
const existingList = database
|
|
.select({ id: circuitLists.id })
|
|
.from(circuitLists)
|
|
.where(eq(circuitLists.id, structure.circuitList.id))
|
|
.get();
|
|
const existingSection = database
|
|
.select({ id: circuitSections.id })
|
|
.from(circuitSections)
|
|
.where(
|
|
inArray(
|
|
circuitSections.id,
|
|
structure.sections.map((section) => section.id)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.get();
|
|
const existingComponent =
|
|
structure.components.length === 0
|
|
? undefined
|
|
: database
|
|
.select({ id: distributionBoardComponents.id })
|
|
.from(distributionBoardComponents)
|
|
.where(
|
|
inArray(
|
|
distributionBoardComponents.id,
|
|
structure.components.map((component) => component.id)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.get();
|
|
if (
|
|
existingBoard ||
|
|
existingList ||
|
|
existingSection ||
|
|
existingComponent
|
|
) {
|
|
throw new Error("Distribution-board structure id already exists.");
|
|
}
|
|
|
|
database
|
|
.insert(distributionBoards)
|
|
.values(structure.distributionBoard)
|
|
.run();
|
|
database.insert(circuitLists).values(structure.circuitList).run();
|
|
database
|
|
.insert(circuitSections)
|
|
.values(structure.sections)
|
|
.run();
|
|
if (structure.components.length > 0) {
|
|
database
|
|
.insert(distributionBoardComponents)
|
|
.values(structure.components)
|
|
.run();
|
|
}
|
|
}
|
|
|
|
private delete(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
structure: NormalizedDistributionBoardStructureSnapshot
|
|
) {
|
|
const board = database
|
|
.select()
|
|
.from(distributionBoards)
|
|
.where(eq(distributionBoards.id, structure.distributionBoard.id))
|
|
.get();
|
|
const list = database
|
|
.select()
|
|
.from(circuitLists)
|
|
.where(eq(circuitLists.id, structure.circuitList.id))
|
|
.get();
|
|
const sections = database
|
|
.select()
|
|
.from(circuitSections)
|
|
.where(
|
|
eq(circuitSections.circuitListId, structure.circuitList.id)
|
|
)
|
|
.orderBy(
|
|
asc(circuitSections.sortOrder),
|
|
asc(circuitSections.id)
|
|
)
|
|
.all();
|
|
const components = database
|
|
.select()
|
|
.from(distributionBoardComponents)
|
|
.where(
|
|
eq(
|
|
distributionBoardComponents.circuitListId,
|
|
structure.circuitList.id
|
|
)
|
|
)
|
|
.orderBy(
|
|
asc(distributionBoardComponents.sortOrder),
|
|
asc(distributionBoardComponents.id)
|
|
)
|
|
.all();
|
|
if (
|
|
!board ||
|
|
!list ||
|
|
board.projectId !== projectId ||
|
|
!structureMatches(structure, {
|
|
board,
|
|
list,
|
|
sections,
|
|
components,
|
|
})
|
|
) {
|
|
throw new Error(
|
|
"Distribution-board structure changed before deletion."
|
|
);
|
|
}
|
|
const populatedCircuit = database
|
|
.select({ id: circuits.id })
|
|
.from(circuits)
|
|
.where(eq(circuits.circuitListId, structure.circuitList.id))
|
|
.limit(1)
|
|
.get();
|
|
if (populatedCircuit) {
|
|
throw new Error(
|
|
"A populated distribution board cannot be removed by history."
|
|
);
|
|
}
|
|
|
|
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
|
|
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
|
|
|
|
const deleted = database
|
|
.delete(distributionBoards)
|
|
.where(
|
|
and(
|
|
eq(
|
|
distributionBoards.id,
|
|
structure.distributionBoard.id
|
|
),
|
|
eq(distributionBoards.projectId, projectId)
|
|
)
|
|
)
|
|
.run();
|
|
if (deleted.changes !== 1) {
|
|
throw new Error("Distribution board could not be deleted.");
|
|
}
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
this.assertFeedingReference(database, input.projectId, current.id, {
|
|
feedingDistributionBoardId:
|
|
patch.feedingDistributionBoardId === undefined
|
|
? current.feedingDistributionBoardId
|
|
: patch.feedingDistributionBoardId,
|
|
feedingCircuitId:
|
|
patch.feedingCircuitId === undefined
|
|
? current.feedingCircuitId
|
|
: patch.feedingCircuitId,
|
|
});
|
|
const inversePatch = Object.fromEntries(
|
|
input.command.payload.changes.map((change) => [
|
|
change.field,
|
|
getDistributionBoardFieldValue(current, change.field),
|
|
])
|
|
) as DistributionBoardUpdatePatch;
|
|
const inverse = createDistributionBoardUpdateProjectCommand(
|
|
current.id,
|
|
inversePatch,
|
|
input.command.schemaVersion
|
|
);
|
|
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;
|
|
}
|
|
|
|
private applyFeederCircuitCommand(
|
|
database: AppDatabase,
|
|
input: Omit<
|
|
ExecuteDistributionBoardStructureCommandInput,
|
|
"command"
|
|
> & {
|
|
command: DistributionBoardFeederCircuitProjectCommand;
|
|
}
|
|
): DistributionBoardFeederCircuitProjectCommand {
|
|
const circuitRepository =
|
|
new CircuitStructureProjectCommandRepository(database);
|
|
const { circuit, boardUpdate } = input.command.payload;
|
|
if (
|
|
input.command.type === distributionBoardCreateFeederCircuitCommandType
|
|
) {
|
|
const circuitInverse = circuitRepository.applyInTransaction(
|
|
database,
|
|
input.projectId,
|
|
input.source,
|
|
createCircuitInsertProjectCommand(circuit)
|
|
);
|
|
if (circuitInverse.type !== circuitDeleteCommandType) {
|
|
throw new Error("Created feeder circuit returned an invalid inverse.");
|
|
}
|
|
const boardInverse = this.update(database, {
|
|
...input,
|
|
command: boardUpdate,
|
|
});
|
|
return createDistributionBoardRemoveFeederCircuitProjectCommand(
|
|
circuit,
|
|
boardInverse
|
|
);
|
|
}
|
|
|
|
const boardInverse = this.update(database, {
|
|
...input,
|
|
command: boardUpdate,
|
|
});
|
|
const circuitInverse = circuitRepository.applyInTransaction(
|
|
database,
|
|
input.projectId,
|
|
input.source,
|
|
createCircuitDeleteProjectCommand(circuit.id, circuit.circuitListId)
|
|
);
|
|
if (
|
|
circuitInverse.type !== circuitInsertCommandType ||
|
|
!isDeepStrictEqual(circuitInverse.payload.circuit, circuit)
|
|
) {
|
|
throw new Error(
|
|
"Generated feeder circuit changed before history removal."
|
|
);
|
|
}
|
|
return createDistributionBoardCreateFeederCircuitProjectCommand(
|
|
circuit,
|
|
boardInverse
|
|
);
|
|
}
|
|
|
|
private assertFeedingReference(
|
|
database: AppDatabase,
|
|
projectId: string,
|
|
distributionBoardId: string,
|
|
reference: {
|
|
feedingDistributionBoardId: string | null;
|
|
feedingCircuitId: string | null;
|
|
}
|
|
) {
|
|
const { feedingDistributionBoardId, feedingCircuitId } = reference;
|
|
if (feedingDistributionBoardId === null) {
|
|
if (feedingCircuitId !== null) {
|
|
throw new Error(
|
|
"Ein speisender Stromkreis erfordert eine speisende Verteilung."
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (feedingDistributionBoardId === distributionBoardId) {
|
|
throw new Error("Eine Verteilung kann sich nicht selbst speisen.");
|
|
}
|
|
const projectBoards = database
|
|
.select({
|
|
id: distributionBoards.id,
|
|
feedingDistributionBoardId:
|
|
distributionBoards.feedingDistributionBoardId,
|
|
})
|
|
.from(distributionBoards)
|
|
.where(eq(distributionBoards.projectId, projectId))
|
|
.all();
|
|
const boardById = new Map(projectBoards.map((board) => [board.id, board]));
|
|
if (!boardById.has(feedingDistributionBoardId)) {
|
|
throw new Error(
|
|
"Die speisende Verteilung gehört nicht zum Projekt."
|
|
);
|
|
}
|
|
const visited = new Set<string>([distributionBoardId]);
|
|
let currentId: string | null = feedingDistributionBoardId;
|
|
while (currentId !== null) {
|
|
if (visited.has(currentId)) {
|
|
throw new Error("Die Einspeisungsverknüpfung würde einen Zyklus erzeugen.");
|
|
}
|
|
visited.add(currentId);
|
|
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
|
|
}
|
|
if (feedingCircuitId === null) {
|
|
return;
|
|
}
|
|
const feedingCircuit = database
|
|
.select({
|
|
projectId: circuitLists.projectId,
|
|
distributionBoardId: circuitLists.distributionBoardId,
|
|
})
|
|
.from(circuits)
|
|
.innerJoin(
|
|
circuitLists,
|
|
eq(circuitLists.id, circuits.circuitListId)
|
|
)
|
|
.where(eq(circuits.id, feedingCircuitId))
|
|
.get();
|
|
if (
|
|
!feedingCircuit ||
|
|
feedingCircuit.projectId !== projectId ||
|
|
feedingCircuit.distributionBoardId !== feedingDistributionBoardId
|
|
) {
|
|
throw new Error(
|
|
"Der speisende Stromkreis gehört nicht zur gewählten Verteilung."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 getDistributionBoardFieldValue<
|
|
TField extends DistributionBoardUpdateField,
|
|
>(
|
|
distributionBoard: typeof distributionBoards.$inferSelect,
|
|
field: TField
|
|
): DistributionBoardUpdateValues[TField] {
|
|
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
|
|
}
|
|
|
|
function structureMatches(
|
|
expected: NormalizedDistributionBoardStructureSnapshot,
|
|
actual: {
|
|
board: typeof distributionBoards.$inferSelect;
|
|
list: typeof circuitLists.$inferSelect;
|
|
sections: Array<typeof circuitSections.$inferSelect>;
|
|
components: Array<typeof distributionBoardComponents.$inferSelect>;
|
|
}
|
|
) {
|
|
const {
|
|
simultaneityFactor,
|
|
boardNumber,
|
|
location,
|
|
fedFrom,
|
|
remark,
|
|
feedingDistributionBoardId,
|
|
feedingCircuitId,
|
|
...actualDistributionBoard
|
|
} = actual.board;
|
|
if (
|
|
simultaneityFactor !== 1 ||
|
|
[
|
|
boardNumber,
|
|
location,
|
|
fedFrom,
|
|
remark,
|
|
feedingDistributionBoardId,
|
|
feedingCircuitId,
|
|
].some((value) => value !== null) ||
|
|
!sameRecord(
|
|
expected.distributionBoard,
|
|
actualDistributionBoard
|
|
) ||
|
|
!sameRecord(expected.circuitList, actual.list) ||
|
|
expected.sections.length !== actual.sections.length ||
|
|
expected.components.length !== actual.components.length
|
|
) {
|
|
return false;
|
|
}
|
|
const expectedSections = [...expected.sections].sort(
|
|
(left, right) =>
|
|
left.sortOrder - right.sortOrder ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
const sectionsMatch = expectedSections.every((section, index) =>
|
|
sameRecord(section, actual.sections[index])
|
|
);
|
|
const expectedComponents = [...expected.components].sort(
|
|
(left, right) =>
|
|
left.sortOrder - right.sortOrder ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
return (
|
|
sectionsMatch &&
|
|
expectedComponents.every((component, index) =>
|
|
sameRecord(component, actual.components[index])
|
|
)
|
|
);
|
|
}
|
|
|
|
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
|
|
)
|
|
);
|
|
}
|