Add Linear XLSX follow-up import

This commit is contained in:
Julian Appel 2026-09-30 20:40:40 +02:00
parent 1a4c8cad8c
commit 2581beee3c
28 changed files with 1114 additions and 31 deletions

View file

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard";
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
@ -19,6 +20,7 @@ export default function RevitPage() {
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [followUpOpen, setFollowUpOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -41,11 +43,12 @@ export default function RevitPage() {
}
return <main className="container py-4">
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
{followUpOpen && project && snapshot ? <RevitFollowUpImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setFollowUpOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setFollowUpOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
</main>;
}

View file

@ -0,0 +1,86 @@
import { createHash } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { projectDevices } from "../schema/project-devices.js";
import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalFollowUpImportProjectCommandStore["execute"]>[0]) {
assertExternalFollowUpImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId);
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get();
if (!currentState.projectExists || !currentConfiguration || !same(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
const rowIds = input.command.payload.rows.map(({ expected }) => expected.id);
const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden.");
const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows);
for (const transition of input.command.payload.rows) {
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert.");
}
replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration);
replaceState(tx, input.projectId, input.command.payload.targetState);
return invertExternalFollowUpImportProjectCommand(input.command);
});
}
}
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt.");
const growing = target.importBatches.length > expectedState.importBatches.length;
const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!;
if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig.");
if (growing) {
if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig.");
const bytes = Buffer.from(latest.originalContentBase64!, "base64");
if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports ist ungültig.");
}
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
const deviceTypeIds = new Set(configuration.deviceTypes.map((entry) => entry.id));
for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); }
for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); }
assertRows(database, projectId, target, rows);
}
function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))];
const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])];
if (!rowsToLoad.length) return;
const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all();
if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt.");
const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition]));
for (const context of contexts) {
const current = toCircuitDeviceRowSnapshot(context.row);
const effective = transitionById.get(current.id)?.target ?? current;
assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
}
}
function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); database.insert(externalModelConfigurations).values(target).run(); }
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run(); if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run(); if (target.objects.length) database.insert(externalModelObjects).values(target.objects).run(); }
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }

View file

@ -0,0 +1,59 @@
import { and, eq, inArray } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import { assertExternalObjectDeleteMissingProjectCommand, invertExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../../domain/ports/external-object-delete-missing-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { projectDevices } from "../schema/project-devices.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalObjectDeleteMissingProjectCommandRepository implements ExternalObjectDeleteMissingProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectDeleteMissingProjectCommandStore["execute"]>[0]) {
assertExternalObjectDeleteMissingProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const rowById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
const rowIds = [...rowById.keys()];
const rowContexts = rowIds.length ? tx.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
if (rowContexts.length !== rowIds.length || rowContexts.some((entry) => entry.projectId !== input.projectId || !snapshotsEqual(toCircuitDeviceRowSnapshot(entry.row), rowById.get(entry.row.id)!.expected))) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
const currentObjects = tx.select().from(externalModelObjects).where(eq(externalModelObjects.projectId, input.projectId)).all().map(toExternalObjectSnapshot);
const currentById = new Map(currentObjects.map((object) => [object.id, object]));
for (const transition of input.command.payload.objects) {
const current = currentById.get((transition.expected ?? transition.target)!.id) ?? null;
if (transition.expected === null ? current !== null : current === null || !snapshotsEqual(current, transition.expected)) throw new Error("Ein fehlendes Revit-Objekt wurde zwischenzeitlich geändert.");
}
const sourceIds = ids(tx.select({ id: externalModelSources.id }).from(externalModelSources).where(eq(externalModelSources.projectId, input.projectId)).all());
const batchIds = ids(tx.select({ id: externalImportBatches.id }).from(externalImportBatches).where(eq(externalImportBatches.projectId, input.projectId)).all());
const mappingIds = ids(tx.select({ id: externalRoomMappings.id }).from(externalRoomMappings).where(eq(externalRoomMappings.projectId, input.projectId)).all());
const boardIds = ids(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all());
const deviceIds = ids(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all());
for (const { target } of input.command.payload.objects) if (target && (target.projectId !== input.projectId || !sourceIds.has(target.sourceId) || !batchIds.has(target.lastSeenImportBatchId) || !batchIds.has(target.lastAcceptedImportBatchId) || (target.externalRoomMappingId !== null && !mappingIds.has(target.externalRoomMappingId)) || (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) || (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)))) throw new Error("Ein wiederhergestelltes Revit-Objekt verweist auf ein anderes Projekt.");
const targetById = new Map(currentObjects.map((object) => [object.id, object]));
for (const transition of input.command.payload.objects) { const id = (transition.expected ?? transition.target)!.id; if (transition.target) targetById.set(id, transition.target); else targetById.delete(id); }
for (const transition of input.command.payload.rows) assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, externalObjects: [...targetById.values()].filter((object) => object.circuitDeviceRowId === transition.target.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
for (const transition of input.command.payload.rows) {
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Löschens geändert.");
}
for (const transition of input.command.payload.objects) {
const object = transition.expected ?? transition.target!;
if (transition.target === null) {
const result = tx.delete(externalModelObjects).where(and(eq(externalModelObjects.id, object.id), eq(externalModelObjects.projectId, input.projectId))).run();
if (result.changes !== 1) throw new Error("Ein fehlendes Revit-Objekt konnte nicht gelöscht werden.");
} else tx.insert(externalModelObjects).values(transition.target).run();
}
return invertExternalObjectDeleteMissingProjectCommand(input.command);
});
}
}
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }

View file

