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 ## 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 Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import, Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash 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. | | 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. | | 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. | | 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. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | | 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. 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 ## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID; - 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, "when": 1790748107566,
"tag": "0014_linear_xlsx_external_model", "tag": "0014_linear_xlsx_external_model",
"breakpoints": true "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"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto } from "../types"; import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44; const rowHeight = 44;
const viewportHeight = 520; const viewportHeight = 520;
@ -23,6 +23,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const [planningValue, setPlanningValue] = useState(""); const [planningValue, setPlanningValue] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); 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."); } } 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 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 first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10); const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null); 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() { async function applyBulk() {
if (!data || selected.size === 0) return; if (!data || selected.size === 0) return;
@ -64,10 +69,38 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
finally { setSaving(false); } 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>; 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> 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} {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="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="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 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>; </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[]; 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 { export interface ProjectDto {
id: string; id: string;
name: string; name: string;

View file

@ -44,6 +44,8 @@ import type {
ExternalInitialImportStatusDto, ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput, ApplyExternalInitialImportInput,
ExternalProjectObjectsDto, ExternalProjectObjectsDto,
ExternalReturnScopeDto,
ExternalReturnWorkbookDto,
CreateEstimateBalanceInput, CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto, EstimateBalanceCommandResultDto,
EstimateBalanceDto, 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 }) }); 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[] = []) { export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, { return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, {
method: "POST", 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 { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js"; import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.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 { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js"; import { UserRepository } from "../../db/repositories/user.repository.js";
@ -39,5 +40,6 @@ export const roomRepository = new RoomRepository(db);
export const externalModelConfigurationRepository = export const externalModelConfigurationRepository =
new ExternalModelConfigurationRepository(db); new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const externalExportBatchRepository = new ExternalExportBatchRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(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, planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js"; } from "../controllers/external-initial-import.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.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(); 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-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit); projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow); 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.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( 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 { readFileSync } from "node:fs";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; 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", () => { describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => { 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(wizard, /GZF 1/);
assert.match(objectTable, /viewportHeight/); assert.match(objectTable, /viewportHeight/);
assert.match(objectTable, /Sammeländerung anwenden/); 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 () => { 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; } 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 } } }]); 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"] } }]);
});
}); });