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
|
`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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user