Delete populated circuit groups safely

This commit is contained in:
2026-07-31 08:01:55 +02:00
parent 93ec294759
commit ab989cc637
6 changed files with 268 additions and 17 deletions
+7
View File
@@ -400,6 +400,13 @@ Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die `circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins; Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
Lücken werden nicht automatisch gefüllt. Lücken werden nicht automatisch gefüllt.
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
Client sendet dafür den vollständigen aktuellen Unterbaum an
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
wieder her.
`distribution-board.update` versioniert Etage, Netzart und den `distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
+2 -2
View File
@@ -39,10 +39,10 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E3b2a: persistent circuit-protection update command. - [x] Phase E3b2a: persistent circuit-protection update command.
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal. - [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
- [x] Phase E4a: persistent same-category group reorder controls. - [x] Phase E4a: persistent same-category group reorder controls.
- [ ] Phase E4b: explicit group renumber, circuit moves and populated-delete warning. - [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically. - [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment. - [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
- [ ] Populated-group delete warning and explicit subtree deletion. - [x] Populated-group delete warning and explicit subtree deletion.
- [ ] Phase E: editor projection and editing. - [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification. - [ ] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until - [ ] Keep full electrical sizing and cable-dimensioning rules separate until
+34 -15
View File
@@ -55,11 +55,13 @@ import {
buildCircuitGroupMovePlan, buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan, buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments, buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot, buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup, canMoveCircuitToGroup,
canRenumberCircuitGroups, canRenumberCircuitGroups,
canDeleteCircuitGroup, canDeleteCircuitGroup,
renameCircuitGroupSnapshot, renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
toCircuitGroupSnapshot, toCircuitGroupSnapshot,
} from "../utils/circuit-group-editing"; } from "../utils/circuit-group-editing";
import { import {
@@ -87,6 +89,7 @@ import {
deleteCircuitCommand, deleteCircuitCommand,
deleteCircuitDeviceRowCommand, deleteCircuitDeviceRowCommand,
deleteCircuitGroupCommand, deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand, deleteDistributionBoardComponentCommand,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
@@ -1222,23 +1225,40 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
async function handleDeleteEmptyCircuitGroup( async function handleDeleteCircuitGroup(
section: CircuitTreeResponseDto["sections"][number] section: CircuitTreeResponseDto["sections"][number]
) { ) {
if (!canDeleteCircuitGroup(section)) { const isEmpty = canDeleteCircuitGroup(section);
return; const subtree = isEmpty
} ? null
if (!confirm(`Leere Stromkreisgruppe „${section.displayName}“ entfernen?`)) { : buildCircuitGroupSubtreeSnapshot(section, circuitListId);
const summary = subtree
? summarizeCircuitGroupSubtree(subtree)
: null;
const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` +
`Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` +
"Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
if (!confirm(warning)) {
return; return;
} }
await runCommand({ await runCommand({
label: "Leere Stromkreisgruppe entfernen", label: isEmpty
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe entfernen",
redo: async () => { redo: async () => {
const result = await deleteCircuitGroupCommand( const result = subtree
projectId, ? await deleteCircuitGroupSubtreeCommand(
getExpectedProjectRevision(), projectId,
toCircuitGroupSnapshot(section, circuitListId) getExpectedProjectRevision(),
); subtree
)
: await deleteCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
toCircuitGroupSnapshot(section, circuitListId)
);
applyProjectCommandResult(result); applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null); setCircuitGroupEditorIntent(null);
return null; return null;
@@ -3711,14 +3731,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
disabled={!canDeleteCircuitGroup(section)}
title={ title={
canDeleteCircuitGroup(section) canDeleteCircuitGroup(section)
? undefined ? "Leere Stromkreisgruppe entfernen"
: "Befüllte Gruppen werden später über einen eigenen Warn- und Löschdialog entfernt." : "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
} }
onClick={() => onClick={() =>
void handleDeleteEmptyCircuitGroup(section) void handleDeleteCircuitGroup(section)
} }
> >
Gruppe entfernen Gruppe entfernen
+20
View File
@@ -68,6 +68,9 @@ import type {
import type { import type {
CircuitGroupMovePlan, CircuitGroupMovePlan,
} from "../../domain/services/circuit-group-move-planning"; } from "../../domain/services/circuit-group-move-planning";
import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -440,6 +443,23 @@ export function deleteCircuitGroupCommand(
); );
} }
export function deleteCircuitGroupSubtreeCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSubtreeSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.delete-subtree",
payload: { snapshot },
},
"Befüllte Stromkreisgruppe entfernen"
);
}
export function reorderCircuitGroupsCommand( export function reorderCircuitGroupsCommand(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
+116
View File
@@ -16,6 +16,12 @@ import type {
import type { import type {
CircuitGroupMovePlan, CircuitGroupMovePlan,
} from "../../domain/services/circuit-group-move-planning"; } from "../../domain/services/circuit-group-move-planning";
import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
import {
toDistributionBoardComponentSnapshot,
} from "./distribution-board-component-editing";
export function toCircuitGroupSnapshot( export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto, section: CircuitTreeSectionDto,
@@ -86,6 +92,116 @@ export function canDeleteCircuitGroup(
return section.circuits.length === 0 && section.components.length === 0; return section.circuits.length === 0 && section.components.length === 0;
} }
export function buildCircuitGroupSubtreeSnapshot(
section: CircuitTreeSectionDto,
circuitListId: string
): CircuitGroupSubtreeSnapshot {
const components = [...section.components]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map(toDistributionBoardComponentSnapshot);
const circuits = [...section.circuits]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((circuit) => ({
circuit: {
id: circuit.id,
circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId,
equipmentIdentifier: circuit.equipmentIdentifier,
displayName: circuit.displayName ?? null,
sortOrder: circuit.sortOrder,
protectionType: circuit.protectionType ?? null,
protectionRatedCurrent:
circuit.protectionRatedCurrent ?? null,
protectionCharacteristic:
circuit.protectionCharacteristic ?? null,
cableType: circuit.cableType ?? null,
cableCrossSection: circuit.cableCrossSection ?? null,
cableLength: circuit.cableLength ?? null,
rcdAssignment: circuit.rcdAssignment ?? null,
terminalDesignation: circuit.terminalDesignation ?? null,
voltage: circuit.voltage ?? null,
controlRequirement: circuit.controlRequirement ?? null,
status: circuit.status ?? null,
isReserve: circuit.isReserve,
remark: circuit.remark ?? null,
deviceRows: [...circuit.deviceRows]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
)
.map((row) => ({
id: row.id,
circuitId: circuit.id,
linkedProjectDeviceId:
row.linkedProjectDeviceId ?? null,
legacyConsumerId: row.legacyConsumerId ?? 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,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? null,
remark: row.remark ?? null,
overriddenFields: row.overriddenFields ?? null,
})),
},
protectionDevice: circuit.protectionDevice
? {
circuitId: circuit.id,
type: circuit.protectionDevice.type,
ratedCurrentA:
circuit.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
circuit.protectionDevice.fuseUtilizationCategory ??
null,
tripCharacteristic:
circuit.protectionDevice.tripCharacteristic ?? null,
rcdType: circuit.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
circuit.protectionDevice.ratedResidualCurrentMa ??
null,
}
: null,
}));
return {
group: toCircuitGroupSnapshot(section, circuitListId),
components,
circuits,
};
}
export function summarizeCircuitGroupSubtree(
snapshot: CircuitGroupSubtreeSnapshot
) {
return {
circuitCount: snapshot.circuits.length,
deviceRowCount: snapshot.circuits.reduce(
(count, entry) =>
count + entry.circuit.deviceRows.length,
0
),
protectionComponentCount: snapshot.components.length,
};
}
export function buildCircuitGroupReorderAssignments( export function buildCircuitGroupReorderAssignments(
sections: readonly CircuitTreeSectionDto[], sections: readonly CircuitTreeSectionDto[],
groupId: string, groupId: string,
+89
View File
@@ -19,11 +19,13 @@ import {
buildCircuitGroupMovePlan, buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan, buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments, buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot, buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup, canMoveCircuitToGroup,
canRenumberCircuitGroups, canRenumberCircuitGroups,
canDeleteCircuitGroup, canDeleteCircuitGroup,
renameCircuitGroupSnapshot, renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
} from "../src/frontend/utils/circuit-group-editing.js"; } from "../src/frontend/utils/circuit-group-editing.js";
describe("circuit group numbering", () => { describe("circuit group numbering", () => {
@@ -294,6 +296,93 @@ describe("circuit group numbering", () => {
); );
}); });
it("captures a complete populated group for warned deletion", () => {
const section = {
...groupSection("lighting-1", "lighting", 1, 10),
components: [
{
id: "rcd-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
protectionDevice: {
type: "FI" as const,
ratedCurrentA: 40,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.1",
displayName: "Flurlicht",
sortOrder: 10,
voltage: 230,
isReserve: false,
circuitTotalPower: 0.1,
protectionDevice: {
type: "LS" as const,
ratedCurrentA: 10,
tripCharacteristic: "B" as const,
},
deviceRows: [
{
id: "row-1",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte Flur",
phaseType: "single_phase",
quantity: 2,
powerPerUnit: 0.05,
simultaneityFactor: 1,
cosPhi: 0.95,
overriddenFields: "[\"displayName\"]",
rowTotalPower: 0.1,
},
],
},
],
};
const snapshot = buildCircuitGroupSubtreeSnapshot(
section,
"list-1"
);
assert.deepEqual(summarizeCircuitGroupSubtree(snapshot), {
circuitCount: 1,
deviceRowCount: 1,
protectionComponentCount: 1,
});
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.linkedProjectDeviceId,
"device-1"
);
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.overriddenFields,
"[\"displayName\"]"
);
assert.deepEqual(snapshot.circuits[0].protectionDevice, {
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
});
});
it("formats the agreed identifiers including the leading hyphen", () => { it("formats the agreed identifiers including the leading hyphen", () => {
assert.equal( assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1), formatGroupUpstreamProtectionIdentifier("lighting", 1),