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
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
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
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
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 E3b2b: circuit-protection defaults on insert and editor modal.
- [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] 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 F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
+34 -15
View File
@@ -55,11 +55,13 @@ import {
buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
toCircuitGroupSnapshot,
} from "../utils/circuit-group-editing";
import {
@@ -87,6 +89,7 @@ import {
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand,
getCircuitTree,
getNextCircuitIdentifier,
@@ -1222,23 +1225,40 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleDeleteEmptyCircuitGroup(
async function handleDeleteCircuitGroup(
section: CircuitTreeResponseDto["sections"][number]
) {
if (!canDeleteCircuitGroup(section)) {
return;
}
if (!confirm(`Leere Stromkreisgruppe „${section.displayName}“ entfernen?`)) {
const isEmpty = canDeleteCircuitGroup(section);
const subtree = isEmpty
? null
: 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;
}
await runCommand({
label: "Leere Stromkreisgruppe entfernen",
label: isEmpty
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe entfernen",
redo: async () => {
const result = await deleteCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
toCircuitGroupSnapshot(section, circuitListId)
);
const result = subtree
? await deleteCircuitGroupSubtreeCommand(
projectId,
getExpectedProjectRevision(),
subtree
)
: await deleteCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
toCircuitGroupSnapshot(section, circuitListId)
);
applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null);
return null;
@@ -3711,14 +3731,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button
type="button"
tabIndex={-1}
disabled={!canDeleteCircuitGroup(section)}
title={
canDeleteCircuitGroup(section)
? undefined
: "Befüllte Gruppen werden später über einen eigenen Warn- und Löschdialog entfernt."
? "Leere Stromkreisgruppe entfernen"
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
}
onClick={() =>
void handleDeleteEmptyCircuitGroup(section)
void handleDeleteCircuitGroup(section)
}
>
Gruppe entfernen
+20
View File
@@ -68,6 +68,9 @@ import type {
import type {
CircuitGroupMovePlan,
} 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> {
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(
projectId: string,
expectedRevision: number,
+116
View File
@@ -16,6 +16,12 @@ import type {
import type {
CircuitGroupMovePlan,
} 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(
section: CircuitTreeSectionDto,
@@ -86,6 +92,116 @@ export function canDeleteCircuitGroup(
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(
sections: readonly CircuitTreeSectionDto[],
groupId: string,
+89
View File
@@ -19,11 +19,13 @@ import {
buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
} from "../src/frontend/utils/circuit-group-editing.js";
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", () => {
assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1),