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

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