Add Revit initial import planning

This commit is contained in:
Julian Appel 2026-08-02 17:38:17 +02:00
parent c634570018
commit c9e5fdd876
10 changed files with 345 additions and 4 deletions

View file

@ -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<string, ExistingRoom[]>();
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<string, {
sourceRoomKey: string;
roomNumber: string;
roomName: string;
objectCount: number;
}>();
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<string, {
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
}>();
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,
},
};
}

View file

@ -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);

View file

@ -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.");

View file

@ -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);

View file

@ -13,3 +13,5 @@ export const previewExternalCsvSchema = z
contentBase64: z.string().min(1).max(24_000_000),
})
.strict();
export const planExternalInitialImportSchema = previewExternalCsvSchema;