Add Revit initial import planning

This commit is contained in:
2026-08-02 17:38:17 +02:00
parent c634570018
commit c9e5fdd876
10 changed files with 345 additions and 4 deletions
+9 -2
View File
@@ -287,8 +287,15 @@ Confirmed initial state is written only through
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole 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 unchanged external state, and Redo restores the same UUIDs and bytes. No
apply endpoint or staging wizard invokes this command yet. 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 The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per 25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy. project. Named snapshots are never removed by this retention policy.
+9
View File
@@ -537,6 +537,15 @@ Konfigurationsstand serverseitig und verwendet den typisierten Command.
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash, Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten. 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, Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln. Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
@@ -288,7 +288,13 @@ einen Commit aufgenommen.
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003` die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert. bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard 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 Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
@@ -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,
},
};
}
@@ -12,6 +12,7 @@ import { ProjectDeviceRepository } from "../../db/repositories/project-device.re
import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.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 = export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db); new CircuitDeviceRowRepository(db);
@@ -31,3 +32,4 @@ export const projectRepository = new ProjectRepository(db);
export const roomRepository = new RoomRepository(db); export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository = export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db); new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
@@ -3,12 +3,21 @@ import type { Request, Response } from "express";
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js"; import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
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 { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js"; import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
import { import {
previewExternalCsvSchema, previewExternalCsvSchema,
planExternalInitialImportSchema,
updateExternalCsvConfigurationSchema, updateExternalCsvConfigurationSchema,
} from "../../shared/validation/external-csv.schemas.js"; } 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 { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.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) { function decodeBase64(value: string) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) { if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert."); throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
+5
View File
@@ -38,6 +38,7 @@ import {
import { import {
getExternalCsvConfiguration, getExternalCsvConfiguration,
previewExternalCsv, previewExternalCsv,
planExternalInitialImport,
updateExternalCsvConfiguration, updateExternalCsvConfiguration,
} from "../controllers/external-csv.controller.js"; } from "../controllers/external-csv.controller.js";
@@ -52,6 +53,10 @@ projectRouter.post("/:projectId/import", importProjectTransfer);
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration); projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration); projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv); 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", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/commands", executeProjectCommand);
@@ -13,3 +13,5 @@ export const previewExternalCsvSchema = z
contentBase64: z.string().min(1).max(24_000_000), contentBase64: z.string().min(1).max(24_000_000),
}) })
.strict(); .strict();
export const planExternalInitialImportSchema = previewExternalCsvSchema;
+16
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
planExternalInitialImportSchema,
previewExternalCsvSchema, previewExternalCsvSchema,
updateExternalCsvConfigurationSchema, updateExternalCsvConfigurationSchema,
} from "../src/shared/validation/external-csv.schemas.js"; } from "../src/shared/validation/external-csv.schemas.js";
@@ -23,6 +24,13 @@ describe("external CSV API contracts", () => {
}).success, }).success,
true true
); );
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
}); });
it("rejects stale-shaped, oversized and unknown request fields", () => { it("rejects stale-shaped, oversized and unknown request fields", () => {
@@ -41,6 +49,14 @@ describe("external CSV API contracts", () => {
}).success, }).success,
false false
); );
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
decisions: [],
}).success,
false
);
assert.equal( assert.equal(
previewExternalCsvSchema.safeParse({ previewExternalCsvSchema.safeParse({
fileName: "revit.csv", fileName: "revit.csv",
@@ -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");
});
});