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

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