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

@ -344,6 +344,13 @@ batch payloads. The Revit page filters and sorts it in the client and renders a
virtualized window. `external-object.update-planning` applies multi-object virtualized window. `external-object.update-planning` applies multi-object
board, ProjectDevice and planning-value changes as one persistent command; board, ProjectDevice and planning-value changes as one persistent command;
linked objects are excluded because they follow their CircuitDeviceRow board. linked objects are excluded because they follow their CircuitDeviceRow board.
R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs
exactly, separates source decisions from return deviations, updates accepted
linked quantities atomically, retains missing objects and compacts every older
batch to metadata. `external-import.apply-follow-up` stores complete inverse
states, so Undo/Redo never parses Excel. A separately confirmed
`external-model-object.delete-missing` command removes missing objects and
adjusts linked row quantities; Undo restores objects, links and quantities.
The target configuration has no independent version counter; `expectedRevision` The target configuration has no independent version counter; `expectedRevision`
invalidates stale plans, and each import batch stores a configuration copy. invalidates stale plans, and each import batch stores a configuration copy.
@ -597,7 +604,7 @@ Critical target rules:
selected combined/separate mode block export. selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation. - Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on - The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 through R6 are complete and R7 is the next package. 2026-09-30; R3 through R9 are complete and R10 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`, - All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary. complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source - Do not add automatic circuit planning, Revit circuit adoption, multi-source

View file

@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen; Worksharing-ID als externem Schlüssel ersetzt. R0 bis R9 sind abgeschlossen;
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport,
Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen. virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe.
Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10. R10 härtet den Ablauf Ende zu Ende und für große Datenmengen.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf
@ -41,9 +41,9 @@ Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der - grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
übernehmen übernehmen
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite, - Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an
Phase-14-Plan in den nächsten Paketen angebunden Linear zurückgeben
## Technik ## Technik

View file

@ -9,14 +9,11 @@
- Named and automatic logical project snapshots can be listed and restored - Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained. created every 25 revisions and only their newest 12 entries are retained.
- The current Revit/CSV foundation and confirmed initial import are a legacy - Die Linear-XLSX-Anbindung bis R9 ist umgesetzt: Erst- und Folgeimport,
starting point. The active Phase-14 plan replaces them with the Linear XLSX Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
exchange and exact Worksharing-ID identity. Löschen fehlender Objekte verwenden persistente Commands. Offen ist die
R2 must pass a real Linear return-import check before database cutover in R3. R10-Härtung des gesamten Ablaufs mit mehreren Tausend synthetischen Objekten,
Existing-row assignment, move and detach plus creation of an external-only Messungen sowie Snapshot-/Transfer- und Neustarttests.
row in an existing or newly created circuit have atomic persistent commands.
Their API planning and editor UI, follow-up import, conflict review and return
export are pending.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no - The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model. versioning, permissions or controlled synchronization model.

View file

@ -843,9 +843,8 @@ Referenzen gesperrt.
## Noch nicht unterstützt ## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung - Mehrbenutzerbetrieb und Konfliktauflösung
- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die - R10-Härtung des Linear-XLSX-Ablaufs für große Datenmengen sowie
Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung Ende-zu-Ende-Nachweise über Snapshot, Transfer und Neustart
folgt dem R0–R10-Plan
- vollständige elektrische Dimensionierung - vollständige elektrische Dimensionierung
- Produktionsdeployment - Produktionsdeployment

View file

@ -430,6 +430,15 @@ Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
auf Metadaten reduziert. auf Metadaten reduziert.
Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute
Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare
`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte
Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command.
Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen
in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte
Löschung verwendet `external-model-object.delete-missing`; Undo stellt
Objekte, Links und Row-Mengen exakt wieder her.
## 15. Commands, Persistenz und Sicherheit ## 15. Commands, Persistenz und Sicherheit
Projektbezogene Writes verwenden typisierte Commands mit vollständiger Projektbezogene Writes verwenden typisierte Commands mit vollständiger

