Compare commits

..

No commits in common. "1a4c8cad8cd9f88044dd3d267616fdd9f58398cd" and "4ac32e103da9b9c81772687002147ba1987c0d8d" have entirely different histories.

39 changed files with 86 additions and 4642 deletions

View file

@ -306,16 +306,6 @@ aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
## 11. Editor-Zuordnung und Löschen ## 11. Editor-Zuordnung und Löschen
Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste
gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen
Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als
Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die
drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen
kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und
Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und
Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen
explizit.
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum, Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
@ -348,14 +338,6 @@ Pfade.
## 12. Rückgabe-Excel ## 12. Rückgabe-Excel
Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import, Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash

View file

@ -15,8 +15,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. | | R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | | R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
| 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 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | | R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
@ -173,13 +173,6 @@ Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf. R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
## R9 – Folgeimport ## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID; - exaktes, case-sensitives Matching nach Worksharing-ID;

View file

@ -1128,17 +1128,6 @@ a.kpi:hover {
border-style: dashed; border-style: dashed;
} }
.project-device-item > summary {
display: flex;
flex-direction: column;
gap: 0.1rem;
cursor: grab;
}
.project-device-item > summary::marker {
color: var(--text-faint);
}
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -45,7 +45,7 @@ export default function RevitPage() {
{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}</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 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()} 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}
</main>; </main>;
} }

View file

@ -1,16 +0,0 @@
CREATE TABLE `external_export_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`project_revision` integer NOT NULL,
`import_batch_id` text NOT NULL,
`exported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`configuration_snapshot` text NOT NULL,
`configuration_sha256` text NOT NULL,
`scope` text NOT NULL,
`object_count` integer NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);

File diff suppressed because it is too large Load diff

View file

@ -106,13 +106,6 @@
"when": 1790748107566, "when": 1790748107566,
"tag": "0014_linear_xlsx_external_model", "tag": "0014_linear_xlsx_external_model",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1790773066696,
"tag": "0015_wide_night_thrasher",
"breakpoints": true
} }
] ]
} }

View file

@ -23,15 +23,8 @@ import {
toCircuitDeviceRowInsertValues, toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore implements CircuitDeviceRowStructureProjectCommandStore
@ -39,11 +32,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
return executeProjectCommandTransactionWithAppliedForward< return executeProjectCommandTransaction(
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand,
CircuitDeviceRowStructureProjectCommand
>(
this.database, this.database,
input, input,
(tx) => (tx) =>
@ -61,15 +50,14 @@ export class CircuitDeviceRowStructureProjectCommandRepository
projectId: string, projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"], source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
command: CircuitDeviceRowStructureProjectCommand command: CircuitDeviceRowStructureProjectCommand
): AppliedProjectCommand<CircuitDeviceRowStructureProjectCommand, CircuitDeviceRowStructureProjectCommand> { ): CircuitDeviceRowStructureProjectCommand {
if (command.type === circuitDeviceRowInsertCommandType) { if (command.type === circuitDeviceRowInsertCommandType) {
assertCircuitDeviceRowInsertProjectCommand(command); assertCircuitDeviceRowInsertProjectCommand(command);
return this.insert( return this.insert(
database, database,
projectId, projectId,
source, source,
command.payload.row, command.payload.row
command.payload.externalObjects ?? []
); );
} }
if (command.type === circuitDeviceRowDeleteCommandType) { if (command.type === circuitDeviceRowDeleteCommandType) {
@ -78,8 +66,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database, database,
projectId, projectId,
command.payload.rowId, command.payload.rowId,
command.payload.expectedCircuitId, command.payload.expectedCircuitId
command.payload.externalObjects
); );
} }
throw new Error("Unsupported circuit device-row structure command."); throw new Error("Unsupported circuit device-row structure command.");
@ -89,8 +76,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
source: ExecuteCircuitDeviceRowStructureCommandInput["source"], source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
row: CircuitDeviceRowSnapshot, row: CircuitDeviceRowSnapshot
externalObjects: ExternalObjectLinkTransition[]
) { ) {
if (source === "user" && !isElectricalPhaseType(row.phaseType)) { if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
throw new Error("Device-row phase type is invalid."); throw new Error("Device-row phase type is invalid.");
@ -121,23 +107,17 @@ export class CircuitDeviceRowStructureProjectCommandRepository
} }
updateDerivedCircuitVoltage(database, projectId, row.circuitId); updateDerivedCircuitVoltage(database, projectId, row.circuitId);
if (externalObjects.length > 0) { return createCircuitDeviceRowDeleteProjectCommand(
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); row.id,
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); row.circuitId
} );
return {
forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects),
inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)),
};
} }
private delete( private delete(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
rowId: string, rowId: string,
expectedCircuitId: string, expectedCircuitId: string
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) { ) {
const row = database const row = database
.select() .select()
@ -150,10 +130,6 @@ export class CircuitDeviceRowStructureProjectCommandRepository
); );
} }
this.assertCircuitInProject(database, projectId, row.circuitId); this.assertCircuitInProject(database, projectId, row.circuitId);
const externalObjects = suppliedExternalObjects ??
captureExternalObjectDetachTransitions(database, projectId, [rowId]);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const result = database const result = database
.delete(circuitDeviceRows) .delete(circuitDeviceRows)
@ -188,10 +164,9 @@ export class CircuitDeviceRowStructureProjectCommandRepository
expectedCircuitId expectedCircuitId
); );
return { return createCircuitDeviceRowInsertProjectCommand(
forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects), toCircuitDeviceRowSnapshot(row)
inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)), );
};
} }
private assertCircuitInProject( private assertCircuitInProject(

View file

@ -25,14 +25,8 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
} from "./external-object-assignment.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository export class CircuitGroupSubtreeProjectCommandRepository
implements CircuitGroupSubtreeProjectCommandStore implements CircuitGroupSubtreeProjectCommandStore
@ -49,11 +43,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
} else { } else {
assertCircuitGroupRestoreSubtreeProjectCommand(input.command); assertCircuitGroupRestoreSubtreeProjectCommand(input.command);
} }
return executeProjectCommandTransactionWithAppliedForward< return executeProjectCommandTransaction(
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand,
CircuitGroupSubtreeProjectCommand
>(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input.projectId, input.command) (tx) => this.applyCommand(tx, input.projectId, input.command)
@ -64,7 +54,7 @@ export class CircuitGroupSubtreeProjectCommandRepository
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
command: CircuitGroupSubtreeProjectCommand command: CircuitGroupSubtreeProjectCommand
): AppliedProjectCommand<CircuitGroupSubtreeProjectCommand, CircuitGroupSubtreeProjectCommand> { ) {
if (command.type === circuitGroupDeleteSubtreeCommandType) { if (command.type === circuitGroupDeleteSubtreeCommandType) {
const current = this.captureFrom( const current = this.captureFrom(
database, database,
@ -81,14 +71,6 @@ export class CircuitGroupSubtreeProjectCommandRepository
projectId, projectId,
current.circuits.map((entry) => entry.circuit.id) current.circuits.map((entry) => entry.circuit.id)
); );
const externalObjects = command.payload.externalObjects ??
captureExternalObjectDetachTransitions(
database,
projectId,
current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id))
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const deleted = database const deleted = database
.delete(circuitSections) .delete(circuitSections)
.where(eq(circuitSections.id, current.group.id)) .where(eq(circuitSections.id, current.group.id))
@ -96,20 +78,15 @@ export class CircuitGroupSubtreeProjectCommandRepository
if (deleted.changes !== 1) { if (deleted.changes !== 1) {
throw new Error("Circuit-group subtree could not be deleted."); throw new Error("Circuit-group subtree could not be deleted.");
} }
return { return createCircuitGroupRestoreSubtreeProjectCommand(
forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects), command.payload.snapshot
inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)), );
};
} }
if (command.type === circuitGroupRestoreSubtreeCommandType) { if (command.type === circuitGroupRestoreSubtreeCommandType) {
this.restore(database, projectId, command.payload.snapshot); this.restore(database, projectId, command.payload.snapshot);
const externalObjects = command.payload.externalObjects ?? []; return createCircuitGroupDeleteSubtreeProjectCommand(
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); command.payload.snapshot
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); );
return {
forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects),
inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
} }
throw new Error("Unsupported circuit-group subtree command."); throw new Error("Unsupported circuit-group subtree command.");
} }

View file

