Add circuit group reordering
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user