View file

@ -17,7 +17,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. | | R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. | | R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. | | R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | | R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | | R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
## R0 – Audit ## R0 – Audit
@ -190,6 +190,17 @@ Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren; - neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo. - ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige
XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend
und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert
Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst
die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe
atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre
Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine
gesondert bestätigte Aktion kann sie anschließend mit passender
Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen;
Undo/Redo liest keine XLSX-Datei.
## R10 – Härtung ## R10 – Härtung
Ende-zu-Ende-Test: Ende-zu-Ende-Test:

View file

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link"; import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; 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 { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal"; import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration"; 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 [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [followUpOpen, setFollowUpOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -41,11 +43,12 @@ export default function RevitPage() {
} }
return <main className="container py-4"> 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} {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> <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 projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} {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} {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} {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>; </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, externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js"; } from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.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 { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.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 { import {
assertExternalObjectRowAssignmentProjectCommand, assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType, externalObjectRowAssignmentCommandType,
@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
) {} ) {}
executeUser( executeUser(
@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case externalObjectPlanningUpdateCommandType: {
assertExternalObjectPlanningUpdateProjectCommand(input.command); assertExternalObjectPlanningUpdateProjectCommand(input.command);
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available."); if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision; 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: { case externalObjectRowAssignmentCommandType: {
assertExternalObjectRowAssignmentProjectCommand(input.command); assertExternalObjectRowAssignmentProjectCommand(input.command);
if (!this.externalObjectRowAssignmentStore) { 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 { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types"; 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 rowHeight = 44;
const viewportHeight = 520; 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 first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10); const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
const selectable = rows.filter((object) => object.circuitDeviceRowId === null); 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 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() { async function applyBulk() {
if (!data || selected.size === 0) return; 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>; 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> 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} {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 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="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(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="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>; </div></section>;
} }

View file

@ -99,6 +99,7 @@ export interface ExternalInitialImportStatusDto {
imported: boolean; imported: boolean;
sourceName: string | null; sourceName: string | null;
objectCount: number; objectCount: number;
missingObjectCount: number;
importedAtIso: string | null; importedAtIso: string | null;
fileName: 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 { export interface ExternalProjectObjectDto {
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string; id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string; familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;

View file

@ -41,6 +41,9 @@ import type {
ExternalModelConfigurationSnapshotDto, ExternalModelConfigurationSnapshotDto,
ExternalInitialImportPlanDto, ExternalInitialImportPlanDto,
ExternalInitialImportResultDto, ExternalInitialImportResultDto,
ExternalFollowUpImportPlanDto,
ExternalFollowUpImportResultDto,
ApplyExternalFollowUpImportInput,
ExternalInitialImportStatusDto, ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput, ApplyExternalInitialImportInput,
ExternalProjectObjectsDto, 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) { async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer()); const bytes = new Uint8Array(await file.arrayBuffer());
let binary = ""; 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 }) }); 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( export function createExternalReturnWorkbook(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,

View file

@ -24,7 +24,9 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.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 { 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 { 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 { 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 { 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"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -76,8 +78,12 @@ export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db); new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore = export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db); new ExternalInitialImportProjectCommandRepository(db);
export const externalFollowUpImportProjectCommandStore =
new ExternalFollowUpImportProjectCommandRepository(db);
export const externalObjectPlanningProjectCommandStore = export const externalObjectPlanningProjectCommandStore =
new ExternalObjectPlanningProjectCommandRepository(db); new ExternalObjectPlanningProjectCommandRepository(db);
export const externalObjectDeleteMissingProjectCommandStore =
new ExternalObjectDeleteMissingProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore = export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db); new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore = export const externalObjectNewRowProjectCommandStore =
@ -115,5 +121,7 @@ export const projectCommandService = new ProjectCommandService(
externalObjectNewCircuitProjectCommandStore, externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore, estimateBalanceProjectCommandStore,
externalInitialImportProjectCommandStore, 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); const result = externalModelStateRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const batch = result.state.importBatches.at(-1) ?? null; 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) { export async function planExternalInitialImport(req: Request, res: Response) {

View file

@ -1,6 +1,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; 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 { 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 { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.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); } } 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) { export async function assignExternalObjectsToRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? ""); const projectId = String(req.params.projectId ?? "");
try { try {

View file

@ -63,8 +63,9 @@ import {
getExternalInitialImportStatus, getExternalInitialImportStatus,
planExternalInitialImport, planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js"; } 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 { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
export const projectRouter = Router(); 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.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); 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.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects); 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-row", assignExternalObjectsToRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow); projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit); projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);

View file

@ -0,0 +1,98 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js";
import { createExternalObjectDeleteMissingProjectCommand } from "../src/domain/models/external-object-delete-missing-project-command.model.js";
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../src/db/repositories/external-object-delete-missing-project-command.repository.js";
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
describe("external follow-up import project command", () => {
it("commits object and row quantities atomically and restores exact batches on undo and redo", async () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
context.db.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run();
context.db.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run();
context.db.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
context.db.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run();
context.db.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration };
context.db.insert(externalModelConfigurations).values(configurationSnapshot).run();
const initialBytes = await createRevitXlsxFixture();
const initialParsed = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs });
let sequence = 0;
const initialState = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initialParsed.document, plan: initialParsed.plan, roomDecisions: initialParsed.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: initialParsed.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) });
const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }]));
const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues });
const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
const repository = new ExternalFollowUpImportProjectCommandRepository(context.db);
const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] });
assert.throws(
() => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: commandWithoutRequiredRowTransition }),
/Materialized quantity 3 does not match manual and external quantity 4/,
);
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
const applied = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: built.rowTransitions }) });
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
let state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
assert.equal(state.importBatches.length, 2); assert.equal(state.importBatches[0]!.document, null); assert.equal(state.objects.find((object) => object.worksharingId === firstWorksharingId)!.planningValues.effectiveQuantity, 3);
const undone = repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse });
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
assert.equal(state.importBatches.length, 1); assert.ok(state.importBatches[0]!.document);
repository.execute({ projectId: "project-1", expectedRevision: 3, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse });
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
context.db.update(externalModelObjects).set({ presenceStatus: "missing" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
const missingObject = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.id === importedFirst.id)!;
const rowBeforeDelete = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
const deleteRepository = new ExternalObjectDeleteMissingProjectCommandRepository(context.db);
const deleted = deleteRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "user", command: createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: missingObject, target: null }], rows: [{ expected: rowBeforeDelete, target: { ...rowBeforeDelete, quantity: 1 } }] }) });
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 1);
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get(), undefined);
const foreignRestore = createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: null, target: { ...missingObject, projectId: "project-2" } }], rows: deleted.inverse.payload.rows });
assert.throws(() => deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "user", command: foreignRestore }), /anderes Projekt/);
deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse });
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get()!.circuitDeviceRowId, "row-1");
} finally { context.close(); }
});
});

