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:
parent
17a3945813
commit
d0bd4fe7e0
11 changed files with 955 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const boardImportInputRef = useRef<HTMLInputElement | null>(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() {
|
|||
</div>
|
||||
|
||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||
{notice ? (
|
||||
<div className="alert alert-info d-flex justify-content-between align-items-start gap-3" role="status">
|
||||
<span>{notice}</span>
|
||||
<button
|
||||
aria-label="Hinweis schließen"
|
||||
className="btn-close"
|
||||
onClick={() => setNotice(null)}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="kpi-grid">
|
||||
<a className="kpi" href="#verteilungen">
|
||||
|
|
@ -1054,6 +1139,23 @@ export default function ProjectDetailPage() {
|
|||
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
disabled={isSaving}
|
||||
icon={Upload}
|
||||
label="Verteilung importieren"
|
||||
onClick={() => boardImportInputRef.current?.click()}
|
||||
showLabel
|
||||
title="Exportierte Verteilerdatei (.verteiler.json) als neue Verteilung importieren"
|
||||
/>
|
||||
<input
|
||||
accept=".json,application/json"
|
||||
className="d-none"
|
||||
onChange={(event) =>
|
||||
void handleImportBoardFile(event.target.files?.[0])
|
||||
}
|
||||
ref={boardImportInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<IconButton
|
||||
icon={Plus}
|
||||
label="Verteilung hinzufügen"
|
||||
|
|
@ -1153,6 +1255,11 @@ export default function ProjectDetailPage() {
|
|||
label="Bearbeiten"
|
||||
onClick={() => openBoardEditor(board)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Download}
|
||||
label="Verteilung exportieren"
|
||||
onClick={() => void handleExportBoard(board)}
|
||||
/>
|
||||
{circuitList ? (
|
||||
<IconLink
|
||||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||||
|
|
@ -1886,4 +1993,24 @@ export default function ProjectDetailPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function downloadJson(value: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Board name of an exported board file, used as the import name proposal.
|
||||
function readTransferBoardName(transfer: unknown): string | null {
|
||||
if (typeof transfer !== "object" || transfer === null) {
|
||||
return null;
|
||||
}
|
||||
const board = (transfer as { board?: { distributionBoard?: { name?: unknown } } })
|
||||
.board?.distributionBoard;
|
||||
return typeof board?.name === "string" ? board.name : null;
|
||||
}
|
||||
|
|
|
|||
190
src/domain/models/distribution-board-transfer.model.ts
Normal file
190
src/domain/models/distribution-board-transfer.model.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
type ProjectVoltageSettings,
|
||||
} from "../services/project-voltage.service.js";
|
||||
import {
|
||||
assertDistributionBoardSubtreeSnapshot,
|
||||
cloneDistributionBoardSubtree,
|
||||
normalizeDistributionBoardSubtreeSnapshot,
|
||||
type DistributionBoardSubtreeSnapshot,
|
||||
} from "./distribution-board-subtree-snapshot.model.js";
|
||||
|
||||
// Portable file of one distribution board (board, circuit list, groups,
|
||||
// circuits, device rows, components and protection). It is imported as a
|
||||
// new board through distribution-board.insert-subtree, so the import is one
|
||||
// undoable revision.
|
||||
export const distributionBoardTransferFormat =
|
||||
"leistungsbilanz.distribution-board" as const;
|
||||
export const distributionBoardTransferFormatVersion = 1 as const;
|
||||
|
||||
export interface DistributionBoardTransferEnvelope {
|
||||
format: typeof distributionBoardTransferFormat;
|
||||
formatVersion: typeof distributionBoardTransferFormatVersion;
|
||||
exportedAtIso: string;
|
||||
sourceProjectName: string;
|
||||
board: DistributionBoardSubtreeSnapshot;
|
||||
}
|
||||
|
||||
export interface DistributionBoardImportTarget {
|
||||
projectId: string;
|
||||
name?: string;
|
||||
voltageSettings: ProjectVoltageSettings;
|
||||
enabledSupplyTypes: readonly DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
export interface PreparedDistributionBoardImport {
|
||||
snapshot: DistributionBoardSubtreeSnapshot;
|
||||
/** German notes on project-specific data that was not carried over. */
|
||||
adjustments: string[];
|
||||
}
|
||||
|
||||
export function createDistributionBoardTransferEnvelope(
|
||||
board: DistributionBoardSubtreeSnapshot,
|
||||
sourceProjectName: string,
|
||||
exportedAtIso: string
|
||||
): DistributionBoardTransferEnvelope {
|
||||
return parseDistributionBoardTransferEnvelope({
|
||||
format: distributionBoardTransferFormat,
|
||||
formatVersion: distributionBoardTransferFormatVersion,
|
||||
exportedAtIso,
|
||||
sourceProjectName,
|
||||
board,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDistributionBoardTransferEnvelope(
|
||||
value: unknown
|
||||
): DistributionBoardTransferEnvelope {
|
||||
if (!isPlainObject(value) || value.format !== distributionBoardTransferFormat) {
|
||||
throw new Error("Die Datei ist keine Verteiler-Exportdatei.");
|
||||
}
|
||||
if (value.formatVersion !== distributionBoardTransferFormatVersion) {
|
||||
throw new Error("Die Version der Verteiler-Exportdatei wird nicht unterstützt.");
|
||||
}
|
||||
if (Object.keys(value).length !== 5) {
|
||||
throw new Error("Die Verteiler-Exportdatei enthält unbekannte Felder.");
|
||||
}
|
||||
if (
|
||||
typeof value.exportedAtIso !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.exportedAtIso)) ||
|
||||
typeof value.sourceProjectName !== "string"
|
||||
) {
|
||||
throw new Error("Die Kopfdaten der Verteiler-Exportdatei sind ungültig.");
|
||||
}
|
||||
let board: DistributionBoardSubtreeSnapshot;
|
||||
try {
|
||||
assertDistributionBoardSubtreeSnapshot(value.board);
|
||||
board = normalizeDistributionBoardSubtreeSnapshot(value.board);
|
||||
} catch {
|
||||
throw new Error("Der Verteiler in der Exportdatei ist ungültig.");
|
||||
}
|
||||
return {
|
||||
format: distributionBoardTransferFormat,
|
||||
formatVersion: distributionBoardTransferFormatVersion,
|
||||
exportedAtIso: value.exportedAtIso,
|
||||
sourceProjectName: value.sourceProjectName,
|
||||
board,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the board for the target project: every id is new, and references
|
||||
* that only exist in the source project are removed. Project-device links,
|
||||
* room links, floor and feed references are dropped (room number and name
|
||||
* stay as text), quantities keep only their manual share because Revit
|
||||
* objects are not part of the file, circuit voltages follow the target
|
||||
* project, and a supply type the target project has disabled is cleared.
|
||||
*/
|
||||
export function prepareDistributionBoardImport(
|
||||
envelope: DistributionBoardTransferEnvelope,
|
||||
target: DistributionBoardImportTarget,
|
||||
createId?: () => 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<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<unknown>(
|
||||
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}/export`
|
||||
);
|
||||
}
|
||||
|
||||
export function importDistributionBoardTransfer(
|
||||
projectId: string,
|
||||
transfer: unknown,
|
||||
name: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<DistributionBoardImportCommandResultDto>(
|
||||
`/api/projects/${projectId}/distribution-boards/import`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expectedRevision, name, transfer }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteDistributionBoard(
|
||||
projectId: string,
|
||||
distributionBoardId: string,
|
||||
|
|
|
|||
116
src/server/controllers/distribution-board-transfer.controller.ts
Normal file
116
src/server/controllers/distribution-board-transfer.controller.ts
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
433
tests/distribution-board-transfer.test.ts
Normal file
433
tests/distribution-board-transfer.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue