Add circuit group reordering

This commit is contained in:
2026-07-30 19:49:27 +02:00
parent 1250f9a6af
commit e702712bd8
11 changed files with 335 additions and 7 deletions
+3
View File
@@ -314,6 +314,9 @@ Empty circuit-group creation, display-name updates and deletion use
Their snapshots keep category, positive group number and derived prefix Their snapshots keep category, positive group number and derived prefix
consistent. General deletion rejects groups containing circuits or group consistent. General deletion rejects groups containing circuits or group
components; populated deletion remains a separate confirmed command. components; populated deletion remains a separate confirmed command.
`circuit-group.reorder` requires a complete expected/target sort assignment for
every group in one circuit list. It changes only sort positions and never
renumbers groups, prefixes or circuits.
Distribution-board floor assignment and a project-enabled supply type use Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.
+7 -2
View File
@@ -305,6 +305,11 @@ BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
eigener späterer Command. eigener späterer Command.
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine
Revision und einen Undo/Redo-Schritt.
`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
@@ -377,8 +382,8 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
sind integriert; Gruppensortierung, befüllte Unterbäume und UI folgen in sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
abgegrenzten Arbeitspaketen. und UI folgen in abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+1 -1
View File
@@ -24,7 +24,7 @@ requirements and intended sequencing, not proof of implementation.
protection and auxiliary components. protection and auxiliary components.
- [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.
- [ ] Phase C2b2: complete group reorder command. - [x] Phase C2b2: complete group reorder command.
- [ ] Phase D: group numbering, moves and destructive operations. - [ ] Phase D: group numbering, moves and destructive operations.
- [ ] 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.
@@ -593,9 +593,7 @@ Acceptance:
### C. Persistent Commands and Board Defaults ### C. Persistent Commands and Board Defaults
Status: In progress. New-board defaults C1, component management C2a and Status: Complete.
empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains
pending.
- extend `distribution-board.insert` with fixed components and three groups - extend `distribution-board.insert` with fixed components and three groups
- implement component and group CRUD commands - implement component and group CRUD commands
@@ -647,6 +645,15 @@ Implemented in C2b1:
- project/list ownership, stale state, Undo/Redo and late rollback are covered - project/list ownership, stale state, Undo/Redo and late rollback are covered
at the shared transaction boundary at the shared transaction boundary
Implemented in C2b2:
- `circuit-group.reorder` requires every group in the circuit list with its
exact expected and target `sortOrder`
- sorting changes no group number, prefix or circuit equipment identifier
- incomplete or stale assignments are rejected before history is recorded
- the complete reorder is one project revision and one persistent Undo/Redo
step; late failures roll back every position
Acceptance: Acceptance:
- new boards contain the agreed fixed structure - new boards contain the agreed fixed structure
@@ -2,13 +2,17 @@ import { and, eq } from "drizzle-orm";
import { import {
assertCircuitGroupDeleteProjectCommand, assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand, assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand, assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType, circuitGroupDeleteCommandType,
circuitGroupInsertCommandType, circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType, circuitGroupUpdateCommandType,
createCircuitGroupDeleteProjectCommand, createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand, createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand, createCircuitGroupUpdateProjectCommand,
type CircuitGroupReorderAssignment,
type CircuitGroupSnapshot, type CircuitGroupSnapshot,
type CircuitGroupStructureProjectCommand, type CircuitGroupStructureProjectCommand,
} from "../../domain/models/circuit-group-structure-project-command.model.js"; } from "../../domain/models/circuit-group-structure-project-command.model.js";
@@ -68,6 +72,19 @@ export class CircuitGroupStructureProjectCommandRepository
command.payload.expected command.payload.expected
); );
} }
if (command.type === circuitGroupReorderCommandType) {
assertCircuitGroupReorderProjectCommand(command);
const inverseAssignments = this.reorder(
database,
projectId,
command.payload.circuitListId,
command.payload.assignments
);
return createCircuitGroupReorderProjectCommand(
command.payload.circuitListId,
inverseAssignments
);
}
throw new Error("Unsupported circuit-group structure command."); throw new Error("Unsupported circuit-group structure command.");
} }
@@ -168,6 +185,61 @@ export class CircuitGroupStructureProjectCommandRepository
} }
} }
private reorder(
database: AppDatabase,
projectId: string,
circuitListId: string,
assignments: CircuitGroupReorderAssignment[]
) {
this.assertListOwnership(database, projectId, circuitListId);
const persisted = database
.select({
id: circuitSections.id,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
const persistedById = new Map(
persisted.map((group) => [group.id, group])
);
if (
persisted.length !== assignments.length ||
assignments.some((assignment) => {
const group = persistedById.get(assignment.groupId);
return !group || group.sortOrder !== assignment.expectedSortOrder;
})
) {
throw new Error(
"Circuit-group reorder must include every unchanged group in the list."
);
}
for (const assignment of assignments) {
if (assignment.expectedSortOrder === assignment.targetSortOrder) {
continue;
}
const updated = database
.update(circuitSections)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuitSections.id, assignment.groupId),
eq(circuitSections.circuitListId, circuitListId),
eq(circuitSections.sortOrder, assignment.expectedSortOrder)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group changed during reorder.");
}
}
return assignments.map((assignment) => ({
groupId: assignment.groupId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
}));
}
private getCurrent(database: AppDatabase, id: string) { private getCurrent(database: AppDatabase, id: string) {
return database return database
.select() .select()
@@ -8,6 +8,7 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupInsertCommandType = "circuit-group.insert" as const; export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const; export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
export const circuitGroupUpdateCommandType = "circuit-group.update" as const; export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
export const circuitGroupReorderCommandType = "circuit-group.reorder" as const;
export const circuitGroupStructureCommandSchemaVersion = 1 as const; export const circuitGroupStructureCommandSchemaVersion = 1 as const;
export interface CircuitGroupSnapshot { export interface CircuitGroupSnapshot {
@@ -30,6 +31,17 @@ interface UpdatePayload {
target: CircuitGroupSnapshot; target: CircuitGroupSnapshot;
} }
export interface CircuitGroupReorderAssignment {
groupId: string;
expectedSortOrder: number;
targetSortOrder: number;
}
interface ReorderPayload {
circuitListId: string;
assignments: CircuitGroupReorderAssignment[];
}
export interface CircuitGroupInsertProjectCommand export interface CircuitGroupInsertProjectCommand
extends SerializedProjectCommand<SnapshotPayload> { extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
@@ -48,10 +60,17 @@ export interface CircuitGroupUpdateProjectCommand
type: typeof circuitGroupUpdateCommandType; type: typeof circuitGroupUpdateCommandType;
} }
export interface CircuitGroupReorderProjectCommand
extends SerializedProjectCommand<ReorderPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupReorderCommandType;
}
export type CircuitGroupStructureProjectCommand = export type CircuitGroupStructureProjectCommand =
| CircuitGroupInsertProjectCommand | CircuitGroupInsertProjectCommand
| CircuitGroupDeleteProjectCommand | CircuitGroupDeleteProjectCommand
| CircuitGroupUpdateProjectCommand; | CircuitGroupUpdateProjectCommand
| CircuitGroupReorderProjectCommand;
export function createCircuitGroupInsertProjectCommand( export function createCircuitGroupInsertProjectCommand(
snapshot: CircuitGroupSnapshot snapshot: CircuitGroupSnapshot
@@ -90,6 +109,19 @@ export function createCircuitGroupUpdateProjectCommand(
return command; return command;
} }
export function createCircuitGroupReorderProjectCommand(
circuitListId: string,
assignments: CircuitGroupReorderAssignment[]
): CircuitGroupReorderProjectCommand {
const command: CircuitGroupReorderProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupReorderCommandType,
payload: { circuitListId, assignments },
};
assertCircuitGroupReorderProjectCommand(command);
return command;
}
export function assertCircuitGroupInsertProjectCommand( export function assertCircuitGroupInsertProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupInsertProjectCommand { ): asserts command is CircuitGroupInsertProjectCommand {
@@ -132,6 +164,44 @@ export function assertCircuitGroupUpdateProjectCommand(
} }
} }
export function assertCircuitGroupReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupReorderProjectCommand {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== circuitGroupReorderCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2 ||
typeof command.payload.circuitListId !== "string" ||
!command.payload.circuitListId.trim() ||
!Array.isArray(command.payload.assignments) ||
command.payload.assignments.length === 0
) {
throw new Error("Unsupported circuit-group reorder command.");
}
const groupIds = new Set<string>();
let hasChange = false;
for (const assignment of command.payload.assignments) {
if (
!isPlainObject(assignment) ||
typeof assignment.groupId !== "string" ||
!assignment.groupId.trim() ||
!Number.isFinite(assignment.expectedSortOrder) ||
!Number.isFinite(assignment.targetSortOrder) ||
groupIds.has(assignment.groupId)
) {
throw new Error(
"Circuit-group reorder contains an invalid or duplicate assignment."
);
}
hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder;
groupIds.add(assignment.groupId);
}
if (!hasChange) {
throw new Error("Circuit-group reorder must change at least one position.");
}
}
function assertSnapshotCommand( function assertSnapshotCommand(
command: SerializedProjectCommand<unknown>, command: SerializedProjectCommand<unknown>,
type: type:
@@ -114,9 +114,11 @@ import type {
import { import {
assertCircuitGroupDeleteProjectCommand, assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand, assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand, assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType, circuitGroupDeleteCommandType,
circuitGroupInsertCommandType, circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType, circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.js"; } from "../models/circuit-group-structure-project-command.model.js";
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js"; import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
@@ -364,6 +366,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitGroupReorderCommandType: {
assertCircuitGroupReorderProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: { case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command); assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ return this.projectLocationStructureStore.execute({
@@ -39,6 +39,7 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.insert": "Stromkreisgruppe angelegt", "circuit-group.insert": "Stromkreisgruppe angelegt",
"circuit-group.delete": "Stromkreisgruppe entfernt", "circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet", "circuit-group.update": "Stromkreisgruppe bearbeitet",
"circuit-group.reorder": "Stromkreisgruppen sortiert",
"project-floor.insert": "Geschoss angelegt", "project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt", "project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt", "project-room.insert": "Raum angelegt",
@@ -17,6 +17,7 @@ import { projects } from "../src/db/schema/projects.js";
import { import {
createCircuitGroupDeleteProjectCommand, createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand, createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand, createCircuitGroupUpdateProjectCommand,
type CircuitGroupSnapshot, type CircuitGroupSnapshot,
} from "../src/domain/models/circuit-group-structure-project-command.model.js"; } from "../src/domain/models/circuit-group-structure-project-command.model.js";
@@ -293,4 +294,137 @@ describe("circuit-group structure project command", () => {
context.close(); context.close();
} }
}); });
it("reorders every group atomically without changing group identity", () => {
const context = createTestDatabase();
try {
const circuitListId = getProjectList(context, "project-1").id;
const groups = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
const expectedPrefixes = new Map(
groups.map((group) => [group.id, group.prefix])
);
const assignments = groups.map((group, index) => ({
groupId: group.id,
expectedSortOrder: group.sortOrder,
targetSortOrder: (groups.length - index) * 10,
}));
const repository =
new CircuitGroupStructureProjectCommandRepository(context.db);
const reordered = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupReorderProjectCommand(
circuitListId,
assignments
),
});
for (const assignment of assignments) {
const group = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, assignment.groupId))
.get();
assert.equal(group?.sortOrder, assignment.targetSortOrder);
assert.equal(
group?.prefix,
expectedPrefixes.get(assignment.groupId)
);
}
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: reordered.inverse,
});
for (const assignment of assignments) {
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, assignment.groupId))
.get()?.sortOrder,
assignment.expectedSortOrder
);
}
} finally {
context.close();
}
});
it("rejects incomplete reorders and rolls back late failures", () => {
const context = createTestDatabase();
try {
const circuitListId = getProjectList(context, "project-1").id;
const groups = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
const assignments = groups.map((group, index) => ({
groupId: group.id,
expectedSortOrder: group.sortOrder,
targetSortOrder: (groups.length - index) * 10,
}));
const repository =
new CircuitGroupStructureProjectCommandRepository(context.db);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupReorderProjectCommand(
circuitListId,
assignments.slice(1)
),
}),
/include every unchanged group/
);
context.sqlite.exec(`
CREATE TRIGGER fail_group_reorder_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced group reorder history failure');
END;
`);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupReorderProjectCommand(
circuitListId,
assignments
),
}),
/forced group reorder history failure/
);
for (const assignment of assignments) {
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, assignment.groupId))
.get()?.sortOrder,
assignment.expectedSortOrder
);
}
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
}); });
+21
View File
@@ -50,6 +50,7 @@ import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { import {
createCircuitGroupInsertProjectCommand, createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand, createCircuitGroupUpdateProjectCommand,
} from "../src/domain/models/circuit-group-structure-project-command.model.js"; } from "../src/domain/models/circuit-group-structure-project-command.model.js";
import { import {
@@ -1270,6 +1271,26 @@ describe("project command service", () => {
.get()?.displayName, .get()?.displayName,
expected.displayName expected.displayName
); );
const groups = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.all();
const reorderAssignments = groups.map((group, index) => ({
groupId: group.id,
expectedSortOrder: group.sortOrder,
targetSortOrder: (groups.length - index) * 10,
}));
const reordered = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 3,
command: createCircuitGroupReorderProjectCommand(
circuitListId,
reorderAssignments
),
});
assert.equal(reordered.history.currentRevision, 4);
} finally { } finally {
context.close(); context.close();
} }
+6
View File
@@ -157,6 +157,12 @@ describe("project version history presentation", () => {
), ),
"Stromkreisgruppe bearbeitet" "Stromkreisgruppe bearbeitet"
); );
assert.equal(
getProjectRevisionDescription(
revision(11, { commandType: "circuit-group.reorder" })
),
"Stromkreisgruppen sortiert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),