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

View file

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

View file

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