Delete populated circuit groups safely
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user