Export and import single distribution boards as JSON

A board with its complete circuit list can be downloaded as a
leistungsbilanz.distribution-board file and imported into any project as
a new board. The import reuses distribution-board.insert-subtree, so it
is one revision with restart-safe Undo/Redo. All ids are new;
project-bound references (project devices with override metadata, room
ids, floor, logical feed, a disabled supply type and the Revit quantity
share) are dropped, circuit voltages follow the target project, and the
response lists these adjustments. The project page offers export per
board and import in the board card header.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 07:38:28 +01:00
parent 17a3945813
commit d0bd4fe7e0
11 changed files with 955 additions and 10 deletions

View file

@ -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<string, string>;
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<T>(response: Response, status: number): Promise<T> {
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<Array<{ id: string }>>(
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<void>((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<Array<{ id: string }>>(
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
);
});
});

View file

@ -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