Plan circuit group renumbering
This commit is contained in:
@@ -310,6 +310,11 @@ Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
|||||||
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||||
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
|
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
|
||||||
Revision und einen Undo/Redo-Schritt.
|
Revision und einen Undo/Redo-Schritt.
|
||||||
|
`src/domain/services/circuit-group-renumbering.ts` plant explizite
|
||||||
|
Gruppennummerierungen deterministisch. Der reine Plan erhält
|
||||||
|
Stromkreis-Endnummern, leitet Gruppenpräfixe und optionale `.0`-Komponenten-BMKs
|
||||||
|
neu ab und prüft Nummerntausch sowie Kollisionen mit unveränderten Gruppen. Die
|
||||||
|
persistente Ausführung dieses Plans folgt als eigener Command.
|
||||||
`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
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ requirements and intended sequencing, not proof of implementation.
|
|||||||
- [x] Phase C2a2: component update and reorder commands.
|
- [x] Phase C2a2: component update and reorder commands.
|
||||||
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
||||||
- [x] Phase C2b2: complete group reorder command.
|
- [x] Phase C2b2: complete group reorder command.
|
||||||
- [ ] Phase D: group numbering, moves and destructive operations.
|
- [x] Phase D1a: deterministic collision-aware group-renumber plan.
|
||||||
|
- [ ] Phase D1b: persistent collision-safe group-renumber command.
|
||||||
|
- [ ] Phase D2: same-category cross-group circuit moves.
|
||||||
|
- [ ] Phase D3: confirmed populated-group 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
|
||||||
|
|||||||
@@ -662,11 +662,24 @@ Acceptance:
|
|||||||
|
|
||||||
### D. Group Numbering and Circuit Moves
|
### D. Group Numbering and Circuit Moves
|
||||||
|
|
||||||
|
Status: In progress. The deterministic renumber planner D1a is complete;
|
||||||
|
persistent execution D1b, cross-group moves D2 and populated deletion D3
|
||||||
|
remain pending.
|
||||||
|
|
||||||
- implement nested identifier generation
|
- implement nested identifier generation
|
||||||
- support same-category cross-group circuit moves
|
- support same-category cross-group circuit moves
|
||||||
- implement explicit collision-safe group renumbering
|
- implement explicit collision-safe group renumbering
|
||||||
- implement confirmed populated-group deletion
|
- implement confirmed populated-group deletion
|
||||||
|
|
||||||
|
Implemented in D1a:
|
||||||
|
|
||||||
|
- a pure renumber planner derives group prefixes, circuit BMKs and optional
|
||||||
|
upstream-protection/RCD BMKs from category and target group number
|
||||||
|
- outgoing-circuit suffixes remain unchanged
|
||||||
|
- number swaps are allowed only when every conflicting group participates
|
||||||
|
- collisions with unchanged groups, no-op mappings and mismatched current BMKs
|
||||||
|
are rejected before persistence
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- target identifiers use highest suffix plus one
|
- target identifiers use highest suffix plus one
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
import {
|
||||||
|
formatCircuitGroupPrefix,
|
||||||
|
formatGroupRcdIdentifier,
|
||||||
|
formatGroupedCircuitIdentifier,
|
||||||
|
formatGroupUpstreamProtectionIdentifier,
|
||||||
|
parseGroupedEquipmentIdentifier,
|
||||||
|
} from "./circuit-group-numbering.js";
|
||||||
|
|
||||||
|
export interface RenumberableCircuitGroup {
|
||||||
|
id: string;
|
||||||
|
category: CircuitGroupCategory;
|
||||||
|
groupNumber: number;
|
||||||
|
prefix: string;
|
||||||
|
circuits: Array<{
|
||||||
|
id: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
}>;
|
||||||
|
components: Array<{
|
||||||
|
id: string;
|
||||||
|
role:
|
||||||
|
| "group_upstream_protection"
|
||||||
|
| "group_residual_current_protection";
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupRenumberRequest {
|
||||||
|
groupId: string;
|
||||||
|
targetGroupNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitGroupRenumberPlan {
|
||||||
|
groups: Array<{
|
||||||
|
groupId: string;
|
||||||
|
category: CircuitGroupCategory;
|
||||||
|
expectedGroupNumber: number;
|
||||||
|
targetGroupNumber: number;
|
||||||
|
expectedPrefix: string;
|
||||||
|
targetPrefix: string;
|
||||||
|
circuits: Array<{
|
||||||
|
circuitId: string;
|
||||||
|
expectedEquipmentIdentifier: string;
|
||||||
|
targetEquipmentIdentifier: string;
|
||||||
|
}>;
|
||||||
|
components: Array<{
|
||||||
|
componentId: string;
|
||||||
|
expectedEquipmentIdentifier: string;
|
||||||
|
targetEquipmentIdentifier: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitGroupRenumberPlan(
|
||||||
|
groups: RenumberableCircuitGroup[],
|
||||||
|
requests: CircuitGroupRenumberRequest[]
|
||||||
|
): CircuitGroupRenumberPlan {
|
||||||
|
if (requests.length === 0) {
|
||||||
|
throw new Error("Circuit-group renumbering requires requests.");
|
||||||
|
}
|
||||||
|
const groupsById = new Map(groups.map((group) => [group.id, group]));
|
||||||
|
if (groupsById.size !== groups.length) {
|
||||||
|
throw new Error("Circuit-group renumbering contains duplicate group ids.");
|
||||||
|
}
|
||||||
|
const requestGroupIds = new Set<string>();
|
||||||
|
const targetKeys = new Set<string>();
|
||||||
|
const selected = requests.map((request) => {
|
||||||
|
const group = groupsById.get(request.groupId);
|
||||||
|
if (
|
||||||
|
!group ||
|
||||||
|
requestGroupIds.has(request.groupId) ||
|
||||||
|
!Number.isInteger(request.targetGroupNumber) ||
|
||||||
|
request.targetGroupNumber < 1
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit-group renumber request is invalid.");
|
||||||
|
}
|
||||||
|
const targetKey = `${group.category}:${request.targetGroupNumber}`;
|
||||||
|
if (targetKeys.has(targetKey)) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group renumbering contains duplicate target numbers."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
requestGroupIds.add(request.groupId);
|
||||||
|
targetKeys.add(targetKey);
|
||||||
|
return { group, targetGroupNumber: request.targetGroupNumber };
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
selected.every(
|
||||||
|
({ group, targetGroupNumber }) =>
|
||||||
|
group.groupNumber === targetGroupNumber
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit-group renumbering must change at least one number."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
if (
|
||||||
|
!requestGroupIds.has(group.id) &&
|
||||||
|
targetKeys.has(`${group.category}:${group.groupNumber}`)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Target group number belongs to an unchanged group."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: selected.map(({ group, targetGroupNumber }) => {
|
||||||
|
assertCurrentGroupIdentity(group);
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
category: group.category,
|
||||||
|
expectedGroupNumber: group.groupNumber,
|
||||||
|
targetGroupNumber,
|
||||||
|
expectedPrefix: group.prefix,
|
||||||
|
targetPrefix: formatCircuitGroupPrefix(
|
||||||
|
group.category,
|
||||||
|
targetGroupNumber
|
||||||
|
),
|
||||||
|
circuits: group.circuits.map((circuit) => {
|
||||||
|
const parsed = parseGroupedEquipmentIdentifier(
|
||||||
|
circuit.equipmentIdentifier
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
parsed?.kind !== "circuit" ||
|
||||||
|
parsed.category !== group.category ||
|
||||||
|
parsed.groupNumber !== group.groupNumber ||
|
||||||
|
parsed.circuitNumber === null
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit BMK does not match its current group."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
circuitId: circuit.id,
|
||||||
|
expectedEquipmentIdentifier:
|
||||||
|
circuit.equipmentIdentifier,
|
||||||
|
targetEquipmentIdentifier:
|
||||||
|
formatGroupedCircuitIdentifier(
|
||||||
|
group.category,
|
||||||
|
targetGroupNumber,
|
||||||
|
parsed.circuitNumber
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
components: group.components.map((component) => {
|
||||||
|
const expected =
|
||||||
|
component.role === "group_upstream_protection"
|
||||||
|
? formatGroupUpstreamProtectionIdentifier(
|
||||||
|
group.category,
|
||||||
|
group.groupNumber
|
||||||
|
)
|
||||||
|
: formatGroupRcdIdentifier(
|
||||||
|
group.category,
|
||||||
|
group.groupNumber
|
||||||
|
);
|
||||||
|
if (component.equipmentIdentifier !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
"Group component BMK does not match its current group."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
componentId: component.id,
|
||||||
|
expectedEquipmentIdentifier: expected,
|
||||||
|
targetEquipmentIdentifier:
|
||||||
|
component.role === "group_upstream_protection"
|
||||||
|
? formatGroupUpstreamProtectionIdentifier(
|
||||||
|
group.category,
|
||||||
|
targetGroupNumber
|
||||||
|
)
|
||||||
|
: formatGroupRcdIdentifier(
|
||||||
|
group.category,
|
||||||
|
targetGroupNumber
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCurrentGroupIdentity(group: RenumberableCircuitGroup) {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(group.groupNumber) ||
|
||||||
|
group.groupNumber < 1 ||
|
||||||
|
group.prefix !==
|
||||||
|
formatCircuitGroupPrefix(group.category, group.groupNumber)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit group has an invalid current identity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
parseGroupedEquipmentIdentifier,
|
parseGroupedEquipmentIdentifier,
|
||||||
} from "../src/domain/services/circuit-group-numbering.js";
|
} from "../src/domain/services/circuit-group-numbering.js";
|
||||||
import "./circuit-group-structure-project-command.repository.test.js";
|
import "./circuit-group-structure-project-command.repository.test.js";
|
||||||
|
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
|
||||||
|
|
||||||
describe("circuit group numbering", () => {
|
describe("circuit group numbering", () => {
|
||||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||||
@@ -103,3 +104,138 @@ describe("circuit group numbering", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("circuit group renumber planning", () => {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
id: "lighting-1",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-1F1.",
|
||||||
|
circuits: [
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F1.1" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1.7" },
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: "fuse-1",
|
||||||
|
role: "group_upstream_protection" as const,
|
||||||
|
equipmentIdentifier: "-1F1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rcd-1",
|
||||||
|
role: "group_residual_current_protection" as const,
|
||||||
|
equipmentIdentifier: "-1Q1.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lighting-2",
|
||||||
|
category: "lighting" as const,
|
||||||
|
groupNumber: 2,
|
||||||
|
prefix: "-1F2.",
|
||||||
|
circuits: [
|
||||||
|
{ id: "circuit-3", equipmentIdentifier: "-1F2.4" },
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single-1",
|
||||||
|
category: "single_phase" as const,
|
||||||
|
groupNumber: 1,
|
||||||
|
prefix: "-2F1.",
|
||||||
|
circuits: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("preserves circuit suffixes and maps optional group components", () => {
|
||||||
|
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 3 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(plan.groups[0], {
|
||||||
|
groupId: "lighting-1",
|
||||||
|
category: "lighting",
|
||||||
|
expectedGroupNumber: 1,
|
||||||
|
targetGroupNumber: 3,
|
||||||
|
expectedPrefix: "-1F1.",
|
||||||
|
targetPrefix: "-1F3.",
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.1",
|
||||||
|
targetEquipmentIdentifier: "-1F3.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.7",
|
||||||
|
targetEquipmentIdentifier: "-1F3.7",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
componentId: "fuse-1",
|
||||||
|
expectedEquipmentIdentifier: "-1F1.0",
|
||||||
|
targetEquipmentIdentifier: "-1F3.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
componentId: "rcd-1",
|
||||||
|
expectedEquipmentIdentifier: "-1Q1.0",
|
||||||
|
targetEquipmentIdentifier: "-1Q3.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a number swap when every affected group participates", () => {
|
||||||
|
const plan = createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||||
|
{ groupId: "lighting-2", targetGroupNumber: 1 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
plan.groups.map((group) => [
|
||||||
|
group.expectedGroupNumber,
|
||||||
|
group.targetGroupNumber,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[1, 2],
|
||||||
|
[2, 1],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects collisions, no-op requests and mismatched current BMKs", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 2 },
|
||||||
|
]),
|
||||||
|
/unchanged group/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(groups, [
|
||||||
|
{ groupId: "lighting-1", targetGroupNumber: 1 },
|
||||||
|
]),
|
||||||
|
/must change/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createCircuitGroupRenumberPlan(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...groups[0],
|
||||||
|
circuits: [
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
equipmentIdentifier: "-1F9.1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ groupId: "lighting-1", targetGroupNumber: 3 }]
|
||||||
|
),
|
||||||
|
/Circuit BMK does not match/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user