@ -25,17 +25,10 @@ import {
toCircuitDeviceRowInsertValues, toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js";
import {
applyExternalObjectLinks,
captureExternalObjectDetachTransitions,
invertExternalObjectLinkTransitions,
loadExpectedExternalObjectTransitions,
type ExternalObjectLinkTransition,
} from "./external-object-assignment.persistence.js";
export class CircuitStructureProjectCommandRepository export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore implements CircuitStructureProjectCommandStore
@ -43,15 +36,11 @@ export class CircuitStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitStructureCommandInput) { execute(input: ExecuteCircuitStructureCommandInput) {
return executeProjectCommandTransactionWithAppliedForward< return executeProjectCommandTransaction(
CircuitStructureProjectCommand,
CircuitStructureProjectCommand,
CircuitStructureProjectCommand
>(
this.database, this.database,
input, input,
(tx) => (tx) =>
this.applyWithForward( this.applyInTransaction(
tx, tx,
input.projectId, input.projectId,
input.source, input.source,
@ -70,23 +59,13 @@ export class CircuitStructureProjectCommandRepository
source: ExecuteCircuitStructureCommandInput["source"], source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand command: CircuitStructureProjectCommand
): CircuitStructureProjectCommand { ): CircuitStructureProjectCommand {
return this.applyWithForward(database, projectId, source, command).inverse;
}
private applyWithForward(
database: AppDatabase,
projectId: string,
source: ExecuteCircuitStructureCommandInput["source"],
command: CircuitStructureProjectCommand
): AppliedProjectCommand<CircuitStructureProjectCommand, CircuitStructureProjectCommand> {
if (command.type === circuitInsertCommandType) { if (command.type === circuitInsertCommandType) {
assertCircuitInsertProjectCommand(command); assertCircuitInsertProjectCommand(command);
return this.insert( return this.insert(
database, database,
projectId, projectId,
source, source,
command.payload.circuit, command.payload.circuit
command.payload.externalObjects ?? []
); );
} }
if (command.type === circuitDeleteCommandType) { if (command.type === circuitDeleteCommandType) {
@ -95,8 +74,7 @@ export class CircuitStructureProjectCommandRepository
database, database,
projectId, projectId,
command.payload.circuitId, command.payload.circuitId,
command.payload.expectedCircuitListId, command.payload.expectedCircuitListId
command.payload.externalObjects
); );
} }
throw new Error("Unsupported circuit structure command."); throw new Error("Unsupported circuit structure command.");
@ -106,8 +84,7 @@ export class CircuitStructureProjectCommandRepository
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
source: ExecuteCircuitStructureCommandInput["source"], source: ExecuteCircuitStructureCommandInput["source"],
snapshot: CircuitSnapshot, snapshot: CircuitSnapshot
externalObjects: ExternalObjectLinkTransition[]
) { ) {
this.assertCircuitLocation(database, projectId, snapshot); this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") { if (source === "user") {
@ -195,23 +172,17 @@ export class CircuitStructureProjectCommandRepository
.run(); .run();
} }
if (externalObjects.length > 0) { return createCircuitDeleteProjectCommand(
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); snapshot.id,
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); snapshot.circuitListId
} );
return {
forward: createCircuitInsertProjectCommand(snapshot, externalObjects),
inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)),
};
} }
private delete( private delete(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
circuitId: string, circuitId: string,
expectedCircuitListId: string, expectedCircuitListId: string
suppliedExternalObjects?: ExternalObjectLinkTransition[]
) { ) {
const circuit = database const circuit = database
.select() .select()
@ -249,12 +220,7 @@ export class CircuitStructureProjectCommandRepository
.from(circuitProtectionDevices) .from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, circuit.id)) .where(eq(circuitProtectionDevices.circuitId, circuit.id))
.get(); .get();
const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions( const inverse = createCircuitInsertProjectCommand({
database, projectId, rows.map((row) => row.id)
);
loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert.");
applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert.");
const circuitSnapshot: CircuitSnapshot = {
id: circuit.id, id: circuit.id,
circuitListId: circuit.circuitListId, circuitListId: circuit.circuitListId,
sectionId: circuit.sectionId, sectionId: circuit.sectionId,
@ -273,7 +239,7 @@ export class CircuitStructureProjectCommandRepository
remark: circuit.remark, remark: circuit.remark,
deviceRows: rows.map(toCircuitDeviceRowSnapshot), deviceRows: rows.map(toCircuitDeviceRowSnapshot),
protectionDevice: protectionDevice ?? null, protectionDevice: protectionDevice ?? null,
}; });
assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]); assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]);
@ -289,10 +255,7 @@ export class CircuitStructureProjectCommandRepository
if (result.changes !== 1) { if (result.changes !== 1) {
throw new Error("Circuit could not be deleted."); throw new Error("Circuit could not be deleted.");
} }
return { return inverse;
forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects),
inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)),
};
} }
private assertCircuitLocation( private assertCircuitLocation(

View file

@ -46,7 +46,6 @@ import { floors } from "../schema/floors.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js";
export class DistributionBoardStructureProjectCommandRepository export class DistributionBoardStructureProjectCommandRepository
@ -291,7 +290,6 @@ export class DistributionBoardStructureProjectCommandRepository
} }
assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]); assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]);
assertDistributionBoardHasNoExternalObjects(database, projectId, board.id);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)

View file

