diff --git a/AGENTS.md b/AGENTS.md index 9418415..3dbce5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,8 +287,15 @@ Confirmed initial state is written only through original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit planning overrides and internal project links in the shared transaction. It never creates or links a CircuitDeviceRow. Its exact inverse removes the whole -unchanged external state, and Redo restores the same UUIDs and bytes. No public -apply endpoint or staging wizard invokes this command yet. +unchanged external state, and Redo restores the same UUIDs and bytes. No +dedicated file-and-decisions apply endpoint or staging wizard invokes this +command yet. +`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless +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. 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 f5ea277..45fd6cf 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -537,6 +537,15 @@ Konfigurationsstand serverseitig und verwendet den typisierten Command. Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash, Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. +`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet +denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen +noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei. +Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen +Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte, +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. 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 0388c3a..c4afaa5 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -288,7 +288,13 @@ einen Commit aufgenommen. die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` bleibt unverändert. 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard - ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. + ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Begonnen:** + Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit + exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge, + 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. 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-plan.ts b/src/external-model/application/external-initial-import-plan.ts new file mode 100644 index 0000000..bc916f8 --- /dev/null +++ b/src/external-model/application/external-initial-import-plan.ts @@ -0,0 +1,164 @@ +import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js"; +import { + createExternalRoomKey, + normalizeExternalRoomPart, + projectInitialExternalObject, +} from "../domain/external-model-matching.js"; +import { createExternalCsvPreview } from "./external-csv-preview.js"; + +interface ExistingRoom { + id: string; + floorId: string | null; + roomNumber: string; + roomName: string; +} + +interface ExistingFloor { + id: string; + name: string; + sortOrder: number; +} + +interface ExistingDistributionBoard { + id: string; + name: string; + floorId: string | null; +} + +interface ExistingProjectDevice { + id: string; + name: string; + displayName: string; + category: string | null; + connectionKind: string | null; + powerPerUnit: number; +} + +export function createExternalInitialImportPlan(input: { + fileName: string; + bytes: Uint8Array; + configurationVersion: number; + configuration: ExternalCsvConfiguration; + rooms: ExistingRoom[]; + floors: ExistingFloor[]; + distributionBoards: ExistingDistributionBoard[]; + projectDevices: ExistingProjectDevice[]; +}) { + const preview = createExternalCsvPreview(input); + const roomCandidatesByNumber = new Map(); + for (const room of input.rooms) { + const key = normalizeExternalRoomPart(room.roomNumber); + if (!key) continue; + const candidates = roomCandidatesByNumber.get(key) ?? []; + candidates.push(room); + roomCandidatesByNumber.set(key, candidates); + } + + const objects = preview.objects.map((object) => { + const projected = projectInitialExternalObject(object, input.configuration); + return { + ifcGuid: projected.ifcGuid, + sourceRoomKey: createExternalRoomKey(object.roomNumber, object.roomName), + sourceValues: projected.sourceValues, + suggestedPlanningValues: projected.planningValues, + issues: projected.issues, + }; + }); + const sourceRooms = new Map(); + for (const object of objects) { + if (object.sourceRoomKey === null) continue; + const existing = sourceRooms.get(object.sourceRoomKey); + if (existing) { + existing.objectCount += 1; + } else { + sourceRooms.set(object.sourceRoomKey, { + sourceRoomKey: object.sourceRoomKey, + roomNumber: object.sourceValues.roomNumber, + roomName: object.sourceValues.roomName, + objectCount: 1, + }); + } + } + const roomPlans = [...sourceRooms.values()] + .map((room) => { + const candidates = roomCandidatesByNumber.get( + normalizeExternalRoomPart(room.roomNumber) + ) ?? []; + return { + ...room, + suggestedRoomId: candidates.length === 1 ? candidates[0].id : null, + matchStatus: + candidates.length === 1 + ? "exact-number" + : candidates.length > 1 + ? "ambiguous-number" + : "new", + candidateRoomIds: candidates.map((candidate) => candidate.id), + } as const; + }) + .sort((left, right) => + left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) || + left.roomName.localeCompare(right.roomName, "de") + ); + + const familyGroups = new Map(); + for (const object of objects) { + const key = object.sourceValues.familyAndType; + const group = familyGroups.get(key); + if (group) { + group.objectCount += 1; + } else { + familyGroups.set(key, { + familyAndType: key, + objectCount: 1, + classified: !object.issues.includes("unknown-family-and-type"), + category: object.suggestedPlanningValues.category, + connectionKind: object.suggestedPlanningValues.connectionKind, + internalDeviceType: object.suggestedPlanningValues.internalDeviceType, + }); + } + } + + return { + configurationVersion: input.configurationVersion, + fileName: preview.fileName, + sha256: preview.sha256, + byteLength: preview.byteLength, + rowCount: preview.rowCount, + objectCount: preview.objectCount, + passthroughCount: preview.passthroughCount, + suspectObjectCount: preview.suspectObjectCount, + suspectRowNumbers: preview.suspectRowNumbers, + sourceRooms: roomPlans, + familyGroups: [...familyGroups.values()].sort((left, right) => + left.familyAndType.localeCompare(right.familyAndType, "de") + ), + issueCounts: { + unknownFamilyAndType: objects.filter((object) => + object.issues.includes("unknown-family-and-type") + ).length, + invalidPower: objects.filter((object) => object.issues.includes("invalid-power")).length, + invalidQuantity: objects.filter((object) => object.issues.includes("invalid-quantity")).length, + missingRoom: objects.filter((object) => object.issues.includes("missing-room")).length, + }, + objects, + existing: { + floors: input.floors, + rooms: input.rooms, + distributionBoards: input.distributionBoards, + projectDevices: input.projectDevices, + }, + }; +} diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index ebeaf5f..a3a9457 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -12,6 +12,7 @@ import { ProjectDeviceRepository } from "../../db/repositories/project-device.re import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js"; import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; +import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; export const circuitDeviceRowRepository = new CircuitDeviceRowRepository(db); @@ -31,3 +32,4 @@ export const projectRepository = new ProjectRepository(db); export const roomRepository = new RoomRepository(db); export const externalCsvConfigurationRepository = new ExternalCsvConfigurationRepository(db); +export const externalModelStateRepository = new ExternalModelStateRepository(db); diff --git a/src/server/controllers/external-csv.controller.ts b/src/server/controllers/external-csv.controller.ts index 592bcd0..0fd0a83 100644 --- a/src/server/controllers/external-csv.controller.ts +++ b/src/server/controllers/external-csv.controller.ts @@ -3,12 +3,21 @@ import type { Request, Response } from "express"; import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; 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 { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js"; import { previewExternalCsvSchema, + planExternalInitialImportSchema, updateExternalCsvConfigurationSchema, } from "../../shared/validation/external-csv.schemas.js"; -import { externalCsvConfigurationRepository } from "../composition/application-repositories.js"; +import { + distributionBoardRepository, + externalCsvConfigurationRepository, + externalModelStateRepository, + floorRepository, + projectDeviceRepository, + roomRepository, +} from "../composition/application-repositories.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; @@ -93,6 +102,58 @@ export function previewExternalCsv(req: Request, res: Response) { } } +export async function planExternalInitialImport(req: Request, res: Response) { + const projectId = getProjectId(req, res); + if (!projectId) return; + const parsed = planExternalInitialImportSchema.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", + }); + } + const externalState = externalModelStateRepository.getByProject(projectId); + if (externalState.state.source !== null) { + return res.status(409).json({ + error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", + code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED", + }); + } + 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), + ]); + return res.json(createExternalInitialImportPlan({ + fileName: parsed.data.fileName, + bytes, + configurationVersion: stored.configuration.configurationVersion, + configuration: stored.configuration.configuration, + floors, + rooms, + distributionBoards, + projectDevices, + })); + } catch (error) { + if (error instanceof ExternalCsvParseError) { + return res.status(400).json({ error: error.message, code: error.code }); + } + return res.status(400).json({ + error: error instanceof Error ? error.message : "Erstimport-Planung fehlgeschlagen.", + }); + } +} + 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 89909d0..133af1f 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -38,6 +38,7 @@ import { import { getExternalCsvConfiguration, previewExternalCsv, + planExternalInitialImport, updateExternalCsvConfiguration, } from "../controllers/external-csv.controller.js"; @@ -52,6 +53,10 @@ projectRouter.post("/:projectId/import", importProjectTransfer); projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration); projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration); projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv); +projectRouter.post( + "/:projectId/external-csv/initial-import/plan", + planExternalInitialImport +); 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 5650b09..7842964 100644 --- a/src/shared/validation/external-csv.schemas.ts +++ b/src/shared/validation/external-csv.schemas.ts @@ -13,3 +13,5 @@ export const previewExternalCsvSchema = z contentBase64: z.string().min(1).max(24_000_000), }) .strict(); + +export const planExternalInitialImportSchema = previewExternalCsvSchema; diff --git a/tests/external-csv-api-contract.test.ts b/tests/external-csv-api-contract.test.ts index 261f962..3b3591e 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 { + planExternalInitialImportSchema, previewExternalCsvSchema, updateExternalCsvConfigurationSchema, } from "../src/shared/validation/external-csv.schemas.js"; @@ -23,6 +24,13 @@ describe("external CSV API contracts", () => { }).success, true ); + assert.equal( + planExternalInitialImportSchema.safeParse({ + fileName: "revit.csv", + contentBase64: "YWJj", + }).success, + true + ); }); it("rejects stale-shaped, oversized and unknown request fields", () => { @@ -41,6 +49,14 @@ describe("external CSV API contracts", () => { }).success, false ); + assert.equal( + planExternalInitialImportSchema.safeParse({ + fileName: "revit.csv", + contentBase64: "YWJj", + decisions: [], + }).success, + false + ); assert.equal( previewExternalCsvSchema.safeParse({ fileName: "revit.csv", diff --git a/tests/external-initial-import-plan.test.ts b/tests/external-initial-import-plan.test.ts new file mode 100644 index 0000000..32c70dc --- /dev/null +++ b/tests/external-initial-import-plan.test.ts @@ -0,0 +1,69 @@ +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 { + externalCsvTestConfiguration, + firstIfcGuid, + quotedRevitCsv, + utf8Bytes, +} from "./fixtures/revit-csv-fixtures.js"; + +describe("external initial import plan", () => { + it("groups source rooms and exposes exact room and classification suggestions", () => { + const configuration = structuredClone(externalCsvTestConfiguration); + configuration.familyTypeRules.push({ + exactFamilyAndType: "Steckdose: Doppelsteckdose", + internalDeviceType: "Steckdose", + connectionKind: "socket", + category: "single_phase", + quantityRule: { kind: "mapped-column" }, + displayNameSuggestion: { kind: "selection-marker" }, + }); + + const plan = createExternalInitialImportPlan({ + fileName: "revit.csv", + bytes: utf8Bytes(quotedRevitCsv, true), + configurationVersion: 4, + configuration, + floors: [{ id: "floor-1", name: "EG", sortOrder: 10 }], + rooms: [{ + id: "room-1", + floorId: "floor-1", + roomNumber: "01/101", + roomName: "Technik Bestand", + }], + distributionBoards: [{ id: "board-1", name: "UV 1", floorId: "floor-1" }], + projectDevices: [{ + id: "device-1", + name: "socket", + displayName: "Steckdose", + category: "single_phase", + connectionKind: "socket", + powerPerUnit: 0.12, + }], + }); + + assert.equal(plan.configurationVersion, 4); + assert.equal(plan.objectCount, 2); + assert.equal(plan.sourceRooms.length, 2); + assert.deepEqual( + plan.sourceRooms.map((room) => ({ + number: room.roomNumber, + status: room.matchStatus, + suggested: room.suggestedRoomId, + })), + [ + { number: "01/101", status: "exact-number", suggested: "room-1" }, + { number: "01/103", status: "new", suggested: null }, + ] + ); + assert.equal(plan.familyGroups.length, 2); + assert.equal(plan.issueCounts.unknownFamilyAndType, 1); + assert.equal(plan.issueCounts.invalidPower, 0); + assert.equal(plan.issueCounts.invalidQuantity, 0); + assert.equal(plan.objects[0].ifcGuid, firstIfcGuid); + assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2); + assert.equal(plan.existing.distributionBoards[0].id, "board-1"); + assert.equal(plan.existing.projectDevices[0].id, "device-1"); + }); +});