@ -0,0 +1,84 @@
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js";
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export const externalFollowUpImportCommandType = "external-import.apply-follow-up" as const;
export const externalFollowUpImportCommandSchemaVersion = 1 as const;
export interface ExternalFollowUpImportPayload {
expectedConfiguration: ExternalModelConfigurationSnapshot;
targetConfiguration: ExternalModelConfigurationSnapshot;
expectedState: ExternalModelStateSnapshot;
targetState: ExternalModelStateSnapshot;
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
}
export interface ExternalFollowUpImportProjectCommand extends SerializedProjectCommand<ExternalFollowUpImportPayload> {
schemaVersion: typeof externalFollowUpImportCommandSchemaVersion;
type: typeof externalFollowUpImportCommandType;
}
export function createExternalFollowUpImportProjectCommand(payload: ExternalFollowUpImportPayload): ExternalFollowUpImportProjectCommand {
const command: ExternalFollowUpImportProjectCommand = { schemaVersion: 1, type: externalFollowUpImportCommandType, payload: { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target) })) } };
assertExternalFollowUpImportProjectCommand(command);
return command;
}
export function invertExternalFollowUpImportProjectCommand(command: ExternalFollowUpImportProjectCommand) {
return createExternalFollowUpImportProjectCommand({
expectedConfiguration: command.payload.targetConfiguration,
targetConfiguration: command.payload.expectedConfiguration,
expectedState: command.payload.targetState,
targetState: command.payload.expectedState,
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
});
}
export function assertExternalFollowUpImportProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalFollowUpImportProjectCommand {
if (command.schemaVersion !== 1 || command.type !== externalFollowUpImportCommandType || !isRecord(command.payload)) throw new Error("Nicht unterstützter Revit-Folgeimport-Command.");
if (Object.keys(command.payload).sort().join(",") !== "expectedConfiguration,expectedState,rows,targetConfiguration,targetState" || !Array.isArray(command.payload.rows)) throw new Error("Der Revit-Folgeimport-Command ist unvollständig.");
const expectedConfiguration = parseConfiguration(command.payload.expectedConfiguration);
const targetConfiguration = parseConfiguration(command.payload.targetConfiguration);
if (expectedConfiguration.id !== targetConfiguration.id || expectedConfiguration.projectId !== targetConfiguration.projectId) throw new Error("Der Folgeimport darf die Konfigurationsidentität nicht ändern.");
const expectedState = parseExternalModelStateSnapshot(command.payload.expectedState);
const targetState = parseExternalModelStateSnapshot(command.payload.targetState);
if (!expectedState.source || !targetState.source || expectedState.source.id !== targetState.source.id || expectedState.source.projectId !== targetState.source.projectId) throw new Error("Der Folgeimport benötigt dieselbe Revit-Quelle auf beiden Seiten.");
const batchDelta = targetState.importBatches.length - expectedState.importBatches.length;
if (Math.abs(batchDelta) !== 1) throw new Error("Der Folgeimport muss genau einen Importbatch hinzufügen oder entfernen.");
const larger = batchDelta > 0 ? targetState : expectedState;
const smaller = batchDelta > 0 ? expectedState : targetState;
const largerConfiguration = batchDelta > 0 ? targetConfiguration : expectedConfiguration;
const addedBatch = larger.importBatches.at(-1)!;
if (addedBatch.importKind !== "follow_up" || addedBatch.configurationSnapshot === null || addedBatch.originalContentBase64 === null || addedBatch.document === null) throw new Error("Der Folgeimport-Batch ist unvollständig.");
if (canonical(addedBatch.configurationSnapshot) !== canonical(largerConfiguration.configuration)) throw new Error("Die Konfigurationskopie des Folgeimports stimmt nicht mit dem Ziel überein.");
if (larger.importBatches.length !== smaller.importBatches.length + 1 || smaller.importBatches.some((batch, index) => !sameBatchMetadata(batch, larger.importBatches[index]))) throw new Error("Der Folgeimport darf bestehende Importbatch-Metadaten nicht verändern.");
const largerObjectById = new Map(larger.objects.map((object) => [object.id, object]));
for (const object of smaller.objects) {
const next = largerObjectById.get(object.id);
if (!next || next.worksharingId !== object.worksharingId || next.sourceId !== object.sourceId || next.circuitDeviceRowId !== object.circuitDeviceRowId || next.linkedProjectDeviceId !== object.linkedProjectDeviceId) throw new Error("Der Folgeimport darf bestehende Objektidentitäten oder Links nicht verändern.");
}
const smallerIds = new Set(smaller.objects.map((object) => object.id));
if (larger.objects.some((object) => !smallerIds.has(object.id) && (object.circuitDeviceRowId !== null || object.presenceStatus !== "present" || object.lastSeenImportBatchId !== addedBatch.id))) throw new Error("Neue Folgeimportobjekte müssen unverknüpft und vorhanden sein.");
if (larger.objects.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === addedBatch.id).length !== addedBatch.objectCount) throw new Error("Die Objektzahl des Folgeimportbatches ist inkonsistent.");
const largerMappings = new Map(larger.roomMappings.map((mapping) => [mapping.id, mapping]));
if (smaller.roomMappings.some((mapping) => canonical(mapping) !== canonical(largerMappings.get(mapping.id)))) throw new Error("Der Folgeimport darf bestehende Raumzuordnungen nicht verändern.");
const rowIds = new Set<string>();
for (const transition of command.payload.rows) {
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang im Folgeimport.");
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität im Folgeimport.");
const expectedRest = { ...expected, quantity: 0 }; const targetRest = { ...target, quantity: 0 };
if (canonical(expectedRest) !== canonical(targetRest) || expected.quantity === target.quantity) throw new Error("Der Folgeimport darf an Gerätezeilen nur die Gesamtmenge ändern.");
rowIds.add(expected.id);
}
}
function parseConfiguration(value: unknown): ExternalModelConfigurationSnapshot { if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") throw new Error("Ungültige Revit-Konfiguration im Folgeimport."); assertExternalModelConfiguration(value.configuration); return value as unknown as ExternalModelConfigurationSnapshot; }
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; }
function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
function sameBatchMetadata(left: ExternalModelStateSnapshot["importBatches"][number], right: ExternalModelStateSnapshot["importBatches"][number] | undefined) { if (!right) return false; return left.id === right.id && left.projectId === right.projectId && left.sourceId === right.sourceId && left.importKind === right.importKind && left.importedAtIso === right.importedAtIso && left.fileName === right.fileName && left.sha256 === right.sha256 && left.objectCount === right.objectCount; }

View file

