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 ## 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 Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum, Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler 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. | | 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. | | 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. | | 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. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | | R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |

View file

@ -1128,6 +1128,17 @@ a.kpi:hover {
border-style: dashed; 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 { .sidebar-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -23,8 +23,15 @@ import {
toCircuitDeviceRowInsertValues, toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } 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 { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore implements CircuitDeviceRowStructureProjectCommandStore
@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return executeProjectCommandTransaction( return executeProjectCommandTransactionWithAppliedForward<
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand
>(
this.database, this.database,
input, input,
(tx) => (tx) =>
@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string, projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"], source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand command: CircuitDeviceRowStructureProjectCommand
): CircuitDeviceRowStructureProjectCommand { ): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> {
if (command.type === circuitDeviceRowInsertCommandType) { if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command); assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert( return this.insert(
database, database,
projectId, projectId,
source, source,
command.payload.row command.payload.row,
command.payload.externalObjects ?? []
); );
} }
if (command.type === circuitDeviceRowDeleteCommandType) { if (command.type === circuitDeviceRowDeleteCommandType) {
@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database, database,
projectId, projectId,
command.payload.rowId, command.payload.rowId,
command.payload.expectedCircuitId command.payload.expectedCircuitId,
command.payload.externalObjects
); );
} }
throw new Error("Unsupported circuit device-row structure command."); throw new Error("Unsupported circuit device-row structure command.");
@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"], source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot row: CircuitDeviceRowSnapshot,
externalObjects: ExternalObjectLinkTransition[]
) { ) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) { if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid."); throw new Error("Device-row phase type is invalid.");
@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository
} }
updateDerivedCircuitVoltage(database, projectId, row.circuitId); updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand( if (externalObjects.length > 0) {
row.id, loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert.");
row.circuitId 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( private delete(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
rowId: string, rowId: string,
expectedCircuitId: string expectedCircuitId: string,
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) { ) {
const row = database const row = database
.select() .select()
@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
); );
} }
this.assertCircuitInProject(database, projectId, row.circuitId); 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 const result = database
.delete(circuitDeviceRows) .delete(circuitDeviceRows)
@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
expectedCircuitId expectedCircuitId
); );
return createCircuitDeviceRowInsertProjectCommand( return {
toCircuitDeviceRowSnapshot(row) forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects),
); inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)),
};
} }
private assertCircuitInProject( 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 { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.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 { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
} from "./external-object-assignment.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore implements CircuitGroupSubtreeProjectCommandStore
@ -43,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository
} else { } else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command); assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
} }
return executeProjectCommandTransaction( return executeProjectCommandTransactionWithAppliedForward<
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand
>(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input.projectId, input.command) (tx) => this.applyCommand(tx, input.projectId, input.command)
@ -54,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
command: CircuitGroupSubtreeProjectCommand command: CircuitGroupSubtreeProjectCommand
) { ): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> {
if (command.type === circuitGroupDeleteSubtreeCommandType) { if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom( const current = this.captureFrom(
database, database,
@ -71,6 +81,14 @@ export class CircuitGroupSubtreeProjectCommandRepository
projectId, projectId,
current.circuits.map((entry) => entry.circuit.id) 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 const deleted = database
.delete(circuitSections) .delete(circuitSections)
.where(eq(circuitSections.id, current.group.id)) .where(eq(circuitSections.id, current.group.id))
@ -78,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository
if (deleted.changes !== 1) { if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted."); throw new Error("Circuit-group subtree could not be deleted.");
} }
return createCircuitGroupRestoreSubtreeProjectCommand( return {
command.payload.snapshot forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects),
); inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
} }
if (command.type === circuitGroupRestoreSubtreeCommandType) { if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot); this.restore(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteSubtreeProjectCommand( const externalObjects = command.payload.externalObjects ?? [];
command.payload.snapshot 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."); throw new Error("Unsupported circuit-group subtree command.");
} }

View file

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

View file

@ -46,6 +46,7 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.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"; import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
@ -290,6 +291,7 @@ export class DistributionBoardStructureProjectCommandRepository
} }
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]); assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)

View file

