Renumber circuit groups explicitly

This commit is contained in:
2026-07-31 07:53:07 +02:00
parent 0cb280ddb2
commit be9c6f0d52
6 changed files with 347 additions and 1 deletions
+5
View File
@@ -390,6 +390,11 @@ Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
vollständige erwartete und gewünschte Sortierreihenfolge über
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
`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
+3
View File
@@ -40,6 +40,9 @@ requirements and intended sequencing, not proof of implementation.
- [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] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
- [ ] Circuit moves between same-category groups.
- [ ] 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
@@ -52,8 +52,10 @@ import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import {
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildNewCircuitGroupSnapshot,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
toCircuitGroupSnapshot,
@@ -97,6 +99,7 @@ import {
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
reorderCircuitGroupsCommand,
renumberCircuitGroupsCommand,
renumberCircuitSectionCommand,
redoProjectCommand,
updateCircuitById,
@@ -126,7 +129,10 @@ import type {
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
import {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
@@ -1267,6 +1273,44 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleRenumberCircuitGroups(
category: CircuitGroupCategory
) {
if (!data) {
return;
}
let plan: ReturnType<typeof buildCircuitGroupRenumberPlan>;
try {
plan = buildCircuitGroupRenumberPlan(data.sections, category);
} catch (err) {
setError(normalizeUiError(err));
return;
}
if (!plan) {
return;
}
if (
!confirm(
`Alle Gruppen der Kategorie „${circuitGroupCategoryLabels[category]}“ gemäß ihrer aktuellen Reihenfolge neu nummerieren? Gruppennummern, Präfixe und zugehörige BMK werden dabei gemeinsam geändert.`
)
) {
return;
}
await runCommand({
label: "Stromkreisgruppen neu nummerieren",
redo: async () => {
const result = await renumberCircuitGroupsCommand(
projectId,
getExpectedProjectRevision(),
circuitListId,
plan
);
applyProjectCommandResult(result);
return null;
},
});
}
async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto
) {
@@ -3543,6 +3587,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
Gruppe nach unten
</button>
<button
type="button"
tabIndex={-1}
disabled={
hasActiveSortOrFilter ||
!section.category ||
!canRenumberCircuitGroups(
data.sections,
section.category
)
}
onClick={() =>
section.category
? void handleRenumberCircuitGroups(
section.category
)
: undefined
}
title={
hasActiveSortOrFilter
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
}
>
Gruppen-BMK neu nummerieren
</button>
<button
type="button"
tabIndex={-1}
+21
View File
@@ -62,6 +62,9 @@ import type {
import type {
CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model";
import type {
CircuitGroupRenumberPlan,
} from "../../domain/services/circuit-group-renumbering";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -452,6 +455,24 @@ export function reorderCircuitGroupsCommand(
);
}
export function renumberCircuitGroupsCommand(
projectId: string,
expectedRevision: number,
circuitListId: string,
plan: CircuitGroupRenumberPlan
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.renumber",
payload: { circuitListId, groups: plan.groups },
},
"Stromkreisgruppen neu nummerieren"
);
}
export function updateCircuitProtectionCommand(
projectId: string,
expectedRevision: number,
+146
View File
@@ -10,6 +10,9 @@ import type { CircuitTreeSectionDto } from "../types";
import type {
CircuitGroupReorderAssignment,
} from "../../domain/models/circuit-group-structure-project-command.model";
import type {
CircuitGroupRenumberPlan,
} from "../../domain/services/circuit-group-renumbering";
export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto,
@@ -115,6 +118,75 @@ export function buildCircuitGroupReorderAssignments(
}));
}
export function buildCircuitGroupRenumberPlan(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
): CircuitGroupRenumberPlan | null {
const groups = getOrderedCategoryGroups(sections, category);
if (!canRenumberCircuitGroups(sections, category)) {
return null;
}
return {
groups: groups.map((group, index) => {
const targetGroupNumber = index + 1;
const expectedPrefix = formatGroupPrefix(
category,
group.groupNumber
);
if (group.prefix !== expectedPrefix) {
throw new Error(
`Das Präfix der Gruppe „${group.displayName}“ passt nicht zu ihrer Gruppennummer.`
);
}
return {
groupId: group.id,
category,
expectedGroupNumber: group.groupNumber,
targetGroupNumber,
expectedPrefix,
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
circuits: group.circuits.map((circuit) => {
const circuitNumber = parseCircuitNumber(
circuit.equipmentIdentifier,
category,
group.groupNumber
);
return {
circuitId: circuit.id,
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
targetEquipmentIdentifier: formatCircuitIdentifier(
category,
targetGroupNumber,
circuitNumber
),
};
}),
components: group.components.map((component) => ({
componentId: component.id,
expectedEquipmentIdentifier: component.equipmentIdentifier,
targetEquipmentIdentifier: formatGroupComponentIdentifier(
category,
targetGroupNumber,
component.role
),
})),
};
}),
};
}
export function canRenumberCircuitGroups(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
) {
const groups = getOrderedCategoryGroups(sections, category);
return (
groups.length > 0 &&
groups.some((group, index) => group.groupNumber !== index + 1)
);
}
function getNewGroupSortOrder(
category: CircuitGroupCategory,
sections: readonly (CircuitTreeSectionDto & {
@@ -141,3 +213,77 @@ function getNewGroupSortOrder(
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
: previous.sortOrder + 10;
}
function formatGroupPrefix(
category: CircuitGroupCategory,
groupNumber: number
) {
return `-${circuitGroupCategoryNumbers[category]}F${groupNumber}.`;
}
function getOrderedCategoryGroups(
sections: readonly CircuitTreeSectionDto[],
category: CircuitGroupCategory
) {
return sections
.filter(
(section): section is CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
} =>
section.category === category &&
Number.isInteger(section.groupNumber) &&
(section.groupNumber ?? 0) > 0
)
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
}
function formatCircuitIdentifier(
category: CircuitGroupCategory,
groupNumber: number,
circuitNumber: number
) {
return `${formatGroupPrefix(category, groupNumber)}${circuitNumber}`;
}
function parseCircuitNumber(
equipmentIdentifier: string,
category: CircuitGroupCategory,
groupNumber: number
) {
const prefix = formatGroupPrefix(category, groupNumber);
const suffix = equipmentIdentifier.startsWith(prefix)
? equipmentIdentifier.slice(prefix.length)
: "";
const circuitNumber = Number(suffix);
if (
!/^[1-9]\d*$/.test(suffix) ||
!Number.isSafeInteger(circuitNumber)
) {
throw new Error(
`Das Stromkreis-BMK „${equipmentIdentifier}“ passt nicht zu seiner Gruppe.`
);
}
return circuitNumber;
}
function formatGroupComponentIdentifier(
category: CircuitGroupCategory,
groupNumber: number,
role: CircuitTreeSectionDto["components"][number]["role"]
) {
const categoryNumber = circuitGroupCategoryNumbers[category];
if (role === "group_upstream_protection") {
return `-${categoryNumber}F${groupNumber}.0`;
}
if (role === "group_residual_current_protection") {
return `-${categoryNumber}Q${groupNumber}.0`;
}
throw new Error(
"Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente."
);
}
+101
View File
@@ -16,8 +16,10 @@ import "./circuit-group-move-project-command.repository.test.js";
import "./circuit-group-subtree-snapshot.test.js";
import "./circuit-group-subtree-project-command.repository.test.js";
import {
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildNewCircuitGroupSnapshot,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
} from "../src/frontend/utils/circuit-group-editing.js";
@@ -118,6 +120,105 @@ describe("circuit group numbering", () => {
);
});
it("renumbers a complete category by group order and preserves circuit suffixes", () => {
const sections = [
{
...groupSection("lighting-3", "lighting", 3, 10),
prefix: "-1F3.",
circuits: [
{
id: "circuit-7",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1F3.7",
sortOrder: 10,
isReserve: false,
circuitTotalPower: 0,
deviceRows: [],
},
],
components: [
{
id: "rcd-3",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1Q3.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
},
{
...groupSection("lighting-1", "lighting", 1, 20),
prefix: "-1F1.",
},
groupSection("single-1", "single_phase", 1, 30),
];
assert.deepEqual(buildCircuitGroupRenumberPlan(sections, "lighting"), {
groups: [
{
groupId: "lighting-3",
category: "lighting",
expectedGroupNumber: 3,
targetGroupNumber: 1,
expectedPrefix: "-1F3.",
targetPrefix: "-1F1.",
circuits: [
{
circuitId: "circuit-7",
expectedEquipmentIdentifier: "-1F3.7",
targetEquipmentIdentifier: "-1F1.7",
},
],
components: [
{
componentId: "rcd-3",
expectedEquipmentIdentifier: "-1Q3.0",
targetEquipmentIdentifier: "-1Q1.0",
},
],
},
{
groupId: "lighting-1",
category: "lighting",
expectedGroupNumber: 1,
targetGroupNumber: 2,
expectedPrefix: "-1F1.",
targetPrefix: "-1F2.",
circuits: [],
components: [],
},
],
});
assert.equal(
buildCircuitGroupRenumberPlan(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
null
);
assert.equal(
canRenumberCircuitGroups(sections, "lighting"),
true
);
assert.equal(
canRenumberCircuitGroups(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
false
);
});
it("formats the agreed identifiers including the leading hyphen", () => {
assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1),