@ -33,7 +33,6 @@ import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js";
import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository export class DistributionBoardSubtreeProjectCommandRepository
implements DistributionBoardSubtreeProjectCommandStore implements DistributionBoardSubtreeProjectCommandStore
@ -87,7 +86,6 @@ export class DistributionBoardSubtreeProjectCommandRepository
assertBoardsAreNotReferencedAsFeeders(database, projectId, [ assertBoardsAreNotReferencedAsFeeders(database, projectId, [
current.distributionBoard.id, current.distributionBoard.id,
]); ]);
assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id);
const deleted = database const deleted = database
.delete(distributionBoards) .delete(distributionBoards)
.where( .where(

View file

@ -1,18 +0,0 @@
import { desc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalExportBatches } from "../schema/external-export-batches.js";
export class ExternalExportBatchRepository {
constructor(private readonly database: AppDatabase) {}
append(batch: typeof externalExportBatches.$inferInsert) {
this.database.insert(externalExportBatches).values(batch).run();
return batch;
}
listByProject(projectId: string) {
return this.database.select().from(externalExportBatches)
.where(eq(externalExportBatches.projectId, projectId))
.orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
}
}

View file

@ -11,27 +11,6 @@ export interface ExternalObjectLinkTransition {
target: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot;
} }
export function captureExternalObjectDetachTransitions(
database: AppDatabase,
projectId: string,
rowIds: string[]
): ExternalObjectLinkTransition[] {
if (rowIds.length === 0) return [];
return database.select().from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
inArray(externalModelObjects.circuitDeviceRowId, rowIds)
)).all()
.map(toExternalObjectSnapshot)
.map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } }));
}
export function invertExternalObjectLinkTransitions(
transitions: ExternalObjectLinkTransition[]
): ExternalObjectLinkTransition[] {
return transitions.map(({ expected, target }) => ({ expected: target, target: expected }));
}
export function loadExpectedExternalObjectTransitions( export function loadExpectedExternalObjectTransitions(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
@ -107,13 +86,7 @@ export function applyExternalObjectLinks(
) { ) {
for (const { expected, target } of transitions) { for (const { expected, target } of transitions) {
const updated = database.update(externalModelObjects) const updated = database.update(externalModelObjects)
.set({ .set({ circuitDeviceRowId: target.circuitDeviceRowId })
circuitDeviceRowId: target.circuitDeviceRowId,
distributionBoardId: target.distributionBoardId,
linkedProjectDeviceId: target.linkedProjectDeviceId,
planningValues: target.planningValues,
overriddenFields: target.overriddenFields,
})
.where(and( .where(and(
eq(externalModelObjects.id, expected.id), eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null expected.circuitDeviceRowId === null

View file

@ -1,37 +0,0 @@
import { and, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
export function assertDistributionBoardHasNoExternalObjects(
database: AppDatabase,
projectId: string,
distributionBoardId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalModelObjects.distributionBoardId, distributionBoardId)
)).limit(1).get();
if (reference) {
throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}
export function assertRoomHasNoExternalObjects(
database: AppDatabase,
projectId: string,
roomId: string
) {
const reference = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId))
.where(and(
eq(externalModelObjects.projectId, projectId),
eq(externalRoomMappings.roomId, roomId)
)).limit(1).get();
if (reference) {
throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen.");
}
}

View file

@ -46,7 +46,6 @@ import { distributionBoards } from "../schema/distribution-boards.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js";
export class ProjectLocationStructureProjectCommandRepository export class ProjectLocationStructureProjectCommandRepository
implements ProjectLocationStructureProjectCommandStore implements ProjectLocationStructureProjectCommandStore
@ -440,7 +439,6 @@ export class ProjectLocationStructureProjectCommandRepository
"Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird." "Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird."
); );
} }
assertRoomHasNoExternalObjects(database, projectId, expected.id);
const deleted = database const deleted = database
.delete(rooms) .delete(rooms)
.where( .where(

View file

@ -1,24 +0,0 @@
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { projects } from "./projects.js";
export const externalExportScopes = ["all", "deviating", "selected"] as const;
export type ExternalExportScope = (typeof externalExportScopes)[number];
export const externalExportBatches = sqliteTable(
"external_export_batches",
{
id: text("id").primaryKey(),
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectRevision: integer("project_revision").notNull(),
importBatchId: text("import_batch_id").notNull(),
exportedAtIso: text("exported_at_iso").notNull(),
fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(),
configurationSnapshot: text("configuration_snapshot", { mode: "json" }).$type<ExternalModelConfiguration>().notNull(),
configurationSha256: text("configuration_sha256").notNull(),
scope: text("scope", { enum: externalExportScopes }).notNull(),
objectCount: integer("object_count").notNull(),
},
(table) => [index("external_export_batches_project_exported_idx").on(table.projectId, table.exportedAtIso)]
);

View file

@ -1,11 +1,4 @@
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export interface ExternalObjectLinkSnapshotTransition {
expected: ExternalModelObjectSnapshot;
target: ExternalModelObjectSnapshot;
}
export const circuitDeviceRowInsertCommandType = export const circuitDeviceRowInsertCommandType =
"circuit-device-row.insert" as const; "circuit-device-row.insert" as const;
@ -39,13 +32,11 @@ export interface CircuitDeviceRowSnapshot {
export interface CircuitDeviceRowInsertCommandPayload { export interface CircuitDeviceRowInsertCommandPayload {
row: CircuitDeviceRowSnapshot; row: CircuitDeviceRowSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitDeviceRowDeleteCommandPayload { export interface CircuitDeviceRowDeleteCommandPayload {
rowId: string; rowId: string;
expectedCircuitId: string; expectedCircuitId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitDeviceRowInsertProjectCommand export interface CircuitDeviceRowInsertProjectCommand
@ -65,8 +56,7 @@ export type CircuitDeviceRowStructureProjectCommand =
| CircuitDeviceRowDeleteProjectCommand; | CircuitDeviceRowDeleteProjectCommand;
export function createCircuitDeviceRowInsertProjectCommand( export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot, row: CircuitDeviceRowSnapshot
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowInsertProjectCommand { ): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = { const normalizedRow = {
...row, ...row,
@ -75,7 +65,7 @@ export function createCircuitDeviceRowInsertProjectCommand(
const command: CircuitDeviceRowInsertProjectCommand = { const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType, type: circuitDeviceRowInsertCommandType,
payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { row: normalizedRow },
}; };
assertCircuitDeviceRowInsertProjectCommand(command); assertCircuitDeviceRowInsertProjectCommand(command);
return command; return command;
@ -83,13 +73,12 @@ export function createCircuitDeviceRowInsertProjectCommand(
export function createCircuitDeviceRowDeleteProjectCommand( export function createCircuitDeviceRowDeleteProjectCommand(
rowId: string, rowId: string,
expectedCircuitId: string, expectedCircuitId: string
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeviceRowDeleteProjectCommand { ): CircuitDeviceRowDeleteProjectCommand {
const command: CircuitDeviceRowDeleteProjectCommand = { const command: CircuitDeviceRowDeleteProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowDeleteCommandType, type: circuitDeviceRowDeleteCommandType,
payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { rowId, expectedCircuitId },
}; };
assertCircuitDeviceRowDeleteProjectCommand(command); assertCircuitDeviceRowDeleteProjectCommand(command);
return command; return command;
@ -154,7 +143,6 @@ export function assertCircuitDeviceRowInsertProjectCommand(
throw new Error("row.cosPhi must be positive or null."); throw new Error("row.cosPhi must be positive or null.");
} }
} }
assertExternalObjectLinkTransitions(command.payload.externalObjects);
} }
export function assertCircuitDeviceRowDeleteProjectCommand( export function assertCircuitDeviceRowDeleteProjectCommand(
@ -175,41 +163,6 @@ export function assertCircuitDeviceRowDeleteProjectCommand(
command.payload.expectedCircuitId, command.payload.expectedCircuitId,
"expectedCircuitId" "expectedCircuitId"
); );
assertExternalObjectLinkTransitions(command.payload.externalObjects);
}
export function assertExternalObjectLinkTransitions(value: unknown) {
if (value === undefined) return;
if (!Array.isArray(value)) throw new Error("externalObjects must be an array.");
const ids = new Set<string>();
for (const transition of value) {
if (!isPlainObject(transition) || !isPlainObject(transition.expected) || !isPlainObject(transition.target)) {
throw new Error("Invalid external object link transition.");
}
const expectedId = transition.expected.id;
const targetId = transition.target.id;
if (typeof expectedId !== "string" || expectedId !== targetId || ids.has(expectedId)) {
throw new Error("External object link transition has mismatched or duplicate ids.");
}
ids.add(expectedId);
const expectedRowId = transition.expected.circuitDeviceRowId;
const targetRowId = transition.target.circuitDeviceRowId;
if ((expectedRowId !== null && typeof expectedRowId !== "string") || (targetRowId !== null && typeof targetRowId !== "string")) {
throw new Error("Invalid external object row link.");
}
const parsed = parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [transition.expected, transition.target] }).objects;
const expectedRest = { ...parsed[0], circuitDeviceRowId: null };
const targetRest = { ...parsed[1], circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest) || expectedRowId === targetRowId) {
throw new Error("External object transition may only change the row link.");
}
}
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
return JSON.stringify(value) ?? "null";
} }
function assertNonEmptyString(value: unknown, field: string) { function assertNonEmptyString(value: unknown, field: string) {

View file

@ -3,7 +3,6 @@ import {
type CircuitGroupSubtreeSnapshot, type CircuitGroupSubtreeSnapshot,
} from "./circuit-group-subtree-snapshot.model.js"; } from "./circuit-group-subtree-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
import { assertExternalObjectLinkTransitions, type ExternalObjectLinkSnapshotTransition } from "./circuit-device-row-structure-project-command.model.js";
export const circuitGroupDeleteSubtreeCommandType = export const circuitGroupDeleteSubtreeCommandType =
"circuit-group.delete-subtree" as const; "circuit-group.delete-subtree" as const;
@ -13,7 +12,6 @@ export const circuitGroupSubtreeCommandSchemaVersion = 1 as const;
interface Payload { interface Payload {
snapshot: CircuitGroupSubtreeSnapshot; snapshot: CircuitGroupSubtreeSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitGroupDeleteSubtreeProjectCommand export interface CircuitGroupDeleteSubtreeProjectCommand
@ -33,26 +31,24 @@ export type CircuitGroupSubtreeProjectCommand =
| CircuitGroupRestoreSubtreeProjectCommand; | CircuitGroupRestoreSubtreeProjectCommand;
export function createCircuitGroupDeleteSubtreeProjectCommand( export function createCircuitGroupDeleteSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot, snapshot: CircuitGroupSubtreeSnapshot
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupDeleteSubtreeProjectCommand { ): CircuitGroupDeleteSubtreeProjectCommand {
const command: CircuitGroupDeleteSubtreeProjectCommand = { const command: CircuitGroupDeleteSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion, schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupDeleteSubtreeCommandType, type: circuitGroupDeleteSubtreeCommandType,
payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { snapshot },
}; };
assertCircuitGroupDeleteSubtreeProjectCommand(command); assertCircuitGroupDeleteSubtreeProjectCommand(command);
return command; return command;
} }
export function createCircuitGroupRestoreSubtreeProjectCommand( export function createCircuitGroupRestoreSubtreeProjectCommand(
snapshot: CircuitGroupSubtreeSnapshot, snapshot: CircuitGroupSubtreeSnapshot
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitGroupRestoreSubtreeProjectCommand { ): CircuitGroupRestoreSubtreeProjectCommand {
const command: CircuitGroupRestoreSubtreeProjectCommand = { const command: CircuitGroupRestoreSubtreeProjectCommand = {
schemaVersion: circuitGroupSubtreeCommandSchemaVersion, schemaVersion: circuitGroupSubtreeCommandSchemaVersion,
type: circuitGroupRestoreSubtreeCommandType, type: circuitGroupRestoreSubtreeCommandType,
payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { snapshot },
}; };
assertCircuitGroupRestoreSubtreeProjectCommand(command); assertCircuitGroupRestoreSubtreeProjectCommand(command);
return command; return command;
@ -80,12 +76,11 @@ function assertCommand(
command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion || command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion ||
command.type !== type || command.type !== type ||
!isPlainObject(command.payload) || !isPlainObject(command.payload) ||
Object.keys(command.payload).some((key) => key !== "snapshot" && key !== "externalObjects") Object.keys(command.payload).length !== 1
) { ) {
throw new Error("Unsupported circuit-group subtree command."); throw new Error("Unsupported circuit-group subtree command.");
} }
assertCircuitGroupSubtreeSnapshot(command.payload.snapshot); assertCircuitGroupSubtreeSnapshot(command.payload.snapshot);
assertExternalObjectLinkTransitions(command.payload.externalObjects);
} }
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {

View file

@ -3,8 +3,6 @@ import {
circuitDeviceRowInsertCommandType, circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion, circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot, type CircuitDeviceRowSnapshot,
type ExternalObjectLinkSnapshotTransition,
assertExternalObjectLinkTransitions,
} from "./circuit-device-row-structure-project-command.model.js"; } from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
import { import {
@ -39,13 +37,11 @@ export interface CircuitSnapshot {
export interface CircuitInsertCommandPayload { export interface CircuitInsertCommandPayload {
circuit: CircuitSnapshot; circuit: CircuitSnapshot;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitDeleteCommandPayload { export interface CircuitDeleteCommandPayload {
circuitId: string; circuitId: string;
expectedCircuitListId: string; expectedCircuitListId: string;
externalObjects?: ExternalObjectLinkSnapshotTransition[];
} }
export interface CircuitInsertProjectCommand export interface CircuitInsertProjectCommand
@ -65,13 +61,12 @@ export type CircuitStructureProjectCommand =
| CircuitDeleteProjectCommand; | CircuitDeleteProjectCommand;
export function createCircuitInsertProjectCommand( export function createCircuitInsertProjectCommand(
circuit: CircuitSnapshot, circuit: CircuitSnapshot
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitInsertProjectCommand { ): CircuitInsertProjectCommand {
const command: CircuitInsertProjectCommand = { const command: CircuitInsertProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion, schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitInsertCommandType, type: circuitInsertCommandType,
payload: { circuit, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { circuit },
}; };
assertCircuitInsertProjectCommand(command); assertCircuitInsertProjectCommand(command);
return command; return command;
@ -79,13 +74,12 @@ export function createCircuitInsertProjectCommand(
export function createCircuitDeleteProjectCommand( export function createCircuitDeleteProjectCommand(
circuitId: string, circuitId: string,
expectedCircuitListId: string, expectedCircuitListId: string
externalObjects?: ExternalObjectLinkSnapshotTransition[]
): CircuitDeleteProjectCommand { ): CircuitDeleteProjectCommand {
const command: CircuitDeleteProjectCommand = { const command: CircuitDeleteProjectCommand = {
schemaVersion: circuitStructureCommandSchemaVersion, schemaVersion: circuitStructureCommandSchemaVersion,
type: circuitDeleteCommandType, type: circuitDeleteCommandType,
payload: { circuitId, expectedCircuitListId, ...(externalObjects?.length ? { externalObjects } : {}) }, payload: { circuitId, expectedCircuitListId },
}; };
assertCircuitDeleteProjectCommand(command); assertCircuitDeleteProjectCommand(command);
return command; return command;
@ -174,7 +168,6 @@ export function assertCircuitInsertProjectCommand(
} }
rowIds.add(row.id); rowIds.add(row.id);
} }
assertExternalObjectLinkTransitions(command.payload.externalObjects);
} }
export function assertCircuitDeleteProjectCommand( export function assertCircuitDeleteProjectCommand(
@ -194,7 +187,6 @@ export function assertCircuitDeleteProjectCommand(
command.payload.expectedCircuitListId, command.payload.expectedCircuitListId,
"expectedCircuitListId" "expectedCircuitListId"
); );
assertExternalObjectLinkTransitions(command.payload.externalObjects);
} }
function assertNonEmptyString(value: unknown, field: string) { function assertNonEmptyString(value: unknown, field: string) {

View file

@ -20,7 +20,6 @@ export interface CircuitTreeDeviceRow {
remark?: string; remark?: string;
overriddenFields?: string; overriddenFields?: string;
rowTotalPower: number; rowTotalPower: number;
externalObjectCount?: number;
} }
export interface CircuitTreeCircuit { export interface CircuitTreeCircuit {

View file

@ -146,8 +146,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot, expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot target: ExternalModelObjectSnapshot
) { ) {
const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null }; const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null }; const targetRest = { ...target, circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-circuit assignment may only change the external object row link."); throw new Error("New-circuit assignment may only change the external object row link.");
} }

View file

@ -141,8 +141,8 @@ function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot, expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot target: ExternalModelObjectSnapshot
) { ) {
const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null }; const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null }; const targetRest = { ...target, circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-row assignment may only change the external object row link."); throw new Error("New-row assignment may only change the external object row link.");
} }

View file

@ -104,10 +104,10 @@ export function assertExternalObjectRowAssignmentProjectCommand(
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) { if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
throw new Error("External object row assignment must change its row link."); throw new Error("External object row assignment must change its row link.");
} }
assertOnlyFieldsChanged( assertOnlyFieldChanged(
expected as unknown as Record<string, unknown>, expected as unknown as Record<string, unknown>,
target as unknown as Record<string, unknown>, target as unknown as Record<string, unknown>,
new Set(["circuitDeviceRowId", "distributionBoardId", "linkedProjectDeviceId", "planningValues", "overriddenFields"]), "circuitDeviceRowId",
"external object" "external object"
); );
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId); if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
@ -161,22 +161,11 @@ function assertOnlyFieldChanged(
target: Record<string, unknown>, target: Record<string, unknown>,
allowedField: string, allowedField: string,
label: string label: string
) {
assertOnlyFieldsChanged(expected, target, new Set([allowedField]), label);
}
function assertOnlyFieldsChanged(
expected: Record<string, unknown>,
target: Record<string, unknown>,
allowedFields: ReadonlySet<string>,
label: string
) { ) {
const expectedRest = { ...expected }; const expectedRest = { ...expected };
const targetRest = { ...target }; const targetRest = { ...target };
for (const field of allowedFields) { delete expectedRest[allowedField];
delete expectedRest[field]; delete targetRest[allowedField];
delete targetRest[field];
}
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error(`Row assignment may only change the ${label} assignment state.`); throw new Error(`Row assignment may only change the ${label} assignment state.`);
} }

View file

@ -1,135 +0,0 @@
import type { ExternalModelObjectSnapshot } from "../domain/external-model-contracts.js";
import type { ExternalXlsxCircuitMode, ExternalXlsxReturnValues } from "../xlsx/external-xlsx-contracts.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export interface ExternalReturnRowContext {
rowId: string;
displayName: string;
powerPerUnitKw: number;
cosPhi: number | null;
phaseType: string | null;
category: CircuitGroupCategory | null;
circuitIdentifier: string;
circuitVoltageV: number | null;
distributionBoardName: string;
}
export interface ExternalReturnProjection {
objectId: string;
worksharingId: string;
values: ExternalXlsxReturnValues;
differsFromImported: boolean;
warnings: string[];
}
export function getRevitBoardName(board: { name: string }) {
return board.name;
}
export function projectExternalXlsxReturn(input: {
object: ExternalModelObjectSnapshot;
row: ExternalReturnRowContext | null;
circuitMode: ExternalXlsxCircuitMode;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}): ExternalReturnProjection {
const { object, row } = input;
const planning = object.planningValues;
const warnings: string[] = [];
const category = row?.category ?? planning.category;
const phaseType = row?.phaseType ?? (category === "three_phase" ? "three_phase" : category ? "single_phase" : null);
const voltageV = row?.circuitVoltageV ?? defaultVoltage(category, input.singlePhaseVoltageV, input.threePhaseVoltageV);
const powerPerUnitW = row ? row.powerPerUnitKw * 1000 : planning.powerPerUnitW;
const electricalPowerW = powerPerUnitW === null
? null
: roundNumber(planning.effectiveQuantity * powerPerUnitW, 6);
const cosPhi = row?.cosPhi ?? planning.cosPhi;
const currentA = calculateCurrent({ electricalPowerW, voltageV, cosPhi, phaseType });
if (electricalPowerW === null) warnings.push("Elektrische Leistung ist nicht berechenbar.");
if (voltageV === null) warnings.push("Spannung ist nicht bestimmbar.");
if (currentA === null) warnings.push("Stromstärke ist nicht berechenbar.");
const values: ExternalXlsxReturnValues = {
...(input.circuitMode === "combined"
? { circuitCombined: row ? `${getRevitBoardName({ name: row.distributionBoardName })}${row.circuitIdentifier}` : "" }
: {
boardName: row ? getRevitBoardName({ name: row.distributionBoardName }) : "",
circuitIdentifier: row?.circuitIdentifier ?? "",
}),
designation: row?.displayName ?? planning.displayName ?? "",
electricalPowerW,
voltageV,
currentA,
};
const source = object.acceptedSourceValues;
const sourceValues: ExternalXlsxReturnValues = input.circuitMode === "combined"
? {
circuitCombined: source.circuitCombined,
designation: source.designation,
electricalPowerW: source.electricalPowerW,
voltageV: source.voltageV,
currentA: source.currentA,
}
: {
boardName: source.boardName,
circuitIdentifier: source.circuitIdentifier,
designation: source.designation,
electricalPowerW: source.electricalPowerW,
voltageV: source.voltageV,
currentA: source.currentA,
};
return {
objectId: object.id,
worksharingId: object.worksharingId,
values,
differsFromImported: Object.keys(values).some((key) =>
!sameScalar(values[key as keyof ExternalXlsxReturnValues], sourceValues[key as keyof ExternalXlsxReturnValues])
),
warnings,
};
}
function defaultVoltage(category: CircuitGroupCategory | null, singlePhase: number, threePhase: number) {
if (category === "three_phase") return threePhase;
if (category === "lighting" || category === "single_phase") return singlePhase;
return null;
}
function calculateCurrent(input: { electricalPowerW: number | null; voltageV: number | null; cosPhi: number | null; phaseType: string | null }) {
if (
input.electricalPowerW === null ||
input.voltageV === null || input.voltageV <= 0 ||
input.cosPhi === null || input.cosPhi <= 0 ||
(input.phaseType !== "single_phase" && input.phaseType !== "three_phase")
) return null;
const divisor = input.phaseType === "three_phase"
? Math.sqrt(3) * input.voltageV * input.cosPhi
: input.voltageV * input.cosPhi;
return roundNumber(input.electricalPowerW / divisor, 2);
}
function sameScalar(left: string | number | boolean | null | undefined, right: string | number | boolean | null | undefined) {
const normalizedLeft = normalizeScalar(left);
const normalizedRight = normalizeScalar(right);
if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
return Math.abs(normalizedLeft - normalizedRight) < 1e-9;
}
return normalizedLeft === normalizedRight;
}
function normalizeScalar(value: string | number | boolean | null | undefined): string | number | boolean {
if (value === null || value === undefined || value === "") return "";
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return "";
const numeric = Number(trimmed.replace(",", "."));
return Number.isFinite(numeric) ? numeric : trimmed;
}
return value;
}
function roundNumber(value: number, decimals: number) {
const factor = 10 ** decimals;
return Math.round((value + Number.EPSILON) * factor) / factor;
}

View file

@ -120,11 +120,6 @@ import {
deleteCircuitGroupCommand, deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand, deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand, deleteDistributionBoardComponentCommand,
assignExternalProjectObjectsToRow,
assignExternalProjectObjectsToNewRow,
assignExternalProjectObjectsToNewCircuit,
detachExternalProjectObjectsFromRow,
getExternalProjectObjects,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
getProjectHistory, getProjectHistory,
@ -159,7 +154,6 @@ import type {
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ExternalProjectObjectsDto,
} from "../types"; } from "../types";
import type { import type {
CircuitDeviceRowSnapshot, CircuitDeviceRowSnapshot,
@ -336,9 +330,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] = const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null); useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState<string[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false); useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState(""); const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@ -390,26 +381,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)), () => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys] [orderedColumns, visibleColumnKeys]
); );
const externalObjectGroups = useMemo(() => {
if (!data || !externalObjects) return [];
const groups = new Map<string, typeof externalObjects.objects>();
for (const object of externalObjects.objects) {
if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
const key = JSON.stringify([
object.roomId, object.selectionMarker, object.deviceTypeId,
object.planningValues.category, object.linkedProjectDeviceId,
object.planningValues,
]);
const entries = groups.get(key) ?? [];
entries.push(object);
groups.set(key, entries);
}
return [...groups.values()].map((objects) => ({
key: objects.map((object) => object.id).sort().join("|"),
objects,
quantity: objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
}));
}, [data, externalObjects]);
async function loadTree(options?: { showLoading?: boolean }) { async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false; const showLoading = options?.showLoading ?? false;
@ -418,17 +389,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
setError(null); setError(null);
try { try {
const [{ tree, history }, external] = await Promise.all([ const { tree, history } = await loadCircuitEditorSnapshot(
loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId), () => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId) () => getProjectHistory(projectId)
), );
getExternalProjectObjects(projectId),
]);
projectRevisionRef.current = history.currentRevision; projectRevisionRef.current = history.currentRevision;
setHistoryState(history); setHistoryState(history);
setData(tree); setData(tree);
setExternalObjects(external);
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
} finally { } finally {
@ -1354,20 +1321,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const summary = subtree const summary = subtree
? summarizeCircuitGroupSubtree(subtree) ? summarizeCircuitGroupSubtree(subtree)
: null; : null;
const externalObjectCount = section.circuits.reduce(
(total, circuit) => total + circuit.deviceRows.reduce(
(rowTotal, row) => rowTotal + (row.externalObjectCount ?? 0), 0
), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
const warning = isEmpty const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?` ? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` + : `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` +
`Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` + `Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` +
"Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden."; "Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
if (!confirm(warning + externalWarning)) { if (!confirm(warning)) {
return; return;
} }
await runCommand({ await runCommand({
@ -2919,15 +2878,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Die Gerätezeile ist ungültig."); setError("Die Gerätezeile ist ungültig.");
return; return;
} }
const externalWarning = (rowSnapshot.externalObjectCount ?? 0) > 0
? `\n\n${rowSnapshot.externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (sourceCircuit.deviceRows.length === 1) { if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm( const keepAsReserve = confirm(
`„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` + `„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" + "OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" +
"Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll." + "Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll."
externalWarning
); );
if (!keepAsReserve) { if (!keepAsReserve) {
const deleteCircuit = confirm( const deleteCircuit = confirm(
@ -2939,7 +2894,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
return; return;
} }
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?${externalWarning}`)) { } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) {
return; return;
} }
await runCommand({ await runCommand({
@ -2962,18 +2917,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Der Stromkreis ist ungültig."); setError("Der Stromkreis ist ungültig.");
return; return;
} }
const externalObjectCount = circuit.deviceRows.reduce(
(total, row) => total + (row.externalObjectCount ?? 0), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if ( if (
askForConfirmation && askForConfirmation &&
!confirm( !confirm(
`Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` + `Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` +
"Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." + "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert."
externalWarning
) )
) { ) {
return; return;
@ -3010,56 +2958,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId); await handleDeleteCircuit(intent.circuitId);
} }
async function handleAssignExternalObjects(rowId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) dieser Gerätezeile zuordnen?\n\nAbweichende Planungswerte werden ausdrücklich zugunsten der Gerätezeile bestätigt.`)) return;
await runCommand({
label: "Revit-Objekte zuordnen",
redo: async () => {
const result = await assignExternalProjectObjectsToRow(
projectId,
getExpectedProjectRevision(),
objectIds,
rowId,
objectIds
);
applyProjectCommandResult(result);
setDraggingExternalObjectIds([]);
return null;
},
});
}
async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Gerätezeile zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewRow(projectId, getExpectedProjectRevision(), objectIds, circuitId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleAssignExternalObjectsToNewCircuit(sectionId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neuen Stromkreis anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Stromkreis zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewCircuit(projectId, getExpectedProjectRevision(), objectIds, sectionId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleDetachExternalObjects(rowId: string) {
const objectIds = externalObjects?.objects
.filter((object) => object.circuitDeviceRowId === rowId)
.map((object) => object.id) ?? [];
if (objectIds.length === 0 || !confirm(`${objectIds.length} Revit-Objekt(e) lösen?\n\nDie aktuellen Werte der Gerätezeile werden als Planungswerte und Overrides übernommen.`)) return;
await runCommand({
label: "Revit-Objekte lösen",
redo: async () => {
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
applyProjectCommandResult(result);
return null;
},
});
}
// Explicit renumber action through project history. Sorting and device rows // Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps. // remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) { async function handleRenumberSection(sectionId: string) {
@ -3483,15 +3381,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
showLabel showLabel
/> />
<IconButton
ariaControls="revit-object-drawer"
ariaExpanded={isRevitDrawerOpen}
className="project-device-drawer-toggle"
icon={isRevitDrawerOpen ? PanelRightClose : PanelRightOpen}
label={isRevitDrawerOpen ? "Revit-Objekte schließen" : `Revit-Objekte öffnen (${externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)})`}
onClick={() => setIsRevitDrawerOpen((current) => !current)}
showLabel
/>
<IconButton <IconButton
ariaControls="project-device-drawer" ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen} ariaExpanded={isProjectDeviceDrawerOpen}
@ -3530,62 +3419,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
) : null} ) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null} {isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
<div className="tree-editor-layout"> <div className="tree-editor-layout">
{isRevitDrawerOpen ? (
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
<div className="project-device-drawer-header">
<div>
<h3>Revit-Objekte</h3>
<span>{externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)} unzugeordnet</span>
</div>
<button type="button" onClick={() => setIsRevitDrawerOpen(false)}>Schließen</button>
</div>
<div className="project-device-list">
{externalObjectGroups.map((group) => {
const first = group.objects[0]!;
return (
<details
key={group.key}
className={`project-device-item ${draggingExternalObjectIds[0] === first.id ? "dragging" : ""}`}
>
<summary
draggable
onDragStart={(event) => {
const ids = group.objects.map((object) => object.id);
setDraggingExternalObjectIds(ids);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify(ids));
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
<strong>{first.planningValues.displayName || first.selectionMarker || first.familyAndType}</strong>
<span>{first.sourceRoomNumber} {first.sourceRoomName}</span>
<span>{group.objects.length} Objekt(e), Menge {formatValue(group.quantity, "quantity")}</span>
<span>{first.familyAndType}</span>
</summary>
{group.objects.map((object) => (
<button
key={object.id}
type="button"
draggable
className="btn btn-sm btn-outline-secondary mt-2 w-100 text-start"
onDragStart={(event) => {
setDraggingExternalObjectIds([object.id]);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify([object.id]));
event.stopPropagation();
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
{object.worksharingId}
</button>
))}
</details>
);
})}
{externalObjectGroups.length === 0 ? <p className="notice muted">Keine unzugeordneten Revit-Objekte für diesen Verteiler.</p> : null}
</div>
</aside>
) : null}
{isProjectDeviceDrawerOpen ? ( {isProjectDeviceDrawerOpen ? (
<aside <aside
className="project-device-sidebar project-device-drawer" className="project-device-sidebar project-device-drawer"
@ -4369,11 +4202,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}`} }`}
onClick={() => setActiveSectionId(row.sectionId)} onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => { onDragOver={(event) => {
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
return;
}
if (draggingCircuitCount > 0) { if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid( const valid = isCircuitDropTargetValid(
row.sectionId row.sectionId
@ -4514,21 +4342,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
}} }}
onDrop={(event) => { onDrop={(event) => {
if (draggingExternalObjectIds.length > 0 && row.device) {
event.preventDefault();
void handleAssignExternalObjects(row.device.id, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.rowType === "placeholder") {
event.preventDefault();
void handleAssignExternalObjectsToNewCircuit(row.sectionId, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.circuit) {
event.preventDefault();
void handleAssignExternalObjectsToNewRow(row.circuit.id, draggingExternalObjectIds);
return;
}
if (draggingCircuitCount > 0) { if (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, { void handleCircuitReorderDrop(event, {
@ -4915,17 +4728,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<>
{(row.device.externalObjectCount ?? 0) > 0 ? (
<button
type="button"
className="badge rounded-pill text-bg-info border-0"
title="Verknüpfte Revit-Objekte anzeigen und lösen"
onClick={() => void handleDetachExternalObjects(row.device!.id)}
>
Revit {row.device.externalObjectCount}
</button>
) : null}
<IconButton <IconButton
disabled={isSaving} disabled={isSaving}
icon={ListX} icon={ListX}
@ -4934,7 +4736,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={-1} tabIndex={-1}
variant="danger" variant="danger"
/> />
</>
) : null} ) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint"> <span className="drop-hint">

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types"; import type { ExternalProjectObjectsDto } from "../types";
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
const rowHeight = 44; const rowHeight = 44;
const viewportHeight = 520; const viewportHeight = 520;
@ -23,10 +23,6 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
const [planningValue, setPlanningValue] = useState(""); const [planningValue, setPlanningValue] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [returnScope, setReturnScope] = useState<ExternalReturnScopeDto>("all");
const [exporting, setExporting] = useState(false);
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
const [exportMessage, setExportMessage] = useState<string | null>(null);
async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } } async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } }
useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
@ -48,7 +44,6 @@ 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 selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
async function applyBulk() { async function applyBulk() {
if (!data || selected.size === 0) return; if (!data || selected.size === 0) return;
@ -69,38 +64,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
finally { setSaving(false); } finally { setSaving(false); }
} }
async function exportReturnWorkbook() {
if (!data || (returnScope === "selected" && selected.size === 0)) return;
setExporting(true); setError(null); setExportWarnings([]); setExportMessage(null);
try {
const result = await createExternalReturnWorkbook(projectId, data.currentRevision, returnScope, [...selected]);
downloadBase64(result.contentBase64, result.fileName);
setExportWarnings(result.warnings);
setExportMessage(`${result.objectCount} Revit-Objekte wurden in ${result.fileName} geschrieben.`);
} catch (value) {
setError(value instanceof Error ? value.message : "Rückgabe-Excel konnte nicht erstellt werden.");
} finally {
setExporting(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 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)} disabled={linked} 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; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div> <div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(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} 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(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></section>; </div></section>;
} }
function downloadBase64(contentBase64: string, fileName: string) {
const binary = atob(contentBase64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
const url = URL.createObjectURL(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -13,9 +13,8 @@ const labels: Record<ExternalXlsxColumnKey, string> = {
electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke", electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke",
}; };
export function RevitXlsxSettingsModal({ configuration, isNewConfiguration = false, isSaving, onClose, onInspectSample, onSave }: { export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: {
configuration: ExternalModelConfiguration; configuration: ExternalModelConfiguration;
isNewConfiguration?: boolean;
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise<string>; onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise<string>;
@ -34,7 +33,7 @@ export function RevitXlsxSettingsModal({ configuration, isNewConfiguration = fal
} }
} }
return <FormModal title="Revit-XLSX-Einstellungen" description="Layout und Spalten der Linear-Bauteilliste" isSaving={isSaving} onClose={onClose} submitDisabled={!isNewConfiguration && JSON.stringify(draft) === JSON.stringify(configuration)} submitLabel="Einstellungen speichern" onSubmit={(event: FormEvent) => { event.preventDefault(); void onSave(draft); }}> return <FormModal title="Revit-XLSX-Einstellungen" description="Layout und Spalten der Linear-Bauteilliste" isSaving={isSaving} onClose={onClose} submitDisabled={JSON.stringify(draft) === JSON.stringify(configuration)} submitLabel="Einstellungen speichern" onSubmit={(event: FormEvent) => { event.preventDefault(); void onSave(draft); }}>
<div className="row g-3"> <div className="row g-3">
<div className="col-12"><label className="form-label" htmlFor="revit-sample">Musterdatei prüfen</label><input id="revit-sample" className="form-control" type="file" accept=".xlsx" onChange={(event) => void inspectSample(event.target.files?.[0])} />{sampleStatus ? <div className="form-text">{sampleStatus}</div> : null}</div> <div className="col-12"><label className="form-label" htmlFor="revit-sample">Musterdatei prüfen</label><input id="revit-sample" className="form-control" type="file" accept=".xlsx" onChange={(event) => void inspectSample(event.target.files?.[0])} />{sampleStatus ? <div className="form-text">{sampleStatus}</div> : null}</div>
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) => <div className="col-md-4" key={key}><label className="form-label" htmlFor={`layout-${key}`}>{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"}</label><input id={`layout-${key}`} className="form-control" min={1} type="number" value={draft.layout[key]} onChange={(event) => setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} /></div>)} {(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) => <div className="col-md-4" key={key}><label className="form-label" htmlFor={`layout-${key}`}>{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"}</label><input id={`layout-${key}`} className="form-control" min={1} type="number" value={draft.layout[key]} onChange={(event) => setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} /></div>)}

View file

@ -126,29 +126,6 @@ export interface ExternalProjectObjectsDto {
objects: ExternalProjectObjectDto[]; objects: ExternalProjectObjectDto[];
} }
export type ExternalReturnScopeDto = "all" | "deviating" | "selected";
export interface ExternalReturnWorkbookDto {
fileName: string;
contentBase64: string;
sha256: string;
objectCount: number;
warnings: string[];
}
export interface ExternalExportBatchDto {
id: string;
projectId: string;
projectRevision: number;
importBatchId: string;
exportedAtIso: string;
fileName: string;
sha256: string;
configurationSha256: string;
scope: ExternalReturnScopeDto;
objectCount: number;
}
export interface ProjectDto { export interface ProjectDto {
id: string; id: string;
name: string; name: string;
@ -470,7 +447,6 @@ export interface CircuitTreeDeviceRowDto {
remark?: string; remark?: string;
overriddenFields?: string; overriddenFields?: string;
rowTotalPower: number; rowTotalPower: number;
externalObjectCount?: number;
} }
export interface CircuitTreeProtectionDeviceDto { export interface CircuitTreeProtectionDeviceDto {

View file

@ -44,8 +44,6 @@ import type {
ExternalInitialImportStatusDto, ExternalInitialImportStatusDto,
ApplyExternalInitialImportInput, ApplyExternalInitialImportInput,
ExternalProjectObjectsDto, ExternalProjectObjectsDto,
ExternalReturnScopeDto,
ExternalReturnWorkbookDto,
CreateEstimateBalanceInput, CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto, EstimateBalanceCommandResultDto,
EstimateBalanceDto, EstimateBalanceDto,
@ -247,44 +245,6 @@ 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 createExternalReturnWorkbook(
projectId: string,
expectedRevision: number,
scope: ExternalReturnScopeDto,
selectedObjectIds: string[]
) {
return request<ExternalReturnWorkbookDto>(`/api/projects/${projectId}/external-model/return-workbook`, {
method: "POST",
body: JSON.stringify({ expectedRevision, scope, selectedObjectIds }),
});
}
export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, {
method: "POST",
body: JSON.stringify({ expectedRevision, objectIds, rowId, confirmedConflictObjectIds }),
});
}
export function detachExternalProjectObjectsFromRow(projectId: string, expectedRevision: number, objectIds: string[]) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/detach-row`, {
method: "POST",
body: JSON.stringify({ expectedRevision, objectIds }),
});
}
export function assignExternalProjectObjectsToNewRow(projectId: string, expectedRevision: number, objectIds: string[], circuitId: string) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-new-row`, {
method: "POST", body: JSON.stringify({ expectedRevision, objectIds, circuitId }),
});
}
export function assignExternalProjectObjectsToNewCircuit(projectId: string, expectedRevision: number, objectIds: string[], sectionId: string) {
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-new-circuit`, {
method: "POST", body: JSON.stringify({ expectedRevision, objectIds, sectionId }),
});
}
export function getProjectHistory(projectId: string) { export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>( return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history` `/api/projects/${projectId}/history`

View file

@ -15,7 +15,6 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js"; import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { ExternalExportBatchRepository } from "../../db/repositories/external-export-batch.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js"; import { UserRepository } from "../../db/repositories/user.repository.js";
@ -40,6 +39,5 @@ export const roomRepository = new RoomRepository(db);
export const externalModelConfigurationRepository = export const externalModelConfigurationRepository =
new ExternalModelConfigurationRepository(db); new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const externalExportBatchRepository = new ExternalExportBatchRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db); export const userRepository = new UserRepository(db);

View file

@ -14,7 +14,6 @@ import {
circuitSectionRepository, circuitSectionRepository,
distributionBoardComponentRepository, distributionBoardComponentRepository,
distributionBoardRepository, distributionBoardRepository,
externalModelStateRepository,
projectRepository, projectRepository,
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
@ -89,13 +88,6 @@ export async function buildProjectCircuitTrees(
circuitListRepository.listByProject(projectId), circuitListRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId),
]); ]);
const externalState = externalModelStateRepository.getByProject(projectId).state;
const externalCountByRowId = new Map<string, number>();
for (const object of externalState.objects) {
if (object.circuitDeviceRowId) {
externalCountByRowId.set(object.circuitDeviceRowId, (externalCountByRowId.get(object.circuitDeviceRowId) ?? 0) + 1);
}
}
const listIds = lists.map((entry) => entry.id); const listIds = lists.map((entry) => entry.id);
const [sections, circuits, components] = await Promise.all([ const [sections, circuits, components] = await Promise.all([
circuitSectionRepository.listByCircuitLists(listIds), circuitSectionRepository.listByCircuitLists(listIds),
@ -245,7 +237,6 @@ export async function buildProjectCircuitTrees(
row.powerPerUnit, row.powerPerUnit,
row.simultaneityFactor row.simultaneityFactor
), ),
externalObjectCount: externalCountByRowId.get(row.id) ?? 0,
})); }));
const circuitLoad = loads.circuits.get(circuit.id); const circuitLoad = loads.circuits.get(circuit.id);
if (!circuitLoad) throw new Error("Circuit load is missing."); if (!circuitLoad) throw new Error("Circuit load is missing.");

View file

@ -1,17 +1,9 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
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 { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
import { createExternalObjectNewRowProjectCommand, externalObjectAssignToNewRowCommandType } from "../../domain/models/external-object-new-row-project-command.model.js";
import { createExternalObjectNewCircuitProjectCommand, externalObjectAssignToNewCircuitCommandType } from "../../domain/models/external-object-new-circuit-project-command.model.js";
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults.js";
import { circuitNumberingService } from "../composition/circuit-numbering-service.js";
export async function listExternalObjects(req: Request, res: Response) { export async function listExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? ""); const projectId = String(req.params.projectId ?? "");
@ -57,221 +49,6 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); } } catch (error) { return respondWithProjectCommandError(error, res); }
} }
export async function assignExternalObjectsToRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const rowId = requiredId(req.body?.rowId, "Ungültige Gerätezeile.");
const confirmedConflictObjectIds = Array.isArray(req.body?.confirmedConflictObjectIds)
? req.body.confirmedConflictObjectIds.map((value: unknown) => requiredId(value, "Ungültige Konfliktbestätigung."))
: [];
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const treeResult = await buildProjectCircuitTrees(projectId);
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Gerätezeile wurde 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) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits einer Gerätezeile zugeordnet.");
return object;
});
const expectedRow = toRowSnapshot(location.row, location.circuitId);
const addedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
const targetRow = { ...expectedRow, quantity: expectedRow.quantity + addedQuantity };
const objects = selected.map((expected) => ({
expected,
target: { ...expected, distributionBoardId: location.distributionBoardId, circuitDeviceRowId: rowId },
}));
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${objects.length} Revit-Objekte zugeordnet`,
command: createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: expectedRow, target: targetRow }],
objects,
confirmedConflictObjectIds,
}),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function detachExternalObjectsFromRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const treeResult = await buildProjectCircuitTrees(projectId);
const byId = new Map(state.objects.map((object) => [object.id, object]));
const selected = objectIds.map((id) => {
const object = byId.get(id);
if (!object?.circuitDeviceRowId) throw new Error("Ein Revit-Objekt ist keiner Gerätezeile zugeordnet.");
return object;
});
const rowIds = new Set(selected.map((object) => object.circuitDeviceRowId!));
if (rowIds.size !== 1) throw new Error("Revit-Objekte können nur aus derselben Gerätezeile gemeinsam gelöst werden.");
const rowId = [...rowIds][0]!;
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Gerätezeile wurde nicht gefunden.");
const expectedRow = toRowSnapshot(location.row, location.circuitId);
const removedQuantity = selected.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
const targetRow = { ...expectedRow, quantity: expectedRow.quantity - removedQuantity };
const copiedFields = ["displayName", "category", "connectionKind", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const;
const objects = selected.map((expected) => ({
expected,
target: {
...expected,
circuitDeviceRowId: null,
distributionBoardId: location.distributionBoardId,
linkedProjectDeviceId: expectedRow.linkedProjectDeviceId,
planningValues: {
...expected.planningValues,
displayName: expectedRow.displayName,
category: location.category,
connectionKind: expectedRow.connectionKind,
powerPerUnitW: expectedRow.powerPerUnit * 1000,
simultaneityFactor: expectedRow.simultaneityFactor,
cosPhi: expectedRow.cosPhi,
costGroup: expectedRow.costGroup,
remark: expectedRow.remark,
},
overriddenFields: [...new Set([...expected.overriddenFields, ...copiedFields])],
},
}));
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${objects.length} Revit-Objekte gelöst`,
command: createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: expectedRow, target: targetRow }],
objects,
confirmedConflictObjectIds: [],
}),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToNewRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const circuitId = requiredId(req.body?.circuitId, "Ungültiger Stromkreis.");
const context = await loadAssignmentContext(projectId, objectIds);
const circuitLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.flatMap((section) => section.circuits.map((circuit) => ({ tree, section, circuit })))).find((entry) => entry.circuit.id === circuitId);
if (!circuitLocation || !circuitLocation.section.category) throw new Error("Stromkreis wurde nicht gefunden.");
const row = buildExternalRow(context.objects, context.roomId, circuitId, Math.max(0, ...circuitLocation.circuit.deviceRows.map((entry) => entry.sortOrder)) + 10);
const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: circuitLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neue Gerätezeile zugeordnet`, command: createExternalObjectNewRowProjectCommand(externalObjectAssignToNewRowCommandType, { row, objects: transitions, confirmedConflictObjectIds: objectIds }) });
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToNewCircuit(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
const sectionId = requiredId(req.body?.sectionId, "Ungültige Stromkreisgruppe.");
const context = await loadAssignmentContext(projectId, objectIds);
const sectionLocation = context.treeResult?.trees.flatMap((tree) => tree.sections.map((section) => ({ tree, section }))).find((entry) => entry.section.id === sectionId);
if (!sectionLocation?.section.category) throw new Error("Stromkreisgruppe wurde nicht gefunden.");
const circuitId = randomUUID();
const row = buildExternalRow(context.objects, context.roomId, circuitId, 10);
const protection = createDefaultCircuitProtection(sectionLocation.section.category);
const circuit = {
id: circuitId,
circuitListId: sectionLocation.tree.circuitListId,
sectionId,
equipmentIdentifier: await circuitNumberingService.getNextIdentifier(sectionId),
displayName: null,
sortOrder: Math.max(0, ...sectionLocation.section.circuits.map((entry) => entry.sortOrder)) + 10,
cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null,
terminalDesignation: null,
voltage: sectionLocation.section.category === "three_phase" ? sectionLocation.tree.threePhaseVoltageV : sectionLocation.tree.singlePhaseVoltageV,
controlRequirement: null, status: null, isReserve: false, remark: null,
deviceRows: [row],
protectionDevice: {
circuitId,
type: protection.type,
ratedCurrentA: protection.ratedCurrentA,
fuseUtilizationCategory: "fuseUtilizationCategory" in protection ? protection.fuseUtilizationCategory : null,
tripCharacteristic: "tripCharacteristic" in protection ? protection.tripCharacteristic : null,
rcdType: "rcdType" in protection ? protection.rcdType : null,
ratedResidualCurrentMa: "ratedResidualCurrentMa" in protection ? protection.ratedResidualCurrentMa : null,
},
};
const transitions = context.objects.map((expected) => ({ expected, target: { ...expected, distributionBoardId: sectionLocation.tree.distributionBoardId, circuitDeviceRowId: row.id } }));
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte als neuen Stromkreis zugeordnet`, command: createExternalObjectNewCircuitProjectCommand(externalObjectAssignToNewCircuitCommandType, { circuit, objects: transitions, confirmedConflictObjectIds: objectIds }) });
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
async function loadAssignmentContext(projectId: string, objectIds: string[]) {
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) throw new Error("Projekt nicht gefunden.");
const byId = new Map(state.objects.map((object) => [object.id, object]));
const objects = objectIds.map((id) => {
const object = byId.get(id);
if (!object) throw new Error("Ein Revit-Objekt wurde nicht gefunden.");
if (object.circuitDeviceRowId !== null) throw new Error("Ein Revit-Objekt ist bereits zugeordnet.");
return object;
});
const roomIds = new Set(objects.map((object) => object.externalRoomMappingId === null ? null : state.roomMappings.find((mapping) => mapping.id === object.externalRoomMappingId)?.roomId ?? null));
if (roomIds.size !== 1) throw new Error("Revit-Objekte aus verschiedenen Räumen benötigen getrennte Gerätezeilen.");
return { objects, roomId: [...roomIds][0]!, treeResult: await buildProjectCircuitTrees(projectId) };
}
function buildExternalRow(objects: ReturnType<typeof externalModelStateRepository.getByProject>["state"]["objects"], roomId: string | null, circuitId: string, sortOrder: number): CircuitDeviceRowSnapshot {
const first = objects[0]!;
const planning = first.planningValues;
const quantity = objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0);
return {
id: randomUUID(), circuitId, linkedProjectDeviceId: first.linkedProjectDeviceId,
sortOrder, name: first.acceptedSourceValues.familyAndType,
displayName: planning.displayName ?? (scalarText(first.acceptedSourceValues.selectionMarker) || first.acceptedSourceValues.familyAndType),
phaseType: planning.category === "three_phase" ? "three_phase" : "single_phase",
connectionKind: planning.connectionKind, costGroup: planning.costGroup,
category: planning.category, level: null,
roomId,
roomNumberSnapshot: scalarText(first.acceptedSourceValues.roomNumber) || null,
roomNameSnapshot: scalarText(first.acceptedSourceValues.roomName) || null,
quantity, manualQuantity: 0,
powerPerUnit: (planning.powerPerUnitW ?? 0) / 1000,
simultaneityFactor: planning.simultaneityFactor ?? 1,
cosPhi: planning.cosPhi, remark: planning.remark, overriddenFields: null,
};
}
function parseExpectedRevision(value: unknown) {
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
return value as number;
}
function parseObjectIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0) throw new Error("Keine Revit-Objekte ausgewählt.");
const ids = value.map((entry) => requiredId(entry, "Ungültige Revit-Objekt-ID."));
if (new Set(ids).size !== ids.length) throw new Error("Revit-Objekte sind doppelt ausgewählt.");
return ids;
}
function requiredId(value: unknown, message: string) { if (typeof value !== "string" || !value) throw new Error(message); return value; }
function findRow(result: Awaited<ReturnType<typeof buildProjectCircuitTrees>>, rowId: string) {
if (!result) return null;
for (const tree of result.trees) for (const section of tree.sections) for (const circuit of section.circuits) {
const row = circuit.deviceRows.find((entry) => entry.id === rowId);
if (row) return { row, circuitId: circuit.id, distributionBoardId: tree.distributionBoardId, category: section.category ?? null };
}
return null;
}
function toRowSnapshot(row: NonNullable<ReturnType<typeof findRow>>["row"], circuitId: string): CircuitDeviceRowSnapshot {
return { id: row.id, circuitId, 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 };
}
const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[]; const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[];
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); } function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); }
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); } function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }

View file

@ -1,180 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { ExternalExportScope } from "../../db/schema/external-export-batches.js";
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
import { createExternalXlsxReturn } from "../../external-model/xlsx/external-xlsx-transport.js";
import {
externalExportBatchRepository,
externalModelStateRepository,
projectRepository,
} from "../composition/application-repositories.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
const exportScopes = new Set<ExternalExportScope>(["all", "deviating", "selected"]);
export async function createExternalReturnWorkbook(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const scope = parseScope(req.body?.scope);
const selectedObjectIds = scope === "selected" ? parseSelectedIds(req.body?.selectedObjectIds) : [];
if (scope === "selected" && selectedObjectIds.length === 0) {
return res.status(400).json({ error: "Für die ausgewählte Rückgabe wurden keine Revit-Objekte markiert." });
}
const project = await projectRepository.findById(projectId);
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
if (project.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
expectedRevision,
currentRevision: project.currentRevision,
});
}
const { state } = externalModelStateRepository.getByProject(projectId);
const importBatch = state.importBatches.at(-1);
if (!importBatch?.originalContentBase64 || !importBatch.configurationSnapshot) {
return res.status(409).json({
error: "Für die Rückgabe fehlt der letzte vollständige Revit-Import.",
code: "EXTERNAL_XLSX_IMPORT_REQUIRED",
});
}
const treeResult = await buildProjectCircuitTrees(projectId);
if (!treeResult || treeResult.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
});
}
const rowById = new Map<string, ExternalReturnRowContext>();
for (const tree of treeResult.trees) {
for (const section of tree.sections) {
for (const circuit of section.circuits) {
for (const row of circuit.deviceRows) {
rowById.set(row.id, {
rowId: row.id,
displayName: row.displayName,
powerPerUnitKw: row.powerPerUnit,
cosPhi: row.cosPhi ?? null,
phaseType: row.phaseType ?? null,
category: (section.category ?? null) as CircuitGroupCategory | null,
circuitIdentifier: circuit.equipmentIdentifier,
circuitVoltageV: circuit.voltage ?? null,
distributionBoardName: tree.distributionBoardName,
});
}
}
}
}
const projections = state.objects
.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === importBatch.id)
.map((object) => projectExternalXlsxReturn({
object,
row: object.circuitDeviceRowId ? rowById.get(object.circuitDeviceRowId) ?? null : null,
circuitMode: importBatch.configurationSnapshot!.circuitMode,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
}));
const projectionById = new Map(projections.map((projection) => [projection.objectId, projection]));
for (const objectId of selectedObjectIds) {
if (!projectionById.has(objectId)) {
return res.status(400).json({ error: "Ein ausgewähltes Revit-Objekt gehört nicht zum letzten Import." });
}
}
const selected = scope === "all"
? projections
: scope === "deviating"
? projections.filter((projection) => projection.differsFromImported)
: selectedObjectIds.map((id) => projectionById.get(id)!);
if (selected.length === 0) {
return res.status(400).json({ error: "Der gewählte Rückgabeumfang enthält keine Revit-Objekte." });
}
const result = await createExternalXlsxReturn({
fileName: importBatch.fileName,
bytes: Buffer.from(importBatch.originalContentBase64, "base64"),
configuration: importBatch.configurationSnapshot,
selectedWorksharingIds: selected.map((projection) => projection.worksharingId),
updates: selected.map((projection) => ({
worksharingId: projection.worksharingId,
values: projection.values,
})),
});
const fileName = `${safeFileName(project.name)}_Revit-Rueckgabe_${new Date().toISOString().slice(0, 10)}.xlsx`;
const sha256 = hash(result.bytes);
const configurationSha256 = hash(Buffer.from(JSON.stringify(importBatch.configurationSnapshot), "utf8"));
const currentProject = await projectRepository.findById(projectId);
if (!currentProject || currentProject.currentRevision !== expectedRevision) {
return res.status(409).json({
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
code: "PROJECT_REVISION_CONFLICT",
expectedRevision,
currentRevision: currentProject?.currentRevision ?? null,
});
}
externalExportBatchRepository.append({
id: randomUUID(),
projectId,
projectRevision: expectedRevision,
importBatchId: importBatch.id,
exportedAtIso: new Date().toISOString(),
fileName,
sha256,
configurationSnapshot: importBatch.configurationSnapshot,
configurationSha256,
scope,
objectCount: result.exportedObjectCount,
});
const projectionWarnings = selected.flatMap((projection) =>
projection.warnings.map((warning) => `${projection.worksharingId}: ${warning}`)
);
return res.json({
fileName,
contentBase64: Buffer.from(result.bytes).toString("base64"),
sha256,
objectCount: result.exportedObjectCount,
warnings: [...new Set([...result.issues.map((issue) => issue.message), ...projectionWarnings])],
});
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Revit-Rückgabe fehlgeschlagen." });
}
}
export async function listExternalExportBatches(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const project = await projectRepository.findById(projectId);
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
return res.json(externalExportBatchRepository.listByProject(projectId).map(({ configurationSnapshot: _configuration, ...batch }) => batch));
}
function parseExpectedRevision(value: unknown) {
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
return value as number;
}
function parseScope(value: unknown): ExternalExportScope {
if (typeof value !== "string" || !exportScopes.has(value as ExternalExportScope)) throw new Error("Ungültiger Rückgabeumfang.");
return value as ExternalExportScope;
}
function parseSelectedIds(value: unknown) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string") || new Set(value).size !== value.length) {
throw new Error("Ungültige Objektauswahl.");
}
return value as string[];
}
function safeFileName(value: string) {
return value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "Projekt";
}
function hash(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex");
}

