Add Revit return workbook export

This commit is contained in:
Julian Appel 2026-09-30 15:15:17 +02:00
parent 77d775fde1
commit 1a4c8cad8c
17 changed files with 3783 additions and 6 deletions

View file

@ -348,6 +348,14 @@ Pfade.
## 12. Rückgabe-Excel
Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash

View file

@ -16,7 +16,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
@ -173,6 +173,13 @@ Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID;

View file

@ -0,0 +1,16 @@
CREATE TABLE `external_export_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`project_revision` integer NOT NULL,
`import_batch_id` text NOT NULL,
`exported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`configuration_snapshot` text NOT NULL,
`configuration_sha256` text NOT NULL,
`scope` text NOT NULL,
`object_count` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);

File diff suppressed because it is too large Load diff

View file

@ -106,6 +106,13 @@
"when": 1790748107566,
"tag": "0014_linear_xlsx_external_model",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1790773066696,
"tag": "0015_wide_night_thrasher",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,18 @@
import { desc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalExportBatches } from "../schema/external-export-batches.js";
export class ExternalExportBatchRepository {
constructor(private readonly database: AppDatabase) {}
append(batch: typeof externalExportBatches.$inferInsert) {
this.database.insert(externalExportBatches).values(batch).run();
return batch;
}
listByProject(projectId: string) {
return this.database.select().from(externalExportBatches)
.where(eq(externalExportBatches.projectId, projectId))
.orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
}
}

View file

@ -0,0 +1,24 @@
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { projects } from "./projects.js";
export const externalExportScopes = ["all", "deviating", "selected"] as const;
export type ExternalExportScope = (typeof externalExportScopes)[number];
export const externalExportBatches = sqliteTable(
"external_export_batches",
{
id: text("id").primaryKey(),
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectRevision: integer("project_revision").notNull(),
importBatchId: text("import_batch_id").notNull(),
exportedAtIso: text("exported_at_iso").notNull(),
fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(),
configurationSnapshot: text("configuration_snapshot", { mode: "json" }).$type<ExternalModelConfiguration>().notNull(),
configurationSha256: text("configuration_sha256").notNull(),
scope: text("scope", { enum: externalExportScopes }).notNull(),
objectCount: integer("object_count").notNull(),
},
(table) => [index("external_export_batches_project_exported_idx").on(table.projectId, table.exportedAtIso)]
);

View file

@ -0,0 +1,135 @@
import type { ExternalModelObjectSnapshot } from "../domain/external-model-contracts.js";
import type { ExternalXlsxCircuitMode, ExternalXlsxReturnValues } from "../xlsx/external-xlsx-contracts.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export interface ExternalReturnRowContext {
rowId: string;
displayName: string;
powerPerUnitKw: number;
cosPhi: number | null;
phaseType: string | null;
category: CircuitGroupCategory | null;
circuitIdentifier: string;
circuitVoltageV: number | null;
distributionBoardName: string;
}
export interface ExternalReturnProjection {
objectId: string;
worksharingId: string;
values: ExternalXlsxReturnValues;
differsFromImported: boolean;
warnings: string[];
}
export function getRevitBoardName(board: { name: string }) {
return board.name;
}
export function projectExternalXlsxReturn(input: {
object: ExternalModelObjectSnapshot;
row: ExternalReturnRowContext | null;
circuitMode: ExternalXlsxCircuitMode;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}): ExternalReturnProjection {
const { object, row } = input;
const planning = object.planningValues;
const warnings: string[] = [];
const category = row?.category ?? planning.category;
const phaseType = row?.phaseType ?? (category === "three_phase" ? "three_phase" : category ? "single_phase" : null);
const voltageV = row?.circuitVoltageV ?? defaultVoltage(category, input.singlePhaseVoltageV, input.threePhaseVoltageV);
const powerPerUnitW = row ? row.powerPerUnitKw * 1000 : planning.powerPerUnitW;
const electricalPowerW = powerPerUnitW === null
? null
: roundNumber(planning.effectiveQuantity * powerPerUnitW, 6);
const cosPhi = row?.cosPhi ?? planning.cosPhi;
const currentA = calculateCurrent({ electricalPowerW, voltageV, cosPhi, phaseType });
if (electricalPowerW === null) warnings.push("Elektrische Leistung ist nicht berechenbar.");
if (voltageV === null) warnings.push("Spannung ist nicht bestimmbar.");
if (currentA === null) warnings.push("Stromstärke ist nicht berechenbar.");
const values: ExternalXlsxReturnValues = {
...(input.circuitMode === "combined"
? { circuitCombined: row ? `${getRevitBoardName({ name: row.distributionBoardName })}${row.circuitIdentifier}` : "" }
: {
boardName: row ? getRevitBoardName({ name: row.distributionBoardName }) : "",
circuitIdentifier: row?.circuitIdentifier ?? "",
}),
designation: row?.displayName ?? planning.displayName ?? "",
electricalPowerW,
voltageV,
currentA,
};
const source = object.acceptedSourceValues;
const sourceValues: ExternalXlsxReturnValues = input.circuitMode === "combined"
? {
circuitCombined: source.circuitCombined,
designation: source.designation,
electricalPowerW: source.electricalPowerW,
voltageV: source.voltageV,
currentA: source.currentA,
}
: {
boardName: source.boardName,
circuitIdentifier: source.circuitIdentifier,
designation: source.designation,
electricalPowerW: source.electricalPowerW,
voltageV: source.voltageV,
currentA: source.currentA,
};
return {
objectId: object.id,
worksharingId: object.worksharingId,
values,
differsFromImported: Object.keys(values).some((key) =>
!sameScalar(values[key as keyof ExternalXlsxReturnValues], sourceValues[key as keyof ExternalXlsxReturnValues])
),
warnings,
};
}
function defaultVoltage(category: CircuitGroupCategory | null, singlePhase: number, threePhase: number) {
if (category === "three_phase") return threePhase;
if (category === "lighting" || category === "single_phase") return singlePhase;
return null;
}
function calculateCurrent(input: { electricalPowerW: number | null; voltageV: number | null; cosPhi: number | null; phaseType: string | null }) {
if (
input.electricalPowerW === null ||
input.voltageV === null || input.voltageV <= 0 ||
input.cosPhi === null || input.cosPhi <= 0 ||
(input.phaseType !== "single_phase" && input.phaseType !== "three_phase")
) return null;
const divisor = input.phaseType === "three_phase"
? Math.sqrt(3) * input.voltageV * input.cosPhi
: input.voltageV * input.cosPhi;
return roundNumber(input.electricalPowerW / divisor, 2);
}
function sameScalar(left: string | number | boolean | null | undefined, right: string | number | boolean | null | undefined) {
const normalizedLeft = normalizeScalar(left);
const normalizedRight = normalizeScalar(right);
if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
return Math.abs(normalizedLeft - normalizedRight) < 1e-9;
}
return normalizedLeft === normalizedRight;
}
function normalizeScalar(value: string | number | boolean | null | undefined): string | number | boolean {
if (value === null || value === undefined || value === "") return "";
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return "";
const numeric = Number(trimmed.replace(",", "."));
return Number.isFinite(numeric) ? numeric : trimmed;
}
return value;
}
function roundNumber(value: number, decimals: number) {
const factor = 10 ** decimals;
return Math.round((value + Number.EPSILON) * factor) / factor;
}

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto } from "../types";
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44;
const viewportHeight = 520;
@ -23,6 +23,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const [planningValue, setPlanningValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [returnScope, setReturnScope] = useState<ExternalReturnScopeDto>("all");
const [exporting, setExporting] = useState(false);
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
const [exportMessage, setExportMessage] = useState<string | null>(null);
async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
@ -44,6 +48,7 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
async function applyBulk() {
if (!data || selected.size === 0) return;
@ -64,10 +69,38 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
finally { setSaving(false); }
}
async function exportReturnWorkbook() {
if (!data || (returnScope === "selected" && selected.size === 0)) return;
setExporting(true); setError(null); setExportWarnings([]); setExportMessage(null);
try {
const result = await createExternalReturnWorkbook(projectId, data.currentRevision, returnScope, [...selected]);
downloadBase64(result.contentBase64, result.fileName);
setExportWarnings(result.warnings);
setExportMessage(`${result.objectCount} Revit-Objekte wurden in ${result.fileName} geschrieben.`);
} catch (value) {
setError(value instanceof Error ? value.message : "Rückgabe-Excel konnte nicht erstellt werden.");
} finally {
setExporting(false);
}
}
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} disabled={linked} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
</div></section>;
}
function downloadBase64(contentBase64: string, fileName: string) {
const binary = atob(contentBase64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
const url = URL.createObjectURL(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -126,6 +126,29 @@ export interface ExternalProjectObjectsDto {
objects: ExternalProjectObjectDto[];
}
export type ExternalReturnScopeDto = "all" | "deviating" | "selected";
export interface ExternalReturnWorkbookDto {
fileName: string;
contentBase64: string;
sha256: string;
objectCount: number;
warnings: string[];
}
export interface ExternalExportBatchDto {
id: string;
projectId: string;
projectRevision: number;
importBatchId: string;
exportedAtIso: string;
fileName: string;
sha256: string;
configurationSha256: string;
scope: ExternalReturnScopeDto;
objectCount: number;
}
export interface ProjectDto {
id: string;
name: string;

View file

@ -44,6 +44,8 @@ import type {
ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput,
ExternalProjectObjectsDto,
ExternalReturnScopeDto,
ExternalReturnWorkbookDto,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
@ -245,6 +247,18 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
}
export function createExternalReturnWorkbook(
projectId: string,
expectedRevision: number,
scope: ExternalReturnScopeDto,
selectedObjectIds: string[]
) {
return request<ExternalReturnWorkbookDto>(`/api/projects/${projectId}/external-model/return-workbook`, {
method: "POST",
body: JSON.stringify({ expectedRevision, scope, selectedObjectIds }),
});
}
export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, {
method: "POST",

View file

@ -15,6 +15,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { ExternalExportBatchRepository } from "../../db/repositories/external-export-batch.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js";
@ -39,5 +40,6 @@ export const roomRepository = new RoomRepository(db);
export const externalModelConfigurationRepository =
new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const externalExportBatchRepository = new ExternalExportBatchRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db);

View file

@ -0,0 +1,180 @@
import { createHash, randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { ExternalExportScope } from "../../db/schema/external-export-batches.js";
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
import { createExternalXlsxReturn } from "../../external-model/xlsx/external-xlsx-transport.js";
import {
externalExportBatchRepository,
externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
const exportScopes = new Set<ExternalExportScope>(["all", "deviating", "selected"]);
export async function createExternalReturnWorkbook(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const scope = parseScope(req.body?.scope);
const selectedObjectIds = scope === "selected" ? parseSelectedIds(req.body?.selectedObjectIds) : [];
if (scope === "selected" && selectedObjectIds.length === 0) {
return res.status(400).json({ error: "Für die ausgewählte Rückgabe wurden keine Revit-Objekte markiert." });
}
const project = await projectRepository.findById(projectId);
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
if (project.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
expectedRevision,
currentRevision: project.currentRevision,
});
}
const { state } = externalModelStateRepository.getByProject(projectId);
const importBatch = state.importBatches.at(-1);
if (!importBatch?.originalContentBase64 || !importBatch.configurationSnapshot) {
return res.status(409).json({
error: "Für die Rückgabe fehlt der letzte vollständige Revit-Import.",
code: "EXTERNAL_XLSX_IMPORT_REQUIRED",
});
}
const treeResult = await buildProjectCircuitTrees(projectId);
if (!treeResult || treeResult.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
});
}
const rowById = new Map<string, ExternalReturnRowContext>();
for (const tree of treeResult.trees) {
for (const section of tree.sections) {
for (const circuit of section.circuits) {
for (const row of circuit.deviceRows) {
rowById.set(row.id, {
rowId: row.id,
displayName: row.displayName,
powerPerUnitKw: row.powerPerUnit,
cosPhi: row.cosPhi ?? null,
phaseType: row.phaseType ?? null,
category: (section.category ?? null) as CircuitGroupCategory | null,
circuitIdentifier: circuit.equipmentIdentifier,
circuitVoltageV: circuit.voltage ?? null,
distributionBoardName: tree.distributionBoardName,
});
}
}
}
}
const projections = state.objects
.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === importBatch.id)
.map((object) => projectExternalXlsxReturn({
object,
row: object.circuitDeviceRowId ? rowById.get(object.circuitDeviceRowId) ?? null : null,
circuitMode: importBatch.configurationSnapshot!.circuitMode,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
}));
const projectionById = new Map(projections.map((projection) => [projection.objectId, projection]));
for (const objectId of selectedObjectIds) {
if (!projectionById.has(objectId)) {
return res.status(400).json({ error: "Ein ausgewähltes Revit-Objekt gehört nicht zum letzten Import." });
}
}
const selected = scope === "all"
? projections
: scope === "deviating"
? projections.filter((projection) => projection.differsFromImported)
: selectedObjectIds.map((id) => projectionById.get(id)!);
if (selected.length === 0) {
return res.status(400).json({ error: "Der gewählte Rückgabeumfang enthält keine Revit-Objekte." });
}
const result = await createExternalXlsxReturn({
fileName: importBatch.fileName,
bytes: Buffer.from(importBatch.originalContentBase64, "base64"),
configuration: importBatch.configurationSnapshot,
selectedWorksharingIds: selected.map((projection) => projection.worksharingId),
updates: selected.map((projection) => ({
worksharingId: projection.worksharingId,
values: projection.values,
})),
});
const fileName = `${safeFileName(project.name)}_Revit-Rueckgabe_${new Date().toISOString().slice(0, 10)}.xlsx`;
const sha256 = hash(result.bytes);
const configurationSha256 = hash(Buffer.from(JSON.stringify(importBatch.configurationSnapshot), "utf8"));
const currentProject = await projectRepository.findById(projectId);
if (!currentProject || currentProject.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
expectedRevision,
currentRevision: currentProject?.currentRevision ?? null,
});
}
externalExportBatchRepository.append({
id: randomUUID(),
projectId,
projectRevision: expectedRevision,
importBatchId: importBatch.id,
exportedAtIso: new Date().toISOString(),
fileName,
sha256,
configurationSnapshot: importBatch.configurationSnapshot,
configurationSha256,
scope,
objectCount: result.exportedObjectCount,
});
const projectionWarnings = selected.flatMap((projection) =>
projection.warnings.map((warning) => `${projection.worksharingId}: ${warning}`)
);
return res.json({
fileName,
contentBase64: Buffer.from(result.bytes).toString("base64"),
sha256,
objectCount: result.exportedObjectCount,
warnings: [...new Set([...result.issues.map((issue) => issue.message), ...projectionWarnings])],
});
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Revit-Rückgabe fehlgeschlagen." });
}
}
export async function listExternalExportBatches(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const project = await projectRepository.findById(projectId);
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
return res.json(externalExportBatchRepository.listByProject(projectId).map(({ configurationSnapshot: _configuration, ...batch }) => batch));
}
function parseExpectedRevision(value: unknown) {
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
return value as number;
}
function parseScope(value: unknown): ExternalExportScope {
if (typeof value !== "string" || !exportScopes.has(value as ExternalExportScope)) throw new Error("Ungültiger Rückgabeumfang.");
return value as ExternalExportScope;
}
function parseSelectedIds(value: unknown) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string") || new Set(value).size !== value.length) {
throw new Error("Ungültige Objektauswahl.");
}
return value as string[];
}
function safeFileName(value: string) {
return value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "Projekt";
}
function hash(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex");
}

