Add Revit initial import planning
This commit is contained in:
@@ -287,8 +287,15 @@ Confirmed initial state is written only through
|
||||
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
|
||||
planning overrides and internal project links in the shared transaction. It
|
||||
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
|
||||
apply endpoint or staging wizard invokes this command yet.
|
||||
unchanged external state, and Redo restores the same UUIDs and bytes. No
|
||||
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
|
||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||
project. Named snapshots are never removed by this retention policy.
|
||||
|
||||
@@ -537,6 +537,15 @@ Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
|
||||
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
|
||||
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,
|
||||
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
|
||||
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`
|
||||
bleibt unverändert.
|
||||
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
|
||||
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 { 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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,3 +13,5 @@ export const previewExternalCsvSchema = z
|
||||
contentBase64: z.string().min(1).max(24_000_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const planExternalInitialImportSchema = previewExternalCsvSchema;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
planExternalInitialImportSchema,
|
||||
previewExternalCsvSchema,
|
||||
updateExternalCsvConfigurationSchema,
|
||||
} from "../src/shared/validation/external-csv.schemas.js";
|
||||
@@ -23,6 +24,13 @@ describe("external CSV API contracts", () => {
|
||||
}).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
planExternalInitialImportSchema.safeParse({
|
||||
fileName: "revit.csv",
|
||||
contentBase64: "YWJj",
|
||||
}).success,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stale-shaped, oversized and unknown request fields", () => {
|
||||
@@ -41,6 +49,14 @@ describe("external CSV API contracts", () => {
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
planExternalInitialImportSchema.safeParse({
|
||||
fileName: "revit.csv",
|
||||
contentBase64: "YWJj",
|
||||
decisions: [],
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
previewExternalCsvSchema.safeParse({
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user