Add Revit initial import apply API

This commit is contained in:
Julian Appel 2026-08-02 17:42:29 +02:00
parent c9e5fdd876
commit 8646f346e6
9 changed files with 370 additions and 2 deletions

View file

@ -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;
}

View file

@ -4,10 +4,17 @@ import { assertExternalCsvConfiguration } from "../../external-model/csv/externa
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.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 {
previewExternalCsvSchema,
planExternalInitialImportSchema,
applyExternalInitialImportSchema,
updateExternalCsvConfigurationSchema,
} from "../../shared/validation/external-csv.schemas.js";
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) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");

View file

@ -37,6 +37,7 @@ import {
} from "../controllers/project-transfer.controller.js";
import {
getExternalCsvConfiguration,
applyExternalInitialImport,
previewExternalCsv,
planExternalInitialImport,
updateExternalCsvConfiguration,
@ -57,6 +58,10 @@ projectRouter.post(
"/:projectId/external-csv/initial-import/plan",
planExternalInitialImport
);
projectRouter.post(
"/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport
);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);

View file

@ -15,3 +15,21 @@ export const previewExternalCsvSchema = z
.strict();
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();