Persist device row moves
This commit is contained in:
@@ -1,282 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type {
|
||||
CircuitDeviceRowTransactionStore,
|
||||
CreateCircuitDeviceRowTransactionInput,
|
||||
CreateCircuitWithDeviceRowsTransactionInput,
|
||||
MoveCircuitDeviceRowsTransactionInput,
|
||||
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowTransactionRepository
|
||||
implements CircuitDeviceRowTransactionStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
|
||||
const id = crypto.randomUUID();
|
||||
this.database.transaction((tx) => {
|
||||
const [circuit] = tx
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, input.circuitId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!circuit) {
|
||||
throw new Error("Der Stromkreis ist ungültig.");
|
||||
}
|
||||
|
||||
const existingRows = tx
|
||||
.select({ sortOrder: circuitDeviceRows.sortOrder })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
|
||||
.all();
|
||||
const lastSortOrder = existingRows.reduce(
|
||||
(highest, row) => Math.max(highest, row.sortOrder),
|
||||
0
|
||||
);
|
||||
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
|
||||
|
||||
tx
|
||||
.insert(circuitDeviceRows)
|
||||
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
|
||||
.run();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
.where(eq(circuits.id, input.circuitId))
|
||||
.run();
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
|
||||
if (input.deviceRows.length === 0) {
|
||||
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
||||
}
|
||||
|
||||
const circuitId = crypto.randomUUID();
|
||||
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
||||
this.database.transaction((tx) => {
|
||||
tx
|
||||
.insert(circuits)
|
||||
.values(
|
||||
toCircuitCreateValues(circuitId, {
|
||||
...input.circuit,
|
||||
isReserve: false,
|
||||
})
|
||||
)
|
||||
.run();
|
||||
|
||||
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
||||
const row = input.deviceRows[index];
|
||||
tx
|
||||
.insert(circuitDeviceRows)
|
||||
.values(
|
||||
toCircuitDeviceRowCreateValues(rowIds[index], {
|
||||
...row,
|
||||
circuitId,
|
||||
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
||||
})
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { circuitId, rowIds };
|
||||
}
|
||||
|
||||
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
||||
this.database.transaction((tx) => {
|
||||
const [row] = tx
|
||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, rowId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!row || row.circuitId !== expectedCircuitId) {
|
||||
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
|
||||
}
|
||||
|
||||
const result = tx
|
||||
.delete(circuitDeviceRows)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.id, rowId),
|
||||
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
const remainingRows = tx
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
||||
.all();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||
.where(eq(circuits.id, expectedCircuitId))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
|
||||
if (input.rows.length === 0) {
|
||||
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
|
||||
}
|
||||
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
|
||||
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
|
||||
}
|
||||
|
||||
const rowIds = input.rows.map((row) => row.id);
|
||||
if (new Set(rowIds).size !== rowIds.length) {
|
||||
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
||||
}
|
||||
|
||||
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
|
||||
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
|
||||
|
||||
this.database.transaction((tx) => {
|
||||
const persistedRows = tx
|
||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||
.from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
if (persistedRows.length !== input.rows.length) {
|
||||
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
|
||||
}
|
||||
|
||||
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
|
||||
for (const expectedRow of input.rows) {
|
||||
if (
|
||||
persistedRowsById.get(expectedRow.id)?.circuitId !==
|
||||
expectedRow.expectedCircuitId
|
||||
) {
|
||||
throw new Error(
|
||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let targetCircuit: { id: string; circuitListId: string };
|
||||
if (input.createTargetCircuit) {
|
||||
tx
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: targetCircuitId,
|
||||
circuitListId: input.createTargetCircuit.circuitListId,
|
||||
sectionId: input.createTargetCircuit.sectionId,
|
||||
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
|
||||
displayName: input.createTargetCircuit.displayName,
|
||||
sortOrder: input.createTargetCircuit.sortOrder,
|
||||
isReserve: 0,
|
||||
})
|
||||
.run();
|
||||
targetCircuit = {
|
||||
id: targetCircuitId,
|
||||
circuitListId: input.createTargetCircuit.circuitListId,
|
||||
};
|
||||
} else {
|
||||
const [persistedTargetCircuit] = tx
|
||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, targetCircuitId))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (!persistedTargetCircuit) {
|
||||
throw new Error("Der Zielstromkreis ist ungültig.");
|
||||
}
|
||||
targetCircuit = persistedTargetCircuit;
|
||||
}
|
||||
|
||||
const sourceCircuitIds = [
|
||||
...new Set(input.rows.map((row) => row.expectedCircuitId)),
|
||||
];
|
||||
const sourceCircuits = tx
|
||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(inArray(circuits.id, sourceCircuitIds))
|
||||
.all();
|
||||
if (sourceCircuits.length !== sourceCircuitIds.length) {
|
||||
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
|
||||
}
|
||||
if (
|
||||
sourceCircuits.some(
|
||||
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
|
||||
);
|
||||
}
|
||||
|
||||
const movedRowIdSet = new Set(rowIds);
|
||||
const retainedTargetRows = tx
|
||||
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
|
||||
.all()
|
||||
.filter((row) => !movedRowIdSet.has(row.id));
|
||||
const lastTargetSortOrder = retainedTargetRows.reduce(
|
||||
(highest, row) => Math.max(highest, row.sortOrder),
|
||||
0
|
||||
);
|
||||
|
||||
for (let index = 0; index < input.rows.length; index += 1) {
|
||||
const row = input.rows[index];
|
||||
const result = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set({
|
||||
circuitId: targetCircuitId,
|
||||
sortOrder: lastTargetSortOrder + (index + 1) * 10,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.id, row.id),
|
||||
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error(
|
||||
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceCircuitId of sourceCircuitIds) {
|
||||
const remainingRows = tx
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
|
||||
.all();
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||
.where(eq(circuits.id, sourceCircuitId))
|
||||
.run();
|
||||
}
|
||||
tx
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
.where(eq(circuits.id, targetCircuitId))
|
||||
.run();
|
||||
});
|
||||
|
||||
return {
|
||||
movedRowIds: rowIds,
|
||||
targetCircuitId,
|
||||
createdCircuitId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface CreateCircuitDeviceRowTransactionInput {
|
||||
circuitId: string;
|
||||
linkedProjectDeviceId?: string;
|
||||
sortOrder?: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
phaseType?: string;
|
||||
connectionKind?: string;
|
||||
costGroup?: string;
|
||||
category?: string;
|
||||
level?: string;
|
||||
roomId?: string;
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
remark?: string;
|
||||
overriddenFields?: string;
|
||||
}
|
||||
|
||||
export interface CreateCircuitTransactionInput {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName?: string;
|
||||
sortOrder: number;
|
||||
protectionType?: string;
|
||||
protectionRatedCurrent?: number;
|
||||
protectionCharacteristic?: string;
|
||||
cableType?: string;
|
||||
cableCrossSection?: string;
|
||||
cableLength?: number;
|
||||
voltage?: number;
|
||||
controlRequirement?: string;
|
||||
remark?: string;
|
||||
rcdAssignment?: string;
|
||||
terminalDesignation?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateCircuitWithDeviceRowsTransactionInput {
|
||||
circuit: CreateCircuitTransactionInput;
|
||||
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
|
||||
}
|
||||
|
||||
export interface MoveCircuitDeviceRowsTransactionInput {
|
||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
||||
targetCircuitId?: string;
|
||||
createTargetCircuit?: {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MoveCircuitDeviceRowsTransactionResult {
|
||||
movedRowIds: string[];
|
||||
targetCircuitId: string;
|
||||
createdCircuitId?: string;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowTransactionStore {
|
||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
||||
createCircuitWithDeviceRows(
|
||||
input: CreateCircuitWithDeviceRowsTransactionInput
|
||||
): { circuitId: string; rowIds: string[] };
|
||||
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
||||
moveRows(
|
||||
input: MoveCircuitDeviceRowsTransactionInput
|
||||
): MoveCircuitDeviceRowsTransactionResult;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
||||
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
|
||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import type {
|
||||
MoveCircuitDeviceRowInput,
|
||||
MoveCircuitDeviceRowsBulkInput,
|
||||
ReorderSectionCircuitsInput,
|
||||
UpdateSectionEquipmentIdentifiersInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
@@ -14,46 +10,21 @@ import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
export class CircuitWriteService {
|
||||
private readonly circuitRepository: CircuitRepository;
|
||||
private readonly circuitSectionRepository: CircuitSectionRepository;
|
||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||
private readonly numberingService: CircuitNumberingService;
|
||||
|
||||
constructor(deps?: {
|
||||
circuitRepository?: CircuitRepository;
|
||||
circuitSectionRepository?: CircuitSectionRepository;
|
||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||
numberingService?: CircuitNumberingService;
|
||||
}) {
|
||||
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
|
||||
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
|
||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||
}
|
||||
|
||||
// Ensures writes never connect a section to the wrong circuit list.
|
||||
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||
if (!section) {
|
||||
throw new Error("Invalid section id.");
|
||||
}
|
||||
if (section.circuitListId !== circuitListId) {
|
||||
throw new Error("Section does not belong to circuit list.");
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
private getDeviceRowTransactionStore() {
|
||||
if (!this.deviceRowTransactionStore) {
|
||||
throw new Error("Circuit device-row transactions are not configured.");
|
||||
}
|
||||
return this.deviceRowTransactionStore;
|
||||
}
|
||||
|
||||
private getCircuitSectionTransactionStore() {
|
||||
if (!this.circuitSectionTransactionStore) {
|
||||
throw new Error("Circuit-section transactions are not configured.");
|
||||
@@ -61,157 +32,6 @@ export class CircuitWriteService {
|
||||
return this.circuitSectionTransactionStore;
|
||||
}
|
||||
|
||||
async deleteCircuit(circuitId: string) {
|
||||
const current = await this.circuitRepository.findById(circuitId);
|
||||
if (!current) {
|
||||
throw new Error("Invalid circuit id.");
|
||||
}
|
||||
await this.circuitRepository.delete(circuitId);
|
||||
}
|
||||
|
||||
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
|
||||
const row = await this.deviceRowRepository.findById(rowId);
|
||||
if (!row) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
|
||||
const sourceCircuit = await this.circuitRepository.findById(row.circuitId);
|
||||
if (!sourceCircuit) {
|
||||
throw new Error("Invalid circuit id.");
|
||||
}
|
||||
|
||||
let targetCircuit = input.targetCircuitId
|
||||
? await this.circuitRepository.findById(input.targetCircuitId)
|
||||
: null;
|
||||
if (input.targetCircuitId && !targetCircuit) {
|
||||
throw new Error("Invalid target circuit id.");
|
||||
}
|
||||
|
||||
let createTargetCircuit:
|
||||
| {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Placeholder-target move prepares a new circuit explicitly. Its creation and
|
||||
// the row assignment are committed together without renumbering other circuits.
|
||||
if (!targetCircuit) {
|
||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||
throw new Error("Invalid move target.");
|
||||
}
|
||||
const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId);
|
||||
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
||||
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
||||
const nextSortOrder =
|
||||
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||
createTargetCircuit = {
|
||||
circuitListId: sourceCircuit.circuitListId,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: nextIdentifier,
|
||||
displayName: "Neuer Stromkreis",
|
||||
sortOrder: nextSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
|
||||
throw new Error("Target circuit does not belong to same circuit list.");
|
||||
}
|
||||
if (targetCircuit?.id === sourceCircuit.id) {
|
||||
return this.deviceRowRepository.findById(rowId);
|
||||
}
|
||||
|
||||
this.getDeviceRowTransactionStore().moveRows({
|
||||
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
|
||||
targetCircuitId: targetCircuit?.id,
|
||||
createTargetCircuit,
|
||||
});
|
||||
|
||||
return this.deviceRowRepository.findById(rowId);
|
||||
}
|
||||
|
||||
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
|
||||
// Bulk move keeps input order and resolves all source circuits first so undo can
|
||||
// restore per-source assignment deterministically.
|
||||
const uniqueRowIds = [...new Set(input.rowIds)];
|
||||
if (uniqueRowIds.length === 0) {
|
||||
throw new Error("No device rows provided.");
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const rowId of uniqueRowIds) {
|
||||
const row = await this.deviceRowRepository.findById(rowId);
|
||||
if (!row) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
|
||||
for (const row of rows) {
|
||||
if (!sourceCircuits.has(row.circuitId)) {
|
||||
const circuit = await this.circuitRepository.findById(row.circuitId);
|
||||
if (!circuit) {
|
||||
throw new Error("Invalid circuit id.");
|
||||
}
|
||||
sourceCircuits.set(row.circuitId, circuit);
|
||||
}
|
||||
}
|
||||
|
||||
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
|
||||
let targetCircuit = input.targetCircuitId
|
||||
? await this.circuitRepository.findById(input.targetCircuitId)
|
||||
: null;
|
||||
if (input.targetCircuitId && !targetCircuit) {
|
||||
throw new Error("Invalid target circuit id.");
|
||||
}
|
||||
|
||||
// Bulk placeholder move prepares exactly one new circuit as common target.
|
||||
// Its actual creation happens together with the row moves in one transaction.
|
||||
let createTargetCircuit:
|
||||
| {
|
||||
circuitListId: string;
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!targetCircuit) {
|
||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||
throw new Error("Invalid move target.");
|
||||
}
|
||||
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
|
||||
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
||||
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
||||
const nextSortOrder =
|
||||
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||
createTargetCircuit = {
|
||||
circuitListId: referenceSourceCircuit.circuitListId,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: nextIdentifier,
|
||||
displayName: "Neuer Stromkreis",
|
||||
sortOrder: nextSortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
|
||||
for (const sourceCircuit of sourceCircuits.values()) {
|
||||
if (sourceCircuit.circuitListId !== targetCircuitListId) {
|
||||
throw new Error("All moved rows must belong to same circuit list as target.");
|
||||
}
|
||||
}
|
||||
|
||||
return this.getDeviceRowTransactionStore().moveRows({
|
||||
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
|
||||
targetCircuitId: targetCircuit?.id,
|
||||
createTargetCircuit,
|
||||
});
|
||||
}
|
||||
|
||||
async getNextIdentifier(sectionId: string) {
|
||||
return this.numberingService.getNextIdentifier(sectionId);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
buildCircuitDeviceRowInsertSnapshot,
|
||||
buildCircuitInsertSnapshot,
|
||||
} from "../utils/circuit-structure-command";
|
||||
import {
|
||||
buildCircuitDeviceRowMoveAssignments,
|
||||
} from "../utils/circuit-device-row-move-command";
|
||||
import type {
|
||||
CellKey,
|
||||
CellKind,
|
||||
@@ -40,7 +43,6 @@ import {
|
||||
} from "../utils/circuit-grid-projection";
|
||||
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
|
||||
import {
|
||||
deleteCircuitById,
|
||||
deleteCircuitCommand,
|
||||
deleteCircuitDeviceRowCommand,
|
||||
getCircuitTree,
|
||||
@@ -48,8 +50,8 @@ import {
|
||||
insertCircuitCommand,
|
||||
insertCircuitDeviceRowCommand,
|
||||
listProjectDevices,
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRowById,
|
||||
moveCircuitDeviceRowsCommand,
|
||||
moveCircuitDeviceRowsToNewCircuitCommand,
|
||||
reorderSectionCircuits,
|
||||
renumberCircuitSection,
|
||||
redoProjectCommand,
|
||||
@@ -2230,8 +2232,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
});
|
||||
}
|
||||
|
||||
// Handles device-row drag intent (single or bulk) and preserves enough source grouping
|
||||
// for deterministic undo back to original circuits.
|
||||
// Handles single and bulk device-row drag through persistent project history.
|
||||
// Exact source and target positions make undo deterministic.
|
||||
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2269,65 +2271,117 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return;
|
||||
}
|
||||
|
||||
// Device-row drag supports bulk selection, but target intent is always explicit:
|
||||
// move into existing circuit or create new circuit in a target section.
|
||||
const circuits = (data?.sections ?? []).flatMap(
|
||||
(section) => section.circuits
|
||||
);
|
||||
const label =
|
||||
rowIds.length > 1
|
||||
? `${rowIds.length} Gerätezeilen verschieben`
|
||||
: "Gerätezeile verschieben";
|
||||
|
||||
if (intent.kind === "move-to-circuit") {
|
||||
const groupedBySource = new Map<string, string[]>();
|
||||
for (const rowId of rowIds) {
|
||||
const source = sourceByRowId.get(rowId)!;
|
||||
if (!groupedBySource.has(source)) {
|
||||
groupedBySource.set(source, []);
|
||||
}
|
||||
groupedBySource.get(source)!.push(rowId);
|
||||
const targetCircuit = circuits.find(
|
||||
(circuit) => circuit.id === intent.circuitId
|
||||
);
|
||||
if (!targetCircuit) {
|
||||
setError("Der Zielstromkreis ist ungültig.");
|
||||
return;
|
||||
}
|
||||
const moves = buildCircuitDeviceRowMoveAssignments({
|
||||
circuits,
|
||||
rowIds,
|
||||
targetCircuitId: targetCircuit.id,
|
||||
targetDeviceRows: targetCircuit.deviceRows,
|
||||
});
|
||||
if (moves.length === 0) {
|
||||
return;
|
||||
}
|
||||
await runCommand({
|
||||
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen verschieben` : "Gerätezeile verschieben",
|
||||
label,
|
||||
redo: async () => {
|
||||
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
|
||||
const result = await moveCircuitDeviceRowsCommand(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
moves,
|
||||
label
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
undo: async () => {
|
||||
for (const [source, ids] of groupedBySource) {
|
||||
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||
}
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
undo: async () => null,
|
||||
persistent: {
|
||||
undoIntent: () => {
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
redoIntent: () => {
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let createdCircuitId: string | null = null;
|
||||
const groupedBySource = new Map<string, string[]>();
|
||||
for (const rowId of rowIds) {
|
||||
const source = sourceByRowId.get(rowId)!;
|
||||
if (!groupedBySource.has(source)) {
|
||||
groupedBySource.set(source, []);
|
||||
}
|
||||
groupedBySource.get(source)!.push(rowId);
|
||||
const targetSection = data?.sections.find(
|
||||
(section) => section.id === intent.sectionId
|
||||
);
|
||||
if (!targetSection) {
|
||||
setError("Der Zielbereich ist ungültig.");
|
||||
return;
|
||||
}
|
||||
const newCircuitLabel =
|
||||
rowIds.length > 1
|
||||
? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben`
|
||||
: "Gerätezeile in neuen Stromkreis verschieben";
|
||||
await runCommand({
|
||||
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` : "Gerätezeile in neuen Stromkreis verschieben",
|
||||
label: newCircuitLabel,
|
||||
redo: async () => {
|
||||
const moved = await moveCircuitDeviceRowsBulk({
|
||||
rowIds,
|
||||
targetSectionId: intent.sectionId,
|
||||
createNewCircuit: true,
|
||||
const next = await getNextCircuitIdentifier(intent.sectionId);
|
||||
const sortOrder =
|
||||
targetSection.circuits.length > 0
|
||||
? Math.max(
|
||||
...targetSection.circuits.map(
|
||||
(circuit) => circuit.sortOrder
|
||||
)
|
||||
) + 10
|
||||
: 10;
|
||||
const targetCircuit = createCircuitSnapshot({
|
||||
sectionId: intent.sectionId,
|
||||
equipmentIdentifier: next.nextIdentifier,
|
||||
displayName: "Neuer Stromkreis",
|
||||
sortOrder,
|
||||
isReserve: true,
|
||||
});
|
||||
createdCircuitId = moved.createdCircuitId ?? null;
|
||||
const moves = buildCircuitDeviceRowMoveAssignments({
|
||||
circuits,
|
||||
rowIds,
|
||||
targetCircuitId: targetCircuit.id,
|
||||
targetDeviceRows: [],
|
||||
});
|
||||
const result =
|
||||
await moveCircuitDeviceRowsToNewCircuitCommand(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
targetCircuit,
|
||||
moves,
|
||||
newCircuitLabel
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
undo: async () => {
|
||||
for (const [source, ids] of groupedBySource) {
|
||||
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||
}
|
||||
if (createdCircuitId) {
|
||||
await deleteCircuitById(createdCircuitId);
|
||||
}
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
undo: async () => null,
|
||||
persistent: {
|
||||
undoIntent: () => {
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
redoIntent: () => {
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+37
-24
@@ -33,6 +33,9 @@ import type {
|
||||
import type {
|
||||
CircuitSnapshot,
|
||||
} from "../../domain/models/circuit-structure-project-command.model";
|
||||
import type {
|
||||
CircuitDeviceRowMoveAssignment,
|
||||
} from "../../domain/models/circuit-device-row-move-project-command.model";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
@@ -212,12 +215,6 @@ export function deleteCircuitCommand(
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitById(circuitId: string) {
|
||||
return request<void>(`/api/circuits/${circuitId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function getNextCircuitIdentifier(sectionId: string) {
|
||||
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||
`/api/circuit-sections/${sectionId}/next-identifier`
|
||||
@@ -275,28 +272,44 @@ export function deleteCircuitDeviceRowCommand(
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCircuitDeviceRowById(
|
||||
rowId: string,
|
||||
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
|
||||
export function moveCircuitDeviceRowsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
moves: CircuitDeviceRowMoveAssignment[],
|
||||
description: string
|
||||
) {
|
||||
return request(`/api/circuit-device-rows/${rowId}/move`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-device-row.move",
|
||||
payload: { moves },
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCircuitDeviceRowsBulk(input: {
|
||||
rowIds: string[];
|
||||
targetCircuitId?: string;
|
||||
targetSectionId?: string;
|
||||
createNewCircuit?: boolean;
|
||||
}) {
|
||||
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
|
||||
"/api/circuit-device-rows/move-bulk",
|
||||
export function moveCircuitDeviceRowsToNewCircuitCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
targetCircuit: CircuitSnapshot,
|
||||
moves: CircuitDeviceRowMoveAssignment[],
|
||||
description: string
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
}
|
||||
schemaVersion: 1,
|
||||
type: "circuit-device-row.move-with-new-circuit",
|
||||
payload: {
|
||||
targetCircuitAction: "create",
|
||||
targetCircuit,
|
||||
moves,
|
||||
},
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
} from "../types.js";
|
||||
import type {
|
||||
CircuitDeviceRowMoveAssignment,
|
||||
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
|
||||
|
||||
export function buildCircuitDeviceRowMoveAssignments(input: {
|
||||
circuits: CircuitTreeCircuitDto[];
|
||||
rowIds: string[];
|
||||
targetCircuitId: string;
|
||||
targetDeviceRows: CircuitTreeDeviceRowDto[];
|
||||
}): CircuitDeviceRowMoveAssignment[] {
|
||||
const { circuits, rowIds, targetCircuitId, targetDeviceRows } =
|
||||
input;
|
||||
if (new Set(rowIds).size !== rowIds.length) {
|
||||
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
||||
}
|
||||
|
||||
const sourceByRowId = new Map<
|
||||
string,
|
||||
{ circuitId: string; sortOrder: number }
|
||||
>();
|
||||
for (const circuit of circuits) {
|
||||
for (const row of circuit.deviceRows) {
|
||||
sourceByRowId.set(row.id, {
|
||||
circuitId: circuit.id,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const movedRowIds = new Set(rowIds);
|
||||
const retainedTargetRows = targetDeviceRows.filter(
|
||||
(row) => !movedRowIds.has(row.id)
|
||||
);
|
||||
const lastTargetSortOrder = retainedTargetRows.reduce(
|
||||
(highest, row) => Math.max(highest, row.sortOrder),
|
||||
0
|
||||
);
|
||||
|
||||
return rowIds.flatMap((rowId, index) => {
|
||||
const source = sourceByRowId.get(rowId);
|
||||
if (!source) {
|
||||
throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden.");
|
||||
}
|
||||
const targetSortOrder =
|
||||
lastTargetSortOrder + (index + 1) * 10;
|
||||
if (
|
||||
source.circuitId === targetCircuitId &&
|
||||
source.sortOrder === targetSortOrder
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
rowId,
|
||||
expectedCircuitId: source.circuitId,
|
||||
expectedSortOrder: source.sortOrder,
|
||||
targetCircuitId,
|
||||
targetSortOrder,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
|
||||
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
|
||||
|
||||
export const circuitWriteService = new CircuitWriteService({
|
||||
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
||||
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
|
||||
});
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||
import {
|
||||
moveCircuitDeviceRowsBulkSchema,
|
||||
moveCircuitDeviceRowSchema,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
|
||||
export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
||||
const { rowId } = req.params;
|
||||
if (typeof rowId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid rowId" });
|
||||
}
|
||||
|
||||
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
try {
|
||||
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
|
||||
return res.json(moved);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
|
||||
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
try {
|
||||
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
|
||||
return res.json(moved);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { circuitWriteService } from "../composition/circuit-write-service.js";
|
||||
|
||||
export async function deleteCircuit(req: Request, res: Response) {
|
||||
const { circuitId } = req.params;
|
||||
if (typeof circuitId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid circuitId" });
|
||||
}
|
||||
|
||||
try {
|
||||
await circuitWriteService.deleteCircuit(circuitId);
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextCircuitIdentifier(req: Request, res: Response) {
|
||||
const { sectionId } = req.params;
|
||||
if (typeof sectionId !== "string") {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express from "express";
|
||||
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
|
||||
import { circuitRouter } from "./routes/circuit.routes.js";
|
||||
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
|
||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
@@ -18,7 +17,6 @@ app.get("/health", (_req, res) => {
|
||||
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api", circuitRouter);
|
||||
app.use("/api", circuitDeviceRowRouter);
|
||||
app.use("/api", circuitSectionRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRow,
|
||||
} from "../controllers/circuit-device-row.controller.js";
|
||||
|
||||
export const circuitDeviceRowRouter = Router();
|
||||
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
deleteCircuit,
|
||||
getNextCircuitIdentifier,
|
||||
} from "../controllers/circuit.controller.js";
|
||||
|
||||
export const circuitRouter = Router();
|
||||
|
||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const moveCircuitDeviceRowSchema = z
|
||||
.object({
|
||||
targetCircuitId: z.string().min(1).optional(),
|
||||
targetSectionId: z.string().min(1).optional(),
|
||||
createNewCircuit: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
Boolean(value.targetCircuitId) ||
|
||||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||
);
|
||||
|
||||
export const moveCircuitDeviceRowsBulkSchema = z
|
||||
.object({
|
||||
rowIds: z.array(z.string().min(1)).min(1),
|
||||
targetCircuitId: z.string().min(1).optional(),
|
||||
targetSectionId: z.string().min(1).optional(),
|
||||
createNewCircuit: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
Boolean(value.targetCircuitId) ||
|
||||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||
);
|
||||
|
||||
export const reorderSectionCircuitsSchema = z.object({
|
||||
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
@@ -42,7 +15,5 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
||||
|
||||
Reference in New Issue
Block a user