@ -0,0 +1,54 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
export const externalObjectDeleteMissingCommandType = "external-model-object.delete-missing" as const;
type ObjectTransition = { expected: ExternalModelObjectSnapshot | null; target: ExternalModelObjectSnapshot | null };
export interface ExternalObjectDeleteMissingProjectCommand extends SerializedProjectCommand<{ objects: ObjectTransition[]; rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> }> {
schemaVersion: 1;
type: typeof externalObjectDeleteMissingCommandType;
}
export function createExternalObjectDeleteMissingProjectCommand(payload: ExternalObjectDeleteMissingProjectCommand["payload"]): ExternalObjectDeleteMissingProjectCommand {
const command: ExternalObjectDeleteMissingProjectCommand = { schemaVersion: 1, type: externalObjectDeleteMissingCommandType, payload };
assertExternalObjectDeleteMissingProjectCommand(command);
return command;
}
export function invertExternalObjectDeleteMissingProjectCommand(command: ExternalObjectDeleteMissingProjectCommand) {
return createExternalObjectDeleteMissingProjectCommand({
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
});
}
export function assertExternalObjectDeleteMissingProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectDeleteMissingProjectCommand {
if (command.schemaVersion !== 1 || command.type !== externalObjectDeleteMissingCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.rows) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Command zum Löschen fehlender Revit-Objekte.");
const objectIds = new Set<string>();
let deletion: boolean | null = null;
for (const transition of command.payload.objects) {
if (!isRecord(transition) || (transition.expected === null) === (transition.target === null)) throw new Error("Ungültiger Löschübergang für ein Revit-Objekt.");
const object = parseObject(transition.expected ?? transition.target);
if (object.presenceStatus !== "missing" || objectIds.has(object.id)) throw new Error("Nur eindeutig fehlende Revit-Objekte dürfen gelöscht werden.");
const nextDeletion = transition.target === null;
if (deletion !== null && deletion !== nextDeletion) throw new Error("Löschen und Wiederherstellen dürfen nicht gemischt werden.");
deletion = nextDeletion;
objectIds.add(object.id);
}
const affectedRowIds = new Set(command.payload.objects.flatMap(({ expected, target }) => (expected ?? target)!.circuitDeviceRowId ? [(expected ?? target)!.circuitDeviceRowId!] : []));
const rowIds = new Set<string>();
for (const transition of command.payload.rows) {
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang.");
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität.");
if (canonical({ ...expected, quantity: 0 }) !== canonical({ ...target, quantity: 0 }) || expected.quantity === target.quantity) throw new Error("Beim Löschen fehlender Objekte darf nur die Gesamtmenge geändert werden.");
rowIds.add(expected.id);
}
if (canonical([...affectedRowIds].sort()) !== canonical([...rowIds].sort())) throw new Error("Die Mengenübergänge der betroffenen Gerätezeilen sind unvollständig.");
}
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
function parseRow(value: unknown) { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }

View file

@ -0,0 +1,6 @@
import type { ExternalFollowUpImportProjectCommand } from "../models/external-follow-up-import-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExternalFollowUpImportProjectCommandStore {
execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalFollowUpImportProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalFollowUpImportProjectCommand };
}

View file

@ -0,0 +1,3 @@
import type { ExternalObjectDeleteMissingProjectCommand } from "../models/external-object-delete-missing-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExternalObjectDeleteMissingProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectDeleteMissingProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectDeleteMissingProjectCommand }; }

View file

@ -182,8 +182,12 @@ import {
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
import { assertExternalFollowUpImportProjectCommand, externalFollowUpImportCommandType } from "../models/external-follow-up-import-project-command.model.js";
import type { ExternalFollowUpImportProjectCommandStore } from "../ports/external-follow-up-import-project-command.store.js";
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
import { assertExternalObjectDeleteMissingProjectCommand, externalObjectDeleteMissingCommandType } from "../models/external-object-delete-missing-project-command.model.js";
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../ports/external-object-delete-missing-project-command.store.js";
import {
assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType,
@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
) {}
executeUser(
@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalFollowUpImportCommandType: {
assertExternalFollowUpImportProjectCommand(input.command);
if (!this.externalFollowUpImportStore) throw new Error("External follow-up import store is not available.");
return this.externalFollowUpImportStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectPlanningUpdateCommandType: {
assertExternalObjectPlanningUpdateProjectCommand(input.command);
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectDeleteMissingCommandType: {
assertExternalObjectDeleteMissingProjectCommand(input.command);
if (!this.externalObjectDeleteMissingStore) throw new Error("External missing-object delete store is not available.");
return this.externalObjectDeleteMissingStore.execute({ ...input, command: input.command }).revision;
}
case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) {

View file

@ -0,0 +1,120 @@
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { ExternalXlsxReturnValues, ExternalXlsxScalar } from "../xlsx/external-xlsx-contracts.js";
import { createExternalXlsxInitialImportPlan, type ExternalInitialImportCatalogs, type ExternalInitialImportPlan, type ExternalInitialImportProjectedObject } from "./external-xlsx-initial-import.js";
export const externalSourceDecisionFields = ["ifcGuid", "roomNumber", "roomName", "familyAndType", "selectionMarker", "socketCount", "sourceRemark"] as const;
export type ExternalSourceDecisionField = (typeof externalSourceDecisionFields)[number];
export interface ExternalFollowUpChange {
objectId: string;
worksharingId: string;
familyAndType: string;
changedFields: ExternalSourceDecisionField[];
previousQuantity: number;
suggestedQuantity: number;
circuitDeviceRowId: string | null;
}
export interface ExternalXlsxFollowUpPlan extends ExternalInitialImportPlan {
newObjectCount: number;
changedObjectCount: number;
unchangedObjectCount: number;
missingObjectCount: number;
returnDeviationCount: number;
newObjectWorksharingIds: string[];
newSourceRoomKeys: string[];
newFamilyAndTypes: string[];
changes: ExternalFollowUpChange[];
missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>;
returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>;
}
export async function createExternalXlsxFollowUpImportPlan(input: {
fileName: string;
bytes: Uint8Array;
configuration: ExternalModelConfiguration;
catalogs: ExternalInitialImportCatalogs;
currentState: ExternalModelStateSnapshot;
currentReturnValuesByObjectId: ReadonlyMap<string, ExternalXlsxReturnValues>;
}): Promise<{ plan: ExternalXlsxFollowUpPlan; document: Awaited<ReturnType<typeof createExternalXlsxInitialImportPlan>>["document"] }> {
if (!input.currentState.source || input.currentState.importBatches.length === 0) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import.");
const parsed = await createExternalXlsxInitialImportPlan(input);
const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object]));
const incomingByWorksharingId = new Map(parsed.plan.objects.map((object) => [object.worksharingId, object]));
const newObjects: ExternalInitialImportProjectedObject[] = [];
const changes: ExternalFollowUpChange[] = [];
let unchangedObjectCount = 0;
const returnDeviations: ExternalXlsxFollowUpPlan["returnDeviations"] = [];
for (const incoming of parsed.plan.objects) {
const existing = existingByWorksharingId.get(incoming.worksharingId);
if (!existing) { newObjects.push(incoming); continue; }
const changedFields = changedSourceFields(existing, incoming);
if (changedFields.length) {
changes.push({
objectId: existing.id,
worksharingId: existing.worksharingId,
familyAndType: incoming.sourceValues.familyAndType,
changedFields,
previousQuantity: existing.planningValues.effectiveQuantity,
suggestedQuantity: incoming.suggestedPlanningValues.effectiveQuantity,
circuitDeviceRowId: existing.circuitDeviceRowId,
});
} else unchangedObjectCount += 1;
const returnFields = changedReturnFields(input.currentReturnValuesByObjectId.get(existing.id), incoming.sourceValues);
if (returnFields.length) returnDeviations.push({ objectId: existing.id, worksharingId: existing.worksharingId, fields: returnFields });
}
const missingObjects = input.currentState.objects
.filter((object) => !incomingByWorksharingId.has(object.worksharingId))
.map((object) => ({ objectId: object.id, worksharingId: object.worksharingId, familyAndType: object.acceptedSourceValues.familyAndType, circuitDeviceRowId: object.circuitDeviceRowId }));
const existingRoomKeys = new Set(input.currentState.roomMappings.map((mapping) => mapping.normalizedSourceRoomKey));
const newSourceRoomKeys = parsed.plan.sourceRooms.map((room) => room.sourceRoomKey).filter((key) => !existingRoomKeys.has(key));
const newFamilyAndTypes = [...new Set(newObjects.map((object) => object.sourceValues.familyAndType))].sort((a, b) => a.localeCompare(b, "de"));
return {
document: parsed.document,
plan: {
...parsed.plan,
newObjectCount: newObjects.length,
changedObjectCount: changes.length,
unchangedObjectCount,
missingObjectCount: missingObjects.length,
returnDeviationCount: returnDeviations.length,
newObjectWorksharingIds: newObjects.map((object) => object.worksharingId),
newSourceRoomKeys,
newFamilyAndTypes,
changes,
missingObjects,
returnDeviations,
},
};
}
function changedSourceFields(existing: ExternalModelObjectSnapshot, incoming: ExternalInitialImportProjectedObject) {
const fields: ExternalSourceDecisionField[] = [];
if (!sameScalar(existing.ifcGuid, incoming.ifcGuid)) fields.push("ifcGuid");
for (const field of externalSourceDecisionFields.slice(1) as readonly (keyof ExternalModelObjectSourceValues)[]) {
if (!sameScalar(existing.acceptedSourceValues[field], incoming.sourceValues[field])) fields.push(field as ExternalSourceDecisionField);
}
return fields;
}
function changedReturnFields(expected: ExternalXlsxReturnValues | undefined, incoming: ExternalModelObjectSourceValues) {
if (!expected) return [];
const fields = ["circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"] as const;
return fields.filter((field) => !sameScalar(expected[field], incoming[field]));
}
function sameScalar(left: ExternalXlsxScalar | undefined, right: ExternalXlsxScalar | undefined) {
return normalize(left) === normalize(right);
}
function normalize(value: ExternalXlsxScalar | undefined): string | number | boolean {
if (value === null || value === undefined || value === "") return "";
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed) return "";
const numeric = Number(trimmed.replace(",", "."));
return Number.isFinite(numeric) ? numeric : trimmed;
}

View file

@ -0,0 +1,138 @@
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js";
import type { ExternalFamilyProjectDeviceDecision, ExternalRoomDecision } from "./external-xlsx-initial-import-target.js";
import type { ExternalSourceDecisionField, ExternalXlsxFollowUpPlan } from "./external-xlsx-follow-up-import.js";
export interface ExternalSourceChangeDecision { objectId: string; accept: boolean; }
export interface ExternalRowQuantityTransition { expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot; }
export function buildExternalXlsxFollowUpTarget(input: {
projectId: string;
expectedRevision: number;
importedAtIso: string;
configuration: ExternalModelConfiguration;
originalContentBase64: string;
document: ExternalXlsxDocument;
plan: ExternalXlsxFollowUpPlan;
currentState: ExternalModelStateSnapshot;
sourceChangeDecisions: ExternalSourceChangeDecision[];
roomDecisions: ExternalRoomDecision[];
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
objectsWithoutRoomDefaultDistributionBoardId: string | null;
rowsById: ReadonlyMap<string, CircuitDeviceRowSnapshot>;
createId: () => string;
}): { targetState: ExternalModelStateSnapshot; rowTransitions: ExternalRowQuantityTransition[] } {
const source = input.currentState.source;
if (!source || source.projectId !== input.projectId) throw new Error("Für den Folgeimport fehlt die Revit-Quelle.");
const decisions = exactDecisionMap(input.plan.changes.map((change) => change.objectId), input.sourceChangeDecisions, (decision) => decision.objectId, "Quelländerung");
const newRoomSet = new Set(input.plan.newSourceRoomKeys);
const roomDecisions = exactDecisionMap(input.plan.newSourceRoomKeys, input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum");
const newFamilySet = new Set(input.plan.newFamilyAndTypes);
const familyDecisions = exactDecisionMap(input.plan.newFamilyAndTypes, input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ");
const batchId = input.createId();
const oldMappings = input.currentState.roomMappings.map((mapping) => ({ ...mapping }));
const mappingByKey = new Map(oldMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping]));
const newObjectIds = new Set(input.plan.newObjectWorksharingIds);
const neededRoomKeys = new Set(input.plan.objects.filter((object) => newObjectIds.has(object.worksharingId)).map((object) => object.sourceRoomKey).filter((key): key is string => key !== null));
for (const change of input.plan.changes) {
if (!decisions.get(change.objectId)!.accept || !change.changedFields.some((field) => field === "roomNumber" || field === "roomName")) continue;
const key = input.plan.objects.find((object) => object.worksharingId === change.worksharingId)?.sourceRoomKey;
if (key) neededRoomKeys.add(key);
}
for (const room of input.plan.sourceRooms.filter((candidate) => newRoomSet.has(candidate.sourceRoomKey) && neededRoomKeys.has(candidate.sourceRoomKey))) {
const decision = roomDecisions.get(room.sourceRoomKey)!;
const mapping = { id: input.createId(), projectId: input.projectId, sourceId: source.id, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId };
oldMappings.push(mapping); mappingByKey.set(mapping.normalizedSourceRoomKey, mapping);
}
const incomingById = new Map(input.plan.objects.map((object) => [object.worksharingId, object]));
const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object]));
const changedById = new Map(input.plan.changes.map((change) => [change.objectId, change]));
const quantityDeltaByRow = new Map<string, number>();
const objects: ExternalModelObjectSnapshot[] = [];
for (const existing of input.currentState.objects) {
const incoming = incomingById.get(existing.worksharingId);
if (!incoming) { objects.push({ ...existing, presenceStatus: "missing" }); continue; }
const change = changedById.get(existing.id);
const accept = change ? decisions.get(existing.id)!.accept : false;
if (accept && incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Eine akzeptierte Quelländerung besitzt keine gültige Familien- oder Mengenregel.");
const planningValues = { ...existing.planningValues };
let deviceTypeId = existing.deviceTypeId;
let externalRoomMappingId = existing.externalRoomMappingId;
let distributionBoardId = existing.distributionBoardId;
let ifcGuid = existing.ifcGuid;
if (accept && change) {
const fields = new Set<ExternalSourceDecisionField>(change.changedFields);
if (fields.has("ifcGuid")) ifcGuid = incoming.ifcGuid;
if (fields.has("familyAndType")) {
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
if (!rule) throw new Error("Eine geänderte Familie ist nicht klassifiziert.");
deviceTypeId = rule.deviceTypeId;
const type = input.configuration.deviceTypes.find((candidate) => candidate.id === rule.deviceTypeId)!;
if (!existing.overriddenFields.includes("category")) planningValues.category = type.category;
}
if (fields.has("selectionMarker") && !existing.overriddenFields.includes("displayName")) planningValues.displayName = scalarText(incoming.sourceValues.selectionMarker) || incoming.sourceValues.familyAndType;
if (fields.has("sourceRemark") && !existing.overriddenFields.includes("remark")) planningValues.remark = scalarText(incoming.sourceValues.sourceRemark) || null;
if (fields.has("socketCount") && !existing.overriddenFields.includes("effectiveQuantity")) planningValues.effectiveQuantity = incoming.suggestedPlanningValues.effectiveQuantity;
if (fields.has("roomNumber") || fields.has("roomName")) {
const oldMapping = existing.externalRoomMappingId ? oldMappings.find((mapping) => mapping.id === existing.externalRoomMappingId) : null;
const nextMapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
externalRoomMappingId = nextMapping?.id ?? null;
if (!existing.circuitDeviceRowId && (existing.distributionBoardId === null || existing.distributionBoardId === oldMapping?.defaultDistributionBoardId)) distributionBoardId = nextMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId;
}
}
if (existing.circuitDeviceRowId && planningValues.effectiveQuantity !== existing.planningValues.effectiveQuantity) {
quantityDeltaByRow.set(existing.circuitDeviceRowId, (quantityDeltaByRow.get(existing.circuitDeviceRowId) ?? 0) + planningValues.effectiveQuantity - existing.planningValues.effectiveQuantity);
}
objects.push({
...existing,
ifcGuid, deviceTypeId, externalRoomMappingId, distributionBoardId,
lastSeenImportBatchId: batchId,
lastAcceptedImportBatchId: change && !accept ? existing.lastAcceptedImportBatchId : batchId,
acceptedSourceValues: mergeAcceptedSource(existing.acceptedSourceValues, incoming.sourceValues, accept),
planningValues,
presenceStatus: "present",
});
}
for (const incoming of input.plan.objects.filter((object) => !existingByWorksharingId.has(object.worksharingId))) {
if (incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Ein neues Revit-Objekt besitzt keine gültige Familien- oder Mengenregel.");
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
if (!rule) throw new Error("Eine neue Familie ist nicht klassifiziert.");
const mapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
objects.push({
id: input.createId(), projectId: input.projectId, sourceId: source.id, worksharingId: incoming.worksharingId, ifcGuid: incoming.ifcGuid,
deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId,
acceptedSourceValues: incoming.sourceValues, planningValues: incoming.suggestedPlanningValues, overriddenFields: [],
externalRoomMappingId: mapping?.id ?? null,
distributionBoardId: mapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId,
linkedProjectDeviceId: newFamilySet.has(incoming.sourceValues.familyAndType) ? familyDecisions.get(incoming.sourceValues.familyAndType)!.projectDeviceId : null,
circuitDeviceRowId: null, presenceStatus: "present",
});
}
const rowTransitions = [...quantityDeltaByRow].map(([rowId, delta]) => {
const expected = input.rowsById.get(rowId);
if (!expected) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
return { expected, target: { ...expected, quantity: expected.quantity + delta } };
});
const previousBatches = input.currentState.importBatches.map((batch) => ({ ...batch, appliedProjectRevision: null, configurationSnapshot: null, originalContentBase64: null, document: null }));
return {
targetState: {
source: { ...source },
importBatches: [...previousBatches, { id: batchId, projectId: input.projectId, sourceId: source.id, importKind: "follow_up", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: input.plan.objectCount, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }],
roomMappings: oldMappings,
objects,
},
rowTransitions,
};
}
const returnFields = new Set<keyof ExternalModelObjectSourceValues>(["rowNumber", "circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"]);
function mergeAcceptedSource(previous: ExternalModelObjectSourceValues, incoming: ExternalModelObjectSourceValues, acceptSourceChanges: boolean) {
if (acceptSourceChanges) return incoming;
return Object.fromEntries(Object.keys(previous).map((key) => [key, returnFields.has(key as keyof ExternalModelObjectSourceValues) ? incoming[key as keyof ExternalModelObjectSourceValues] : previous[key as keyof ExternalModelObjectSourceValues]])) as unknown as ExternalModelObjectSourceValues;
}
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value).trim(); }
function exactDecisionMap<T>(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { const expected = new Set(expectedKeys); const result = new Map<string, T>(); for (const decision of decisions) { const key = keyOf(decision); if (!expected.has(key) || result.has(key)) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); result.set(key, decision); } if (result.size !== expected.size) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); return result; }

View file

@ -0,0 +1,91 @@
"use client";
import { useState, type FormEvent } from "react";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ExternalFollowUpImportPlanDto, ProjectHistoryStateDto } from "../types";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../utils/api";
import { createClientId } from "../utils/client-id";
import { FormModal } from "./form-modal";
const steps = ["Vorschau", "Quelländerungen", "Neue Räume", "Familienregeln", "Projektgeräte", "Bestätigung"];
const fieldLabels: Record<string, string> = { ifcGuid: "IfcGUID", roomNumber: "Raumnummer", roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner", socketCount: "Anzahl", sourceRemark: "Bemerkung" };
export function RevitFollowUpImportWizard({ projectId, expectedRevision, initialConfiguration, onClose, onImported }: { projectId: string; expectedRevision: number; initialConfiguration: ExternalModelConfiguration; onClose: () => void; onImported: (result: { history: ProjectHistoryStateDto; objectCount: number }) => void }) {
const [step, setStep] = useState(0);
const [file, setFile] = useState<File | null>(null);
const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration));
const [plan, setPlan] = useState<ExternalFollowUpImportPlanDto | null>(null);
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [roomIds, setRoomIds] = useState<Record<string, string>>({});
const [boardIds, setBoardIds] = useState<Record<string, string>>({});
const [deviceIds, setDeviceIds] = useState<Record<string, string>>({});
const [noRoomBoardId, setNoRoomBoardId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function loadPlan(nextFile: File, nextConfiguration = configuration) {
setBusy(true); setError(null);
try {
const loaded = await planExternalFollowUpImport(projectId, nextFile, nextConfiguration, expectedRevision);
setPlan(loaded);
setAccepted((current) => Object.fromEntries(loaded.changes.map((change) => [change.objectId, current[change.objectId] ?? true])));
setRoomIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => { const room = loaded.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return [key, current[key] ?? room.suggestedRoomId ?? ""]; })));
setBoardIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => [key, current[key] ?? ""])));
setDeviceIds((current) => Object.fromEntries(loaded.newFamilyAndTypes.map((family) => [family, current[family] ?? ""])));
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport-Vorschau fehlgeschlagen."); setPlan(null); }
finally { setBusy(false); }
}
async function addRule(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount", deviceTypeId?: string) {
if (!file) return;
const next = structuredClone(configuration);
const targetTypeId = deviceTypeId ?? createClientId();
if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } });
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, quantityRule });
setConfiguration(next); await loadPlan(file, next);
}
async function toggleQuantityRule(familyAndType: string) {
if (!file) return;
const next = structuredClone(configuration);
const rule = next.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === familyAndType);
if (!rule) return;
rule.quantityRule = rule.quantityRule === "one" ? "socketCount" : "one";
setConfiguration(next); await loadPlan(file, next);
}
async function submit(event: FormEvent) {
event.preventDefault();
if (step < steps.length - 1) { setStep(step + 1); return; }
if (!file || !plan) return;
setBusy(true); setError(null);
try {
const result = await applyExternalFollowUpImport(projectId, file, {
expectedRevision, expectedSha256: plan.sha256, configuration, objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null,
sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: accepted[change.objectId] ?? false })),
roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: roomIds[sourceRoomKey] || null, defaultDistributionBoardId: boardIds[sourceRoomKey] || null })),
familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: deviceIds[familyAndType] || null })),
});
onImported({ history: result.history, objectCount: result.import.objectCount });
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport fehlgeschlagen."); }
finally { setBusy(false); }
}
const relevantUnknownFamilies = plan?.familyGroups.filter((family) => !family.classified && (plan.newFamilyAndTypes.includes(family.familyAndType) || plan.changes.some((change) => accepted[change.objectId] && change.familyAndType === family.familyAndType))) ?? [];
const relevantInvalidQuantity = plan?.objects.some((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))) ?? false;
const invalidQuantityFamilies = plan ? [...new Set(plan.objects.filter((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))).map((object) => object.sourceValues.familyAndType))] : [];
const blocked = !plan || relevantUnknownFamilies.length > 0 || relevantInvalidQuantity;
return <FormModal dialogSize="viewport" title="Linear-XLSX-Folgeimport" description={`Schritt ${step + 1} von ${steps.length}: ${steps[step]}`} isSaving={busy} onClose={onClose} onSubmit={(event) => void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Folgeimport übernehmen" : "Weiter"}>
<div className="d-flex gap-2 flex-wrap mb-3">{steps.map((label, index) => <span className={`badge ${index === step ? "text-bg-primary" : index < step ? "text-bg-success" : "text-bg-secondary"}`} key={label}>{index + 1}. {label}</span>)}</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{step === 0 ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="follow-up-xlsx">Vollständige Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="follow-up-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><Count label="Neu" value={plan.newObjectCount} /><Count label="Geändert" value={plan.changedObjectCount} /><Count label="Unverändert" value={plan.unchangedObjectCount} /><Count label="Fehlend" value={plan.missingObjectCount} /><div className="col-12 text-secondary small">{plan.returnDeviationCount} Objekte enthalten Rückgabefelder, die in Revit noch nicht aktualisiert sind.</div>{plan.warnings.length ? <div className="col-12"><div className="alert alert-warning mb-0">{plan.warnings.map((warning) => <div key={warning}>{warning}</div>)}</div></div> : null}</> : null}</div> : null}
{step === 1 && plan ? <div><p className="text-secondary">Nur fachliche Quelländerungen benötigen eine Entscheidung. Abweichende Rückgabefelder werden lediglich als „in Revit noch nicht aktualisiert“ gemeldet.</p>{plan.changes.length ? plan.changes.map((change) => <label className="border rounded p-2 mb-2 d-flex gap-2" key={change.objectId}><input checked={accepted[change.objectId] ?? true} type="checkbox" onChange={(event) => setAccepted({ ...accepted, [change.objectId]: event.target.checked })} /><span><strong>{change.familyAndType}</strong> · {change.worksharingId}<br /><span className="small text-secondary">{change.changedFields.map((field) => fieldLabels[field] ?? field).join(", ")}{change.previousQuantity !== change.suggestedQuantity ? ` · Menge ${change.previousQuantity} → ${change.suggestedQuantity}` : ""}</span></span></label>) : <div className="alert alert-info">Keine fachlichen Quelländerungen.</div>}{plan.missingObjects.length ? <div className="alert alert-warning">{plan.missingObjects.length} fehlende Objekte werden markiert. Links und Mengen bleiben bestehen.</div> : null}</div> : null}
{step === 2 && plan ? <div><p className="text-secondary">Nur neue, tatsächlich übernommene Quellräume benötigen eine Zuordnung.</p>{plan.newSourceRoomKeys.map((key) => { const room = plan.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return <div className="row g-2 mb-2" key={key}><div className="col-md-4"><strong>{room.roomNumber || "–"}</strong> {room.roomName}</div><div className="col-md-4"><select className="form-select" value={roomIds[key] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [key]: event.target.value })}><option value="">Kein Projektraum</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div><div className="col-md-4"><select className="form-select" value={boardIds[key] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [key]: event.target.value })}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div>; })}<label className="form-label">Standardverteiler für neue Objekte ohne Raum<select className="form-select" value={noRoomBoardId} onChange={(event) => setNoRoomBoardId(event.target.value)}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label></div> : null}
{step === 3 && plan ? <div><p className="text-secondary">Unbekannte Familien müssen nur für neue Objekte oder akzeptierte Familienänderungen klassifiziert werden.</p>{relevantUnknownFamilies.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><strong>{family.familyAndType}</strong><div className="d-flex gap-2 flex-wrap mt-2">{configuration.deviceTypes.flatMap((type) => [<button className="btn btn-sm btn-outline-secondary" key={`${type.id}-one`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "one", type.id)}>{type.name} · Menge 1</button>, <button className="btn btn-sm btn-outline-secondary" key={`${type.id}-socket`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "socketCount", type.id)}>{type.name} · XLSX-Anzahl</button>])}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button></div></div>)}{!relevantUnknownFamilies.length ? <div className="alert alert-success">Alle benötigten Familien sind klassifiziert.</div> : null}{relevantInvalidQuantity ? <div className="alert alert-danger">Mindestens eine übernommene Mengenangabe ist ungültig.{invalidQuantityFamilies.map((family) => <button className="btn btn-sm btn-outline-danger ms-2" key={family} type="button" onClick={() => void toggleQuantityRule(family)}>Mengenregel für {family} wechseln</button>)}</div> : null}</div> : null}
{step === 4 && plan ? <div><p className="text-secondary">Neue Revit-Objekte können familienweise mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.newFamilyAndTypes.map((family) => <div className="row g-2 align-items-center mb-2" key={family}><div className="col-md-5">{family}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family]: event.target.value })}><option value="">Kein Projektgerät</option>{plan.existing.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name} · {device.displayName}</option>)}</select></div></div>)}</div> : null}
{step === 5 && plan ? <div className="alert alert-info"><strong>{plan.objectCount} Objekte im vollständigen Import.</strong><br />Neu: {plan.newObjectCount}, fachlich geändert: {plan.changedObjectCount}, fehlend: {plan.missingObjectCount}. Fehlende Objekte werden nicht gelöst oder gelöscht. Apply liest die Datei erneut; Undo und Redo verwenden ausschließlich die gespeicherten Zustände.</div> : null}
{step > 0 ? <button className="btn btn-link px-0 mt-3" disabled={busy} onClick={() => setStep(step - 1)} type="button">Zurück</button> : null}
</FormModal>;
}
function Count({ label, value }: { label: string; value: number }) { return <div className="col-6 col-md-3"><strong>{value}</strong><div className="text-secondary small">{label}</div></div>; }

View file

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
import { createExternalReturnWorkbook, deleteMissingExternalProjectObjects, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44;
const viewportHeight = 520;
@ -48,7 +48,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
const returnSelectable = rows.filter((object) => object.presenceStatus === "present");
const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
const selectedMissing = data?.objects.filter((object) => selected.has(object.id) && object.presenceStatus === "missing") ?? [];
const selectionContainsPresentObject = data?.objects.some((object) => selected.has(object.id) && object.presenceStatus === "present") ?? false;
async function applyBulk() {
if (!data || selected.size === 0) return;
@ -84,12 +87,21 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
}
}
async function deleteMissing() {
if (!data || selectedMissing.length === 0 || selectionContainsPresentObject) return;
if (!window.confirm(`${selectedMissing.length} fehlende Revit-Objekte wirklich löschen? Verknüpfte Mengen werden angepasst. Undo stellt Objekte, Links und Mengen wieder her.`)) return;
setSaving(true); setError(null);
try { const result = await deleteMissingExternalProjectObjects(projectId, data.currentRevision, selectedMissing.map((object) => object.id)); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
catch (value) { setError(value instanceof Error ? value.message : "Fehlende Revit-Objekte konnten nicht gelöscht werden."); }
finally { setSaving(false); }
}
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; const missing = object.presenceStatus === "missing"; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${missing ? "text-bg-warning" : linked ? "text-bg-success" : "text-bg-secondary"}`}>{missing ? "fehlend" : linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(returnSelectable.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><button className="btn btn-sm btn-danger" disabled={saving || selectedMissing.length === 0 || selectionContainsPresentObject} type="button" onClick={() => void deleteMissing()}>Ausgewählte fehlende löschen</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
</div></section>;
}

View file

@ -99,6 +99,7 @@ export interface ExternalInitialImportStatusDto {
imported: boolean;
sourceName: string | null;
objectCount: number;
missingObjectCount: number;
importedAtIso: string | null;
fileName: string | null;
}
@ -111,6 +112,34 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto
};
}
export interface ExternalFollowUpImportPlanDto extends ExternalInitialImportPlanDto {
newObjectCount: number;
changedObjectCount: number;
unchangedObjectCount: number;
missingObjectCount: number;
returnDeviationCount: number;
newObjectWorksharingIds: string[];
newSourceRoomKeys: string[];
newFamilyAndTypes: string[];
changes: Array<{ objectId: string; worksharingId: string; familyAndType: string; changedFields: string[]; previousQuantity: number; suggestedQuantity: number; circuitDeviceRowId: string | null }>;
missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>;
returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>;
}
export interface ApplyExternalFollowUpImportInput {
expectedRevision: number;
expectedSha256: string;
configuration: ExternalModelConfiguration;
objectsWithoutRoomDefaultDistributionBoardId: string | null;
sourceChangeDecisions: Array<{ objectId: string; accept: boolean }>;
roomDecisions: Array<{ sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null }>;
familyProjectDeviceDecisions: Array<{ familyAndType: string; projectDeviceId: string | null }>;
}
export interface ExternalFollowUpImportResultDto extends ProjectCommandResultDto {
import: { importBatchId: string; objectCount: number; newObjectCount: number; changedObjectCount: number; missingObjectCount: number };
}
export interface ExternalProjectObjectDto {
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;

View file

@ -41,6 +41,9 @@ import type {
ExternalModelConfigurationSnapshotDto,
ExternalInitialImportPlanDto,
ExternalInitialImportResultDto,
ExternalFollowUpImportPlanDto,
ExternalFollowUpImportResultDto,
ApplyExternalFollowUpImportInput,
ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput,
ExternalProjectObjectsDto,
@ -232,6 +235,18 @@ export async function applyExternalInitialImport(
});
}
export async function planExternalFollowUpImport(projectId: string, file: File, configuration: ExternalModelConfiguration, expectedRevision: number) {
return request<ExternalFollowUpImportPlanDto>(`/api/projects/${projectId}/external-model/follow-up-import/plan`, {
method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }),
});
}
export async function applyExternalFollowUpImport(projectId: string, file: File, input: ApplyExternalFollowUpImportInput) {
return request<ExternalFollowUpImportResultDto>(`/api/projects/${projectId}/external-model/follow-up-import/apply`, {
method: "POST", body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }),
});
}
async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
@ -247,6 +262,10 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
}
export function deleteMissingExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[]) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/delete-missing`, { method: "POST", body: JSON.stringify({ expectedRevision, objectIds, confirmed: true }) });
}
export function createExternalReturnWorkbook(
projectId: string,
expectedRevision: number,

View file

@ -24,7 +24,9 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalFollowUpImportProjectCommandRepository } from "../../db/repositories/external-follow-up-import-project-command.repository.js";
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/repositories/external-object-delete-missing-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -76,8 +78,12 @@ export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalFollowUpImportProjectCommandStore =
new ExternalFollowUpImportProjectCommandRepository(db);
export const externalObjectPlanningProjectCommandStore =
new ExternalObjectPlanningProjectCommandRepository(db);
export const externalObjectDeleteMissingProjectCommandStore =
new ExternalObjectDeleteMissingProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
@ -115,5 +121,7 @@ export const projectCommandService = new ProjectCommandService(
externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore,
externalInitialImportProjectCommandStore,
externalObjectPlanningProjectCommandStore
externalObjectPlanningProjectCommandStore,
externalFollowUpImportProjectCommandStore,
externalObjectDeleteMissingProjectCommandStore
);

View file

@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import { createExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
import { createExternalXlsxFollowUpImportPlan } from "../../external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../../external-model/application/external-xlsx-follow-up-target.js";
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function planExternalFollowUpImport(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
const { plan } = await createExternalXlsxFollowUpImportPlan({
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration: context.configuration,
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
});
return res.json(plan);
} catch (error) { return res.status(400).json({ error: error instanceof Error ? error.message : "Folgeimport-Vorschau fehlgeschlagen." }); }
}
export async function applyExternalFollowUpImport(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
const bytes = decodeBase64(req.body?.contentBase64);
const { plan, document } = await createExternalXlsxFollowUpImportPlan({
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: context.configuration,
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
});
if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" });
const built = buildExternalXlsxFollowUpTarget({
projectId, expectedRevision, importedAtIso: new Date().toISOString(), configuration: context.configuration,
originalContentBase64: String(req.body.contentBase64), document, plan, currentState: context.state,
sourceChangeDecisions: requireArray(req.body?.sourceChangeDecisions, "Quelländerungsentscheidungen"),
roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"),
familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"),
objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"),
rowsById: context.rowsById, createId: randomUUID,
});
const targetConfiguration = { ...context.configurationSnapshot, configuration: context.configuration };
const result = projectCommandService.executeUser({
projectId, expectedRevision, description: `Revit-Folgeimport ${plan.fileName}`,
command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: context.configurationSnapshot, targetConfiguration, expectedState: context.state, targetState: built.targetState, rows: built.rowTransitions }),
});
return res.status(201).json({ ...result, import: { importBatchId: built.targetState.importBatches.at(-1)!.id, objectCount: plan.objectCount, newObjectCount: plan.newObjectCount, changedObjectCount: plan.changedObjectCount, missingObjectCount: plan.missingObjectCount } });
} catch (error) { return respondWithProjectCommandError(error, res); }
}
async function loadContext(projectId: string, expectedRevision: number, configurationValue: unknown) {
const [project, treeResult] = await Promise.all([projectRepository.findById(projectId), buildProjectCircuitTrees(projectId)]);
if (!project || !treeResult) throw new Error("Projekt nicht gefunden.");
if (project.currentRevision !== expectedRevision || treeResult.currentRevision !== expectedRevision) throw new Error("Der Folgeimportplan ist wegen einer neueren Projektrevision ungültig.");
const stored = externalModelConfigurationRepository.getByProject(projectId);
if (!stored.configuration) throw new Error("Für das Projekt ist keine Revit-XLSX-Konfiguration gespeichert.");
assertExternalModelConfiguration(configurationValue);
const stateResult = externalModelStateRepository.getByProject(projectId);
if (!stateResult.state.source) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import.");
const rowContexts = createRowContexts(treeResult);
const returnValues = new Map(stateResult.state.objects.map((object) => [object.id, projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? rowContexts.get(object.circuitDeviceRowId)?.returnContext ?? null : null, circuitMode: configurationValue.circuitMode, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV }).values]));
return { configuration: configurationValue, configurationSnapshot: stored.configuration, state: stateResult.state, returnValues, rowsById: new Map([...rowContexts].map(([id, value]) => [id, value.snapshot])) };
}
function createRowContexts(treeResult: NonNullable<Awaited<ReturnType<typeof buildProjectCircuitTrees>>>) {
const result = new Map<string, { returnContext: ExternalReturnRowContext; snapshot: CircuitDeviceRowSnapshot }>();
for (const tree of treeResult.trees) for (const section of tree.sections) for (const circuit of section.circuits) for (const row of circuit.deviceRows) result.set(row.id, {
returnContext: { rowId: row.id, displayName: row.displayName, powerPerUnitKw: row.powerPerUnit, cosPhi: row.cosPhi ?? null, phaseType: row.phaseType ?? null, category: section.category ?? null, circuitIdentifier: circuit.equipmentIdentifier, circuitVoltageV: circuit.voltage ?? null, distributionBoardName: tree.distributionBoardName },
snapshot: { id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null },
});
return result;
}
async function loadCatalogs(projectId: string) { const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); return { floors, rooms, distributionBoards, projectDevices }; }
function parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; }
function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); }
function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; }
function requireArray<T>(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; }