View file

@ -64,6 +64,7 @@ import {
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
export const projectRouter = Router();
@ -97,6 +98,8 @@ projectRouter.post("/:projectId/external-model/objects/assign-row", assignExtern
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow);
projectRouter.post("/:projectId/external-model/return-workbook", createExternalReturnWorkbook);
projectRouter.get("/:projectId/external-model/export-batches", listExternalExportBatches);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post(

View file

@ -0,0 +1,40 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalExportBatchRepository } from "../src/db/repositories/external-export-batch.repository.js";
import { projects } from "../src/db/schema/projects.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
describe("external export batch repository", () => {
it("appends immutable audit metadata without changing the project revision", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt", currentRevision: 7 }).run();
const repository = new ExternalExportBatchRepository(context.db);
const configuration = createDefaultExternalModelConfiguration();
repository.append({
id: "export-1", projectId: "project-1", projectRevision: 7,
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T10:00:00.000Z",
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "a".repeat(64),
configurationSnapshot: configuration, configurationSha256: "b".repeat(64), scope: "all", objectCount: 4,
});
configuration.columns.designation = "Nachträglich geändert";
repository.append({
id: "export-2", projectId: "project-1", projectRevision: 7,
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T11:00:00.000Z",
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "c".repeat(64),
configurationSnapshot: configuration, configurationSha256: "d".repeat(64), scope: "selected", objectCount: 1,
});
const batches = repository.listByProject("project-1");
assert.deepEqual(batches.map((batch) => batch.id), ["export-2", "export-1"]);
assert.notEqual(batches[0]!.configurationSnapshot.columns.designation, batches[1]!.configurationSnapshot.columns.designation);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()!.revision, 7);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
function object(): ExternalModelObjectSnapshot {
return {
id: "object-1", projectId: "project-1", sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: null,
deviceTypeId: "type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose", selectionMarker: "SD", socketCount: 2, sourceRemark: null, circuitCombined: "UV 1-2F1.1", boardName: "UV 1", circuitIdentifier: "-2F1.1", designation: "Steckdose", electricalPowerW: "240", voltageV: 230, currentA: "1,16" },
planningValues: { displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null, remark: null },
overriddenFields: [], externalRoomMappingId: null, distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: "row-1", presenceStatus: "present",
};
}
describe("external XLSX return projection", () => {
it("projects typed row values and compares normalized imported scalars", () => {
const result = projectExternalXlsxReturn({ object: object(), circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400, row: { rowId: "row-1", displayName: "Steckdose", powerPerUnitKw: 0.12, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } });
assert.deepEqual(result.values, { circuitCombined: "UV 1-2F1.1", designation: "Steckdose", electricalPowerW: 240, voltageV: 230, currentA: 1.16 });
assert.equal(result.differsFromImported, false);
assert.deepEqual(result.warnings, []);
});
it("uses object planning without a row and leaves an uncomputable current empty", () => {
const input = object(); input.circuitDeviceRowId = null; input.planningValues.cosPhi = null;
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "separate", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
assert.equal(result.values.boardName, "");
assert.equal(result.values.circuitIdentifier, "");
assert.equal(result.values.electricalPowerW, 240);
assert.equal(result.values.currentA, null);
assert.equal(result.differsFromImported, true);
assert.match(result.warnings.join(" "), /Stromstärke/);
});
it("uses three-phase current and object-specific effective quantity", () => {
const input = object(); input.planningValues.category = "three_phase"; input.planningValues.effectiveQuantity = 3;
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
assert.equal(result.values.electricalPowerW, 360);
assert.equal(result.values.voltageV, 400);
assert.equal(result.values.currentA, 0.58);
});
});

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
import { applyExternalInitialImport, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
@ -66,6 +66,8 @@ describe("Revit XLSX settings", () => {
assert.match(wizard, /GZF 1/);
assert.match(objectTable, /viewportHeight/);
assert.match(objectTable, /Sammeländerung anwenden/);
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
assert.match(objectTable, /Nur geänderte Objekte/);
});
it("loads one project object list and sends revision-safe bulk updates", async () => {
@ -76,4 +78,16 @@ describe("Revit XLSX settings", () => {
finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]);
});
it("creates a revision-safe return workbook for the requested scope", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify({ fileName: "return.xlsx", contentBase64: "", sha256: "a".repeat(64), objectCount: 1, warnings: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
};
try { await createExternalReturnWorkbook("project-1", 7, "selected", ["object-1"]); }
finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
});
});