View file

@ -63,8 +63,7 @@ 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 { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@ -94,12 +93,6 @@ projectRouter.post("/:projectId/external-model/initial-import/plan", planExterna
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
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/assign-row", assignExternalObjectsToRow);
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/detach-row", detachExternalObjectsFromRow);
projectRouter.post("/:projectId/external-model/return-workbook", createExternalReturnWorkbook);
projectRouter.get("/:projectId/external-model/export-batches", listExternalExportBatches);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( projectRouter.post(

View file

@ -1,40 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalExportBatchRepository } from "../src/db/repositories/external-export-batch.repository.js";
import { projects } from "../src/db/schema/projects.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
describe("external export batch repository", () => {
it("appends immutable audit metadata without changing the project revision", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt", currentRevision: 7 }).run();
const repository = new ExternalExportBatchRepository(context.db);
const configuration = createDefaultExternalModelConfiguration();
repository.append({
id: "export-1", projectId: "project-1", projectRevision: 7,
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T10:00:00.000Z",
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "a".repeat(64),
configurationSnapshot: configuration, configurationSha256: "b".repeat(64), scope: "all", objectCount: 4,
});
configuration.columns.designation = "Nachträglich geändert";
repository.append({
id: "export-2", projectId: "project-1", projectRevision: 7,
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T11:00:00.000Z",
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "c".repeat(64),
configurationSnapshot: configuration, configurationSha256: "d".repeat(64), scope: "selected", objectCount: 1,
});
const batches = repository.listByProject("project-1");
assert.deepEqual(batches.map((batch) => batch.id), ["export-2", "export-1"]);
assert.notEqual(batches[0]!.configurationSnapshot.columns.designation, batches[1]!.configurationSnapshot.columns.designation);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()!.revision, 7);
} finally {
context.close();
}
});
});

