Persist populated group deletion

This commit is contained in:
2026-07-30 20:10:10 +02:00
parent db4c757018
commit 99bdf6491b
15 changed files with 754 additions and 5 deletions
+6
View File
@@ -326,6 +326,12 @@ groups of the same category. The stored target BMK is highest target suffix
plus one at planning time and is never recalculated for Redo. Only circuit plus one at planning time and is never recalculated for Redo. Only circuit
group, BMK and sort order change; device rows and circuit protection retain the group, BMK and sort order change; device rows and circuit protection retain the
stable circuit id. stable circuit id.
Confirmed populated-group deletion uses
`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact
snapshot contains the group, optional component protections, complete circuits,
circuit protections and all device-row link/override metadata. Delete
re-captures and compares the subtree before cascading; restore preserves every
UUID in foreign-key-safe order.
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 -1
View File
@@ -336,7 +336,13 @@ Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt
1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche 1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche
Gerätezeilen einschließlich Link- und Override-Metadaten. Seine Gerätezeilen einschließlich Link- und Override-Metadaten. Seine
Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und
Gerätezeilenanzahl. Persistentes Löschen/Wiederherstellen folgt separat. Gerätezeilenanzahl.
`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor
dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten
Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge
mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse
Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch
Teilwiederherstellungen.
`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
+1 -1
View File
@@ -30,7 +30,7 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase D2a: deterministic same-category circuit-move plan. - [x] Phase D2a: deterministic same-category circuit-move plan.
- [x] Phase D2b: persistent same-category circuit-move command. - [x] Phase D2b: persistent same-category circuit-move command.
- [x] Phase D3a: complete validated group-subtree snapshot and warning summary. - [x] Phase D3a: complete validated group-subtree snapshot and warning summary.
- [ ] Phase D3b: persistent populated-group delete/restore command. - [x] Phase D3b: persistent populated-group delete/restore command.
- [ ] 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,9 +662,7 @@ Acceptance:
### D. Group Numbering and Circuit Moves ### D. Group Numbering and Circuit Moves
Status: In progress. Group renumbering D1 and deterministic circuit-move Status: Complete.
planning and persistent moves D2 plus the deletion snapshot D3a are complete;
persistent populated deletion D3b remains pending.
- implement nested identifier generation - implement nested identifier generation
- support same-category cross-group circuit moves - support same-category cross-group circuit moves
@@ -721,6 +719,16 @@ Implemented in D3a:
- the snapshot derives the confirmation summary for upstream protection, RCD, - the snapshot derives the confirmation summary for upstream protection, RCD,
circuit count and device-row count circuit count and device-row count
Implemented in D3b:
- `circuit-group.delete-subtree` requires the exact previously confirmed
snapshot and re-captures the full current subtree before deletion
- `circuit-group.restore-subtree` restores stable UUIDs in foreign-key-safe
order, including both protection layers and row link/override metadata
- deletion and restoration are exact persistent inverses for Undo/Redo
- stale confirmation state and late history failures leave no partial delete
or restore
Acceptance: Acceptance:
- target identifiers use highest suffix plus one - target identifiers use highest suffix plus one
@@ -0,0 +1,257 @@
import { asc, eq } from "drizzle-orm";
import {
circuitGroupDeleteSubtreeCommandType,
circuitGroupRestoreSubtreeCommandType,
assertCircuitGroupDeleteSubtreeProjectCommand,
assertCircuitGroupRestoreSubtreeProjectCommand,
createCircuitGroupDeleteSubtreeProjectCommand,
createCircuitGroupRestoreSubtreeProjectCommand,
type CircuitGroupSubtreeProjectCommand,
} from "../../domain/models/circuit-group-subtree-project-command.model.js";
import type {
CircuitGroupSubtreeSnapshot,
CircuitProtectionDeviceSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model.js";
import type {
CircuitGroupSubtreeProjectCommandStore,
ExecuteCircuitGroupSubtreeCommandInput,
} from "../../domain/ports/circuit-group-subtree-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
capture(projectId: string, groupId: string) {
return this.captureFrom(this.database, projectId, groupId);
}
execute(input: ExecuteCircuitGroupSubtreeCommandInput) {
if (input.command.type === circuitGroupDeleteSubtreeCommandType) {
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
} else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
}
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupSubtreeProjectCommand
) {
if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.group.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
throw new Error(
"Circuit-group subtree changed before deletion."
);
}
const deleted = database
.delete(circuitSections)
.where(eq(circuitSections.id, current.group.id))
.run();
if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted.");
}
return createCircuitGroupRestoreSubtreeProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteSubtreeProjectCommand(
command.payload.snapshot
);
}
throw new Error("Unsupported circuit-group subtree command.");
}
private captureFrom(
database: AppDatabase,
projectId: string,
groupId: string
): CircuitGroupSubtreeSnapshot {
const group = database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, groupId))
.get();
if (!group || group.category === null || group.groupNumber === null) {
throw new Error("Circuit group not found.");
}
this.assertListOwnership(database, projectId, group.circuitListId);
const components = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, group.id))
.orderBy(
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all()
.map((component) => ({
component: {
...component,
role: component.role as
| "group_upstream_protection"
| "group_residual_current_protection",
},
protectionDevice:
database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
component.id
)
)
.get() ?? null,
}));
const circuitSnapshots = database
.select()
.from(circuits)
.where(eq(circuits.sectionId, group.id))
.orderBy(asc(circuits.sortOrder), asc(circuits.id))
.all()
.map((circuit) => {
const { isReserve, ...values } = circuit;
return {
circuit: {
...values,
isReserve: Boolean(isReserve),
deviceRows: database
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuit.id))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all(),
},
protectionDevice:
(database
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get() as CircuitProtectionDeviceSnapshot | undefined) ??
null,
};
});
return {
group: {
...group,
category: group.category,
groupNumber: group.groupNumber,
},
components,
circuits: circuitSnapshots,
};
}
private restore(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSubtreeSnapshot
) {
this.assertListOwnership(
database,
projectId,
snapshot.group.circuitListId
);
const existing = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.group.id))
.get();
if (existing) {
throw new Error("Circuit group already exists before restoration.");
}
database.insert(circuitSections).values(snapshot.group).run();
for (const component of snapshot.components) {
database
.insert(distributionBoardComponents)
.values(component.component)
.run();
if (component.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(component.protectionDevice)
.run();
}
}
for (const entry of snapshot.circuits) {
const { deviceRows, isReserve, ...circuit } = entry.circuit;
database
.insert(circuits)
.values({ ...circuit, isReserve: isReserve ? 1 : 0 })
.run();
if (entry.protectionDevice !== null) {
database
.insert(circuitProtectionDevices)
.values(entry.protectionDevice)
.run();
}
if (deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(deviceRows).run();
}
}
}
private assertListOwnership(
database: AppDatabase,
projectId: string,
circuitListId: string
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit-group list does not belong to project.");
}
}
}
function sameSnapshot(
left: CircuitGroupSubtreeSnapshot,
right: CircuitGroupSubtreeSnapshot
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([key, child]) =>
`${JSON.stringify(key)}:${canonicalJson(child)}`
)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
@@ -0,0 +1,88 @@
import {
assertCircuitGroupSubtreeSnapshot,
type CircuitGroupSubtreeSnapshot,
} from "./circuit-group-subtree-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupDeleteSubtreeCommandType =
"circuit-group.delete-subtree" as const;
export const circuitGroupRestoreSubtreeCommandType =
"circuit-group.restore-subtree" as const;
export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
interface Payload {
snapshot: CircuitGroupSubtreeSnapshot;
}
export interface CircuitGroupDeleteSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
type: typeof circuitGroupDeleteSubtreeCommandType;
}
export interface CircuitGroupRestoreSubtreeProjectCommand
extends SerializedProjectCommand<Payload> {
schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion;
type: typeof circuitGroupRestoreSubtreeCommandType;
}
export type CircuitGroupSubtreeProjectCommand =
| CircuitGroupDeleteSubtreeProjectCommand
| CircuitGroupRestoreSubtreeProjectCommand;
export function createCircuitGroupDeleteSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
): CircuitGroupDeleteSubtreeProjectCommand {
const command: CircuitGroupDeleteSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupDeleteSubtreeCommandType,
payload: { snapshot },
};
assertCircuitGroupDeleteSubtreeProjectCommand(command);
return command;
}
export function createCircuitGroupRestoreSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot
): CircuitGroupRestoreSubtreeProjectCommand {
const command: CircuitGroupRestoreSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupRestoreSubtreeCommandType,
payload: { snapshot },
};
assertCircuitGroupRestoreSubtreeProjectCommand(command);
return command;
}
export function assertCircuitGroupDeleteSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupDeleteSubtreeProjectCommand {
assertCommand(command, circuitGroupDeleteSubtreeCommandType);
}
export function assertCircuitGroupRestoreSubtreeProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupRestoreSubtreeProjectCommand {
assertCommand(command, circuitGroupRestoreSubtreeCommandType);
}
function assertCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof circuitGroupDeleteSubtreeCommandType
| typeof circuitGroupRestoreSubtreeCommandType
) {
if (
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported circuit-group subtree command.");
}
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,24 @@
import type { CircuitGroupSubtreeSnapshot } from "../models/circuit-group-subtree-snapshot.model.js";
import type { CircuitGroupSubtreeProjectCommand } from "../models/circuit-group-subtree-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupSubtreeCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupSubtreeProjectCommand;
}
export interface CircuitGroupSubtreeProjectCommandStore {
capture(projectId: string, groupId: string): CircuitGroupSubtreeSnapshot;
execute(input: ExecuteCircuitGroupSubtreeCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupSubtreeProjectCommand;
};
}
@@ -101,6 +101,7 @@ import type { CircuitStructureProjectCommandStore } from "../ports/circuit-struc
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js"; import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js";
import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js"; import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js";
import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js"; import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js";
import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-project-command.store.js";
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js"; import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js"; import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type { import type {
@@ -131,6 +132,12 @@ import {
assertCircuitGroupMoveProjectCommand, assertCircuitGroupMoveProjectCommand,
circuitGroupMoveCommandType, circuitGroupMoveCommandType,
} from "../models/circuit-group-move-project-command.model.js"; } from "../models/circuit-group-move-project-command.model.js";
import {
assertCircuitGroupDeleteSubtreeProjectCommand,
assertCircuitGroupRestoreSubtreeProjectCommand,
circuitGroupDeleteSubtreeCommandType,
circuitGroupRestoreSubtreeCommandType,
} from "../models/circuit-group-subtree-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";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js"; import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
@@ -169,6 +176,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore, private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore, private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -399,6 +407,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitGroupDeleteSubtreeCommandType: {
assertCircuitGroupDeleteSubtreeProjectCommand(input.command);
return this.circuitGroupSubtreeStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupRestoreSubtreeCommandType: {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
return this.circuitGroupSubtreeStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: { case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command); assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ return this.projectLocationStructureStore.execute({
@@ -42,6 +42,8 @@ const commandTypeLabels: Record<string, string> = {
"circuit-group.reorder": "Stromkreisgruppen sortiert", "circuit-group.reorder": "Stromkreisgruppen sortiert",
"circuit-group.renumber": "Stromkreisgruppen neu nummeriert", "circuit-group.renumber": "Stromkreisgruppen neu nummeriert",
"circuit.move-group": "Stromkreis in andere Gruppe verschoben", "circuit.move-group": "Stromkreis in andere Gruppe verschoben",
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"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",
@@ -9,6 +9,7 @@ import { CircuitStructureProjectCommandRepository } from "../../db/repositories/
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
@@ -35,6 +36,8 @@ export const circuitGroupRenumberProjectCommandStore =
new CircuitGroupRenumberProjectCommandRepository(db); new CircuitGroupRenumberProjectCommandRepository(db);
export const circuitGroupMoveProjectCommandStore = export const circuitGroupMoveProjectCommandStore =
new CircuitGroupMoveProjectCommandRepository(db); new CircuitGroupMoveProjectCommandRepository(db);
export const circuitGroupSubtreeProjectCommandStore =
new CircuitGroupSubtreeProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore = export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db); new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore = export const distributionBoardComponentStructureProjectCommandStore =
@@ -75,5 +78,6 @@ export const projectCommandService = new ProjectCommandService(
circuitGroupStructureProjectCommandStore, circuitGroupStructureProjectCommandStore,
circuitGroupRenumberProjectCommandStore, circuitGroupRenumberProjectCommandStore,
circuitGroupMoveProjectCommandStore, circuitGroupMoveProjectCommandStore,
circuitGroupSubtreeProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
+1
View File
@@ -14,6 +14,7 @@ import "./circuit-group-renumber-project-command.repository.test.js";
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js"; import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
import "./circuit-group-move-project-command.repository.test.js"; 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";
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", () => {
@@ -0,0 +1,269 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values({ id: "project-1", name: "Projekt" })
.run();
new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db.select().from(circuitLists).get()!;
const group = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.category, "lighting"))
.get()!;
context.db
.insert(distributionBoardComponents)
.values({
id: "subtree-rcd",
circuitListId: list.id,
sectionId: group.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
})
.run();
context.db
.insert(distributionBoardComponentProtectionDevices)
.values({
componentId: "subtree-rcd",
type: "FI",
ratedCurrentA: 40,
rcdType: "A",
ratedResidualCurrentMa: 30,
})
.run();
context.db
.insert(circuits)
.values({
id: "subtree-circuit",
circuitListId: list.id,
sectionId: group.id,
equipmentIdentifier: "-1F1.1",
displayName: "Beleuchtung",
sortOrder: 10,
voltage: 230,
isReserve: 0,
})
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "subtree-circuit",
type: "LS",
ratedCurrentA: 10,
tripCharacteristic: "B",
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "subtree-row",
circuitId: "subtree-circuit",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
cosPhi: 0.95,
remark: null,
overriddenFields: "[\"displayName\"]",
})
.run();
return context;
}
describe("circuit-group subtree project command", () => {
it("deletes and restores the exact populated subtree through undo/redo", () => {
const context = createTestDatabase();
try {
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
const command =
createCircuitGroupDeleteSubtreeProjectCommand(snapshot);
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, groupId))
.get(),
undefined
);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "subtree-row"))
.get(),
undefined
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
repository.capture("project-1", groupId),
snapshot
);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "redo")?.changeSetId,
command,
});
assert.equal(
context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects a subtree changed after confirmation", () => {
const context = createTestDatabase();
try {
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
context.db
.update(circuitDeviceRows)
.set({ displayName: "Geändert" })
.where(eq(circuitDeviceRows.id, "subtree-row"))
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
}),
/changed before deletion/
);
assert.ok(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, groupId))
.get()
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rolls back a cascaded deletion when history persistence fails", () => {
const context = createTestDatabase();
try {
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
context.sqlite.exec(`
CREATE TRIGGER fail_group_subtree_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced group subtree history failure');
END;
`);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
}),
/forced group subtree history failure/
);
assert.deepEqual(
repository.capture("project-1", groupId),
snapshot
);
} finally {
context.close();
}
});
});
+44
View File
@@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
@@ -59,6 +60,7 @@ import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/c
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js"; import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js"; import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js";
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js"; import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
import { import {
createDistributionBoardInsertProjectCommand, createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot, createDistributionBoardStructureSnapshot,
@@ -146,6 +148,7 @@ function createService(context: DatabaseContext) {
new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupStructureProjectCommandRepository(context.db),
new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db),
new CircuitGroupMoveProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db),
new CircuitGroupSubtreeProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
@@ -1447,6 +1450,47 @@ describe("project command service", () => {
} }
}); });
it("dispatches populated circuit-group deletion and restoration", () => {
const context = createTestDatabase();
try {
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
const deleted = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command:
createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
});
assert.equal(deleted.history.currentRevision, 1);
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, groupId))
.get(),
undefined
);
const restored = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(restored.history.currentRevision, 2);
assert.deepEqual(
repository.capture("project-1", groupId),
snapshot
);
} finally {
context.close();
}
});
it("dispatches floor and room setup with their persisted inverses", () => { it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
@@ -201,6 +202,7 @@ function createService(context: DatabaseContext) {
new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupStructureProjectCommandRepository(context.db),
new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db),
new CircuitGroupMoveProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db),
new CircuitGroupSubtreeProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
+16
View File
@@ -175,6 +175,22 @@ describe("project version history presentation", () => {
), ),
"Stromkreis in andere Gruppe verschoben" "Stromkreis in andere Gruppe verschoben"
); );
assert.equal(
getProjectRevisionDescription(
revision(14, {
commandType: "circuit-group.delete-subtree",
})
),
"Stromkreisgruppe vollständig entfernt"
);
assert.equal(
getProjectRevisionDescription(
revision(15, {
commandType: "circuit-group.restore-subtree",
})
),
"Stromkreisgruppe vollständig wiederhergestellt"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal( assert.equal(
getProjectSnapshotKindLabel("automatic"), getProjectSnapshotKindLabel("automatic"),