@ -33,6 +33,7 @@ import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore implements DistributionBoardSubtreeProjectCommandStore
@ -86,6 +87,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
assertBoardsAreNotReferencedAsFeeders(database, projectId, [ assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id, current.distributionBoard.id,
]); ]);
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)
.where( .where(

View file

@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition {
target: ExternalModelObjectSnapshot; 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( export function loadExpectedExternalObjectTransitions(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
@ -86,7 +107,13 @@ export function applyExternalObjectLinks(
) { ) {
for (const { expected, target } of transitions) { for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects) 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( .where(and(
eq(externalModelObjects.id, expected.id), eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null 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 { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class ProjectLocationStructureProjectCommandRepository export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore implements ProjectLocationStructureProjectCommandStore
@ -439,6 +440,7 @@ export class ProjectLocationStructureProjectCommandRepository
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird." "Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
); );
} }
assertRoomHasNoExternalObjects(database, projectId, expected.id);
const deleted = database const deleted = database
.delete(rooms) .delete(rooms)
.where( .where(

View file

@ -1,4 +1,11 @@
import type { SerializedProjectCommand } from "./project-command.model.js"; 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 = export const circuitDeviceRowInsertCommandType =
"circuit-device-row.insert" as const; "circuit-device-row.insert" as const;
@ -32,11 +39,13 @@ export interface CircuitDeviceRowSnapshot {
export interface CircuitDeviceRowInsertCommandPayload { export interface CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot; row: CircuitDeviceRowSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitDeviceRowDeleteCommandPayload { export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string; rowId: string;
expectedCircuitId: string; expectedCircuitId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitDeviceRowInsertProjectCommand export interface CircuitDeviceRowInsertProjectCommand
@ -56,7 +65,8 @@ export type CircuitDeviceRowStructureProjectCommand =
| CircuitDeviceRowDeleteProjectCommand; | CircuitDeviceRowDeleteProjectCommand;
export function createCircuitDeviceRowInsertProjectCommand( export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot row: CircuitDeviceRowSnapshot,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowInsertProjectCommand { ): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = { const normalizedRow = {
...row, ...row,
@ -65,7 +75,7 @@ export function createCircuitDeviceRowInsertProjectCommand(
const command: CircuitDeviceRowInsertProjectCommand = { const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType, type: circuitDeviceRowInsertCommandType,
payload: { row: normalizedRow }, payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) },
}; };
assertCircuitDeviceRowInsertProjectCommand(command); assertCircuitDeviceRowInsertProjectCommand(command);
return command; return command;
@ -73,12 +83,13 @@ export function createCircuitDeviceRowInsertProjectCommand(
export function createCircuitDeviceRowDeleteProjectCommand( export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string, rowId: string,
expectedCircuitId: string expectedCircuitId: string,
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowDeleteProjectCommand { ): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = { const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType, type: circuitDeviceRowDeleteCommandType,
payload: { rowId, expectedCircuitId }, payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) },
}; };
assertCircuitDeviceRowDeleteProjectCommand(command); assertCircuitDeviceRowDeleteProjectCommand(command);
return command; return command;
@ -143,6 +154,7 @@ export function assertCircuitDeviceRowInsertProjectCommand(
throw new Error("row.cosPhi must be positive or null."); throw new Error("row.cosPhi must be positive or null.");
} }
} }
assertExternalObjectLinkTransitions(command.payload.externalObjects);
} }
export function assertCircuitDeviceRowDeleteProjectCommand( export function assertCircuitDeviceRowDeleteProjectCommand(
@ -163,6 +175,41 @@ export function assertCircuitDeviceRowDeleteProjectCommand(
command.payload.expectedCircuitId, command.payload.expectedCircuitId,
"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) { function assertNonEmptyString(value: unknown, field: string) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -120,6 +120,11 @@ import {
deleteCircuitGroupCommand, deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand, deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand, deleteDistributionBoardComponentCommand,
assignExternalProjectObjectsToRow,
assignExternalProjectObjectsToNewRow,
assignExternalProjectObjectsToNewCircuit,
detachExternalProjectObjectsFromRow,
getExternalProjectObjects,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
getProjectHistory, getProjectHistory,
@ -154,6 +159,7 @@ import type {
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ExternalProjectObjectsDto,
} from "../types"; } from "../types";
import type { import type {
CircuitDeviceRowSnapshot, CircuitDeviceRowSnapshot,
@ -330,6 +336,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null); useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); 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] = const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false); useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState(""); const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@ -381,6 +390,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)), () => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys] [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 }) { async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false; const showLoading = options?.showLoading ?? false;
@ -389,13 +418,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
setError(null); setError(null);
try { try {
const { tree, history } = await loadCircuitEditorSnapshot( const [{ tree, history }, external] = await Promise.all([
() => getCircuitTree(projectId, circuitListId), loadCircuitEditorSnapshot(
() => getProjectHistory(projectId) () => getCircuitTree(projectId, circuitListId),
); () => getProjectHistory(projectId)
),
getExternalProjectObjects(projectId),
]);
projectRevisionRef.current = history.currentRevision; projectRevisionRef.current = history.currentRevision;
setHistoryState(history); setHistoryState(history);
setData(tree); setData(tree);
setExternalObjects(external);
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
} finally { } finally {
@ -1321,12 +1354,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const summary = subtree const summary = subtree
? summarizeCircuitGroupSubtree(subtree) ? summarizeCircuitGroupSubtree(subtree)
: null; : 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 const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?` ? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` + : `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` + `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."; "Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
if (!confirm(warning)) { if (!confirm(warning + externalWarning)) {
return; return;
} }
await runCommand({ await runCommand({
@ -2878,11 +2919,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Die Gerätezeile ist ungültig."); setError("Die Gerätezeile ist ungültig.");
return; return;
} }
const externalWarning = (rowSnapshot.externalObjectCount ?? 0) > 0
? `\n\n${rowSnapshot.externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (sourceCircuit.deviceRows.length === 1) { if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm( const keepAsReserve = confirm(
`„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` + `„${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" + "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) { if (!keepAsReserve) {
const deleteCircuit = confirm( const deleteCircuit = confirm(
@ -2894,7 +2939,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
return; return;
} }
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) { } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?${externalWarning}`)) {
return; return;
} }
await runCommand({ await runCommand({
@ -2917,11 +2962,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Der Stromkreis ist ungültig."); setError("Der Stromkreis ist ungültig.");
return; 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 ( if (
askForConfirmation && askForConfirmation &&
!confirm( !confirm(
`Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` + `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; return;
@ -2958,6 +3010,56 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId); 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 // Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps. // remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) { async function handleRenumberSection(sectionId: string) {
@ -3381,6 +3483,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
showLabel 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 <IconButton
ariaControls="project-device-drawer" ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen} ariaExpanded={isProjectDeviceDrawerOpen}
@ -3419,6 +3530,62 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
) : null} ) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null} {isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
<div className="tree-editor-layout"> <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 ? ( {isProjectDeviceDrawerOpen ? (
<aside <aside
className="project-device-sidebar project-device-drawer" className="project-device-sidebar project-device-drawer"
@ -4202,6 +4369,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}`} }`}
onClick={() => setActiveSectionId(row.sectionId)} onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => { onDragOver={(event) => {
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
return;
}
if (draggingCircuitCount > 0) { if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid( const valid = isCircuitDropTargetValid(
row.sectionId row.sectionId
@ -4342,6 +4514,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
}} }}
onDrop={(event) => { 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 (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, { void handleCircuitReorderDrop(event, {
@ -4728,14 +4915,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<IconButton <>
disabled={isSaving} {(row.device.externalObjectCount ?? 0) > 0 ? (
icon={ListX} <button
label="Gerät löschen" type="button"
onClick={() => void handleDeleteDevice(row.device!.id)} className="badge rounded-pill text-bg-info border-0"
tabIndex={-1} title="Verknüpfte Revit-Objekte anzeigen und lösen"
variant="danger" 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} ) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint"> <span className="drop-hint">

View file

@ -447,6 +447,7 @@ export interface CircuitTreeDeviceRowDto {
remark?: string; remark?: string;
overriddenFields?: string; overriddenFields?: string;
rowTotalPower: number; rowTotalPower: number;
externalObjectCount?: number;
} }
export interface CircuitTreeProtectionDeviceDto { 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 }) }); 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) { export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>( return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history` `/api/projects/${projectId}/history`

View file

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

View file

@ -1,9 +1,17 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; 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 type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.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) { export async function listExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? ""); const projectId = String(req.params.projectId ?? "");
@ -49,6 +57,221 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); } } 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)[]; 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 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); } 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, getExternalInitialImportStatus,
planExternalInitialImport, planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js"; } 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(); 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.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects); projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects); 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.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( 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 { 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 { 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 { 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 { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js";
@ -31,6 +34,10 @@ import {
externalObjectAssignToNewCircuitCommandType, externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js"; } 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 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") { function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:"); const context = createDatabaseContext(":memory:");
@ -626,4 +633,63 @@ describe("external object new-circuit project command", () => {
context.close(); 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();
}
});
}); });