Add circuit group commands
This commit is contained in:
@@ -309,6 +309,11 @@ snapshots include the optional one-to-one protection-device state and reject
|
||||
stale data. Updates preserve id, ownership, role and placement. Fixed
|
||||
main-switch and surge-protection header roles are excluded from these general
|
||||
component commands.
|
||||
Empty circuit-group creation, display-name updates and deletion use
|
||||
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -298,6 +298,13 @@ veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
||||
nächsten Arbeitspaketen.
|
||||
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||
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.
|
||||
`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
|
||||
@@ -369,8 +376,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||
veränderliche Verteilerkomponenten sind integriert; Gruppen- und UI-Schritte
|
||||
folgen in abgegrenzten Arbeitspaketen.
|
||||
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
|
||||
sind integriert; Gruppensortierung, 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
|
||||
|
||||
@@ -23,7 +23,8 @@ requirements and intended sequencing, not proof of implementation.
|
||||
- [x] Phase C2a1: persistent insert/delete commands for mutable group
|
||||
protection and auxiliary components.
|
||||
- [x] Phase C2a2: component update and reorder commands.
|
||||
- [ ] Phase C2b: group CRUD and reorder commands.
|
||||
- [x] Phase C2b1: empty-group insert/update/delete commands.
|
||||
- [ ] 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,8 +593,9 @@ Acceptance:
|
||||
|
||||
### C. Persistent Commands and Board Defaults
|
||||
|
||||
Status: In progress. New-board defaults C1 and component management C2a are
|
||||
complete. Group management C2b remains pending.
|
||||
Status: In progress. New-board defaults C1, component management C2a and
|
||||
empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains
|
||||
pending.
|
||||
|
||||
- extend `distribution-board.insert` with fixed components and three groups
|
||||
- implement component and group CRUD commands
|
||||
@@ -635,6 +636,17 @@ Implemented in C2a2:
|
||||
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
|
||||
restores the exact previous snapshot
|
||||
|
||||
Implemented in C2b1:
|
||||
|
||||
- `circuit-group.insert`, `circuit-group.update` and
|
||||
`circuit-group.delete` persist complete group snapshots with stable UUIDs
|
||||
- category, group number and prefix are validated as one consistent identity;
|
||||
general updates change only the display name
|
||||
- deletion requires an exact unchanged and empty group; populated destructive
|
||||
deletion remains the separately confirmed Phase-D operation
|
||||
- project/list ownership, stale state, Undo/Redo and late rollback are covered
|
||||
at the shared transaction boundary
|
||||
|
||||
Acceptance:
|
||||
|
||||
- new boards contain the agreed fixed structure
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupSnapshot,
|
||||
type CircuitGroupStructureProjectCommand,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
CircuitGroupStructureProjectCommandStore,
|
||||
ExecuteCircuitGroupStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-group-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupStructureProjectCommandRepository
|
||||
implements CircuitGroupStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput) {
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
(tx) => this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: CircuitGroupStructureProjectCommand
|
||||
): CircuitGroupStructureProjectCommand {
|
||||
if (command.type === circuitGroupInsertCommandType) {
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
this.insert(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupDeleteProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupDeleteCommandType) {
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
this.delete(database, projectId, command.payload.snapshot);
|
||||
return createCircuitGroupInsertProjectCommand(
|
||||
command.payload.snapshot
|
||||
);
|
||||
}
|
||||
if (command.type === circuitGroupUpdateCommandType) {
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
return createCircuitGroupUpdateProjectCommand(
|
||||
command.payload.target,
|
||||
command.payload.expected
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const existing = database
|
||||
.select({ id: circuitSections.id })
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit-group id already exists.");
|
||||
}
|
||||
database.insert(circuitSections).values(snapshot).run();
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, snapshot.circuitListId);
|
||||
const current = this.getCurrent(database, snapshot.id);
|
||||
if (!current || !sameRecord(snapshot, current)) {
|
||||
throw new Error("Circuit group changed before deletion.");
|
||||
}
|
||||
const circuit = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.sectionId, snapshot.id))
|
||||
.get();
|
||||
const component = database
|
||||
.select({ id: distributionBoardComponents.id })
|
||||
.from(distributionBoardComponents)
|
||||
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
|
||||
.get();
|
||||
if (circuit || component) {
|
||||
throw new Error(
|
||||
"Only empty circuit groups can be deleted by this command."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(circuitSections)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, snapshot.id),
|
||||
eq(circuitSections.circuitListId, snapshot.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Circuit group could not be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
) {
|
||||
this.assertListOwnership(database, projectId, expected.circuitListId);
|
||||
const current = this.getCurrent(database, expected.id);
|
||||
if (!current || !sameRecord(expected, current)) {
|
||||
throw new Error("Circuit group changed before update.");
|
||||
}
|
||||
const updated = database
|
||||
.update(circuitSections)
|
||||
.set({ displayName: target.displayName })
|
||||
.where(
|
||||
and(
|
||||
eq(circuitSections.id, expected.id),
|
||||
eq(circuitSections.circuitListId, expected.circuitListId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Circuit group could not be updated.");
|
||||
}
|
||||
}
|
||||
|
||||
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 does not belong to project.");
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrent(database: AppDatabase, id: string) {
|
||||
return database
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, id))
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(expected: object, actual: object) {
|
||||
const expectedEntries = Object.entries(expected);
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
return (
|
||||
expectedEntries.length === Object.keys(actual).length &&
|
||||
expectedEntries.every(
|
||||
([key, value]) => actualRecord[key] === value
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
|
||||
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 circuitGroupStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitGroupSnapshot {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
prefix: string;
|
||||
sortOrder: number;
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
}
|
||||
|
||||
interface SnapshotPayload {
|
||||
snapshot: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
interface UpdatePayload {
|
||||
expected: CircuitGroupSnapshot;
|
||||
target: CircuitGroupSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitGroupInsertProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupInsertCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupDeleteProjectCommand
|
||||
extends SerializedProjectCommand<SnapshotPayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitGroupUpdateProjectCommand
|
||||
extends SerializedProjectCommand<UpdatePayload> {
|
||||
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
|
||||
type: typeof circuitGroupUpdateCommandType;
|
||||
}
|
||||
|
||||
export type CircuitGroupStructureProjectCommand =
|
||||
| CircuitGroupInsertProjectCommand
|
||||
| CircuitGroupDeleteProjectCommand
|
||||
| CircuitGroupUpdateProjectCommand;
|
||||
|
||||
export function createCircuitGroupInsertProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupInsertProjectCommand {
|
||||
const command: CircuitGroupInsertProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupInsertCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupDeleteProjectCommand(
|
||||
snapshot: CircuitGroupSnapshot
|
||||
): CircuitGroupDeleteProjectCommand {
|
||||
const command: CircuitGroupDeleteProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupDeleteCommandType,
|
||||
payload: { snapshot },
|
||||
};
|
||||
assertCircuitGroupDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitGroupUpdateProjectCommand(
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
): CircuitGroupUpdateProjectCommand {
|
||||
const command: CircuitGroupUpdateProjectCommand = {
|
||||
schemaVersion: circuitGroupStructureCommandSchemaVersion,
|
||||
type: circuitGroupUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
assertCircuitGroupUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitGroupInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupInsertProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupInsertCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupDeleteProjectCommand {
|
||||
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitGroupUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== circuitGroupUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group update command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.expected);
|
||||
assertCircuitGroupSnapshot(command.payload.target);
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"prefix",
|
||||
"sortOrder",
|
||||
"category",
|
||||
"groupNumber",
|
||||
] as const) {
|
||||
if (command.payload.expected[field] !== command.payload.target[field]) {
|
||||
throw new Error(
|
||||
"Circuit-group updates can only change the display name."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSnapshotCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type:
|
||||
| typeof circuitGroupInsertCommandType
|
||||
| typeof circuitGroupDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported circuit-group structure command.");
|
||||
}
|
||||
assertCircuitGroupSnapshot(command.payload.snapshot);
|
||||
}
|
||||
|
||||
export function assertCircuitGroupSnapshot(
|
||||
value: unknown
|
||||
): asserts value is CircuitGroupSnapshot {
|
||||
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
|
||||
throw new Error("Circuit-group snapshot is invalid.");
|
||||
}
|
||||
for (const field of [
|
||||
"id",
|
||||
"circuitListId",
|
||||
"key",
|
||||
"displayName",
|
||||
"prefix",
|
||||
] as const) {
|
||||
if (typeof value[field] !== "string" || !value[field].trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!circuitGroupCategories.includes(
|
||||
value.category as CircuitGroupCategory
|
||||
) ||
|
||||
!Number.isInteger(value.groupNumber) ||
|
||||
(value.groupNumber as number) < 1 ||
|
||||
!Number.isFinite(value.sortOrder)
|
||||
) {
|
||||
throw new Error("Circuit-group category, number or order is invalid.");
|
||||
}
|
||||
if (
|
||||
value.prefix !==
|
||||
formatCircuitGroupPrefix(
|
||||
value.category as CircuitGroupCategory,
|
||||
value.groupNumber as number
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit-group prefix does not match its category and number.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitGroupStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitGroupStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitGroupStructureProjectCommandStore {
|
||||
execute(input: ExecuteCircuitGroupStructureCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitGroupStructureProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,13 @@ function getGroupStem(
|
||||
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
|
||||
}
|
||||
|
||||
export function formatCircuitGroupPrefix(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
return `${getGroupStem(category, "F", groupNumber)}.`;
|
||||
}
|
||||
|
||||
export function formatGroupUpstreamProtectionIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
|
||||
@@ -98,6 +98,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
|
||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-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 {
|
||||
@@ -110,6 +111,14 @@ import type {
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryStore,
|
||||
} from "../ports/project-history.store.js";
|
||||
import {
|
||||
assertCircuitGroupDeleteProjectCommand,
|
||||
assertCircuitGroupInsertProjectCommand,
|
||||
assertCircuitGroupUpdateProjectCommand,
|
||||
circuitGroupDeleteCommandType,
|
||||
circuitGroupInsertCommandType,
|
||||
circuitGroupUpdateCommandType,
|
||||
} from "../models/circuit-group-structure-project-command.model.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 { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
|
||||
@@ -145,6 +154,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
|
||||
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
|
||||
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -333,6 +343,27 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupInsertCommandType: {
|
||||
assertCircuitGroupInsertProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupDeleteCommandType: {
|
||||
assertCircuitGroupDeleteProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitGroupUpdateCommandType: {
|
||||
assertCircuitGroupUpdateProjectCommand(input.command);
|
||||
return this.circuitGroupStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
||||
@@ -36,6 +36,9 @@ const commandTypeLabels: Record<string, string> = {
|
||||
"distribution-board-component.insert": "Verteilergerät angelegt",
|
||||
"distribution-board-component.delete": "Verteilergerät entfernt",
|
||||
"distribution-board-component.update": "Verteilergerät bearbeitet",
|
||||
"circuit-group.insert": "Stromkreisgruppe angelegt",
|
||||
"circuit-group.delete": "Stromkreisgruppe entfernt",
|
||||
"circuit-group.update": "Stromkreisgruppe bearbeitet",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
"project-room.insert": "Raum angelegt",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-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 { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
@@ -26,6 +27,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const circuitGroupStructureProjectCommandStore =
|
||||
new CircuitGroupStructureProjectCommandRepository(db);
|
||||
export const distributionBoardStructureProjectCommandStore =
|
||||
new DistributionBoardStructureProjectCommandRepository(db);
|
||||
export const distributionBoardComponentStructureProjectCommandStore =
|
||||
@@ -63,5 +66,6 @@ export const projectCommandService = new ProjectCommandService(
|
||||
projectSettingsProjectCommandStore,
|
||||
projectStateRestoreCommandStore,
|
||||
distributionBoardComponentStructureProjectCommandStore,
|
||||
circuitGroupStructureProjectCommandStore,
|
||||
projectHistoryStore
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getNextGroupedCircuitIdentifier,
|
||||
parseGroupedEquipmentIdentifier,
|
||||
} from "../src/domain/services/circuit-group-numbering.js";
|
||||
import "./circuit-group-structure-project-command.repository.test.js";
|
||||
|
||||
describe("circuit group numbering", () => {
|
||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createCircuitGroupDeleteProjectCommand,
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
type CircuitGroupSnapshot,
|
||||
} from "../src/domain/models/circuit-group-structure-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" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-2", "UV-02");
|
||||
return context;
|
||||
}
|
||||
|
||||
function getProjectList(context: DatabaseContext, projectId: string) {
|
||||
const list = context.db
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.get();
|
||||
assert.ok(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function groupSnapshot(circuitListId: string): CircuitGroupSnapshot {
|
||||
return {
|
||||
id: "group-lighting-2",
|
||||
circuitListId,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit-group structure project command", () => {
|
||||
it("inserts, renames and restores a group through persistent history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const expected = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const target = {
|
||||
...expected,
|
||||
displayName: "Beleuchtung Besprechungsräume",
|
||||
};
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(expected),
|
||||
});
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
target.displayName
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get(),
|
||||
expected
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes only an exact empty group and restores the same UUID", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo")?.changeSetId,
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
snapshot
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
const repository =
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitGroupInsertProjectCommand({
|
||||
...snapshot,
|
||||
prefix: "-1F9.",
|
||||
}),
|
||||
/prefix does not match/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-2",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
});
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "group-circuit",
|
||||
circuitListId: snapshot.circuitListId,
|
||||
sectionId: snapshot.id,
|
||||
equipmentIdentifier: "-1F2.1",
|
||||
displayName: "Beleuchtung",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupDeleteProjectCommand(snapshot),
|
||||
}),
|
||||
/Only empty circuit groups/
|
||||
);
|
||||
context.db
|
||||
.update(circuitSections)
|
||||
.set({ displayName: "Direkt geändert" })
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitGroupUpdateProjectCommand(snapshot, {
|
||||
...snapshot,
|
||||
displayName: "Ziel",
|
||||
}),
|
||||
}),
|
||||
/changed before update/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(projectRevisions).all().length,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back group insertion when history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const snapshot = groupSnapshot(
|
||||
getProjectList(context, "project-1").id
|
||||
);
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_group_history
|
||||
BEFORE INSERT ON project_history_stack_entries
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced group history failure');
|
||||
END;
|
||||
`);
|
||||
assert.throws(
|
||||
() =>
|
||||
new CircuitGroupStructureProjectCommandRepository(
|
||||
context.db
|
||||
).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitGroupInsertProjectCommand(snapshot),
|
||||
}),
|
||||
/forced group history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, snapshot.id))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -47,6 +48,10 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
|
||||
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
|
||||
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
|
||||
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
|
||||
import {
|
||||
createCircuitGroupInsertProjectCommand,
|
||||
createCircuitGroupUpdateProjectCommand,
|
||||
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
|
||||
import {
|
||||
createDistributionBoardInsertProjectCommand,
|
||||
createDistributionBoardStructureSnapshot,
|
||||
@@ -131,6 +136,7 @@ function createService(context: DatabaseContext) {
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
),
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
@@ -1207,6 +1213,68 @@ describe("project command service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches circuit-group structure commands", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const circuitListId = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.get()!.circuitListId;
|
||||
const expected = {
|
||||
id: "group-service",
|
||||
circuitListId,
|
||||
key: "lighting_2",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2.",
|
||||
sortOrder: 40,
|
||||
category: "lighting" as const,
|
||||
groupNumber: 2,
|
||||
};
|
||||
const target = {
|
||||
...expected,
|
||||
displayName: "Beleuchtung Nebenräume",
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createCircuitGroupInsertProjectCommand(expected),
|
||||
});
|
||||
const updated = service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
command: createCircuitGroupUpdateProjectCommand(
|
||||
expected,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 2);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
target.displayName
|
||||
);
|
||||
|
||||
createService(context).undo({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
});
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.id, expected.id))
|
||||
.get()?.displayName,
|
||||
expected.displayName
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches floor and room setup with their persisted inverses", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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";
|
||||
@@ -195,6 +196,7 @@ function createService(context: DatabaseContext) {
|
||||
new DistributionBoardComponentStructureProjectCommandRepository(
|
||||
context.db
|
||||
),
|
||||
new CircuitGroupStructureProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,24 @@ describe("project version history presentation", () => {
|
||||
),
|
||||
"Verteilergerät bearbeitet"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(8, { commandType: "circuit-group.insert" })
|
||||
),
|
||||
"Stromkreisgruppe angelegt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(9, { commandType: "circuit-group.delete" })
|
||||
),
|
||||
"Stromkreisgruppe entfernt"
|
||||
);
|
||||
assert.equal(
|
||||
getProjectRevisionDescription(
|
||||
revision(10, { commandType: "circuit-group.update" })
|
||||
),
|
||||
"Stromkreisgruppe bearbeitet"
|
||||
);
|
||||
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
|
||||
assert.equal(
|
||||
getProjectSnapshotKindLabel("automatic"),
|
||||
|
||||
Reference in New Issue
Block a user