View file

@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) {
const result = externalModelStateRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const batch = result.state.importBatches.at(-1) ?? null;
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: batch?.objectCount ?? result.state.objects.length, missingObjectCount: result.state.objects.filter((object) => object.presenceStatus === "missing").length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
}
export async function planExternalInitialImport(req: Request, res: Response) {

View file

@ -1,6 +1,7 @@
import type { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
@ -57,6 +58,39 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function deleteMissingExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden.");
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const byId = new Map(state.objects.map((object) => [object.id, object]));
const selected = objectIds.map((id) => {
const object = byId.get(id);
if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden.");
return object;
});
const treeResult = await buildProjectCircuitTrees(projectId);
const quantitiesByRowId = new Map<string, number>();
for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity);
const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => {
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
const expected = toRowSnapshot(location.row, location.circuitId);
return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } };
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${selected.length} fehlende Revit-Objekte gelöscht`,
command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {

View file

@ -63,8 +63,9 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
export const projectRouter = Router();
@ -92,8 +93,11 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.post("/:projectId/external-model/follow-up-import/plan", planExternalFollowUpImport);
projectRouter.post("/:projectId/external-model/follow-up-import/apply", applyExternalFollowUpImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
projectRouter.post("/:projectId/external-model/objects/delete-missing", deleteMissingExternalObjects);
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);