Complete Revit object editor assignments

This commit is contained in:
Julian Appel 2026-09-30 14:35:14 +02:00
parent 4489cd76f0
commit 77d775fde1
25 changed files with 853 additions and 77 deletions

View file

@ -306,6 +306,16 @@ aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
## 11. Editor-Zuordnung und Löschen
Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste
gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen
Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als
Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die
drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen
kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und
Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und
Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen
explizit.
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler

View file

@ -15,7 +15,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |

View file

@ -1128,6 +1128,17 @@ a.kpi:hover {
border-style: dashed;
}
.project-device-item > summary {
display: flex;
flex-direction: column;
gap: 0.1rem;
cursor: grab;
}
.project-device-item > summary::marker {
color: var(--text-faint);
}
.sidebar-actions {
display: flex;
flex-direction: column;

View file

@ -23,8 +23,15 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore
@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand
>(
this.database,
input,
(tx) =>
@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand
): CircuitDeviceRowStructureProjectCommand {
): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> {
if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.row
command.payload.row,
command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeviceRowDeleteCommandType) {
@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database,
projectId,
command.payload.rowId,
command.payload.expectedCircuitId
command.payload.expectedCircuitId,
command.payload.externalObjects
);
}
throw new Error("Unsupported circuit device-row structure command.");
@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot
row: CircuitDeviceRowSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid.");
@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
}
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
if (externalObjects.length > 0) {
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
}
return {
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private delete(
database: AppDatabase,
projectId: string,
rowId: string,
expectedCircuitId: string
expectedCircuitId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const row = database
.select()
@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
);
}
this.assertCircuitInProject(database, projectId, row.circuitId);
const externalObjects = suppliedExternalObjects ??
captureExternalObjectDetachTransitions(database, projectId, [rowId]);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const result = database
.delete(circuitDeviceRows)
@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
expectedCircuitId
);
return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row)
);
return {
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
};
}
private assertCircuitInProject(

View file

@ -25,8 +25,14 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
} from "./external-object-assignment.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
@ -43,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
} else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
}
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand
>(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
@ -54,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
database: AppDatabase,
projectId: string,
command: CircuitGroupSubtreeProjectCommand
) {
): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> {
if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
@ -71,6 +81,14 @@ export class CircuitGroupSubtreeProjectCommandRepository
projectId,
current.circuits.map((entry) => entry.circuit.id)
);
const externalObjects = command.payload.externalObjects ??
captureExternalObjectDetachTransitions(
database,
projectId,
current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id))
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))
@ -78,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted.");
}
return createCircuitGroupRestoreSubtreeProjectCommand(
command.payload.snapshot
);
return {
forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteSubtreeProjectCommand(
command.payload.snapshot
);
const externalObjects = command.payload.externalObjects ?? [];
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
return {
forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects),
inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
throw new Error("Unsupported circuit-group subtree command.");
}

View file

