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 Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
vollständige erwartete und gewünschte Sortierreihenfolge über vollständige erwartete und gewünschte Sortierreihenfolge über
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert. `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 `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
+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 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. - [ ] 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 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
@@ -52,8 +52,10 @@ import {
buildCircuitSectionRenumberAssignments, buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command"; } from "../utils/circuit-section-renumber-command";
import { import {
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments, buildCircuitGroupReorderAssignments,
buildNewCircuitGroupSnapshot, buildNewCircuitGroupSnapshot,
canRenumberCircuitGroups,
canDeleteCircuitGroup, canDeleteCircuitGroup,
renameCircuitGroupSnapshot, renameCircuitGroupSnapshot,
toCircuitGroupSnapshot, toCircuitGroupSnapshot,
@@ -97,6 +99,7 @@ import {
reorderCircuitSectionCommand, reorderCircuitSectionCommand,
reorderCircuitSectionsCommand, reorderCircuitSectionsCommand,
reorderCircuitGroupsCommand, reorderCircuitGroupsCommand,
renumberCircuitGroupsCommand,
renumberCircuitSectionCommand, renumberCircuitSectionCommand,
redoProjectCommand, redoProjectCommand,
updateCircuitById, updateCircuitById,
@@ -126,7 +129,10 @@ import type {
import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-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 SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar"; 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( async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto protection: CircuitTreeProtectionDeviceDto
) { ) {
@@ -3543,6 +3587,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
Gruppe nach unten Gruppe nach unten
</button> </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 <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
+21
View File
@@ -62,6 +62,9 @@ import type {
import type { import type {
CircuitProtectionSnapshot, CircuitProtectionSnapshot,
} from "../../domain/models/circuit-protection-project-command.model"; } 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> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { 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( export function updateCircuitProtectionCommand(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
+146
View File
@@ -10,6 +10,9 @@ import type { CircuitTreeSectionDto } from "../types";
import type { import type {
CircuitGroupReorderAssignment, CircuitGroupReorderAssignment,
} from "../../domain/models/circuit-group-structure-project-command.model"; } from "../../domain/models/circuit-group-structure-project-command.model";
import type {
CircuitGroupRenumberPlan,
} from "../../domain/services/circuit-group-renumbering";
export function toCircuitGroupSnapshot( export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto, 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( function getNewGroupSortOrder(
category: CircuitGroupCategory, category: CircuitGroupCategory,
sections: readonly (CircuitTreeSectionDto & { sections: readonly (CircuitTreeSectionDto & {
@@ -141,3 +213,77 @@ function getNewGroupSortOrder(
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2 ? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
: previous.sortOrder + 10; : 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-snapshot.test.js";
import "./circuit-group-subtree-project-command.repository.test.js"; import "./circuit-group-subtree-project-command.repository.test.js";
import { import {
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments, buildCircuitGroupReorderAssignments,
buildNewCircuitGroupSnapshot, buildNewCircuitGroupSnapshot,
canRenumberCircuitGroups,
canDeleteCircuitGroup, canDeleteCircuitGroup,
renameCircuitGroupSnapshot, renameCircuitGroupSnapshot,
} from "../src/frontend/utils/circuit-group-editing.js"; } 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", () => { it("formats the agreed identifiers including the leading hyphen", () => {
assert.equal( assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1), formatGroupUpstreamProtectionIdentifier("lighting", 1),