Add Revit initial import apply API

This commit is contained in:
2026-08-02 17:42:29 +02:00
parent c9e5fdd876
commit 8646f346e6
9 changed files with 370 additions and 2 deletions
+10 -1
View File
@@ -295,7 +295,16 @@ read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions, external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
domain row; the decision UI and public apply endpoint are still pending. domain row; the decision UI is still pending.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
version and project revision. It requires one explicit decision per source-room
and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The
decision UI is still pending.
The central revision boundary creates an automatic logical snapshot after each The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per 25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy. project. Named snapshots are never removed by this retention policy.
+9
View File
@@ -546,6 +546,15 @@ liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die
vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge. vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge.
Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben
sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt. sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt.
`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei,
erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen.
Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen
Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional
einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende
Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt
stabile interne UUIDs und übergibt den vollständigen Zustand an
`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte
und alle `circuitDeviceRowId` bleiben `null`.
Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt, Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
@@ -294,7 +294,13 @@ einen Commit aufgenommen.
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Entscheidungs-UI und Apply- schreibt weder Entwurf noch Projektzustand. Entscheidungs-UI und Apply-
Endpunkt stehen noch aus. Endpunkt stehen noch aus. **Backend-Apply erledigt:** Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
ausgeschlossen. Die Entscheidungs-UI steht noch aus.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
@@ -0,0 +1,130 @@
import type { ExternalCsvConfiguration, ExternalCsvDocument } from "../csv/external-csv-contracts.js";
import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { createExternalInitialImportPlan } from "./external-initial-import-plan.js";
type ExternalInitialImportPlan = ReturnType<typeof createExternalInitialImportPlan>;
export interface ExternalRoomDecision {
sourceRoomKey: string;
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export interface ExternalFamilyProjectDeviceDecision {
familyAndType: string;
projectDeviceId: string | null;
}
export function buildExternalInitialImportTarget(input: {
projectId: string;
expectedRevision: number;
sourceName: string;
importedAtIso: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
plan: ExternalInitialImportPlan;
roomDecisions: ExternalRoomDecision[];
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
createId: () => string;
}): ExternalModelStateSnapshot {
if (input.plan.familyGroups.some((group) => !group.classified)) {
throw new Error("Alle Familie-und-Typ-Werte müssen vor dem Erstimport klassifiziert sein.");
}
const roomDecisionByKey = exactDecisionMap(
input.plan.sourceRooms.map((room) => room.sourceRoomKey),
input.roomDecisions,
(decision) => decision.sourceRoomKey,
"Quellraum"
);
const familyDecisionByName = exactDecisionMap(
input.plan.familyGroups.map((group) => group.familyAndType),
input.familyProjectDeviceDecisions,
(decision) => decision.familyAndType,
"Familie und Typ"
);
const sourceId = input.createId();
const batchId = input.createId();
const roomMappingIdByKey = new Map(
input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()])
);
return {
source: {
id: sourceId,
projectId: input.projectId,
name: input.sourceName.trim(),
sourceType: "revit_csv",
},
importBatches: [{
id: batchId,
projectId: input.projectId,
sourceId,
importKind: "initial",
importedAtIso: input.importedAtIso,
fileName: input.plan.fileName,
sha256: input.plan.sha256,
appliedProjectRevision: input.expectedRevision + 1,
configurationVersion: input.configurationVersion,
configurationSnapshot: input.configuration,
originalContentBase64: input.originalContentBase64,
document: input.document,
}],
roomMappings: input.plan.sourceRooms.map((room) => {
const decision = roomDecisionByKey.get(room.sourceRoomKey)!;
return {
id: roomMappingIdByKey.get(room.sourceRoomKey)!,
projectId: input.projectId,
sourceId,
normalizedSourceRoomKey: room.sourceRoomKey,
sourceFloorName: null,
sourceRoomNumber: room.roomNumber,
sourceRoomName: room.roomName,
roomId: decision.roomId,
defaultDistributionBoardId: decision.defaultDistributionBoardId,
};
}),
objects: input.plan.objects.map((object) => ({
id: input.createId(),
projectId: input.projectId,
sourceId,
ifcGuid: object.ifcGuid,
lastSeenImportBatchId: batchId,
lastAcceptedImportBatchId: batchId,
acceptedSourceValues: object.sourceValues,
planningValues: object.suggestedPlanningValues,
overriddenFields: [],
externalRoomMappingId:
object.sourceRoomKey === null
? null
: roomMappingIdByKey.get(object.sourceRoomKey)!,
distributionBoardId: null,
linkedProjectDeviceId:
familyDecisionByName.get(object.sourceValues.familyAndType)!
.projectDeviceId,
circuitDeviceRowId: null,
presenceStatus: "present",
})),
};
}
function exactDecisionMap<T>(
expectedKeys: string[],
decisions: T[],
keyOf: (decision: T) => string,
label: string
) {
const expected = new Set(expectedKeys);
const result = new Map<string, T>();
for (const decision of decisions) {
const key = keyOf(decision);
if (!expected.has(key) || result.has(key)) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
result.set(key, decision);
}
if (result.size !== expected.size) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
return result;
}
@@ -4,10 +4,17 @@ import { assertExternalCsvConfiguration } from "../../external-model/csv/externa
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js"; import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js"; import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js"; import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../../external-model/application/external-initial-import-target.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import {
createEmptyExternalModelState,
createExternalInitialImportProjectCommand,
} from "../../domain/models/external-initial-import-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js"; import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
import { import {
previewExternalCsvSchema, previewExternalCsvSchema,
planExternalInitialImportSchema, planExternalInitialImportSchema,
applyExternalInitialImportSchema,
updateExternalCsvConfigurationSchema, updateExternalCsvConfigurationSchema,
} from "../../shared/validation/external-csv.schemas.js"; } from "../../shared/validation/external-csv.schemas.js";
import { import {
@@ -154,6 +161,96 @@ export async function planExternalInitialImport(req: Request, res: Response) {
} }
} }
export async function applyExternalInitialImport(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = applyExternalInitialImportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
if (
stored.configuration.configurationVersion !==
parsed.data.expectedConfigurationVersion
) {
return res.status(409).json({
error: "Die Revit-CSV-Konfiguration wurde seit der Planung geändert.",
code: "EXTERNAL_CSV_CONFIGURATION_CHANGED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
floorRepository.listByProject(projectId),
roomRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
projectDeviceRepository.listByProject(projectId),
]);
const plan = createExternalInitialImportPlan({
fileName: parsed.data.fileName,
bytes,
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
floors,
rooms,
distributionBoards,
projectDevices,
});
if (plan.sha256 !== parsed.data.expectedSha256) {
return res.status(409).json({
error: "Die CSV-Datei stimmt nicht mehr mit der geprüften Planung überein.",
code: "EXTERNAL_CSV_FILE_CHANGED",
});
}
const target = buildExternalInitialImportTarget({
projectId,
expectedRevision: parsed.data.expectedRevision,
sourceName: parsed.data.sourceName,
importedAtIso: new Date().toISOString(),
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
originalContentBase64: parsed.data.contentBase64,
document: parseExternalCsv(bytes, stored.configuration.configuration),
plan,
roomDecisions: parsed.data.roomDecisions,
familyProjectDeviceDecisions:
parsed.data.familyProjectDeviceDecisions,
createId: () => crypto.randomUUID(),
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: `Revit-Erstimport ${parsed.data.fileName}`,
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
});
return res.status(201).json({
...result,
import: {
sourceId: target.source!.id,
importBatchId: target.importBatches[0].id,
objectCount: target.objects.length,
},
});
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return respondWithProjectCommandError(error, res);
}
}
function decodeBase64(value: string) { function decodeBase64(value: string) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) { if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert."); throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
+5
View File
@@ -37,6 +37,7 @@ import {
} from "../controllers/project-transfer.controller.js"; } from "../controllers/project-transfer.controller.js";
import { import {
getExternalCsvConfiguration, getExternalCsvConfiguration,
applyExternalInitialImport,
previewExternalCsv, previewExternalCsv,
planExternalInitialImport, planExternalInitialImport,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
@@ -57,6 +58,10 @@ projectRouter.post(
"/:projectId/external-csv/initial-import/plan", "/:projectId/external-csv/initial-import/plan",
planExternalInitialImport planExternalInitialImport
); );
projectRouter.post(
"/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport
);
projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/commands", executeProjectCommand);
@@ -15,3 +15,21 @@ export const previewExternalCsvSchema = z
.strict(); .strict();
export const planExternalInitialImportSchema = previewExternalCsvSchema; export const planExternalInitialImportSchema = previewExternalCsvSchema;
export const applyExternalInitialImportSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(24_000_000),
sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1),
roomId: z.string().trim().min(1).nullable(),
defaultDistributionBoardId: z.string().trim().min(1).nullable(),
}).strict()),
familyProjectDeviceDecisions: z.array(z.object({
familyAndType: z.string(),
projectDeviceId: z.string().trim().min(1).nullable(),
}).strict()),
}).strict();
+14
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
applyExternalInitialImportSchema,
planExternalInitialImportSchema, planExternalInitialImportSchema,
previewExternalCsvSchema, previewExternalCsvSchema,
updateExternalCsvConfigurationSchema, updateExternalCsvConfigurationSchema,
@@ -31,6 +32,19 @@ describe("external CSV API contracts", () => {
}).success, }).success,
true true
); );
assert.equal(
applyExternalInitialImportSchema.safeParse({
expectedRevision: 5,
expectedConfigurationVersion: 2,
expectedSha256: "a".repeat(64),
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [],
familyProjectDeviceDecisions: [],
}).success,
true
);
}); });
it("rejects stale-shaped, oversized and unknown request fields", () => { it("rejects stale-shaped, oversized and unknown request fields", () => {
@@ -1,6 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js"; import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../src/external-model/application/external-initial-import-target.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import { import {
externalCsvTestConfiguration, externalCsvTestConfiguration,
firstIfcGuid, firstIfcGuid,
@@ -66,4 +68,82 @@ describe("external initial import plan", () => {
assert.equal(plan.existing.distributionBoards[0].id, "board-1"); assert.equal(plan.existing.distributionBoards[0].id, "board-1");
assert.equal(plan.existing.projectDevices[0].id, "device-1"); assert.equal(plan.existing.projectDevices[0].id, "device-1");
}); });
it("builds a stable row-free target only after every family is classified", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
for (const exactFamilyAndType of [
"Steckdose: Doppelsteckdose",
"Steckdose: Standard",
]) {
configuration.familyTypeRules.push({
exactFamilyAndType,
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: exactFamilyAndType.includes("Doppel")
? { kind: "mapped-column" }
: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
}
const bytes = utf8Bytes(quotedRevitCsv, true);
const plan = createExternalInitialImportPlan({
fileName: "revit.csv",
bytes,
configurationVersion: 2,
configuration,
floors: [],
rooms: [],
distributionBoards: [],
projectDevices: [],
});
let id = 0;
const target = buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: plan.sourceRooms.map((room) => ({
sourceRoomKey: room.sourceRoomKey,
roomId: null,
defaultDistributionBoardId: null,
})),
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => `generated-${++id}`,
});
assert.equal(target.importBatches[0].appliedProjectRevision, 8);
assert.equal(target.importBatches[0].configurationVersion, 2);
assert.equal(target.objects.length, 2);
assert.ok(target.objects.every((object) => object.circuitDeviceRowId === null));
assert.ok(target.objects.every((object) => object.overriddenFields.length === 0));
assert.throws(
() => buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: [],
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => "unused",
}),
/Quellraum-Entscheidungen/
);
});
}); });