@ -25,10 +25,17 @@ import {
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore
@ -36,11 +43,15 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) {
return executeProjectCommandTransaction(
return executeProjectCommandTransactionWithAppliedForward<
CircuitStructureProjectCommand,
CircuitStructureProjectCommand,
CircuitStructureProjectCommand
>(
this.database,
input,
(tx) =>
this.applyInTransaction(
this.applyWithForward(
tx,
input.projectId,
input.source,
@ -59,13 +70,23 @@ export class CircuitStructureProjectCommandRepository
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand {
return this.applyWithForward(database, projectId, source, command).inverse;
}
private applyWithForward(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): AppliedProjectCommand<CircuitStructureProjectCommand, CircuitStructureProjectCommand> {
if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command);
return this.insert(
database,
projectId,
source,
command.payload.circuit
command.payload.circuit,
command.payload.externalObjects ?? []
);
}
if (command.type === circuitDeleteCommandType) {
@ -74,7 +95,8 @@ export class CircuitStructureProjectCommandRepository
database,
projectId,
command.payload.circuitId,
command.payload.expectedCircuitListId
command.payload.expectedCircuitListId,
command.payload.externalObjects
);
}
throw new Error("Unsupported circuit structure command.");
@ -84,7 +106,8 @@ export class CircuitStructureProjectCommandRepository
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot
snapshot: CircuitSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) {
this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
@ -172,17 +195,23 @@ export class CircuitStructureProjectCommandRepository
.run();
}
return createCircuitDeleteProjectCommand(
snapshot.id,
snapshot.circuitListId
);
if (externalObjects.length > 0) {
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert.");
}
return {
forward: createCircuitInsertProjectCommand(snapshot, externalObjects),
inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private delete(
database: AppDatabase,
projectId: string,
circuitId: string,
expectedCircuitListId: string
expectedCircuitListId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) {
const circuit = database
.select()
@ -220,7 +249,12 @@ export class CircuitStructureProjectCommandRepository
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get();
const inverse = createCircuitInsertProjectCommand({
const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions(
database, projectId, rows.map((row) => row.id)
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const circuitSnapshot: CircuitSnapshot = {
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
@ -239,7 +273,7 @@ export class CircuitStructureProjectCommandRepository
remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null,
});
};
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
@ -255,7 +289,10 @@ export class CircuitStructureProjectCommandRepository
if (result.changes !== 1) {
throw new Error("Circuit could not be deleted.");
}
return inverse;
return {
forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
}
private assertCircuitLocation(

View file

@ -46,6 +46,7 @@ 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
@ -290,6 +291,7 @@ export class DistributionBoardStructureProjectCommandRepository
}
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
const deleted = database
.delete(distributionBoards)

View file

@ -33,6 +33,7 @@ import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.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";
export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore
@ -86,6 +87,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id,
]);
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
const deleted = database
.delete(distributionBoards)
.where(

View file

@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition {
target: ExternalModelObjectSnapshot;
}
export function captureExternalObjectDetachTransitions(
database: AppDatabase,
projectId: string,
rowIds: string[]
): ExternalObjectLinkTransition[] {
if (rowIds.length === 0) return [];
return database.select().from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
inArray(externalModelObjects.circuitDeviceRowId, rowIds)
)).all()
.map(toExternalObjectSnapshot)
.map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } }));
}
export function invertExternalObjectLinkTransitions(
transitions: ExternalObjectLinkTransition[]
): ExternalObjectLinkTransition[] {
return transitions.map(({ expected, target }) => ({ expected: target, target: expected }));
}
export function loadExpectedExternalObjectTransitions(
database: AppDatabase,
projectId: string,
@ -86,7 +107,13 @@ export function applyExternalObjectLinks(
) {
for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects)
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
.set({
circuitDeviceRowId: target.circuitDeviceRowId,
distributionBoardId: target.distributionBoardId,
linkedProjectDeviceId: target.linkedProjectDeviceId,
planningValues: target.planningValues,
overriddenFields: target.overriddenFields,
})
.where(and(
eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null

View file

@ -0,0 +1,37 @@
import { and, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
export function assertDistributionBoardHasNoExternalObjects(
database: AppDatabase,
projectId: string,
distributionBoardId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalModelObjects.distributionBoardId, distributionBoardId)
)).limit(1).get();
if (reference) {
throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}
export function assertRoomHasNoExternalObjects(
database: AppDatabase,
projectId: string,
roomId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId))
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalRoomMappings.roomId, roomId)
)).limit(1).get();
if (reference) {
throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}

View file

