diff --git a/AGENTS.md b/AGENTS.md index 39a9be6..8d097c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -454,6 +454,11 @@ with a complete stable snapshot of the board, circuit list, three default groups, main switch `-Q0` and surge protective device `-FA`. Its inverse removes only the same unchanged and still-empty structure; the POST route requires `expectedRevision` and returns the updated history state. +A single board can be exported as `leistungsbilanz.distribution-board` JSON +and imported into any project as a new board through the same +`distribution-board.insert-subtree` command; the import drops project-bound +references (project devices, rooms, floor, feed, disabled supply type, Revit +quantity share) and reports them as `adjustments`. Complete populated distribution-board copying and deletion use `distribution-board.insert-subtree` and `distribution-board.delete-subtree`. Their exact snapshots include the board, circuit list, groups, circuits, diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 18646ed..dcd2b05 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -352,6 +352,20 @@ Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an; Löschen verlangt dort eine ausdrückliche Bestätigung. +Derselbe Unterbaum ist auch als portable Datei verfügbar: +`GET /api/projects/:projectId/distribution-boards/:distributionBoardId/export` +liefert das Format `leistungsbilanz.distribution-board` (Version 1, +`src/domain/models/distribution-board-transfer.model.ts`), und +`POST /api/projects/:projectId/distribution-boards/import` (`expectedRevision`, +optionaler Name, Datei) legt ihn über `distribution-board.insert-subtree` als +neue Verteilung an – eine Revision, Undo/Redo wie beim Kopieren. Beim Import +werden alle UUIDs neu vergeben und projektgebundene Bezüge entfernt: +Projektgeräte-Verknüpfungen samt Override-Metadaten, Raum-IDs (Raumnummer und +-name bleiben als Text), Etage, logische Einspeisung, eine im Zielprojekt nicht +freigegebene Netzart und der Revit-Anteil der Anzahl (`quantity = +manualQuantity`). Stromkreisspannungen folgen den Spannungen des Zielprojekts. +Die Antwort nennt diese Anpassungen in `adjustments`; die Projektseite bietet +Export je Verteilung und Import im Kopf der Verteilerkarte an. `distribution-board-component.insert` und `distribution-board-component.delete` sowie `distribution-board-component.update` versionieren Anlage, Entfernung, diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 45725b7..5ff2d25 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -2,9 +2,10 @@ import Link from "next/link"; import { useParams } from "next/navigation"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, + Download, FileSpreadsheet, Library, Link2, @@ -15,6 +16,7 @@ import { Settings, Trash2, Unlink, + Upload, X, } from "lucide-react"; import { IconButton } from "../../../frontend/components/icon-button"; @@ -54,6 +56,7 @@ import { deleteProjectDevice, deleteDistributionBoard, disconnectProjectDeviceRows, + exportDistributionBoardTransfer, exportProjectTransfer, getProject, getProjectCircuitTrees, @@ -65,6 +68,7 @@ import { listGlobalDevices, listProjectDevices, listRooms, + importDistributionBoardTransfer, importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, @@ -214,6 +218,8 @@ export default function ProjectDetailPage() { const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const boardImportInputRef = useRef(null); const [isSaving, setIsSaving] = useState(false); useEffect(() => { @@ -766,6 +772,79 @@ export default function ProjectDetailPage() { } } + async function handleExportBoard(board: DistributionBoardDto) { + if (!projectId) { + return; + } + setError(null); + try { + const transfer = await exportDistributionBoardTransfer(projectId, board.id); + downloadJson( + transfer, + `${(board.boardNumber ?? board.name).replace(/[^a-zA-Z0-9_-]+/g, "-") || "verteiler"}.verteiler.json` + ); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht exportiert werden." + ); + } + } + + // Imports an exported board file as a new board of this project. + async function handleImportBoardFile(file: File | undefined) { + if (boardImportInputRef.current) { + boardImportInputRef.current.value = ""; + } + if (!file || !projectId || !project) { + return; + } + setError(null); + setNotice(null); + let transfer: unknown; + try { + transfer = JSON.parse(await file.text()) as unknown; + } catch { + setError("Die ausgewählte Datei enthält kein gültiges JSON."); + return; + } + const name = window.prompt( + "Bezeichnung der importierten Verteilung", + readTransferBoardName(transfer) ?? "" + ); + if (name === null || !name.trim()) { + return; + } + setIsSaving(true); + try { + const result = await importDistributionBoardTransfer( + projectId, + transfer, + name.trim(), + project.currentRevision + ); + setBoards((current) => [...current, result.distributionBoard]); + setCircuitLists((current) => [...current, result.circuitList]); + applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); + setNotice( + [ + `Verteilung „${result.distributionBoard.name}“ importiert.`, + ...result.adjustments, + ].join(" ") + ); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht importiert werden." + ); + } finally { + setIsSaving(false); + } + } + async function handleExportProject() { if (!project) { return; @@ -774,15 +853,10 @@ export default function ProjectDetailPage() { setError(null); try { const transfer = await exportProjectTransfer(project.id); - const blob = new Blob([JSON.stringify(transfer, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`; - anchor.click(); - URL.revokeObjectURL(url); + downloadJson( + transfer, + `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json` + ); } catch (err) { setError( err instanceof Error @@ -1011,6 +1085,17 @@ export default function ProjectDetailPage() { {error ?
{error}
: null} + {notice ? ( +
+ {notice} +
+ ) : null}
+ boardImportInputRef.current?.click()} + showLabel + title="Exportierte Verteilerdatei (.verteiler.json) als neue Verteilung importieren" + /> + + void handleImportBoardFile(event.target.files?.[0]) + } + ref={boardImportInputRef} + type="file" + /> openBoardEditor(board)} /> + void handleExportBoard(board)} + /> {circuitList ? ( string +): PreparedDistributionBoardImport { + const source = envelope.board; + const name = target.name?.trim() || source.distributionBoard.name; + const clone = cloneDistributionBoardSubtree(source, name, createId); + const rows = clone.circuits.flatMap((circuit) => circuit.deviceRows); + const linkedRowCount = rows.filter((row) => row.linkedProjectDeviceId !== null).length; + const roomRowCount = rows.filter((row) => row.roomId !== null).length; + const externalQuantityRowCount = rows.filter( + (row) => (row.manualQuantity ?? row.quantity) !== row.quantity + ).length; + const sectionKeyById = new Map( + clone.sections.map((section) => [section.id, section.key]) + ); + const supplyType = + clone.distributionBoard.supplyType !== null && + target.enabledSupplyTypes.includes(clone.distributionBoard.supplyType) + ? clone.distributionBoard.supplyType + : null; + + const snapshot: DistributionBoardSubtreeSnapshot = { + ...clone, + distributionBoard: { + ...clone.distributionBoard, + projectId: target.projectId, + floorId: null, + supplyType, + feedingDistributionBoardId: null, + feedingCircuitId: null, + }, + circuitList: { ...clone.circuitList, projectId: target.projectId }, + circuits: clone.circuits.map((circuit) => ({ + ...circuit, + voltage: resolveProjectVoltage( + resolveCircuitPhaseType( + sectionKeyById.get(circuit.sectionId) ?? "", + circuit.deviceRows.map((row) => row.phaseType) + ), + target.voltageSettings + ), + deviceRows: circuit.deviceRows.map((row) => { + const manualQuantity = row.manualQuantity ?? row.quantity; + return { + ...row, + linkedProjectDeviceId: null, + overriddenFields: null, + roomId: null, + quantity: manualQuantity, + manualQuantity, + }; + }), + })), + }; + assertDistributionBoardSubtreeSnapshot(snapshot); + + const adjustments: string[] = []; + if (linkedRowCount > 0) { + adjustments.push( + `${linkedRowCount} Gerätezeile(n) sind nicht mehr mit Projektgeräten verknüpft.` + ); + } + if (roomRowCount > 0) { + adjustments.push( + `${roomRowCount} Raumzuordnung(en) wurden als Raumnummer/-name (Text) übernommen.` + ); + } + if (externalQuantityRowCount > 0) { + adjustments.push( + `${externalQuantityRowCount} Gerätezeile(n) enthalten nur noch die manuelle Anzahl (ohne Revit-Anteil).` + ); + } + if (source.distributionBoard.floorId !== null) { + adjustments.push("Die Etage ist nicht übernommen und muss neu zugeordnet werden."); + } + if (source.distributionBoard.feedingDistributionBoardId !== null) { + adjustments.push("Die Einspeisung aus einem anderen Verteiler ist nicht übernommen."); + } + if (source.distributionBoard.supplyType !== null && supplyType === null) { + adjustments.push( + "Die Netzart ist in diesem Projekt nicht freigegeben und wurde entfernt." + ); + } + return { snapshot, adjustments }; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 78b8227..45a2d76 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -312,6 +312,12 @@ export interface DistributionBoardCopyCommandResultDto circuitList: CircuitListDto; } +export interface DistributionBoardImportCommandResultDto + extends DistributionBoardCopyCommandResultDto { + /** Notes on source-project data that the import did not carry over. */ + adjustments: string[]; +} + export interface DistributionBoardDeleteCommandResultDto extends ProjectCommandResultDto { distributionBoardId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 8e97efb..e3cfab0 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -13,6 +13,7 @@ import type { DistributionBoardDto, DistributionBoardCommandResultDto, DistributionBoardCopyCommandResultDto, + DistributionBoardImportCommandResultDto, DistributionBoardDeleteCommandResultDto, DistributionBoardInfoDto, DocumentLogoMetadataDto, @@ -633,6 +634,30 @@ export function copyDistributionBoard( ); } +export function exportDistributionBoardTransfer( + projectId: string, + distributionBoardId: string +) { + return request( + `/api/projects/${projectId}/distribution-boards/${distributionBoardId}/export` + ); +} + +export function importDistributionBoardTransfer( + projectId: string, + transfer: unknown, + name: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/distribution-boards/import`, + { + method: "POST", + body: JSON.stringify({ expectedRevision, name, transfer }), + } + ); +} + export function deleteDistributionBoard( projectId: string, distributionBoardId: string, diff --git a/src/server/controllers/distribution-board-transfer.controller.ts b/src/server/controllers/distribution-board-transfer.controller.ts new file mode 100644 index 0000000..29f0e58 --- /dev/null +++ b/src/server/controllers/distribution-board-transfer.controller.ts @@ -0,0 +1,116 @@ +import type { Request, Response } from "express"; +import { createDistributionBoardInsertSubtreeProjectCommand } from "../../domain/models/distribution-board-subtree-project-command.model.js"; +import { + createDistributionBoardTransferEnvelope, + parseDistributionBoardTransferEnvelope, + prepareDistributionBoardImport, +} from "../../domain/models/distribution-board-transfer.model.js"; +import { importDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js"; +import { projectRepository } from "../composition/application-repositories.js"; +import { + distributionBoardSubtreeProjectCommandStore, + projectCommandService, +} from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +// Downloads one board with its complete circuit list as a portable file. +export async function exportDistributionBoard(req: Request, res: Response) { + const { projectId, distributionBoardId } = req.params; + if ( + typeof projectId !== "string" || + typeof distributionBoardId !== "string" || + !projectId.trim() || + !distributionBoardId.trim() + ) { + return res.status(400).json({ error: "Invalid distribution board id" }); + } + const project = await projectRepository.findById(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } + let board; + try { + board = distributionBoardSubtreeProjectCommandStore.capture( + projectId, + distributionBoardId + ); + } catch { + return res.status(404).json({ error: "Distribution board not found" }); + } + const transfer = createDistributionBoardTransferEnvelope( + board, + project.name, + new Date().toISOString() + ); + const filename = safeFilename( + board.distributionBoard.boardNumber ?? board.distributionBoard.name + ); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}.verteiler.json"` + ); + return res.json(transfer); +} + +// Inserts the board of an exported file as a new board of this project. +export async function importDistributionBoard(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string" || !projectId.trim()) { + return res.status(400).json({ error: "Invalid projectId" }); + } + const parsed = importDistributionBoardSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const project = await projectRepository.findById(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } + let prepared; + try { + prepared = prepareDistributionBoardImport( + parseDistributionBoardTransferEnvelope(parsed.data.transfer), + { + projectId, + name: parsed.data.name, + voltageSettings: project, + enabledSupplyTypes: project.enabledDistributionBoardSupplyTypes, + } + ); + } catch (error) { + return res.status(400).json({ + error: + error instanceof Error + ? error.message + : "Die Verteilerdatei ist ungültig.", + }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Verteilung importieren", + command: createDistributionBoardInsertSubtreeProjectCommand( + prepared.snapshot + ), + }); + return res.status(201).json({ + ...result, + distributionBoard: prepared.snapshot.distributionBoard, + circuitList: prepared.snapshot.circuitList, + adjustments: prepared.adjustments, + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +function safeFilename(name: string) { + return ( + name + .normalize("NFKD") + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "verteiler" + ); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 5d83ee0..1d91670 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -65,6 +65,10 @@ import { } from "../controllers/external-initial-import.controller.js"; import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js"; +import { + exportDistributionBoard, + importDistributionBoard, +} from "../controllers/distribution-board-transfer.controller.js"; import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js"; export const projectRouter = Router(); @@ -110,6 +114,14 @@ projectRouter.post( "/:projectId/distribution-boards/:distributionBoardId/copy", copyDistributionBoard ); +projectRouter.post( + "/:projectId/distribution-boards/import", + importDistributionBoard +); +projectRouter.get( + "/:projectId/distribution-boards/:distributionBoardId/export", + exportDistributionBoard +); projectRouter.put( "/:projectId/distribution-boards/:distributionBoardId", updateDistributionBoard diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index a8f899c..65ee156 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -98,6 +98,17 @@ export const copyDistributionBoardSchema = z }) .strict(); +// The file itself is checked by parseDistributionBoardTransferEnvelope. +export const importDistributionBoardSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200).optional(), + transfer: z.unknown().refine((value) => value !== undefined, { + message: "Verteilerdatei fehlt.", + }), + }) + .strict(); + export const deleteDistributionBoardSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, diff --git a/tests/distribution-board-transfer.test.ts b/tests/distribution-board-transfer.test.ts new file mode 100644 index 0000000..c2c32d1 --- /dev/null +++ b/tests/distribution-board-transfer.test.ts @@ -0,0 +1,433 @@ +/** + * Distribution-board export/import: pure import preparation plus the HTTP + * round trip from one project into another against createApp() with an + * in-memory database and synthetic accounts. + */ +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { after, before, describe, it } from "node:test"; +import type { DistributionBoardSubtreeSnapshot } from "../src/domain/models/distribution-board-subtree-snapshot.model.js"; +import { + createDistributionBoardTransferEnvelope, + parseDistributionBoardTransferEnvelope, + prepareDistributionBoardImport, +} from "../src/domain/models/distribution-board-transfer.model.js"; +import config, { WRITE_HEADER, signIn } from "./security/security.config.ts"; + +type Headers = Record; + +function sourceBoard(): DistributionBoardSubtreeSnapshot { + return { + distributionBoard: { + id: "board-1", + projectId: "project-source", + name: "UV-EG", + floorId: "floor-1", + supplyType: "SV", + simultaneityFactor: 0.8, + feedingDistributionBoardId: "board-main", + feedingCircuitId: null, + boardNumber: "UV-1", + location: "Technik", + fedFrom: null, + remark: null, + }, + circuitList: { + id: "board-1", + projectId: "project-source", + distributionBoardId: "board-1", + name: "UV-EG Stromkreisliste", + }, + sections: [ + { + id: "section-1", + circuitListId: "board-1", + key: "single_phase", + displayName: "Einphasig", + prefix: "-2F1.", + sortOrder: 20, + category: "single_phase", + groupNumber: 1, + }, + ], + components: [], + circuits: [ + { + id: "circuit-1", + circuitListId: "board-1", + sectionId: "section-1", + equipmentIdentifier: "-2F1.1", + displayName: "Steckdose", + sortOrder: 10, + cableType: "NYM-J", + cableCrossSection: "3x2,5", + cableLength: 20, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: false, + remark: null, + protectionDevice: { + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 16, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, + }, + deviceRows: [ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + sortOrder: 10, + name: "Steckdose", + displayName: "Steckdose", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: "room-1", + roomNumberSnapshot: "0.01", + roomNameSnapshot: "Büro", + quantity: 5, + manualQuantity: 2, + powerPerUnit: 0.2, + simultaneityFactor: 1, + cosPhi: 1, + remark: null, + overriddenFields: "[\"displayName\"]", + }, + ], + }, + ], + }; +} + +describe("distribution-board transfer model", () => { + it("imports with new ids and without source-project references", () => { + let nextId = 0; + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + const { snapshot, adjustments } = prepareDistributionBoardImport( + parseDistributionBoardTransferEnvelope(JSON.parse(JSON.stringify(envelope))), + { + projectId: "project-target", + name: "UV-EG neu", + voltageSettings: { singlePhaseVoltageV: 220, threePhaseVoltageV: 380 }, + enabledSupplyTypes: ["AV"], + }, + () => `new-${++nextId}` + ); + + const board = snapshot.distributionBoard; + assert.equal(board.projectId, "project-target"); + assert.equal(board.name, "UV-EG neu"); + assert.equal(board.boardNumber, "UV-1"); + assert.equal(board.simultaneityFactor, 0.8); + assert.deepEqual( + [board.floorId, board.supplyType, board.feedingDistributionBoardId], + [null, null, null] + ); + assert.equal(snapshot.circuitList.projectId, "project-target"); + const [circuit] = snapshot.circuits; + assert.ok(circuit.id.startsWith("new-")); + assert.equal(circuit.equipmentIdentifier, "-2F1.1"); + assert.equal(circuit.cableCrossSection, "3x2,5"); + assert.equal(circuit.voltage, 220); + assert.equal(circuit.protectionDevice?.circuitId, circuit.id); + assert.equal(circuit.protectionDevice?.ratedCurrentA, 16); + const [row] = circuit.deviceRows; + assert.ok(row.id.startsWith("new-")); + assert.deepEqual( + { + linkedProjectDeviceId: row.linkedProjectDeviceId, + overriddenFields: row.overriddenFields, + roomId: row.roomId, + roomNumberSnapshot: row.roomNumberSnapshot, + quantity: row.quantity, + manualQuantity: row.manualQuantity, + }, + { + linkedProjectDeviceId: null, + overriddenFields: null, + roomId: null, + roomNumberSnapshot: "0.01", + quantity: 2, + manualQuantity: 2, + } + ); + assert.equal(adjustments.length, 6); + }); + + it("rejects foreign files, unknown fields and broken boards in German", () => { + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + assert.throws( + () => parseDistributionBoardTransferEnvelope({ format: "leistungsbilanz.project" }), + /keine Verteiler-Exportdatei/ + ); + assert.throws( + () => parseDistributionBoardTransferEnvelope({ ...envelope, extra: 1 }), + /unbekannte Felder/ + ); + assert.throws( + () => + parseDistributionBoardTransferEnvelope({ + ...envelope, + board: { ...envelope.board, circuits: "kaputt" }, + }), + /Verteiler in der Exportdatei ist ungültig/ + ); + }); +}); + +describe("distribution-board transfer routes", () => { + let server: import("node:http").Server; + let baseUrl = ""; + let user: Headers = {}; + + async function call(method: string, path: string, headers: Headers, body?: unknown) { + return fetch(baseUrl + path, { + method, + headers: { ...headers, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } + + async function json(response: Response, status: number): Promise { + const text = await response.text(); + assert.equal(response.status, status, text); + return JSON.parse(text) as T; + } + + async function createProject(name: string) { + return json<{ id: string }>(await call("POST", "/api/projects", user, { name }), 201); + } + + async function getRevision(projectId: string) { + return ( + await json<{ currentRevision: number }>( + await call("GET", `/api/projects/${projectId}/history`, user), + 200 + ) + ).currentRevision; + } + + async function loadTree(projectId: string) { + const [circuitList] = await json>( + await call("GET", `/api/projects/${projectId}/circuit-lists`, user), + 200 + ); + return json<{ + sections: Array<{ + id: string; + key: string; + circuits: Array<{ + id: string; + equipmentIdentifier: string; + cableType?: string; + deviceRows: Array<{ id: string; powerPerUnit: number }>; + }>; + }>; + }>( + await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitList.id}/tree`, user), + 200 + ); + } + + before(async () => { + const app = await config.createApp(); + server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + user = await signIn(baseUrl, "planer-a"); + }); + + after(() => new Promise((resolve) => server.close(() => resolve()))); + + it("exports a board and imports it into another project as one undoable revision", async () => { + const source = await createProject("Quelle"); + const created = await json<{ distributionBoard: { id: string } }>( + await call("POST", `/api/projects/${source.id}/distribution-boards`, user, { + expectedRevision: 0, + name: "UV-EG", + floorId: null, + supplyType: "AV", + }), + 201 + ); + const section = (await loadTree(source.id)).sections.find( + (entry) => entry.key === "single_phase" + ); + assert.ok(section); + const [circuitList] = await json>( + await call("GET", `/api/projects/${source.id}/circuit-lists`, user), + 200 + ); + await json( + await call("POST", `/api/projects/${source.id}/commands`, user, { + expectedRevision: 1, + command: { + schemaVersion: 1, + type: "circuit.insert", + payload: { + circuit: { + ...sourceBoard().circuits[0], + id: "source-circuit", + circuitListId: circuitList.id, + sectionId: section.id, + cableType: null, + protectionDevice: null, + deviceRows: [ + { + ...sourceBoard().circuits[0].deviceRows[0], + id: "source-row", + circuitId: "source-circuit", + linkedProjectDeviceId: null, + roomId: null, + overriddenFields: null, + quantity: 2, + }, + ], + }, + }, + }, + }), + 200 + ); + // Cell edits of several entities go through the bulk command. + await json( + await call("POST", `/api/projects/${source.id}/commands`, user, { + expectedRevision: 2, + command: { + schemaVersion: 1, + type: "circuit-list.bulk-update", + payload: { + updates: [ + { + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "source-circuit", + changes: [{ field: "cableType", value: "NYM-J" }], + }, + }, + { + schemaVersion: 1, + type: "circuit-device-row.update", + payload: { + rowId: "source-row", + changes: [{ field: "powerPerUnit", value: 0.5 }], + }, + }, + ], + }, + }, + }), + 200 + ); + + const exported = await call( + "GET", + `/api/projects/${source.id}/distribution-boards/${created.distributionBoard.id}/export`, + user + ); + assert.match( + exported.headers.get("content-disposition") ?? "", + /UV-EG\.verteiler\.json/ + ); + const transfer = await json<{ format: string }>(exported, 200); + assert.equal(transfer.format, "leistungsbilanz.distribution-board"); + + const target = await createProject("Ziel"); + const imported = await json<{ adjustments: string[]; distributionBoard: { name: string } }>( + await call("POST", `/api/projects/${target.id}/distribution-boards/import`, user, { + expectedRevision: 0, + name: "UV-EG importiert", + transfer, + }), + 201 + ); + assert.equal(imported.distributionBoard.name, "UV-EG importiert"); + assert.deepEqual(imported.adjustments, []); + const importedCircuit = (await loadTree(target.id)).sections + .flatMap((entry) => entry.circuits) + .find((circuit) => circuit.equipmentIdentifier === "-2F1.1"); + assert.ok(importedCircuit); + assert.notEqual(importedCircuit.id, "source-circuit"); + assert.equal(importedCircuit.cableType, "NYM-J"); + assert.equal(importedCircuit.deviceRows[0].powerPerUnit, 0.5); + assert.equal(await getRevision(target.id), 1); + + await json( + await call("POST", `/api/projects/${target.id}/history/undo`, user, { + expectedRevision: 1, + }), + 200 + ); + assert.deepEqual( + await json(await call("GET", `/api/projects/${target.id}/distribution-boards`, user), 200), + [] + ); + }); + + it("rejects unknown boards, invalid files, stale revisions and anonymous requests", async () => { + const project = await createProject("Fehlerfälle"); + assert.equal( + ( + await call( + "GET", + `/api/projects/${project.id}/distribution-boards/unbekannt/export`, + user + ) + ).status, + 404 + ); + const importPath = `/api/projects/${project.id}/distribution-boards/import`; + const invalid = await call("POST", importPath, user, { + expectedRevision: 0, + transfer: { format: "leistungsbilanz.project" }, + }); + assert.equal(invalid.status, 400); + assert.match((await invalid.json()).error, /keine Verteiler-Exportdatei/); + assert.equal( + (await call("POST", importPath, user, { expectedRevision: 0, transfer: {}, extra: 1 })).status, + 400 + ); + const envelope = createDistributionBoardTransferEnvelope( + sourceBoard(), + "Quelle", + "2026-10-29T10:00:00.000Z" + ); + assert.equal( + (await call("POST", importPath, user, { expectedRevision: 7, transfer: envelope })).status, + 409 + ); + assert.equal( + (await call("POST", importPath, WRITE_HEADER, { expectedRevision: 0, transfer: envelope })).status, + 401 + ); + assert.equal( + ( + await call( + "POST", + "/api/projects/unbekannt/distribution-boards/import", + user, + { expectedRevision: 0, transfer: envelope } + ) + ).status, + 404 + ); + }); +}); diff --git a/tests/security/security.config.ts b/tests/security/security.config.ts index cba0c38..329fa7d 100644 --- a/tests/security/security.config.ts +++ b/tests/security/security.config.ts @@ -124,6 +124,12 @@ const config = { { method: "POST", path: "/api/projects", body: "{kaputt" }, { method: "PUT", path: "/api/projects/{id}", body: {} }, { method: "PUT", path: "/api/projects/{id}", body: "{kaputt" }, + { method: "POST", path: "/api/projects/{id}/distribution-boards/import", body: {} }, + { + method: "POST", + path: "/api/projects/{id}/distribution-boards/import", + body: { expectedRevision: 0, transfer: { format: "fremd" } }, + }, ] as Case[], // Keine Cross-Origin-Freigabe: Browser erreichen die API nur same-origin über den