Add explicit project device sync
This commit is contained in:
@@ -149,3 +149,15 @@ Request sketch:
|
|||||||
- `DELETE /consumers/:consumerId`
|
- `DELETE /consumers/:consumerId`
|
||||||
|
|
||||||
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
|
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
|
||||||
|
|
||||||
|
## Linked Project Device Review
|
||||||
|
|
||||||
|
- `GET /project-devices/projects/:projectId/:projectDeviceId/links`
|
||||||
|
- returns all linked circuit device rows with distribution-board/circuit context and field differences
|
||||||
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
|
||||||
|
- applies only explicitly selected fields to explicitly selected linked rows
|
||||||
|
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
|
||||||
|
- disconnects explicitly selected rows without changing their local values
|
||||||
|
|
||||||
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
|
project device never triggers synchronization implicitly.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
- Undo/redo history is session-local only.
|
- Undo/redo history is session-local only.
|
||||||
- Undo/redo history is not persisted across reloads or between users.
|
- Undo/redo history is not persisted across reloads or between users.
|
||||||
- No linked project-device sync review dialog is implemented yet.
|
|
||||||
- No global cross-project device library workflow is implemented yet.
|
- No global cross-project device library workflow is implemented yet.
|
||||||
- Final electrical sizing logic is not implemented yet.
|
- Final electrical sizing logic is not implemented yet.
|
||||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||||
@@ -10,3 +9,5 @@
|
|||||||
- 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.
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
|
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
|
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
createProjectDevice,
|
createProjectDevice,
|
||||||
createRoom,
|
createRoom,
|
||||||
deleteProjectDevice,
|
deleteProjectDevice,
|
||||||
|
disconnectProjectDeviceRows,
|
||||||
|
getProjectDeviceSyncPreview,
|
||||||
listCircuitLists,
|
listCircuitLists,
|
||||||
listDistributionBoards,
|
listDistributionBoards,
|
||||||
listFloors,
|
listFloors,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
listRooms,
|
listRooms,
|
||||||
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
} from "../../../frontend/utils/api";
|
} from "../../../frontend/utils/api";
|
||||||
@@ -28,9 +31,28 @@ import type {
|
|||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
} from "../../../frontend/types";
|
} from "../../../frontend/types";
|
||||||
|
import {
|
||||||
|
projectDeviceSyncFields,
|
||||||
|
type ProjectDeviceSyncField,
|
||||||
|
} from "../../../shared/constants/project-device-sync-fields";
|
||||||
|
|
||||||
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
|
name: "Technischer Name",
|
||||||
|
displayName: "Anzeigename",
|
||||||
|
phaseType: "Phasenart",
|
||||||
|
connectionKind: "Anschlussart",
|
||||||
|
costGroup: "Kostengruppe",
|
||||||
|
category: "Kategorie",
|
||||||
|
quantity: "Anzahl",
|
||||||
|
powerPerUnit: "Leistung je Stück",
|
||||||
|
simultaneityFactor: "Gleichzeitigkeitsfaktor",
|
||||||
|
cosPhi: "cos Phi",
|
||||||
|
remark: "Bemerkung",
|
||||||
|
};
|
||||||
|
|
||||||
const emptyProjectDevice: CreateProjectDeviceInput = {
|
const emptyProjectDevice: CreateProjectDeviceInput = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -85,6 +107,9 @@ export default function ProjectDetailPage() {
|
|||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||||
|
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||||||
|
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||||||
|
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
@@ -311,11 +336,81 @@ export default function ProjectDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await updateProjectDevice(projectId, device.id, payload);
|
const updated = await updateProjectDevice(projectId, device.id, payload);
|
||||||
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
||||||
|
await openProjectDeviceSyncPreview(updated.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
||||||
|
setSyncPreview(preview);
|
||||||
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
|
const differentFields = new Set(
|
||||||
|
preview.rows.flatMap((row) => row.differences.map((difference) => difference.field))
|
||||||
|
);
|
||||||
|
setSelectedSyncFields(
|
||||||
|
projectDeviceSyncFields.filter((field) => field !== "displayName" && differentFields.has(field))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSyncRow(rowId: string) {
|
||||||
|
setSelectedSyncRowIds((current) =>
|
||||||
|
current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSyncField(field: ProjectDeviceSyncField) {
|
||||||
|
setSelectedSyncFields((current) =>
|
||||||
|
current.includes(field) ? current.filter((entry) => entry !== field) : [...current, field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSynchronizeProjectDeviceRows() {
|
||||||
|
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const preview = await synchronizeProjectDeviceRows(
|
||||||
|
projectId,
|
||||||
|
syncPreview.projectDevice.id,
|
||||||
|
selectedSyncRowIds,
|
||||||
|
selectedSyncFields
|
||||||
|
);
|
||||||
|
setSyncPreview(preview);
|
||||||
|
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnectProjectDeviceRows() {
|
||||||
|
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await disconnectProjectDeviceRows(projectId, syncPreview.projectDevice.id, selectedSyncRowIds);
|
||||||
|
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopyGlobalToProject() {
|
async function handleCopyGlobalToProject() {
|
||||||
if (!projectId || !selectedGlobalDeviceId) {
|
if (!projectId || !selectedGlobalDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -815,6 +910,14 @@ export default function ProjectDetailPage() {
|
|||||||
<td>{device.totalPower}</td>
|
<td>{device.totalPower}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="btn-group btn-group-sm">
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Verknüpfungen
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary"
|
className="btn btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -845,6 +948,116 @@ export default function ProjectDetailPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{syncPreview ? (
|
||||||
|
<div className="card-body border-top">
|
||||||
|
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="h6 mb-1">Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}</h3>
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex flex-wrap gap-3 mb-3">
|
||||||
|
{projectDeviceSyncFields.map((field) => (
|
||||||
|
<label className="form-check" key={field}>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSyncFields.includes(field)}
|
||||||
|
onChange={() => toggleSyncField(field)}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
{projectDeviceSyncFieldLabels[field]}
|
||||||
|
{field === "displayName" ? " (lokal)" : ""}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-responsive border rounded mb-3">
|
||||||
|
<table className="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Auswahl</th>
|
||||||
|
<th>Verteilung / Stromkreis</th>
|
||||||
|
<th>Gerätezeile</th>
|
||||||
|
<th>Abweichungen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{syncPreview.rows.map((row) => (
|
||||||
|
<tr key={row.rowId}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSyncRowIds.includes(row.rowId)}
|
||||||
|
onChange={() => toggleSyncRow(row.rowId)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{row.distributionBoardName}</strong>
|
||||||
|
<div className="small text-secondary">
|
||||||
|
{row.equipmentIdentifier}
|
||||||
|
{row.circuitDisplayName ? ` – ${row.circuitDisplayName}` : ""}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{row.rowDisplayName}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{row.differences.map((difference) => (
|
||||||
|
<span
|
||||||
|
className={`badge ${difference.isOverridden ? "text-bg-warning" : "text-bg-light"}`}
|
||||||
|
key={difference.field}
|
||||||
|
title={difference.isOverridden ? "Lokal überschrieben" : undefined}
|
||||||
|
>
|
||||||
|
{projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "–")} →{" "}
|
||||||
|
{String(difference.sourceValue ?? "–")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{row.differences.length === 0 ? (
|
||||||
|
<span className="text-success small">Aktuell</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{syncPreview.rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-center text-secondary py-3">
|
||||||
|
Keine verknüpften Circuit-First-Gerätezeilen vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||||||
|
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||||||
|
>
|
||||||
|
Auswahl synchronisieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||||||
|
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||||||
|
>
|
||||||
|
Auswahl trennen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } 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 { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
||||||
export class CircuitDeviceRowRepository {
|
export class CircuitDeviceRowRepository {
|
||||||
async findById(rowId: string) {
|
async findById(rowId: string) {
|
||||||
@@ -25,6 +28,50 @@ export class CircuitDeviceRowRepository {
|
|||||||
.orderBy(asc(circuitDeviceRows.sortOrder));
|
.orderBy(asc(circuitDeviceRows.sortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
circuitId: circuitDeviceRows.circuitId,
|
||||||
|
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
||||||
|
sortOrder: circuitDeviceRows.sortOrder,
|
||||||
|
name: circuitDeviceRows.name,
|
||||||
|
displayName: circuitDeviceRows.displayName,
|
||||||
|
phaseType: circuitDeviceRows.phaseType,
|
||||||
|
connectionKind: circuitDeviceRows.connectionKind,
|
||||||
|
costGroup: circuitDeviceRows.costGroup,
|
||||||
|
category: circuitDeviceRows.category,
|
||||||
|
level: circuitDeviceRows.level,
|
||||||
|
roomId: circuitDeviceRows.roomId,
|
||||||
|
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||||
|
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||||
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||||
|
cosPhi: circuitDeviceRows.cosPhi,
|
||||||
|
remark: circuitDeviceRows.remark,
|
||||||
|
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
circuitDisplayName: circuits.displayName,
|
||||||
|
circuitListId: circuitLists.id,
|
||||||
|
circuitListName: circuitLists.name,
|
||||||
|
distributionBoardId: distributionBoards.id,
|
||||||
|
distributionBoardName: distributionBoards.name,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
|
||||||
|
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
|
||||||
|
.innerJoin(distributionBoards, eq(circuitLists.distributionBoardId, distributionBoards.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||||
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
circuitId: string;
|
circuitId: string;
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import type {
|
|||||||
UpdateCircuitInput,
|
UpdateCircuitInput,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||||
|
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
||||||
|
import {
|
||||||
|
parseOverriddenFields,
|
||||||
|
serializeOverriddenFields,
|
||||||
|
} from "./project-device-sync.service.js";
|
||||||
|
|
||||||
export class CircuitWriteService {
|
export class CircuitWriteService {
|
||||||
private readonly circuitRepository: CircuitRepository;
|
private readonly circuitRepository: CircuitRepository;
|
||||||
@@ -218,6 +223,21 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid device row id.");
|
throw new Error("Invalid device row id.");
|
||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||||
|
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
|
||||||
|
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||||
|
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||||
|
const inputValues = input as Record<string, unknown>;
|
||||||
|
const currentValues = current as unknown as Record<string, unknown>;
|
||||||
|
for (const field of projectDeviceSyncFields) {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(input, field) &&
|
||||||
|
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
|
||||||
|
) {
|
||||||
|
overrides.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overriddenFields = serializeOverriddenFields(overrides);
|
||||||
|
}
|
||||||
await this.deviceRowRepository.update(rowId, {
|
await this.deviceRowRepository.update(rowId, {
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
|
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
|
||||||
name: input.name ?? current.name,
|
name: input.name ?? current.name,
|
||||||
@@ -235,7 +255,7 @@ export class CircuitWriteService {
|
|||||||
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
|
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
|
||||||
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
|
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
|
||||||
remark: input.remark ?? current.remark ?? undefined,
|
remark: input.remark ?? current.remark ?? undefined,
|
||||||
overriddenFields: input.overriddenFields ?? current.overriddenFields ?? undefined,
|
overriddenFields,
|
||||||
});
|
});
|
||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
|
import {
|
||||||
|
projectDeviceSyncFields,
|
||||||
|
type ProjectDeviceSyncField,
|
||||||
|
} from "../../shared/constants/project-device-sync-fields.js";
|
||||||
|
|
||||||
|
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||||
|
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||||
|
|
||||||
|
type SyncDependencies = {
|
||||||
|
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||||
|
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||||
|
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
|
||||||
|
const fieldSet = new Set(fields);
|
||||||
|
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||||
|
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||||
|
return projectDevice[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesEqual(left: unknown, right: unknown) {
|
||||||
|
return (left ?? null) === (right ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
||||||
|
return projectDeviceSyncFields
|
||||||
|
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
|
||||||
|
.map((field) => ({
|
||||||
|
field,
|
||||||
|
currentValue: row[field] ?? null,
|
||||||
|
sourceValue: sourceValue(projectDevice, field) ?? null,
|
||||||
|
isOverridden: parseOverriddenFields(row.overriddenFields).includes(field),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectDeviceSyncService {
|
||||||
|
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||||
|
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||||
|
|
||||||
|
constructor(deps?: Partial<SyncDependencies>) {
|
||||||
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPreview(projectId: string, projectDeviceId: string) {
|
||||||
|
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
|
if (!projectDevice) {
|
||||||
|
throw new Error("Project device not found.");
|
||||||
|
}
|
||||||
|
const rows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectDevice: {
|
||||||
|
id: projectDevice.id,
|
||||||
|
name: projectDevice.name,
|
||||||
|
displayName: projectDevice.displayName,
|
||||||
|
},
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
rowId: row.id,
|
||||||
|
circuitId: row.circuitId,
|
||||||
|
equipmentIdentifier: row.equipmentIdentifier,
|
||||||
|
circuitDisplayName: row.circuitDisplayName,
|
||||||
|
circuitListId: row.circuitListId,
|
||||||
|
circuitListName: row.circuitListName,
|
||||||
|
distributionBoardId: row.distributionBoardId,
|
||||||
|
distributionBoardName: row.distributionBoardName,
|
||||||
|
rowDisplayName: row.displayName,
|
||||||
|
overriddenFields: parseOverriddenFields(row.overriddenFields),
|
||||||
|
differences: buildDifferences(projectDevice, row),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async synchronize(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rowIds: string[],
|
||||||
|
fields: ProjectDeviceSyncField[]
|
||||||
|
) {
|
||||||
|
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||||
|
if (!projectDevice) {
|
||||||
|
throw new Error("Project device not found.");
|
||||||
|
}
|
||||||
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
|
const selectedFields = [...new Set(fields)];
|
||||||
|
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
const next = this.copyRow(row);
|
||||||
|
for (const field of selectedFields) {
|
||||||
|
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
|
||||||
|
}
|
||||||
|
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
|
||||||
|
(field) => !selectedFields.includes(field)
|
||||||
|
);
|
||||||
|
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
||||||
|
await this.deviceRowRepository.update(row.id, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPreview(projectId, projectDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
||||||
|
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||||
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
|
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
await this.deviceRowRepository.update(row.id, {
|
||||||
|
...this.copyRow(row),
|
||||||
|
linkedProjectDeviceId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { disconnectedRowIds: selectedRows.map((row) => row.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||||
|
const uniqueRowIds = [...new Set(rowIds)];
|
||||||
|
const byId = new Map(linkedRows.map((row) => [row.id, row]));
|
||||||
|
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[];
|
||||||
|
if (selectedRows.length !== uniqueRowIds.length) {
|
||||||
|
throw new Error("One or more rows are not linked to this project device.");
|
||||||
|
}
|
||||||
|
return selectedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyRow(row: LinkedRow) {
|
||||||
|
return {
|
||||||
|
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
||||||
|
name: row.name,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phaseType: row.phaseType ?? undefined,
|
||||||
|
connectionKind: row.connectionKind ?? undefined,
|
||||||
|
costGroup: row.costGroup ?? undefined,
|
||||||
|
category: row.category ?? undefined,
|
||||||
|
level: row.level ?? undefined,
|
||||||
|
roomId: row.roomId ?? undefined,
|
||||||
|
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
||||||
|
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
||||||
|
quantity: row.quantity,
|
||||||
|
powerPerUnit: row.powerPerUnit,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi ?? undefined,
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
overriddenFields: row.overriddenFields ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,6 +180,36 @@ export interface CreateProjectDeviceInput {
|
|||||||
voltageV?: number;
|
voltageV?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncDifferenceDto {
|
||||||
|
field: ProjectDeviceSyncField;
|
||||||
|
currentValue: string | number | null;
|
||||||
|
sourceValue: string | number | null;
|
||||||
|
isOverridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncRowDto {
|
||||||
|
rowId: string;
|
||||||
|
circuitId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
circuitDisplayName: string | null;
|
||||||
|
circuitListId: string;
|
||||||
|
circuitListName: string;
|
||||||
|
distributionBoardId: string;
|
||||||
|
distributionBoardName: string;
|
||||||
|
rowDisplayName: string;
|
||||||
|
overriddenFields: ProjectDeviceSyncField[];
|
||||||
|
differences: ProjectDeviceSyncDifferenceDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceSyncPreviewDto {
|
||||||
|
projectDevice: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
rows: ProjectDeviceSyncRowDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitTreeDeviceRowDto {
|
export interface CircuitTreeDeviceRowDto {
|
||||||
id: string;
|
id: string;
|
||||||
linkedProjectDeviceId?: string;
|
linkedProjectDeviceId?: string;
|
||||||
@@ -296,3 +326,4 @@ export interface CreateCircuitDeviceRowInputDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
||||||
|
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
UpdateConsumerInput,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
CreateCircuitDeviceRowInputDto,
|
CreateCircuitDeviceRowInputDto,
|
||||||
UpdateCircuitDeviceRowInputDto,
|
UpdateCircuitDeviceRowInputDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -285,3 +287,38 @@ export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: str
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
||||||
|
return request<ProjectDeviceSyncPreviewDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/links`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synchronizeProjectDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rowIds: string[],
|
||||||
|
fields: ProjectDeviceSyncField[]
|
||||||
|
) {
|
||||||
|
return request<ProjectDeviceSyncPreviewDto>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rowIds, fields }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectProjectDeviceRows(
|
||||||
|
projectId: string,
|
||||||
|
projectDeviceId: string,
|
||||||
|
rowIds: string[]
|
||||||
|
) {
|
||||||
|
return request<{ disconnectedRowIds: string[] }>(
|
||||||
|
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rowIds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
|
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceSchema,
|
createProjectDeviceSchema,
|
||||||
|
disconnectProjectDeviceRowsSchema,
|
||||||
|
synchronizeProjectDeviceRowsSchema,
|
||||||
updateProjectDeviceSchema,
|
updateProjectDeviceSchema,
|
||||||
} from "../../shared/validation/project-device.schemas.js";
|
} from "../../shared/validation/project-device.schemas.js";
|
||||||
|
|
||||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||||
|
const projectDeviceSyncService = new ProjectDeviceSyncService();
|
||||||
|
|
||||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
@@ -88,3 +92,55 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
|
|
||||||
return res.status(201).json(created);
|
return res.status(201).json(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
||||||
|
const { projectId, projectDeviceId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid parameters" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await projectDeviceSyncService.getPreview(projectId, projectDeviceId);
|
||||||
|
return res.json(preview);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to load sync preview." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synchronizeProjectDeviceRows(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 = synchronizeProjectDeviceRowsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await projectDeviceSyncService.synchronize(
|
||||||
|
projectId,
|
||||||
|
projectDeviceId,
|
||||||
|
parsed.data.rowIds,
|
||||||
|
parsed.data.fields
|
||||||
|
);
|
||||||
|
return res.json(preview);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectProjectDeviceRows(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 = disconnectProjectDeviceRowsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
copyGlobalDeviceToProject,
|
copyGlobalDeviceToProject,
|
||||||
createProjectDevice,
|
createProjectDevice,
|
||||||
deleteProjectDevice,
|
deleteProjectDevice,
|
||||||
|
disconnectProjectDeviceRows,
|
||||||
|
getProjectDeviceSyncPreview,
|
||||||
listProjectDevicesByProject,
|
listProjectDevicesByProject,
|
||||||
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
} from "../controllers/project-device.controller.js";
|
} from "../controllers/project-device.controller.js";
|
||||||
|
|
||||||
@@ -12,5 +15,8 @@ export const projectDeviceRouter = Router();
|
|||||||
projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject);
|
projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject);
|
||||||
projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
|
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.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
||||||
|
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
||||||
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
||||||
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const projectDeviceSyncFields = [
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProjectDeviceSyncField = (typeof projectDeviceSyncFields)[number];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
||||||
|
|
||||||
export const createProjectDeviceSchema = z.object({
|
export const createProjectDeviceSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -18,5 +19,14 @@ export const createProjectDeviceSchema = z.object({
|
|||||||
|
|
||||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||||
|
|
||||||
|
export const synchronizeProjectDeviceRowsSchema = z.object({
|
||||||
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
|
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const disconnectProjectDeviceRowsSchema = z.object({
|
||||||
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
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>;
|
||||||
|
|||||||
@@ -599,4 +599,34 @@ describe("circuit write service rules", () => {
|
|||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks local edits on linked device rows as overridden fields", async () => {
|
||||||
|
let savedOverrides: string | undefined;
|
||||||
|
const current = {
|
||||||
|
id: "r1",
|
||||||
|
circuitId: "c1",
|
||||||
|
linkedProjectDeviceId: "pd1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Local name",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
sortOrder: 10,
|
||||||
|
overriddenFields: null,
|
||||||
|
};
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
deviceRowRepository: {
|
||||||
|
async findById() {
|
||||||
|
return current as never;
|
||||||
|
},
|
||||||
|
async update(_rowId: string, input: { overriddenFields?: string }) {
|
||||||
|
savedOverrides = input.overriddenFields;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateDeviceRow("r1", { quantity: 2 });
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(savedOverrides ?? "[]"), ["quantity"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
|
||||||
|
|
||||||
|
function projectDevice() {
|
||||||
|
return {
|
||||||
|
id: "pd1",
|
||||||
|
projectId: "p1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: "fixed",
|
||||||
|
costGroup: "440",
|
||||||
|
category: "Lighting",
|
||||||
|
quantity: 6,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
totalPower: 0.192,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedRow() {
|
||||||
|
return {
|
||||||
|
id: "row1",
|
||||||
|
circuitId: "c1",
|
||||||
|
linkedProjectDeviceId: "pd1",
|
||||||
|
legacyConsumerId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Old luminaire",
|
||||||
|
displayName: "Local display name",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
costGroup: null,
|
||||||
|
category: "Lighting",
|
||||||
|
level: null,
|
||||||
|
roomId: null,
|
||||||
|
roomNumberSnapshot: "1.01",
|
||||||
|
roomNameSnapshot: "Office",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 1,
|
||||||
|
remark: null,
|
||||||
|
overriddenFields: JSON.stringify(["displayName", "quantity"]),
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
circuitDisplayName: "Office",
|
||||||
|
circuitListId: "list1",
|
||||||
|
circuitListName: "UV-01 Stromkreisliste",
|
||||||
|
distributionBoardId: "db1",
|
||||||
|
distributionBoardName: "UV-01",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const rows = [linkedRow()];
|
||||||
|
const updates: Array<Record<string, unknown>> = [];
|
||||||
|
const service = new ProjectDeviceSyncService({
|
||||||
|
projectDeviceRepository: {
|
||||||
|
async findById() {
|
||||||
|
return projectDevice() as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deviceRowRepository: {
|
||||||
|
async listLinkedByProjectDevice() {
|
||||||
|
return rows as never;
|
||||||
|
},
|
||||||
|
async update(rowId, input) {
|
||||||
|
updates.push({ rowId, ...input });
|
||||||
|
Object.assign(rows[0], input);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
return { service, rows, updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project device synchronization", () => {
|
||||||
|
it("shows field differences and local overrides without changing rows", async () => {
|
||||||
|
const { service, updates } = createService();
|
||||||
|
const preview = await service.getPreview("p1", "pd1");
|
||||||
|
|
||||||
|
assert.equal(updates.length, 0);
|
||||||
|
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
|
||||||
|
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
|
||||||
|
assert.equal(
|
||||||
|
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("synchronizes only explicitly selected fields", async () => {
|
||||||
|
const { service, rows } = createService();
|
||||||
|
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]);
|
||||||
|
|
||||||
|
assert.equal(rows[0].quantity, 6);
|
||||||
|
assert.equal(rows[0].powerPerUnit, 0.04);
|
||||||
|
assert.equal(rows[0].displayName, "Local display name");
|
||||||
|
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects rows that are not linked to the selected project device", async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]),
|
||||||
|
/not linked/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnects selected rows without changing local values", async () => {
|
||||||
|
const { service, updates } = createService();
|
||||||
|
await service.disconnect("p1", "pd1", ["row1"]);
|
||||||
|
|
||||||
|
assert.equal(updates[0].linkedProjectDeviceId, undefined);
|
||||||
|
assert.equal(updates[0].displayName, "Local display name");
|
||||||
|
assert.equal(updates[0].quantity, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user