View file

@ -7,9 +7,6 @@ import { createDatabaseContext, type AppDatabase } from "../src/db/database-cont
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js";
@ -34,10 +31,6 @@ import {
externalObjectAssignToNewCircuitCommandType, externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js"; } from "../src/domain/models/external-object-new-circuit-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { createCircuitDeviceRowDeleteProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitDeleteProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
import { assertDistributionBoardHasNoExternalObjects, assertRoomHasNoExternalObjects } from "../src/db/repositories/external-object-deletion-guard.persistence.js";
function createFixture(initialObjectRowId: string | null = "row-1") { function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:"); const context = createDatabaseContext(":memory:");
@ -633,63 +626,4 @@ describe("external object new-circuit project command", () => {
context.close(); context.close();
} }
}); });
it("detaches linked objects on row deletion and restores the exact link on undo", () => {
const context = createFixture();
try {
const repository = new CircuitDeviceRowStructureProjectCommandRepository(context.db);
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowDeleteProjectCommand("row-1", "circuit-1"),
});
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
assert.equal(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get(), undefined);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
assert.ok(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get());
} finally {
context.close();
}
});
it("preserves external links through circuit and populated-group deletion undo", () => {
for (const kind of ["circuit", "group"] as const) {
const context = createFixture();
try {
const repository = kind === "circuit"
? new CircuitStructureProjectCommandRepository(context.db)
: new CircuitGroupSubtreeProjectCommandRepository(context.db);
const command = kind === "circuit"
? createCircuitDeleteProjectCommand("circuit-1", "list-1")
: createCircuitGroupDeleteSubtreeProjectCommand(
(repository as CircuitGroupSubtreeProjectCommandRepository).capture("project-1", "section-1")
);
const deleted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command } as never);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse } as never);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
} finally {
context.close();
}
}
});
it("blocks room and distribution-board deletion while external objects reference them", () => {
const context = createFixture(null);
try {
assert.throws(() => assertDistributionBoardHasNoExternalObjects(context.db, "project-1", "board-1"), /Revit-Objekte/);
assert.throws(() => assertRoomHasNoExternalObjects(context.db, "project-1", "room-1"), /Revit-Objekte/);
} finally {
context.close();
}
});
}); });

