From 74bea1a3c9550a53e7c92ea471e9fedbbf4adc7c Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 18:59:01 +0200 Subject: [PATCH] Add Revit circuit list object feed --- AGENTS.md | 5 + docs/circuit-list-editor-api.md | 6 + docs/current-architecture.md | 7 ++ .../spec/revit-csv-phase-14-audit-and-plan.md | 5 + .../external-circuit-list-objects.ts | 88 +++++++++++++++ src/frontend/types.ts | 26 +++++ src/frontend/utils/api.ts | 10 ++ .../controllers/external-csv.controller.ts | 23 ++++ src/server/routes/project.routes.ts | 5 + tests/external-circuit-list-objects.test.ts | 105 ++++++++++++++++++ tests/revit-csv-frontend-api.test.ts | 7 ++ 11 files changed, 287 insertions(+) create mode 100644 src/external-model/application/external-circuit-list-objects.ts create mode 100644 tests/external-circuit-list-objects.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0b7077d..f1a509d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,6 +305,11 @@ protection and its external object links. Its history-only inverse requires the complete unchanged circuit snapshot and exact link set. Redo never recalculates the BMK. All three external assignment stores share `external-object-assignment.persistence.ts` for compatibility and link safety. +The read-only +`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` +projection resolves the board through the owned CircuitList and returns only +that board's compact external objects, unassigned first. It exposes no original +CSV bytes or command payloads and returns an empty list before optional import. Confirmed initial state is written only through `external-import.apply-initial`. The command rechecks configuration version, original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 70b5bb5..4ed696b 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -414,6 +414,12 @@ project device never triggers synchronization implicitly. - reparses the file, blocks unknown family/type values and executes `external-import.apply-initial` as one undoable project revision - creates no Circuit or CircuitDeviceRow +- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects` + - resolves the circuit list's distribution board server-side + - returns only external objects assigned to that board, with unassigned + objects first, their source room/marker values and current row-link status + - returns an empty list when the optional Revit import does not exist + - is read-only and exposes no stored command payloads or import bytes Parser errors expose stable codes such as `header-not-found`, `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed diff --git a/docs/current-architecture.md b/docs/current-architecture.md index a0282de..32f6814 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -570,6 +570,13 @@ dieselben IDs, dasselbe BMK und denselben Schutzsnapshot. Die drei Zuordnungsadapter teilen sich `external-object-assignment.persistence.ts` für Snapshotvergleich, Kompatibilitätsregeln und link-sichere Updates. +`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` +liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer. +Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur +dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte +Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige +Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen +Revit-Import ist die Projektion leer. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. 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 b721c3a..be50eb1 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -338,6 +338,11 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt. BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. + **Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene + GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers, + unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker, + Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden; + Drawer, Filter und Mengenwirkung folgen. 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload testen. diff --git a/src/external-model/application/external-circuit-list-objects.ts b/src/external-model/application/external-circuit-list-objects.ts new file mode 100644 index 0000000..43f57c5 --- /dev/null +++ b/src/external-model/application/external-circuit-list-objects.ts @@ -0,0 +1,88 @@ +import type { + ExternalModelStateSnapshot, +} from "../domain/external-model-contracts.js"; + +export interface ExternalCircuitListObjectDto { + id: string; + ifcGuid: string; + selectionMarker: string; + familyAndType: string; + sourceCircuitIdentifier: string; + displayName: string | null; + category: string | null; + connectionKind: string | null; + effectiveQuantity: number; + powerPerUnitW: number | null; + sourceRoomNumber: string; + sourceRoomName: string; + roomId: string | null; + linkedProjectDeviceId: string | null; + circuitDeviceRowId: string | null; + presenceStatus: "present" | "missing"; +} + +export interface ExternalCircuitListObjectsDto { + sourceName: string | null; + objectCount: number; + unassignedObjectCount: number; + objects: ExternalCircuitListObjectDto[]; +} + +export function createExternalCircuitListObjects(input: { + state: ExternalModelStateSnapshot; + distributionBoardId: string; +}): ExternalCircuitListObjectsDto { + const mappingById = new Map( + input.state.roomMappings.map((mapping) => [mapping.id, mapping]) + ); + const objects = input.state.objects + .filter((object) => object.distributionBoardId === input.distributionBoardId) + .map((object) => { + const mapping = object.externalRoomMappingId === null + ? null + : mappingById.get(object.externalRoomMappingId) ?? null; + return { + id: object.id, + ifcGuid: object.ifcGuid, + selectionMarker: object.acceptedSourceValues.selectionMarker, + familyAndType: object.acceptedSourceValues.familyAndType, + sourceCircuitIdentifier: object.acceptedSourceValues.circuitIdentifier, + displayName: object.planningValues.displayName, + category: object.planningValues.category, + connectionKind: object.planningValues.connectionKind, + effectiveQuantity: object.planningValues.effectiveQuantity, + powerPerUnitW: object.planningValues.powerPerUnitW, + sourceRoomNumber: + mapping?.sourceRoomNumber ?? object.acceptedSourceValues.roomNumber, + sourceRoomName: + mapping?.sourceRoomName ?? object.acceptedSourceValues.roomName, + roomId: mapping?.roomId ?? null, + linkedProjectDeviceId: object.linkedProjectDeviceId, + circuitDeviceRowId: object.circuitDeviceRowId, + presenceStatus: object.presenceStatus, + } satisfies ExternalCircuitListObjectDto; + }) + .sort(compareExternalObjects); + return { + sourceName: input.state.source?.name ?? null, + objectCount: objects.length, + unassignedObjectCount: objects.filter( + (object) => object.circuitDeviceRowId === null + ).length, + objects, + }; +} + +function compareExternalObjects( + left: ExternalCircuitListObjectDto, + right: ExternalCircuitListObjectDto +) { + const assignmentOrder = Number(left.circuitDeviceRowId !== null) - + Number(right.circuitDeviceRowId !== null); + if (assignmentOrder !== 0) return assignmentOrder; + return left.sourceRoomNumber.localeCompare(right.sourceRoomNumber, "de", { + numeric: true, + }) || left.selectionMarker.localeCompare(right.selectionMarker, "de", { + numeric: true, + }) || left.ifcGuid.localeCompare(right.ifcGuid); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 6effc53..2bce5d9 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -428,6 +428,32 @@ export interface CircuitTreeResponseDto { footerComponents: CircuitTreeComponentDto[]; } +export interface ExternalCircuitListObjectDto { + id: string; + ifcGuid: string; + selectionMarker: string; + familyAndType: string; + sourceCircuitIdentifier: string; + displayName: string | null; + category: string | null; + connectionKind: string | null; + effectiveQuantity: number; + powerPerUnitW: number | null; + sourceRoomNumber: string; + sourceRoomName: string; + roomId: string | null; + linkedProjectDeviceId: string | null; + circuitDeviceRowId: string | null; + presenceStatus: "present" | "missing"; +} + +export interface ExternalCircuitListObjectsDto { + sourceName: string | null; + objectCount: number; + unassignedObjectCount: number; + objects: ExternalCircuitListObjectDto[]; +} + export interface CreateCircuitInputDto { sectionId: string; equipmentIdentifier: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index fcc8e94..34af668 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -23,6 +23,7 @@ import type { ProjectRoomCommandResultDto, ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncPreviewDto, + ExternalCircuitListObjectsDto, ProjectDto, RoomDto, CircuitTreeResponseDto, @@ -346,6 +347,15 @@ export function getExternalCsvConfiguration(projectId: string) { ); } +export function getExternalCircuitListObjects( + projectId: string, + circuitListId: string +) { + return request( + `/api/projects/${projectId}/circuit-lists/${circuitListId}/external-objects` + ); +} + export function updateExternalCsvConfiguration( projectId: string, expectedRevision: number, diff --git a/src/server/controllers/external-csv.controller.ts b/src/server/controllers/external-csv.controller.ts index 986d7de..4862d9b 100644 --- a/src/server/controllers/external-csv.controller.ts +++ b/src/server/controllers/external-csv.controller.ts @@ -5,6 +5,7 @@ import { ExternalCsvParseError } from "../../external-model/csv/external-csv-tra 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 { createExternalCircuitListObjects } from "../../external-model/application/external-circuit-list-objects.js"; import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js"; import { createEmptyExternalModelState, @@ -24,6 +25,7 @@ import { floorRepository, projectDeviceRepository, roomRepository, + circuitListRepository, } from "../composition/application-repositories.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { respondWithProjectCommandError } from "./project-command.controller.js"; @@ -38,6 +40,27 @@ export function getExternalCsvConfiguration(req: Request, res: Response) { return res.json({ configuration: result.configuration }); } +export async function getExternalCircuitListObjects(req: Request, res: Response) { + const projectId = getProjectId(req, res); + if (!projectId) return; + const { circuitListId } = req.params; + if (typeof circuitListId !== "string" || !circuitListId.trim()) { + return res.status(400).json({ error: "Invalid circuitListId" }); + } + const circuitList = await circuitListRepository.findById(projectId, circuitListId); + if (!circuitList) { + return res.status(404).json({ error: "Circuit list not found" }); + } + const state = externalModelStateRepository.getByProject(projectId); + if (!state.projectExists) { + return res.status(404).json({ error: "Project not found" }); + } + return res.json(createExternalCircuitListObjects({ + state: state.state, + distributionBoardId: circuitList.distributionBoardId, + })); +} + export function updateExternalCsvConfiguration(req: Request, res: Response) { const projectId = getProjectId(req, res); if (!projectId) return; diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 2ac20a5..604417e 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -41,6 +41,7 @@ import { previewExternalCsv, planExternalInitialImport, updateExternalCsvConfiguration, + getExternalCircuitListObjects, } from "../controllers/external-csv.controller.js"; export const projectRouter = Router(); @@ -58,6 +59,10 @@ projectRouter.post( "/:projectId/external-csv/initial-import/plan", planExternalInitialImport ); +projectRouter.get( + "/:projectId/circuit-lists/:circuitListId/external-objects", + getExternalCircuitListObjects +); projectRouter.post( "/:projectId/external-csv/initial-import/apply", applyExternalInitialImport diff --git a/tests/external-circuit-list-objects.test.ts b/tests/external-circuit-list-objects.test.ts new file mode 100644 index 0000000..4552d58 --- /dev/null +++ b/tests/external-circuit-list-objects.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createExternalCircuitListObjects } from "../src/external-model/application/external-circuit-list-objects.js"; +import type { + ExternalModelObjectSnapshot, + ExternalModelStateSnapshot, +} from "../src/external-model/domain/external-model-contracts.js"; + +function object(input: { + id: string; + distributionBoardId: string; + circuitDeviceRowId: string | null; + roomNumber: string; +}): ExternalModelObjectSnapshot { + return { + id: input.id, + projectId: "project-1", + sourceId: "source-1", + ifcGuid: `ifc-${input.id}`, + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 2, + roomNumber: input.roomNumber, + roomName: "Büro", + familyAndType: "Steckdose: Standard", + selectionMarker: `S-${input.id}`, + circuitIdentifier: "-2F1.1", + power: "120", + quantity: "1", + additionalSourceValues: {}, + }, + planningValues: { + displayName: "Steckdose", + internalDeviceType: "socket", + category: "single_phase", + connectionKind: "socket", + effectiveQuantity: 1, + powerPerUnitW: 120, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + overriddenFields: [], + externalRoomMappingId: input.distributionBoardId === "board-1" ? "mapping-1" : null, + distributionBoardId: input.distributionBoardId, + linkedProjectDeviceId: null, + circuitDeviceRowId: input.circuitDeviceRowId, + presenceStatus: "present", + }; +} + +describe("external circuit-list object projection", () => { + it("returns only the board objects with unassigned objects first", () => { + const state: ExternalModelStateSnapshot = { + source: { + id: "source-1", + projectId: "project-1", + name: "Revit-Gesamtmodell", + sourceType: "revit_csv", + }, + importBatches: [], + roomMappings: [{ + id: "mapping-1", + projectId: "project-1", + sourceId: "source-1", + normalizedSourceRoomKey: "number:101", + sourceFloorName: "EG", + sourceRoomNumber: "101", + sourceRoomName: "Besprechung", + roomId: "room-1", + defaultDistributionBoardId: "board-1", + }], + objects: [ + object({ id: "assigned", distributionBoardId: "board-1", circuitDeviceRowId: "row-1", roomNumber: "102" }), + object({ id: "unassigned", distributionBoardId: "board-1", circuitDeviceRowId: null, roomNumber: "101" }), + object({ id: "other-board", distributionBoardId: "board-2", circuitDeviceRowId: null, roomNumber: "001" }), + ], + }; + + const result = createExternalCircuitListObjects({ + state, + distributionBoardId: "board-1", + }); + + assert.equal(result.sourceName, "Revit-Gesamtmodell"); + assert.equal(result.objectCount, 2); + assert.equal(result.unassignedObjectCount, 1); + assert.deepEqual(result.objects.map(({ id }) => id), ["unassigned", "assigned"]); + assert.equal(result.objects[0].sourceRoomName, "Besprechung"); + assert.equal(result.objects[0].roomId, "room-1"); + assert.equal(result.objects[0].sourceCircuitIdentifier, "-2F1.1"); + }); + + it("returns an empty projection before the optional Revit import", () => { + assert.deepEqual( + createExternalCircuitListObjects({ + state: { source: null, importBatches: [], roomMappings: [], objects: [] }, + distributionBoardId: "board-1", + }), + { sourceName: null, objectCount: 0, unassignedObjectCount: 0, objects: [] } + ); + }); +}); diff --git a/tests/revit-csv-frontend-api.test.ts b/tests/revit-csv-frontend-api.test.ts index d3695e5..3603aa7 100644 --- a/tests/revit-csv-frontend-api.test.ts +++ b/tests/revit-csv-frontend-api.test.ts @@ -12,6 +12,7 @@ import { RoomModal } from "../src/frontend/components/room-modal.js"; import { applyExternalInitialImport, getExternalCsvConfiguration, + getExternalCircuitListObjects, planExternalInitialImport, previewExternalCsv, updateExternalCsvConfiguration, @@ -39,6 +40,7 @@ describe("Revit CSV frontend API", () => { try { await getExternalCsvConfiguration("project-1"); + await getExternalCircuitListObjects("project-1", "list-1"); await updateExternalCsvConfiguration("project-1", 7, configuration); await previewExternalCsv("project-1", "revit.csv", "YWJj"); await planExternalInitialImport("project-1", "revit.csv", "YWJj"); @@ -62,6 +64,11 @@ describe("Revit CSV frontend API", () => { method: "GET", body: null, }, + { + url: "/api/projects/project-1/circuit-lists/list-1/external-objects", + method: "GET", + body: null, + }, { url: "/api/projects/project-1/external-csv/configuration", method: "PUT",