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
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
+6
View File
@@ -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
+7
View File
@@ -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.
@@ -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.
@@ -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[];
}
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;
+10
View File
@@ -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<ExternalCircuitListObjectsDto>(
`/api/projects/${projectId}/circuit-lists/${circuitListId}/external-objects`
);
}
export function updateExternalCsvConfiguration(
projectId: string,
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 { 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;
+5
View File
@@ -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
+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 {
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",