View file

@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
function object(): ExternalModelObjectSnapshot {
return {
id: "object-1", projectId: "project-1", sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: null,
deviceTypeId: "type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose", selectionMarker: "SD", socketCount: 2, sourceRemark: null, circuitCombined: "UV 1-2F1.1", boardName: "UV 1", circuitIdentifier: "-2F1.1", designation: "Steckdose", electricalPowerW: "240", voltageV: 230, currentA: "1,16" },
planningValues: { displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null, remark: null },
overriddenFields: [], externalRoomMappingId: null, distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: "row-1", presenceStatus: "present",
};
}
describe("external XLSX return projection", () => {
it("projects typed row values and compares normalized imported scalars", () => {
const result = projectExternalXlsxReturn({ object: object(), circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400, row: { rowId: "row-1", displayName: "Steckdose", powerPerUnitKw: 0.12, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } });
assert.deepEqual(result.values, { circuitCombined: "UV 1-2F1.1", designation: "Steckdose", electricalPowerW: 240, voltageV: 230, currentA: 1.16 });
assert.equal(result.differsFromImported, false);
assert.deepEqual(result.warnings, []);
});
it("uses object planning without a row and leaves an uncomputable current empty", () => {
const input = object(); input.circuitDeviceRowId = null; input.planningValues.cosPhi = null;
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "separate", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
assert.equal(result.values.boardName, "");
assert.equal(result.values.circuitIdentifier, "");
assert.equal(result.values.electricalPowerW, 240);
assert.equal(result.values.currentA, null);
assert.equal(result.differsFromImported, true);
assert.match(result.warnings.join(" "), /Stromstärke/);
});
it("uses three-phase current and object-specific effective quantity", () => {
const input = object(); input.planningValues.category = "three_phase"; input.planningValues.effectiveQuantity = 3;
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
assert.equal(result.values.electricalPowerW, 360);
assert.equal(result.values.voltageV, 400);
assert.equal(result.values.currentA, 0.58);
});
});

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 { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, 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 () => {
@ -59,15 +59,11 @@ describe("Revit XLSX settings", () => {
assert.match(revitPage, /Importstatus/); assert.match(revitPage, /Importstatus/);
assert.match(settings, /Musterdatei prüfen/); assert.match(settings, /Musterdatei prüfen/);
assert.match(settings, /Kombinierte Stromkreisspalte/); assert.match(settings, /Kombinierte Stromkreisspalte/);
assert.match(settings, /isNewConfiguration/);
assert.match(revitPage, /isNewConfiguration={snapshot === null}/);
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(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, /Nur geänderte Objekte/);
}); });
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 () => {
@ -78,16 +74,4 @@ describe("Revit XLSX settings", () => {
finally { globalThis.fetch = originalFetch; } finally { globalThis.fetch = originalFetch; }
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]); assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]);
}); });
it("creates a revision-safe return workbook for the requested scope", 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({ fileName: "return.xlsx", contentBase64: "", sha256: "a".repeat(64), objectCount: 1, warnings: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
};
try { await createExternalReturnWorkbook("project-1", 7, "selected", ["object-1"]); }
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"] } }]);
});
}); });