Make device sync atomic and undoable
This commit is contained in:
@@ -156,8 +156,12 @@ These remain for migration/legacy views. New circuit-list interactions must use
|
|||||||
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
||||||
- applies only explicitly selected fields to explicitly selected linked rows
|
- applies only explicitly selected fields to explicitly selected linked rows
|
||||||
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore`
|
||||||
|
- restores the captured pre-sync values for session-local undo
|
||||||
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
||||||
- disconnects explicitly selected rows without changing their local values
|
- disconnects explicitly selected rows without changing their local values
|
||||||
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect`
|
||||||
|
- restores a disconnected link if the row has not been linked elsewhere meanwhile
|
||||||
|
|
||||||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
project device never triggers synchronization implicitly.
|
project device never triggers synchronization implicitly.
|
||||||
|
|||||||
@@ -9,5 +9,3 @@
|
|||||||
- Sorting is view-only until users explicitly apply sorted order.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Cross-section circuit drag-reorder is intentionally blocked.
|
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||||
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
|
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
|
||||||
- Project-device synchronization and disconnect actions are explicit, but are not yet connected to an undo command.
|
|
||||||
- Multi-row project-device synchronization is sequential and not yet wrapped in one database transaction.
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
listRooms,
|
listRooms,
|
||||||
|
reconnectProjectDeviceRows,
|
||||||
|
restoreProjectDeviceRows,
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
@@ -32,6 +34,7 @@ import type {
|
|||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
|
ProjectDeviceSyncRestoreRowDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
} from "../../../frontend/types";
|
} from "../../../frontend/types";
|
||||||
@@ -54,6 +57,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
|||||||
remark: "Bemerkung",
|
remark: "Bemerkung",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProjectDeviceUndoState =
|
||||||
|
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
|
||||||
|
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
|
||||||
|
|
||||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -110,6 +117,7 @@ export default function ProjectDetailPage() {
|
|||||||
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
|
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
@@ -347,6 +355,7 @@ export default function ProjectDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
setProjectDeviceUndo(null);
|
||||||
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
||||||
setSyncPreview(preview);
|
setSyncPreview(preview);
|
||||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
@@ -380,14 +389,21 @@ export default function ProjectDetailPage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const preview = await synchronizeProjectDeviceRows(
|
const result = await synchronizeProjectDeviceRows(
|
||||||
projectId,
|
projectId,
|
||||||
syncPreview.projectDevice.id,
|
syncPreview.projectDevice.id,
|
||||||
selectedSyncRowIds,
|
selectedSyncRowIds,
|
||||||
selectedSyncFields
|
selectedSyncFields
|
||||||
);
|
);
|
||||||
setSyncPreview(preview);
|
setSyncPreview(result.preview);
|
||||||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
setSelectedSyncRowIds(
|
||||||
|
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||||||
|
);
|
||||||
|
setProjectDeviceUndo({
|
||||||
|
kind: "synchronize",
|
||||||
|
projectDeviceId: syncPreview.projectDevice.id,
|
||||||
|
rows: result.undo.rows,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -402,8 +418,17 @@ export default function ProjectDetailPage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await disconnectProjectDeviceRows(projectId, syncPreview.projectDevice.id, selectedSyncRowIds);
|
const result = await disconnectProjectDeviceRows(
|
||||||
|
projectId,
|
||||||
|
syncPreview.projectDevice.id,
|
||||||
|
selectedSyncRowIds
|
||||||
|
);
|
||||||
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
|
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
|
||||||
|
setProjectDeviceUndo({
|
||||||
|
kind: "disconnect",
|
||||||
|
projectDeviceId: syncPreview.projectDevice.id,
|
||||||
|
rowIds: result.undo.rowIds,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -411,6 +436,35 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUndoProjectDeviceOperation() {
|
||||||
|
if (!projectId || !projectDeviceUndo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const preview =
|
||||||
|
projectDeviceUndo.kind === "synchronize"
|
||||||
|
? await restoreProjectDeviceRows(
|
||||||
|
projectId,
|
||||||
|
projectDeviceUndo.projectDeviceId,
|
||||||
|
projectDeviceUndo.rows
|
||||||
|
)
|
||||||
|
: await reconnectProjectDeviceRows(
|
||||||
|
projectId,
|
||||||
|
projectDeviceUndo.projectDeviceId,
|
||||||
|
projectDeviceUndo.rowIds
|
||||||
|
);
|
||||||
|
setSyncPreview(preview);
|
||||||
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
|
setProjectDeviceUndo(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject() {
|
||||||
if (!projectId || !selectedGlobalDeviceId) {
|
if (!projectId || !selectedGlobalDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -1039,6 +1093,16 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{projectDeviceUndo ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleUndoProjectDeviceOperation()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Letzte Aktion rückgängig
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
||||||
|
export interface CircuitDeviceRowUpdateInput {
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUpdateValues(input: CircuitDeviceRowUpdateInput) {
|
||||||
|
return {
|
||||||
|
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
||||||
|
name: input.name,
|
||||||
|
displayName: input.displayName,
|
||||||
|
phaseType: input.phaseType ?? null,
|
||||||
|
connectionKind: input.connectionKind ?? null,
|
||||||
|
costGroup: input.costGroup ?? null,
|
||||||
|
category: input.category ?? null,
|
||||||
|
level: input.level ?? null,
|
||||||
|
roomId: input.roomId ?? null,
|
||||||
|
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
||||||
|
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
||||||
|
quantity: input.quantity,
|
||||||
|
powerPerUnit: input.powerPerUnit,
|
||||||
|
simultaneityFactor: input.simultaneityFactor,
|
||||||
|
cosPhi: input.cosPhi ?? null,
|
||||||
|
remark: input.remark ?? null,
|
||||||
|
overriddenFields: input.overriddenFields ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class CircuitDeviceRowRepository {
|
export class CircuitDeviceRowRepository {
|
||||||
async findById(rowId: string) {
|
async findById(rowId: string) {
|
||||||
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
||||||
@@ -72,6 +114,21 @@ export class CircuitDeviceRowRepository {
|
|||||||
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
|
||||||
|
if (rowIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
|
||||||
|
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
|
||||||
|
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
circuitId: string;
|
circuitId: string;
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
@@ -123,50 +180,72 @@ export class CircuitDeviceRowRepository {
|
|||||||
|
|
||||||
async update(
|
async update(
|
||||||
rowId: string,
|
rowId: string,
|
||||||
input: {
|
input: CircuitDeviceRowUpdateInput
|
||||||
linkedProjectDeviceId?: string;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
await db
|
await db
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set({
|
.set(toUpdateValues(input))
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
|
||||||
name: input.name,
|
|
||||||
displayName: input.displayName,
|
|
||||||
phaseType: input.phaseType ?? null,
|
|
||||||
connectionKind: input.connectionKind ?? null,
|
|
||||||
costGroup: input.costGroup ?? null,
|
|
||||||
category: input.category ?? null,
|
|
||||||
level: input.level ?? null,
|
|
||||||
roomId: input.roomId ?? null,
|
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
|
||||||
quantity: input.quantity,
|
|
||||||
powerPerUnit: input.powerPerUnit,
|
|
||||||
simultaneityFactor: input.simultaneityFactor,
|
|
||||||
cosPhi: input.cosPhi ?? null,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
overriddenFields: input.overriddenFields ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(circuitDeviceRows.id, rowId));
|
.where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLinkedRowsTransactional(
|
||||||
|
projectDeviceId: string,
|
||||||
|
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
||||||
|
) {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const change of changes) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set(toUpdateValues(change.input))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, change.rowId),
|
||||||
|
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("A linked row changed before the operation could be completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ linkedProjectDeviceId: null })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, rowId),
|
||||||
|
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("A linked row changed before the operation could be completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ linkedProjectDeviceId: projectDeviceId })
|
||||||
|
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("A disconnected row changed before the operation could be undone.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async delete(rowId: string) {
|
async delete(rowId: string) {
|
||||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,22 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
|
|||||||
|
|
||||||
type SyncDependencies = {
|
type SyncDependencies = {
|
||||||
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||||
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">;
|
deviceRowRepository: Pick<
|
||||||
|
CircuitDeviceRowRepository,
|
||||||
|
| "listLinkedByProjectDevice"
|
||||||
|
| "listLinkStatesByIds"
|
||||||
|
| "updateLinkedRowsTransactional"
|
||||||
|
| "disconnectLinkedRowsTransactional"
|
||||||
|
| "reconnectRowsTransactional"
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncRestoreRow {
|
||||||
|
rowId: string;
|
||||||
|
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
||||||
|
overriddenFields: ProjectDeviceSyncField[];
|
||||||
|
}
|
||||||
|
|
||||||
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return [];
|
return [];
|
||||||
@@ -106,7 +119,13 @@ export class ProjectDeviceSyncService {
|
|||||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
const selectedFields = [...new Set(fields)];
|
const selectedFields = [...new Set(fields)];
|
||||||
|
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
|
||||||
|
rowId: row.id,
|
||||||
|
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
|
||||||
|
overriddenFields: parseOverriddenFields(row.overriddenFields),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
for (const row of selectedRows) {
|
for (const row of selectedRows) {
|
||||||
const next = this.copyRow(row);
|
const next = this.copyRow(row);
|
||||||
for (const field of selectedFields) {
|
for (const field of selectedFields) {
|
||||||
@@ -116,24 +135,62 @@ export class ProjectDeviceSyncService {
|
|||||||
(field) => !selectedFields.includes(field)
|
(field) => !selectedFields.includes(field)
|
||||||
);
|
);
|
||||||
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
||||||
await this.deviceRowRepository.update(row.id, next);
|
changes.push({ rowId: row.id, input: next });
|
||||||
}
|
}
|
||||||
|
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
|
||||||
|
|
||||||
return this.getPreview(projectId, projectDeviceId);
|
return {
|
||||||
|
preview: await this.getPreview(projectId, projectDeviceId),
|
||||||
|
undo: { rows: undoRows },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
||||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
|
const disconnectedRowIds = selectedRows.map((row) => row.id);
|
||||||
|
|
||||||
for (const row of selectedRows) {
|
this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds);
|
||||||
await this.deviceRowRepository.update(row.id, {
|
|
||||||
...this.copyRow(row),
|
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
|
||||||
linkedProjectDeviceId: undefined,
|
}
|
||||||
});
|
|
||||||
|
async restore(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
restoreRows: ProjectDeviceSyncRestoreRow[]
|
||||||
|
) {
|
||||||
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
|
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
|
||||||
|
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
|
||||||
|
const changes = selectedRows.map((row) => {
|
||||||
|
const restore = restoreById.get(row.id)!;
|
||||||
|
const next = this.copyRow(row);
|
||||||
|
for (const [field, value] of Object.entries(restore.values) as Array<
|
||||||
|
[ProjectDeviceSyncField, string | number | null]
|
||||||
|
>) {
|
||||||
|
Object.assign(next, { [field]: value ?? undefined });
|
||||||
|
}
|
||||||
|
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
|
||||||
|
return { rowId: row.id, input: next };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
|
||||||
|
return this.getPreview(projectId, projectDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
||||||
|
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
|
if (!projectDevice) {
|
||||||
|
throw new Error("Project device not found.");
|
||||||
}
|
}
|
||||||
|
const uniqueRowIds = [...new Set(rowIds)];
|
||||||
return { disconnectedRowIds: selectedRows.map((row) => row.id) };
|
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
|
||||||
|
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
|
||||||
|
throw new Error("One or more rows cannot be reconnected.");
|
||||||
|
}
|
||||||
|
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds);
|
||||||
|
return this.getPreview(projectId, projectDeviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||||
|
|||||||
@@ -210,6 +210,26 @@ export interface ProjectDeviceSyncPreviewDto {
|
|||||||
rows: ProjectDeviceSyncRowDto[];
|
rows: ProjectDeviceSyncRowDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncRestoreRowDto {
|
||||||
|
rowId: string;
|
||||||
|
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
|
||||||
|
overriddenFields: ProjectDeviceSyncField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncResultDto {
|
||||||
|
preview: ProjectDeviceSyncPreviewDto;
|
||||||
|
undo: {
|
||||||
|
rows: ProjectDeviceSyncRestoreRowDto[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceDisconnectResultDto {
|
||||||
|
disconnectedRowIds: string[];
|
||||||
|
undo: {
|
||||||
|
rowIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitTreeDeviceRowDto {
|
export interface CircuitTreeDeviceRowDto {
|
||||||
id: string;
|
id: string;
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type {
|
|||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
|
ProjectDeviceDisconnectResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
|
ProjectDeviceSyncRestoreRowDto,
|
||||||
|
ProjectDeviceSyncResultDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
UpdateConsumerInput,
|
||||||
@@ -300,7 +303,7 @@ export function synchronizeProjectDeviceRows(
|
|||||||
rowIds: string[],
|
rowIds: string[],
|
||||||
fields: ProjectDeviceSyncField[]
|
fields: ProjectDeviceSyncField[]
|
||||||
) {
|
) {
|
||||||
return request<ProjectDeviceSyncPreviewDto>(
|
return request<ProjectDeviceSyncResultDto>(
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -314,7 +317,7 @@ export function disconnectProjectDeviceRows(
|
|||||||
projectDeviceId: string,
|
projectDeviceId: string,
|
||||||
rowIds: string[]
|
rowIds: string[]
|
||||||
) {
|
) {
|
||||||
return request<{ disconnectedRowIds: string[] }>(
|
return request<ProjectDeviceDisconnectResultDto>(
|
||||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -322,3 +325,31 @@ export function disconnectProjectDeviceRows(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function restoreProjectDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rows: ProjectDeviceSyncRestoreRowDto[]
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceSyncPreviewDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rows }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconnectProjectDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rowIds: string[]
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceSyncPreviewDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rowIds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ProjectDeviceSyncService } from "../../domain/services/project-device-s
|
|||||||
import {
|
import {
|
||||||
createProjectDeviceSchema,
|
createProjectDeviceSchema,
|
||||||
disconnectProjectDeviceRowsSchema,
|
disconnectProjectDeviceRowsSchema,
|
||||||
|
reconnectProjectDeviceRowsSchema,
|
||||||
|
restoreProjectDeviceRowsSchema,
|
||||||
synchronizeProjectDeviceRowsSchema,
|
synchronizeProjectDeviceRowsSchema,
|
||||||
updateProjectDeviceSchema,
|
updateProjectDeviceSchema,
|
||||||
} from "../../shared/validation/project-device.schemas.js";
|
} from "../../shared/validation/project-device.schemas.js";
|
||||||
@@ -116,18 +118,35 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const preview = await projectDeviceSyncService.synchronize(
|
const result = await projectDeviceSyncService.synchronize(
|
||||||
projectId,
|
projectId,
|
||||||
projectDeviceId,
|
projectDeviceId,
|
||||||
parsed.data.rowIds,
|
parsed.data.rowIds,
|
||||||
parsed.data.fields
|
parsed.data.fields
|
||||||
);
|
);
|
||||||
return res.json(preview);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreProjectDeviceRows(req: Request, res: Response) {
|
||||||
|
const { projectId, projectDeviceId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
|
}
|
||||||
|
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
|
||||||
|
return res.json(preview);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
||||||
const { projectId, projectDeviceId } = req.params;
|
const { projectId, projectDeviceId } = req.params;
|
||||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||||
@@ -144,3 +163,20 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
|
||||||
|
const { projectId, projectDeviceId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
|
}
|
||||||
|
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
||||||
|
return res.json(preview);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
getProjectDeviceSyncPreview,
|
getProjectDeviceSyncPreview,
|
||||||
listProjectDevicesByProject,
|
listProjectDevicesByProject,
|
||||||
|
reconnectProjectDeviceRows,
|
||||||
|
restoreProjectDeviceRows,
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
} from "../controllers/project-device.controller.js";
|
} from "../controllers/project-device.controller.js";
|
||||||
@@ -17,6 +19,8 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
|
|||||||
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
||||||
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
||||||
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
|
||||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
||||||
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
|
||||||
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
||||||
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
||||||
|
|||||||
@@ -28,5 +28,33 @@ export const disconnectProjectDeviceRowsSchema = z.object({
|
|||||||
rowIds: z.array(z.string().min(1)).min(1),
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectDeviceRestoreValuesSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
|
||||||
|
connectionKind: z.string().nullable().optional(),
|
||||||
|
costGroup: z.string().nullable().optional(),
|
||||||
|
category: z.string().nullable().optional(),
|
||||||
|
quantity: z.number().min(0).optional(),
|
||||||
|
powerPerUnit: z.number().min(0).optional(),
|
||||||
|
simultaneityFactor: z.number().min(0).max(1).optional(),
|
||||||
|
cosPhi: z.number().min(0).max(1).nullable().optional(),
|
||||||
|
remark: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const restoreProjectDeviceRowsSchema = z.object({
|
||||||
|
rows: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
rowId: z.string().min(1),
|
||||||
|
values: projectDeviceRestoreValuesSchema,
|
||||||
|
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
|
||||||
|
|
||||||
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
|
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
|
||||||
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
|
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
|
||||||
|
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
||||||
|
import { db } from "../src/db/client.js";
|
||||||
|
|
||||||
function projectDevice() {
|
function projectDevice() {
|
||||||
return {
|
return {
|
||||||
@@ -56,6 +58,7 @@ function linkedRow() {
|
|||||||
function createService() {
|
function createService() {
|
||||||
const rows = [linkedRow()];
|
const rows = [linkedRow()];
|
||||||
const updates: Array<Record<string, unknown>> = [];
|
const updates: Array<Record<string, unknown>> = [];
|
||||||
|
let transactionCalls = 0;
|
||||||
const service = new ProjectDeviceSyncService({
|
const service = new ProjectDeviceSyncService({
|
||||||
projectDeviceRepository: {
|
projectDeviceRepository: {
|
||||||
async findById() {
|
async findById() {
|
||||||
@@ -64,15 +67,35 @@ function createService() {
|
|||||||
},
|
},
|
||||||
deviceRowRepository: {
|
deviceRowRepository: {
|
||||||
async listLinkedByProjectDevice() {
|
async listLinkedByProjectDevice() {
|
||||||
return rows as never;
|
return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never;
|
||||||
},
|
},
|
||||||
async update(rowId, input) {
|
async listLinkStatesByIds() {
|
||||||
updates.push({ rowId, ...input });
|
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
|
||||||
Object.assign(rows[0], input);
|
},
|
||||||
|
updateLinkedRowsTransactional(_projectDeviceId, changes) {
|
||||||
|
transactionCalls += 1;
|
||||||
|
for (const change of changes) {
|
||||||
|
updates.push({ rowId: change.rowId, ...change.input });
|
||||||
|
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) {
|
||||||
|
transactionCalls += 1;
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const row = rows.find((entry) => entry.id === rowId)!;
|
||||||
|
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
|
||||||
|
Object.assign(row, { linkedProjectDeviceId: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reconnectRowsTransactional(projectDeviceId, rowIds) {
|
||||||
|
transactionCalls += 1;
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
return { service, rows, updates };
|
return { service, rows, updates, transactionCalls: () => transactionCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("project device synchronization", () => {
|
describe("project device synchronization", () => {
|
||||||
@@ -90,13 +113,14 @@ describe("project device synchronization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("synchronizes only explicitly selected fields", async () => {
|
it("synchronizes only explicitly selected fields", async () => {
|
||||||
const { service, rows } = createService();
|
const { service, rows, transactionCalls } = createService();
|
||||||
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
||||||
|
|
||||||
assert.equal(rows[0].quantity, 6);
|
assert.equal(rows[0].quantity, 6);
|
||||||
assert.equal(rows[0].powerPerUnit, 0.04);
|
assert.equal(rows[0].powerPerUnit, 0.04);
|
||||||
assert.equal(rows[0].displayName, "Local display name");
|
assert.equal(rows[0].displayName, "Local display name");
|
||||||
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
|
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
|
||||||
|
assert.equal(transactionCalls(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects rows that are not linked to the selected project device", async () => {
|
it("rejects rows that are not linked to the selected project device", async () => {
|
||||||
@@ -115,4 +139,75 @@ describe("project device synchronization", () => {
|
|||||||
assert.equal(updates[0].displayName, "Local display name");
|
assert.equal(updates[0].displayName, "Local display name");
|
||||||
assert.equal(updates[0].quantity, 2);
|
assert.equal(updates[0].quantity, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores synchronized values and override markers", async () => {
|
||||||
|
const { service, rows } = createService();
|
||||||
|
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
||||||
|
|
||||||
|
await service.restore("p1", "pd1", result.undo.rows);
|
||||||
|
|
||||||
|
assert.equal(rows[0].quantity, 2);
|
||||||
|
assert.equal(rows[0].powerPerUnit, 0.03);
|
||||||
|
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects rows only while they remain disconnected", async () => {
|
||||||
|
const { service, rows } = createService();
|
||||||
|
const result = await service.disconnect("p1", "pd1", ["row1"]);
|
||||||
|
|
||||||
|
await service.reconnect("p1", "pd1", result.undo.rowIds);
|
||||||
|
|
||||||
|
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes multi-row updates inside one synchronous transaction", () => {
|
||||||
|
const repository = new CircuitDeviceRowRepository();
|
||||||
|
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
||||||
|
let transactionCalls = 0;
|
||||||
|
let updateCalls = 0;
|
||||||
|
let returnedPromise = false;
|
||||||
|
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => {
|
||||||
|
transactionCalls += 1;
|
||||||
|
const fakeTx = {
|
||||||
|
update() {
|
||||||
|
return {
|
||||||
|
set() {
|
||||||
|
return {
|
||||||
|
where() {
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
updateCalls += 1;
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = callback(fakeTx);
|
||||||
|
returnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
linkedProjectDeviceId: "pd1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
};
|
||||||
|
repository.updateLinkedRowsTransactional("pd1", [
|
||||||
|
{ rowId: "r1", input },
|
||||||
|
{ rowId: "r2", input },
|
||||||
|
]);
|
||||||
|
assert.equal(transactionCalls, 1);
|
||||||
|
assert.equal(updateCalls, 2);
|
||||||
|
assert.equal(returnedPromise, false);
|
||||||
|
} finally {
|
||||||
|
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user