From 8646f346e624b6ce7723ec5ac04711ea18feddd4 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 17:42:29 +0200 Subject: [PATCH] Add Revit initial import apply API --- AGENTS.md | 11 +- docs/current-architecture.md | 9 ++ .../spec/revit-csv-phase-14-audit-and-plan.md | 8 +- .../external-initial-import-target.ts | 130 ++++++++++++++++++ .../controllers/external-csv.controller.ts | 97 +++++++++++++ src/server/routes/project.routes.ts | 5 + src/shared/validation/external-csv.schemas.ts | 18 +++ tests/external-csv-api-contract.test.ts | 14 ++ tests/external-initial-import-plan.test.ts | 80 +++++++++++ 9 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 src/external-model/application/external-initial-import-target.ts diff --git a/AGENTS.md b/AGENTS.md index 3dbce5b..b892a9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, exact family/type groups, projected object values, issue counts and the current 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 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 45fd6cf..fd94776 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -546,6 +546,15 @@ liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge. Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben 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, Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index c4afaa5..645c42d 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -294,7 +294,13 @@ einen Commit aufgenommen. Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und 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 statt serverseitiger Entwürfe, die Konfigurationsversionierung und die diff --git a/src/external-model/application/external-initial-import-target.ts b/src/external-model/application/external-initial-import-target.ts new file mode 100644 index 0000000..be0925e --- /dev/null +++ b/src/external-model/application/external-initial-import-target.ts @@ -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; + +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( + expectedKeys: string[], + decisions: T[], + keyOf: (decision: T) => string, + label: string +) { + const expected = new Set(expectedKeys); + const result = new Map(); + 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; +} diff --git a/src/server/controllers/external-csv.controller.ts b/src/server/controllers/external-csv.controller.ts index 0fd0a83..986d7de 100644 --- a/src/server/controllers/external-csv.controller.ts +++ b/src/server/controllers/external-csv.controller.ts @@ -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."); diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 133af1f..2ac20a5 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -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); diff --git a/src/shared/validation/external-csv.schemas.ts b/src/shared/validation/external-csv.schemas.ts index 7842964..60f9b5a 100644 --- a/src/shared/validation/external-csv.schemas.ts +++ b/src/shared/validation/external-csv.schemas.ts @@ -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(); diff --git a/tests/external-csv-api-contract.test.ts b/tests/external-csv-api-contract.test.ts index 3b3591e..2555e8a 100644 --- a/tests/external-csv-api-contract.test.ts +++ b/tests/external-csv-api-contract.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + applyExternalInitialImportSchema, planExternalInitialImportSchema, previewExternalCsvSchema, updateExternalCsvConfigurationSchema, @@ -31,6 +32,19 @@ describe("external CSV API contracts", () => { }).success, 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", () => { diff --git a/tests/external-initial-import-plan.test.ts b/tests/external-initial-import-plan.test.ts index 32c70dc..7fc1a43 100644 --- a/tests/external-initial-import-plan.test.ts +++ b/tests/external-initial-import-plan.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; 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 { externalCsvTestConfiguration, firstIfcGuid, @@ -66,4 +68,82 @@ describe("external initial import plan", () => { assert.equal(plan.existing.distributionBoards[0].id, "board-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/ + ); + }); });