Compare commits
No commits in common. "1a4c8cad8cd9f88044dd3d267616fdd9f58398cd" and "4ac32e103da9b9c81772687002147ba1987c0d8d" have entirely different histories.
1a4c8cad8c
...
4ac32e103d
39 changed files with 86 additions and 4642 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
|
||||||
);
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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"] } }]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue