Add Revit circuit list object feed

This commit is contained in:
2026-08-02 18:59:01 +02:00
parent a69b7b603f
commit 74bea1a3c9
11 changed files with 287 additions and 0 deletions
+5
View File
@@ -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 complete unchanged circuit snapshot and exact link set. Redo never recalculates
the BMK. All three external assignment stores share the BMK. All three external assignment stores share
`external-object-assignment.persistence.ts` for compatibility and link safety. `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 Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version, `external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
+6
View File
@@ -414,6 +414,12 @@ project device never triggers synchronization implicitly.
- reparses the file, blocks unknown family/type values and executes - reparses the file, blocks unknown family/type values and executes
`external-import.apply-initial` as one undoable project revision `external-import.apply-initial` as one undoable project revision
- creates no Circuit or CircuitDeviceRow - 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`, Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
+7
View File
@@ -570,6 +570,13 @@ dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
Die drei Zuordnungsadapter teilen sich Die drei Zuordnungsadapter teilen sich
`external-object-assignment.persistence.ts` für Snapshotvergleich, `external-object-assignment.persistence.ts` für Snapshotvergleich,
Kompatibilitätsregeln und link-sichere Updates. 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 `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -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 BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen. Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. 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 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen. testen.
@@ -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);
}
+26
View File
@@ -428,6 +428,32 @@ export interface CircuitTreeResponseDto {
footerComponents: CircuitTreeComponentDto[]; 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 { export interface CreateCircuitInputDto {
sectionId: string; sectionId: string;
equipmentIdentifier: string; equipmentIdentifier: string;
+10
View File
@@ -23,6 +23,7 @@ import type {
ProjectRoomCommandResultDto, ProjectRoomCommandResultDto,
ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ExternalCircuitListObjectsDto,
ProjectDto, ProjectDto,
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
@@ -346,6 +347,15 @@ export function getExternalCsvConfiguration(projectId: string) {
); );
} }
export function getExternalCircuitListObjects(
projectId: string,
circuitListId: string
) {
return request<ExternalCircuitListObjectsDto>(
`/api/projects/${projectId}/circuit-lists/${circuitListId}/external-objects`
);
}
export function updateExternalCsvConfiguration( export function updateExternalCsvConfiguration(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
@@ -5,6 +5,7 @@ import { ExternalCsvParseError } from "../../external-model/csv/external-csv-tra
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 { 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 { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import { import {
createEmptyExternalModelState, createEmptyExternalModelState,
@@ -24,6 +25,7 @@ import {
floorRepository, floorRepository,
projectDeviceRepository, projectDeviceRepository,
roomRepository, roomRepository,
circuitListRepository,
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.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 }); 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) { export function updateExternalCsvConfiguration(req: Request, res: Response) {
const projectId = getProjectId(req, res); const projectId = getProjectId(req, res);
if (!projectId) return; if (!projectId) return;
+5
View File
@@ -41,6 +41,7 @@ import {
previewExternalCsv, previewExternalCsv,
planExternalInitialImport, planExternalInitialImport,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
getExternalCircuitListObjects,
} from "../controllers/external-csv.controller.js"; } from "../controllers/external-csv.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@@ -58,6 +59,10 @@ projectRouter.post(
"/:projectId/external-csv/initial-import/plan", "/:projectId/external-csv/initial-import/plan",
planExternalInitialImport planExternalInitialImport
); );
projectRouter.get(
"/:projectId/circuit-lists/:circuitListId/external-objects",
getExternalCircuitListObjects
);
projectRouter.post( projectRouter.post(
"/:projectId/external-csv/initial-import/apply", "/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport applyExternalInitialImport
+105
View File
@@ -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: [] }
);
});
});
+7
View File
@@ -12,6 +12,7 @@ import { RoomModal } from "../src/frontend/components/room-modal.js";
import { import {
applyExternalInitialImport, applyExternalInitialImport,
getExternalCsvConfiguration, getExternalCsvConfiguration,
getExternalCircuitListObjects,
planExternalInitialImport, planExternalInitialImport,
previewExternalCsv, previewExternalCsv,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
@@ -39,6 +40,7 @@ describe("Revit CSV frontend API", () => {
try { try {
await getExternalCsvConfiguration("project-1"); await getExternalCsvConfiguration("project-1");
await getExternalCircuitListObjects("project-1", "list-1");
await updateExternalCsvConfiguration("project-1", 7, configuration); await updateExternalCsvConfiguration("project-1", 7, configuration);
await previewExternalCsv("project-1", "revit.csv", "YWJj"); await previewExternalCsv("project-1", "revit.csv", "YWJj");
await planExternalInitialImport("project-1", "revit.csv", "YWJj"); await planExternalInitialImport("project-1", "revit.csv", "YWJj");
@@ -62,6 +64,11 @@ describe("Revit CSV frontend API", () => {
method: "GET", method: "GET",
body: null, 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", url: "/api/projects/project-1/external-csv/configuration",
method: "PUT", method: "PUT",