@ -46,6 +46,7 @@ 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";
import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore
@ -439,6 +440,7 @@ export class ProjectLocationStructureProjectCommandRepository
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
);
}
assertRoomHasNoExternalObjects(database, projectId, expected.id);
const deleted = database
.delete(rooms)
.where(

View file

@ -1,4 +1,11 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export interface ExternalObjectLinkSnapshotTransition {
expected: ExternalModelObjectSnapshot;
target: ExternalModelObjectSnapshot;
}
export const circuitDeviceRowInsertCommandType =
"circuit-device-row.insert" as const;
@ -32,11 +39,13 @@ export interface CircuitDeviceRowSnapshot {
export interface CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string;
expectedCircuitId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeviceRowInsertProjectCommand
@ -56,7 +65,8 @@ export type CircuitDeviceRowStructureProjectCommand =
| CircuitDeviceRowDeleteProjectCommand;
export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot
row: CircuitDeviceRowSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = {
...row,
@ -65,7 +75,7 @@ export function createCircuitDeviceRowInsertProjectCommand(
const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row: normalizedRow },
payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeviceRowInsertProjectCommand(command);
return command;
@ -73,12 +83,13 @@ export function createCircuitDeviceRowInsertProjectCommand(
export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string,
expectedCircuitId: string
expectedCircuitId: string,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType,
payload: { rowId, expectedCircuitId },
payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeviceRowDeleteProjectCommand(command);
return command;
@ -143,6 +154,7 @@ export function assertCircuitDeviceRowInsertProjectCommand(
throw new Error("row.cosPhi must be positive or null.");
}
}
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertCircuitDeviceRowDeleteProjectCommand(
@ -163,6 +175,41 @@ export function assertCircuitDeviceRowDeleteProjectCommand(
command.payload.expectedCircuitId,
"expectedCircuitId"
);
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertExternalObjectLinkTransitions(value: unknown) {
if (value === undefined) return;
if (!Array.isArray(value)) throw new Error("externalObjects must be an array.");
const ids = new Set<string>();
for (const transition of value) {
if (!isPlainObject(transition) || !isPlainObject(transition.expected) || !isPlainObject(transition.target)) {
throw new Error("Invalid external object link transition.");
}
const expectedId = transition.expected.id;
const targetId = transition.target.id;
if (typeof expectedId !== "string" || expectedId !== targetId || ids.has(expectedId)) {
throw new Error("External object link transition has mismatched or duplicate ids.");
}
ids.add(expectedId);
const expectedRowId = transition.expected.circuitDeviceRowId;
const targetRowId = transition.target.circuitDeviceRowId;
if ((expectedRowId !== null && typeof expectedRowId !== "string") || (targetRowId !== null && typeof targetRowId !== "string")) {
throw new Error("Invalid external object row link.");
}
const parsed = parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [transition.expected, transition.target] }).objects;
const expectedRest = { ...parsed[0], circuitDeviceRowId: null };
const targetRest = { ...parsed[1], circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest) || expectedRowId === targetRowId) {
throw new Error("External object transition may only change the row link.");
}
}
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
return JSON.stringify(value) ?? "null";
}
function assertNonEmptyString(value: unknown, field: string) {

View file

@ -3,6 +3,7 @@ import {
type CircuitGroupSubtreeSnapshot,
} from "./circuit-group-subtree-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { assertExternalObjectLinkTransitions, type ExternalObjectLinkSnapshotTransition } from "./circuit-device-row-structure-project-command.model.js";
export const circuitGroupDeleteSubtreeCommandType =
"circuit-group.delete-subtree" as const;
@ -12,6 +13,7 @@ export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
interface Payload {
snapshot: CircuitGroupSubtreeSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitGroupDeleteSubtreeProjectCommand
@ -31,24 +33,26 @@ export type CircuitGroupSubtreeProjectCommand =
| CircuitGroupRestoreSubtreeProjectCommand;
export function createCircuitGroupDeleteSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
snapshot: CircuitGroupSubtreeSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupDeleteSubtreeProjectCommand {
const command: CircuitGroupDeleteSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupDeleteSubtreeCommandType,
payload: { snapshot },
payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitGroupDeleteSubtreeProjectCommand(command);
return command;
}
export function createCircuitGroupRestoreSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
snapshot: CircuitGroupSubtreeSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupRestoreSubtreeProjectCommand {
const command: CircuitGroupRestoreSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupRestoreSubtreeCommandType,
payload: { snapshot },
payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitGroupRestoreSubtreeProjectCommand(command);
return command;
@ -76,11 +80,12 @@ function assertCommand(
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
Object.keys(command.payload).some((key) => key !== "snapshot" && key !== "externalObjects")
) {
throw new Error("Unsupported circuit-group subtree command.");
}
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {

View file

@ -3,6 +3,8 @@ import {
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
type ExternalObjectLinkSnapshotTransition,
assertExternalObjectLinkTransitions,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
@ -37,11 +39,13 @@ export interface CircuitSnapshot {
export interface CircuitInsertCommandPayload {
circuit: CircuitSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitDeleteCommandPayload {
circuitId: string;
expectedCircuitListId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
}
export interface CircuitInsertProjectCommand
@ -61,12 +65,13 @@ export type CircuitStructureProjectCommand =
| CircuitDeleteProjectCommand;
export function createCircuitInsertProjectCommand(
circuit: CircuitSnapshot
circuit: CircuitSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitInsertProjectCommand {
const command: CircuitInsertProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType,
payload: { circuit },
payload: { circuit, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitInsertProjectCommand(command);
return command;
@ -74,12 +79,13 @@ export function createCircuitInsertProjectCommand(
export function createCircuitDeleteProjectCommand(
circuitId: string,
expectedCircuitListId: string
expectedCircuitListId: string,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeleteProjectCommand {
const command: CircuitDeleteProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitDeleteCommandType,
payload: { circuitId, expectedCircuitListId },
payload: { circuitId, expectedCircuitListId, ...(externalObjects?.length ? { externalObjects } : {}) },
};
assertCircuitDeleteProjectCommand(command);
return command;
@ -168,6 +174,7 @@ export function assertCircuitInsertProjectCommand(
}
rowIds.add(row.id);
}
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertCircuitDeleteProjectCommand(
@ -187,6 +194,7 @@ export function assertCircuitDeleteProjectCommand(
command.payload.expectedCircuitListId,
"expectedCircuitListId"
);
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
function assertNonEmptyString(value: unknown, field: string) {

View file

@ -20,6 +20,7 @@ export interface CircuitTreeDeviceRow {
remark?: string;
overriddenFields?: string;
rowTotalPower: number;
externalObjectCount?: number;
}
export interface CircuitTreeCircuit {

View file

@ -146,8 +146,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null };
const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null };
const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-circuit assignment may only change the external object row link.");
}

View file

@ -141,8 +141,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null };
const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null };
const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-row assignment may only change the external object row link.");
}

View file

@ -104,10 +104,10 @@ export function assertExternalObjectRowAssignmentProjectCommand(
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
throw new Error("External object row assignment must change its row link.");
}
assertOnlyFieldChanged(
assertOnlyFieldsChanged(
expected as unknown as Record<string, unknown>,
target as unknown as Record<string, unknown>,
"circuitDeviceRowId",
new Set(["circuitDeviceRowId", "distributionBoardId", "linkedProjectDeviceId", "planningValues", "overriddenFields"]),
"external object"
);
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
@ -161,11 +161,22 @@ function assertOnlyFieldChanged(
target: Record<string, unknown>,
allowedField: string,
label: string
) {
assertOnlyFieldsChanged(expected, target, new Set([allowedField]), label);
}
function assertOnlyFieldsChanged(
expected: Record<string, unknown>,
target: Record<string, unknown>,
allowedFields: ReadonlySet<string>,
label: string
) {
const expectedRest = { ...expected };
const targetRest = { ...target };
delete expectedRest[allowedField];
delete targetRest[allowedField];
for (const field of allowedFields) {
delete expectedRest[field];
delete targetRest[field];
}
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error(`Row assignment may only change the ${label} assignment state.`);
}

View file

@ -120,6 +120,11 @@ import {
deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand,
assignExternalProjectObjectsToRow,
assignExternalProjectObjectsToNewRow,
assignExternalProjectObjectsToNewCircuit,
detachExternalProjectObjectsFromRow,
getExternalProjectObjects,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
@ -154,6 +159,7 @@ import type {
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ExternalProjectObjectsDto,
} from "../types";
import type {
CircuitDeviceRowSnapshot,
@ -330,6 +336,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState<string[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@ -381,6 +390,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys]
);
const externalObjectGroups = useMemo(() => {
if (!data || !externalObjects) return [];
const groups = new Map<string, typeof externalObjects.objects>();
for (const object of externalObjects.objects) {
if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
const key = JSON.stringify([
object.roomId, object.selectionMarker, object.deviceTypeId,
object.planningValues.category, object.linkedProjectDeviceId,
object.planningValues,
]);
const entries = groups.get(key) ?? [];
entries.push(object);
groups.set(key, entries);
}
return [...groups.values()].map((objects) => ({
key: objects.map((object) => object.id).sort().join("|"),
objects,
quantity: objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
}));
}, [data, externalObjects]);
async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false;
@ -389,13 +418,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
setError(null);
try {
const { tree, history } = await loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
);
const [{ tree, history }, external] = await Promise.all([
loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
),
getExternalProjectObjects(projectId),
]);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree);
setExternalObjects(external);
} catch (err) {
setError(normalizeUiError(err));
} finally {
@ -1321,12 +1354,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const summary = subtree
? summarizeCircuitGroupSubtree(subtree)
: null;
const externalObjectCount = section.circuits.reduce(
(total, circuit) => total + circuit.deviceRows.reduce(
(rowTotal, row) => rowTotal + (row.externalObjectCount ?? 0), 0
), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` +
`Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` +
"Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
if (!confirm(warning)) {
if (!confirm(warning + externalWarning)) {
return;
}
await runCommand({
@ -2878,11 +2919,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Die Gerätezeile ist ungültig.");
return;
}
const externalWarning = (rowSnapshot.externalObjectCount ?? 0) > 0
? `\n\n${rowSnapshot.externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" +
"Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll."
"Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll." +
externalWarning
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
@ -2894,7 +2939,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) {
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?${externalWarning}`)) {
return;
}
await runCommand({
@ -2917,11 +2962,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Der Stromkreis ist ungültig.");
return;
}
const externalObjectCount = circuit.deviceRows.reduce(
(total, row) => total + (row.externalObjectCount ?? 0), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (
askForConfirmation &&
!confirm(
`Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` +
"Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert."
"Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." +
externalWarning
)
) {
return;
@ -2958,6 +3010,56 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
async function handleAssignExternalObjects(rowId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) dieser Gerätezeile zuordnen?\n\nAbweichende Planungswerte werden ausdrücklich zugunsten der Gerätezeile bestätigt.`)) return;
await runCommand({
label: "Revit-Objekte zuordnen",
redo: async () => {
const result = await assignExternalProjectObjectsToRow(
projectId,
getExpectedProjectRevision(),
objectIds,
rowId,
objectIds
);
applyProjectCommandResult(result);
setDraggingExternalObjectIds([]);
return null;
},
});
}
async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Gerätezeile zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewRow(projectId, getExpectedProjectRevision(), objectIds, circuitId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleAssignExternalObjectsToNewCircuit(sectionId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neuen Stromkreis anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Stromkreis zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewCircuit(projectId, getExpectedProjectRevision(), objectIds, sectionId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleDetachExternalObjects(rowId: string) {
const objectIds = externalObjects?.objects
.filter((object) => object.circuitDeviceRowId === rowId)
.map((object) => object.id) ?? [];
if (objectIds.length === 0 || !confirm(`${objectIds.length} Revit-Objekt(e) lösen?\n\nDie aktuellen Werte der Gerätezeile werden als Planungswerte und Overrides übernommen.`)) return;
await runCommand({
label: "Revit-Objekte lösen",
redo: async () => {
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
applyProjectCommandResult(result);
return null;
},
});
}
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
@ -3381,6 +3483,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
showLabel
/>
<IconButton
ariaControls="revit-object-drawer"
ariaExpanded={isRevitDrawerOpen}
className="project-device-drawer-toggle"
icon={isRevitDrawerOpen ? PanelRightClose : PanelRightOpen}
label={isRevitDrawerOpen ? "Revit-Objekte schließen" : `Revit-Objekte öffnen (${externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)})`}
onClick={() => setIsRevitDrawerOpen((current) => !current)}
showLabel
/>
<IconButton
ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen}
@ -3419,6 +3530,62 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
<div className="tree-editor-layout">
{isRevitDrawerOpen ? (
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
<div className="project-device-drawer-header">
<div>
<h3>Revit-Objekte</h3>
<span>{externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)} unzugeordnet</span>
</div>
<button type="button" onClick={() => setIsRevitDrawerOpen(false)}>Schließen</button>
</div>
<div className="project-device-list">
{externalObjectGroups.map((group) => {
const first = group.objects[0]!;
return (
<details
key={group.key}
className={`project-device-item ${draggingExternalObjectIds[0] === first.id ? "dragging" : ""}`}
>
<summary
draggable
onDragStart={(event) => {
const ids = group.objects.map((object) => object.id);
setDraggingExternalObjectIds(ids);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify(ids));
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
<strong>{first.planningValues.displayName || first.selectionMarker || first.familyAndType}</strong>
<span>{first.sourceRoomNumber} {first.sourceRoomName}</span>
<span>{group.objects.length} Objekt(e), Menge {formatValue(group.quantity, "quantity")}</span>
<span>{first.familyAndType}</span>
</summary>
{group.objects.map((object) => (
<button
key={object.id}
type="button"
draggable
className="btn btn-sm btn-outline-secondary mt-2 w-100 text-start"
onDragStart={(event) => {
setDraggingExternalObjectIds([object.id]);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify([object.id]));
event.stopPropagation();
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
{object.worksharingId}
</button>
))}
</details>
);
})}
{externalObjectGroups.length === 0 ? <p className="notice muted">Keine unzugeordneten Revit-Objekte für diesen Verteiler.</p> : null}
</div>
</aside>
) : null}
{isProjectDeviceDrawerOpen ? (
<aside
className="project-device-sidebar project-device-drawer"
@ -4202,6 +4369,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
return;
}
if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid(
row.sectionId
@ -4342,6 +4514,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
onDrop={(event) => {
if (draggingExternalObjectIds.length > 0 && row.device) {
event.preventDefault();
void handleAssignExternalObjects(row.device.id, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.rowType === "placeholder") {
event.preventDefault();
void handleAssignExternalObjectsToNewCircuit(row.sectionId, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.circuit) {
event.preventDefault();
void handleAssignExternalObjectsToNewRow(row.circuit.id, draggingExternalObjectIds);
return;
}
if (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, {
@ -4728,14 +4915,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</>
) : null}
{row.device ? (
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
<>
{(row.device.externalObjectCount ?? 0) > 0 ? (
<button
type="button"
className="badge rounded-pill text-bg-info border-0"
title="Verknüpfte Revit-Objekte anzeigen und lösen"
onClick={() => void handleDetachExternalObjects(row.device!.id)}
>
Revit {row.device.externalObjectCount}
</button>
) : null}
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
</>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">

View file

@ -447,6 +447,7 @@ export interface CircuitTreeDeviceRowDto {
remark?: string;
overriddenFields?: string;
rowTotalPower: number;
externalObjectCount?: number;
}
export interface CircuitTreeProtectionDeviceDto {

View file

@ -245,6 +245,32 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
}
export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, {
method: "POST",
body: JSON.stringify({ expectedRevision, objectIds, rowId, confirmedConflictObjectIds }),
});
}
export function detachExternalProjectObjectsFromRow(projectId: string, expectedRevision: number, objectIds: string[]) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/detach-row`, {
method: "POST",
body: JSON.stringify({ expectedRevision, objectIds }),
});
}
export function assignExternalProjectObjectsToNewRow(projectId: string, expectedRevision: number, objectIds: string[], circuitId: string) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-new-row`, {
method: "POST", body: JSON.stringify({ expectedRevision, objectIds, circuitId }),
});
}
export function assignExternalProjectObjectsToNewCircuit(projectId: string, expectedRevision: number, objectIds: string[], sectionId: string) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-new-circuit`, {
method: "POST", body: JSON.stringify({ expectedRevision, objectIds, sectionId }),
});
}
export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history`

View file

@ -14,6 +14,7 @@ import {
circuitSectionRepository,
distributionBoardComponentRepository,
distributionBoardRepository,
externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
@ -88,6 +89,13 @@ export async function buildProjectCircuitTrees(
circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
]);
const externalState = externalModelStateRepository.getByProject(projectId).state;
const externalCountByRowId = new Map<string, number>();
for (const object of externalState.objects) {
if (object.circuitDeviceRowId) {
externalCountByRowId.set(object.circuitDeviceRowId, (externalCountByRowId.get(object.circuitDeviceRowId) ?? 0) + 1);
}
}
const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds),
@ -237,6 +245,7 @@ export async function buildProjectCircuitTrees(
row.powerPerUnit,
row.simultaneityFactor
),
externalObjectCount: externalCountByRowId.get(row.id) ?? 0,
}));
const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing.");

View file

@ -1,9 +1,17 @@
import type { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType } from "../../domain/models/external-object-new-row-project-command.model.js";
import { createExternalObjectNewCircuitProjectCommand, externalObjectAssignToNewCircuitCommandType } from "../../domain/models/external-object-new-circuit-project-command.model.js";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults.js";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function listExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
@ -49,6 +57,221 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const rowId = requiredId(req.body?.rowId, "Ungültige Gerätezeile.");
const confirmedConflictObjectIds = Array.isArray(req.body?.confirmedConflictObjectIds)
? req.body.confirmedConflictObjectIds.map((value: unknown) => requiredId(value, "Ungültige Konfliktbestätigung."))
: [];
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const treeResult = await buildProjectCircuitTrees(projectId);
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Gerätezeile wurde nicht gefunden.");
const byId = new Map(state.objects.map((object) => [object.id, object]));
const selected = objectIds.map((id) => {
const object = byId.get(id);
if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits einer Gerätezeile zugeordnet.");
return object;
});
const expectedRow = toRowSnapshot(location.row, location.circuitId);
const addedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
const targetRow = { ...expectedRow, quantity: expectedRow.quantity + addedQuantity };
const objects = selected.map((expected) => ({
expected,
target: { ...expected, distributionBoardId: location.distributionBoardId, circuitDeviceRowId: rowId },
}));
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${objects.length} Revit-Objekte zugeordnet`,
command: createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: expectedRow, target: targetRow }],
objects,
confirmedConflictObjectIds,
}),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function detachExternalObjectsFromRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const treeResult = await buildProjectCircuitTrees(projectId);
const byId = new Map(state.objects.map((object) => [object.id, object]));
const selected = objectIds.map((id) => {
const object = byId.get(id);
if (!object?.circuitDeviceRowId) throw new Error("Ein Revit-Objekt ist keiner Gerätezeile zugeordnet.");
return object;
});
const rowIds = new Set(selected.map((object) => object.circuitDeviceRowId!));
if (rowIds.size !== 1) throw new Error("Revit-Objekte können nur aus derselben Gerätezeile gemeinsam gelöst werden.");
const rowId = [...rowIds][0]!;
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Gerätezeile wurde nicht gefunden.");
const expectedRow = toRowSnapshot(location.row, location.circuitId);
const removedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
const targetRow = { ...expectedRow, quantity: expectedRow.quantity - removedQuantity };
const copiedFields = ["displayName", "category", "connectionKind", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const;
const objects = selected.map((expected) => ({
expected,
target: {
...expected,
circuitDeviceRowId: null,
distributionBoardId: location.distributionBoardId,
linkedProjectDeviceId: expectedRow.linkedProjectDeviceId,
planningValues: {
...expected.planningValues,
displayName: expectedRow.displayName,
category: location.category,
connectionKind: expectedRow.connectionKind,
powerPerUnitW: expectedRow.powerPerUnit * 1000,
simultaneityFactor: expectedRow.simultaneityFactor,
cosPhi: expectedRow.cosPhi,
costGroup: expectedRow.costGroup,
remark: expectedRow.remark,
},
overriddenFields: [...new Set([...expected.overriddenFields, ...copiedFields])],
},
}));
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${objects.length} Revit-Objekte gelöst`,
command: createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: expectedRow, target: targetRow }],
objects,
confirmedConflictObjectIds: [],
}),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToNewRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const circuitId = requiredId(req.body?.circuitId, "Ungültiger Stromkreis.");
const context = await loadAssignmentContext(projectId, objectIds);
const circuitLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.flatMap((section) => section.circuits.map((circuit) => ({ tree, section, circuit })))).find((entry) => entry.circuit.id === circuitId);
if (!circuitLocation || !circuitLocation.section.category) throw new Error("Stromkreis wurde nicht gefunden.");
const row = buildExternalRow(context.objects, context.roomId, circuitId, Math.max(0, ...circuitLocation.circuit.deviceRows.map((entry) => entry.sortOrder)) + 10);
const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: circuitLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neue Gerätezeile zugeordnet`, command: createExternalObjectNewRowProjectCommand(externalObjectAssignToNewRowCommandType, { row, objects: transitions, confirmedConflictObjectIds: objectIds }) });
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToNewCircuit(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const sectionId = requiredId(req.body?.sectionId, "Ungültige Stromkreisgruppe.");
const context = await loadAssignmentContext(projectId, objectIds);
const sectionLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.map((section) => ({ tree, section }))).find((entry) => entry.section.id === sectionId);
if (!sectionLocation?.section.category) throw new Error("Stromkreisgruppe wurde nicht gefunden.");
const circuitId = randomUUID();
const row = buildExternalRow(context.objects, context.roomId, circuitId, 10);
const protection = createDefaultCircuitProtection(sectionLocation.section.category);
const circuit = {
id: circuitId,
circuitListId: sectionLocation.tree.circuitListId,
sectionId,
equipmentIdentifier: await circuitNumberingService.getNextIdentifier(sectionId),
displayName: null,
sortOrder: Math.max(0, ...sectionLocation.section.circuits.map((entry) => entry.sortOrder)) + 10,
cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null,
terminalDesignation: null,
voltage: sectionLocation.section.category === "three_phase" ? sectionLocation.tree.threePhaseVoltageV : sectionLocation.tree.singlePhaseVoltageV,
controlRequirement: null, status: null, isReserve: false, remark: null,
deviceRows: [row],
protectionDevice: {
circuitId,
type: protection.type,
ratedCurrentA: protection.ratedCurrentA,
fuseUtilizationCategory: "fuseUtilizationCategory" in protection ? protection.fuseUtilizationCategory : null,
tripCharacteristic: "tripCharacteristic" in protection ? protection.tripCharacteristic : null,
rcdType: "rcdType" in protection ? protection.rcdType : null,
ratedResidualCurrentMa: "ratedResidualCurrentMa" in protection ? protection.ratedResidualCurrentMa : null,
},
};
const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: sectionLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neuen Stromkreis zugeordnet`, command: createExternalObjectNewCircuitProjectCommand(externalObjectAssignToNewCircuitCommandType, { circuit, objects: transitions, confirmedConflictObjectIds: objectIds }) });
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
async function loadAssignmentContext(projectId: string, objectIds: string[]) {
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) throw new Error("Projekt nicht gefunden.");
const byId = new Map(state.objects.map((object) => [object.id, object]));
const objects = objectIds.map((id) => {
const object = byId.get(id);
if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits zugeordnet.");
return object;
});
const roomIds = new Set(objects.map((object) => object.externalRoomMappingId === null ? null : state.roomMappings.find((mapping) => mapping.id === object.externalRoomMappingId)?.roomId ?? null));
if (roomIds.size !== 1) throw new Error("Revit-Objekte aus verschiedenen Räumen benötigen getrennte Gerätezeilen.");
return { objects, roomId: [...roomIds][0]!, treeResult: await buildProjectCircuitTrees(projectId) };
}
function buildExternalRow(objects: ReturnType<typeof externalModelStateRepository.getByProject>["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot {
const first = objects[0]!;
const planning = first.planningValues;
const quantity = objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
return {
id: randomUUID(), circuitId, linkedProjectDeviceId: first.linkedProjectDeviceId,
sortOrder, name: first.acceptedSourceValues.familyAndType,
displayName: planning.displayName ?? (scalarText(first.acceptedSourceValues.selectionMarker) || first.acceptedSourceValues.familyAndType),
phaseType: planning.category === "three_phase" ? "three_phase" : "single_phase",
connectionKind: planning.connectionKind, costGroup: planning.costGroup,
category: planning.category, level: null,
roomId,
roomNumberSnapshot: scalarText(first.acceptedSourceValues.roomNumber) || null,
roomNameSnapshot: scalarText(first.acceptedSourceValues.roomName) || null,
quantity, manualQuantity: 0,
powerPerUnit: (planning.powerPerUnitW ?? 0) / 1000,
simultaneityFactor: planning.simultaneityFactor ?? 1,
cosPhi: planning.cosPhi, remark: planning.remark, overriddenFields: null,
};
}
function parseExpectedRevision(value: unknown) {
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
return value as number;
}
function parseObjectIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0) throw new Error("Keine Revit-Objekte ausgewählt.");
const ids = value.map((entry) => requiredId(entry, "Ungültige Revit-Objekt-ID."));
if (new Set(ids).size !== ids.length) throw new Error("Revit-Objekte sind doppelt ausgewählt.");
return ids;
}
function requiredId(value: unknown, message: string) { if (typeof value !== "string" || !value) throw new Error(message); return value; }
function findRow(result: Awaited<ReturnType<typeof buildProjectCircuitTrees>>, rowId: string) {
if (!result) return null;
for (const tree of result.trees) for (const section of tree.sections) for (const circuit of section.circuits) {
const row = circuit.deviceRows.find((entry) => entry.id === rowId);
if (row) return { row, circuitId: circuit.id, distributionBoardId: tree.distributionBoardId, category: section.category ?? null };
}
return null;
}
function toRowSnapshot(row: NonNullable<ReturnType<typeof findRow>>["row"], circuitId: string): CircuitDeviceRowSnapshot {
return { id: row.id, circuitId, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null };
}
const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[];
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }

View file

@ -63,7 +63,7 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
export const projectRouter = Router();
@ -93,6 +93,10 @@ projectRouter.post("/:projectId/external-model/initial-import/plan", planExterna
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post(

View file

@ -7,6 +7,9 @@ import { createDatabaseContext, type AppDatabase } from "../src/db/database-cont
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
@ -31,6 +34,10 @@ import {
externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { createCircuitDeviceRowDeleteProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitDeleteProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
import { assertDistributionBoardHasNoExternalObjects, assertRoomHasNoExternalObjects } from "../src/db/repositories/external-object-deletion-guard.persistence.js";
function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:");
@ -626,4 +633,63 @@ describe("external object new-circuit project command", () => {
context.close();
}
});
it("detaches linked objects on row deletion and restores the exact link on undo", () => {
const context = createFixture();
try {
const repository = new CircuitDeviceRowStructureProjectCommandRepository(context.db);
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowDeleteProjectCommand("row-1", "circuit-1"),
});
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
assert.equal(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get(), undefined);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
assert.ok(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get());
} finally {
context.close();
}
});
it("preserves external links through circuit and populated-group deletion undo", () => {
for (const kind of ["circuit", "group"] as const) {
const context = createFixture();
try {
const repository = kind === "circuit"
? new CircuitStructureProjectCommandRepository(context.db)
: new CircuitGroupSubtreeProjectCommandRepository(context.db);
const command = kind === "circuit"
? createCircuitDeleteProjectCommand("circuit-1", "list-1")
: createCircuitGroupDeleteSubtreeProjectCommand(
(repository as CircuitGroupSubtreeProjectCommandRepository).capture("project-1", "section-1")
);
const deleted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command } as never);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse } as never);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
} finally {
context.close();
}
}
});
it("blocks room and distribution-board deletion while external objects reference them", () => {
const context = createFixture(null);
try {
assert.throws(() => assertDistributionBoardHasNoExternalObjects(context.db, "project-1", "board-1"), /Revit-Objekte/);
assert.throws(() => assertRoomHasNoExternalObjects(context.db, "project-1", "room-1"), /Revit-Objekte/);
} finally {
context.close();
}
});
});