View file

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js";
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003";
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
async function initialState() {
const bytes = await createRevitXlsxFixture();
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs });
let sequence = 0;
const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
const missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099";
state.objects.push(missing);
return state;
}
async function changedWorkbook(firstId = firstWorksharingId) {
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture());
const sheet = workbook.worksheets[0]!;
sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3;
sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1;
return new Uint8Array(await workbook.xlsx.writeBuffer());
}
function currentReturns(state: Awaited<ReturnType<typeof initialState>>) {
const map = new Map<string, ExternalXlsxReturnValues>();
for (const object of state.objects) map.set(object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA });
map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" });
return map;
}
describe("Linear XLSX follow-up import", () => {
it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => {
const state = await initialState();
const bytes = await changedWorkbook();
const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 });
assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]);
assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true);
const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
assert.equal(caseChanged.plan.newObjectCount, 2);
assert.equal(caseChanged.plan.missingObjectCount, 2);
});
it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => {
const state = await initialState();
const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
first.circuitDeviceRowId = "row-1";
const bytes = await changedWorkbook();
const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
let sequence = 0;
const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null };
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
assert.equal(built.rowTransitions[0]!.target.quantity, 4);
assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3);
const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!;
assert.equal(missing.presenceStatus, "missing");
assert.equal(missing.circuitDeviceRowId, null);
assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null);
assert.ok(built.targetState.importBatches[1]!.originalContentBase64);
const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!;
assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity);
assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId);
assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId);
assert.equal(rejected.rowTransitions.length, 0);
});
});

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { applyExternalInitialImport, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; import { applyExternalFollowUpImport, applyExternalInitialImport, createExternalReturnWorkbook, deleteMissingExternalProjectObjects, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalFollowUpImport, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => { describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", async () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
@ -53,6 +53,7 @@ describe("Revit XLSX settings", () => {
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8"); const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8"); const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8"); const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8");
const followUpWizard = readFileSync("src/frontend/components/revit-follow-up-import-wizard.tsx", "utf8");
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8"); const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/); assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/); assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
@ -64,10 +65,23 @@ describe("Revit XLSX settings", () => {
assert.match(wizard, /Schritt.*von/); assert.match(wizard, /Schritt.*von/);
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
assert.match(wizard, /GZF 1/); assert.match(wizard, /GZF 1/);
assert.match(revitPage, /Folgeimport/);
assert.match(followUpWizard, /in Revit noch nicht aktualisiert/);
assert.match(followUpWizard, /Fehlende Objekte werden nicht gelöst oder gelöscht/);
assert.match(objectTable, /viewportHeight/); assert.match(objectTable, /viewportHeight/);
assert.match(objectTable, /Sammeländerung anwenden/); assert.match(objectTable, /Sammeländerung anwenden/);
assert.match(objectTable, /Rückgabe-Excel herunterladen/); assert.match(objectTable, /Rückgabe-Excel herunterladen/);
assert.match(objectTable, /Nur geänderte Objekte/); assert.match(objectTable, /Nur geänderte Objekte/);
assert.match(objectTable, /Ausgewählte fehlende löschen/);
});
it("deletes missing objects only after explicit confirmation", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 6 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
try { await deleteMissingExternalProjectObjects("project-1", 5, ["object-1"]); }
finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/delete-missing", method: "POST", body: { expectedRevision: 5, objectIds: ["object-1"], confirmed: true } }]);
}); });
it("loads one project object list and sends revision-safe bulk updates", async () => { it("loads one project object list and sends revision-safe bulk updates", async () => {
@ -90,4 +104,21 @@ describe("Revit XLSX settings", () => {
finally { globalThis.fetch = originalFetch; } finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]); assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
}); });
it("previews and retransmits the complete workbook for follow-up apply", async () => {
const requests: Array<{ url: string; method: string; body: Record<string, unknown> | undefined }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 10 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
try {
const configuration = createDefaultExternalModelConfiguration();
const file = new File([new Uint8Array([1, 2])], "follow.xlsx");
await planExternalFollowUpImport("project-1", file, configuration, 9);
await applyExternalFollowUpImport("project-1", file, { expectedRevision: 9, expectedSha256: "a".repeat(64), configuration, objectsWithoutRoomDefaultDistributionBoardId: null, sourceChangeDecisions: [], roomDecisions: [], familyProjectDeviceDecisions: [] });
} finally { globalThis.fetch = originalFetch; }
assert.equal(requests[0].url, "/api/projects/project-1/external-model/follow-up-import/plan");
assert.equal(requests[1].url, "/api/projects/project-1/external-model/follow-up-import/apply");
assert.equal(requests[0].body?.contentBase64, "AQI=");
assert.equal(requests[1].body?.contentBase64, "AQI=");
assert.equal(requests[1].body?.expectedRevision, 9);
});
}); });