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
consistent. General deletion rejects groups containing circuits or group
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.update`; both values are snapshot/export fields and one
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
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
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
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
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
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
sind integriert; Gruppensortierung, befüllte Unterbäume und UI folgen in
abgegrenzten Arbeitspaketen.
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
und UI folgen in abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
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.
- [x] Phase C2a2: component update and reorder 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 E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification.
@@ -593,9 +593,7 @@ Acceptance:
### C. Persistent Commands and Board Defaults
Status: In progress. New-board defaults C1, component management C2a and
empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains
pending.
Status: Complete.
- extend `distribution-board.insert` with fixed components and three groups
- 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
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:
- new boards contain the agreed fixed structure
@@ -2,13 +2,17 @@ import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType,
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupReorderAssignment,
type CircuitGroupSnapshot,
type CircuitGroupStructureProjectCommand,
} from "../../domain/models/circuit-group-structure-project-command.model.js";
@@ -68,6 +72,19 @@ export class CircuitGroupStructureProjectCommandRepository
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.");
}
@@ -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) {
return database
.select()
@@ -8,6 +8,7 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
export const circuitGroupDeleteCommandType = "circuit-group.delete" 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 interface CircuitGroupSnapshot {
@@ -30,6 +31,17 @@ interface UpdatePayload {
target: CircuitGroupSnapshot;
}
export interface CircuitGroupReorderAssignment {
groupId: string;
expectedSortOrder: number;
targetSortOrder: number;
}
interface ReorderPayload {
circuitListId: string;
assignments: CircuitGroupReorderAssignment[];
}
export interface CircuitGroupInsertProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
@@ -48,10 +60,17 @@ export interface CircuitGroupUpdateProjectCommand
type: typeof circuitGroupUpdateCommandType;
}
export interface CircuitGroupReorderProjectCommand
extends SerializedProjectCommand<ReorderPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupReorderCommandType;
}
export type CircuitGroupStructureProjectCommand =
| CircuitGroupInsertProjectCommand
| CircuitGroupDeleteProjectCommand
| CircuitGroupUpdateProjectCommand;
| CircuitGroupUpdateProjectCommand
| CircuitGroupReorderProjectCommand;
export function createCircuitGroupInsertProjectCommand(
snapshot: CircuitGroupSnapshot
@@ -90,6 +109,19 @@ export function createCircuitGroupUpdateProjectCommand(
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(
command: SerializedProjectCommand<unknown>
): 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(
command: SerializedProjectCommand<unknown>,
type:
@@ -114,9 +114,11 @@ import type {
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupReorderProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupReorderCommandType,
circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.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,
}).revision;
}
case circuitGroupReorderCommandType: {
assertCircuitGroupReorderProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -39,6 +39,7 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.insert": "Stromkreisgruppe angelegt",
"circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet",
"circuit-group.reorder": "Stromkreisgruppen sortiert",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -17,6 +17,7 @@ import { projects } from "../src/db/schema/projects.js";
import {
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupSnapshot,
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
@@ -293,4 +294,137 @@ describe("circuit-group structure project command", () => {
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 {
createCircuitGroupInsertProjectCommand,
createCircuitGroupReorderProjectCommand,
createCircuitGroupUpdateProjectCommand,
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
import {
@@ -1270,6 +1271,26 @@ describe("project command service", () => {
.get()?.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 {
context.close();
}
+6
View File
@@ -157,6 +157,12 @@ describe("project version history presentation", () => {
),
"Stromkreisgruppe bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(11, { commandType: "circuit-group.reorder" })
),
"Stromkreisgruppen sortiert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),