Compare commits
2 commits
c633e78cd4
...
4ac32e103d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ac32e103d | |||
| bfdd048269 |
25 changed files with 1201 additions and 86 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -333,6 +333,17 @@ R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
|
||||||
server-side sample detection, and removes the old project-page CSV modal. See
|
server-side sample detection, and removes the old project-page CSV modal. See
|
||||||
`docs/spec/revit-excel-integration-requirements.md` and
|
`docs/spec/revit-excel-integration-requirements.md` and
|
||||||
`docs/spec/revit-excel-phase-14-plan.md`.
|
`docs/spec/revit-excel-phase-14-plan.md`.
|
||||||
|
R5 adds stateless XLSX preview/planning and the six-step initial-import wizard.
|
||||||
|
`external-model.apply-initial-import` stores the configuration and complete
|
||||||
|
external model state atomically; its inverse powers persistent Undo/Redo
|
||||||
|
without parsing workbook bytes. Apply retransmits and reparses the XLSX,
|
||||||
|
verifies its preview SHA-256 and creates no circuit, equipment identifier or
|
||||||
|
CircuitDeviceRow.
|
||||||
|
R6 adds one compact project-wide external-object GET without pagination or
|
||||||
|
batch payloads. The Revit page filters and sorts it in the client and renders a
|
||||||
|
virtualized window. `external-object.update-planning` applies multi-object
|
||||||
|
board, ProjectDevice and planning-value changes as one persistent command;
|
||||||
|
linked objects are excluded because they follow their CircuitDeviceRow board.
|
||||||
|
|
||||||
The target configuration has no independent version counter; `expectedRevision`
|
The target configuration has no independent version counter; `expectedRevision`
|
||||||
invalidates stale plans, and each import batch stores a configuration copy.
|
invalidates stale plans, and each import batch stores a configuration copy.
|
||||||
|
|
@ -586,7 +597,7 @@ Critical target rules:
|
||||||
selected combined/separate mode block export.
|
selected combined/separate mode block export.
|
||||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||||
- The corrected R2 return workbook was successfully imported into Linear on
|
- The corrected R2 return workbook was successfully imported into Linear on
|
||||||
2026-09-30; R3 and R4 are complete and R5 is the next package.
|
2026-09-30; R3 through R6 are complete and R7 is the next package.
|
||||||
- All confirmed writes use typed project commands with `expectedRevision`,
|
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||||
complete inverse data and the shared project-command transaction boundary.
|
complete inverse data and the shared project-command transaction boundary.
|
||||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
|
||||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||||
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
||||||
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
||||||
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen;
|
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen;
|
||||||
die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport,
|
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten
|
||||||
Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10.
|
Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen.
|
||||||
|
Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -643,7 +643,7 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||||
|
|
||||||
## Revit-Anbindung: Linear-XLSX-Grundlage nach R3
|
## Revit-Anbindung: Linear-XLSX-Erstimport nach R5
|
||||||
|
|
||||||
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
||||||
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
||||||
|
|
@ -663,9 +663,20 @@ CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
|
||||||
abgelehnt.
|
abgelehnt.
|
||||||
|
|
||||||
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
||||||
seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte
|
seine Endpunkte und Laufzeitverträge sind entfernt. Die Route
|
||||||
Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch
|
`/projects/:projectId/revit` enthält Einstellungen, Importstatus und den
|
||||||
die Route `/projects/:projectId/revit` ersetzt.
|
sechsstufigen Erstimportwizard.
|
||||||
|
|
||||||
|
`POST /api/projects/:projectId/external-model/initial-import/plan` liest eine
|
||||||
|
lokale XLSX zustandslos, gruppiert Quellräume und exakte Familie-und-Typ-Werte
|
||||||
|
und liefert die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge.
|
||||||
|
Unbekannte Familien bleiben blockierend; fehlender GZF wird sichtbar mit 1
|
||||||
|
vorbelegt. `apply` erhält die Datei erneut, parst sie erneut und vergleicht den
|
||||||
|
Vorschau-SHA-256. Der Command `external-model.apply-initial-import` speichert
|
||||||
|
die dabei verwendete Konfiguration und den vollständigen externen Zustand in
|
||||||
|
derselben Transaktion. Er erzeugt weder CircuitDeviceRows noch Circuits oder
|
||||||
|
BMKs. Seine vollständige Inverse macht Undo/Redo nach Neustart möglich, ohne
|
||||||
|
Workbookbytes erneut einzulesen oder zu parsen.
|
||||||
|
|
||||||
### Historische Ausgangslage vor R3
|
### Historische Ausgangslage vor R3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
||||||
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
|
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
|
||||||
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
|
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
|
||||||
| 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 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
|
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
|
||||||
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
|
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
|
||||||
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
|
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
|
||||||
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
|
| 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. |
|
||||||
|
|
@ -121,6 +121,17 @@ vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
|
||||||
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
|
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
|
||||||
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
|
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
|
||||||
|
|
||||||
|
Implementierungsstand vom 30.09.2026: Vorschau und Apply verwenden die
|
||||||
|
projektweite XLSX-Konfiguration; Apply überträgt und parst die Datei erneut und
|
||||||
|
vergleicht den SHA-256 der Vorschau. Der Wizard führt über Vorschau, Räume,
|
||||||
|
Standardverteiler einschließlich Objekten ohne Raum, exakte Familienregeln,
|
||||||
|
optionale ProjectDevices und Bestätigung. Unbekannte Familien können als
|
||||||
|
Geräteart mit stabilen Client-UUIDs und sichtbarem GZF-Startwert 1 klassifiziert
|
||||||
|
werden. `external-model.apply-initial-import` schreibt Konfiguration, Quelle,
|
||||||
|
vollständigen letzten Batch, Raumzuordnungen und Objekte in einer Revision;
|
||||||
|
alle `circuitDeviceRowId` bleiben `null`. Seine Inverse speichert beide
|
||||||
|
Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen.
|
||||||
|
|
||||||
## R6 – Objekttabelle und Sammelaktionen
|
## R6 – Objekttabelle und Sammelaktionen
|
||||||
|
|
||||||
- kompakte Objektliste einmal laden;
|
- kompakte Objektliste einmal laden;
|
||||||
|
|
@ -131,6 +142,14 @@ vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
|
||||||
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
|
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
|
||||||
Objekte folgen unverändert ihrer Row.
|
Objekte folgen unverändert ihrer Row.
|
||||||
|
|
||||||
|
Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert
|
||||||
|
eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite
|
||||||
|
sucht, filtert und sortiert vollständig im Client und rendert nur den
|
||||||
|
sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler,
|
||||||
|
ProjectDevice, Leistung und GZF in einem persistenten
|
||||||
|
`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese
|
||||||
|
Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
|
||||||
|
|
||||||
## R7 – Stromkreislisteneditor
|
## R7 – Stromkreislisteneditor
|
||||||
|
|
||||||
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
|
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,51 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, FileSpreadsheet, Settings } from "lucide-react";
|
import { ArrowLeft, FileSpreadsheet, Settings, Upload } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconLink } from "../../../../frontend/components/icon-link";
|
import { IconLink } from "../../../../frontend/components/icon-link";
|
||||||
|
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
|
||||||
|
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
|
||||||
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
||||||
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
||||||
import { detectExternalModelWorkbook, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
|
import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
|
||||||
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
|
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
|
||||||
import type { ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
|
import type { ExternalInitialImportStatusDto, ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
|
||||||
|
|
||||||
export default function RevitPage() {
|
export default function RevitPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
|
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useEffect(() => { Promise.all([getProject(projectId), getExternalModelConfiguration(projectId)]).then(([loadedProject, result]) => { setProject(loadedProject); setSnapshot(result.configuration); }).catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); }, [projectId]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getProject(projectId), getExternalModelConfiguration(projectId), getExternalInitialImportStatus(projectId)])
|
||||||
|
.then(([loadedProject, configuration, status]) => { setProject(loadedProject); setSnapshot(configuration.configuration); setImportStatus(status); })
|
||||||
|
.catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden."));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
async function save(configuration: ExternalModelConfiguration) {
|
async function save(configuration: ExternalModelConfiguration) {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
setSaving(true); setError(null);
|
setSaving(true); setError(null);
|
||||||
try { const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); setSnapshot(result.configuration); setProject({ ...project, currentRevision: result.history.currentRevision }); setOpen(false); }
|
try {
|
||||||
catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
|
const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration);
|
||||||
|
setSnapshot(result.configuration);
|
||||||
|
setProject({ ...project, currentRevision: result.history.currentRevision });
|
||||||
|
setSettingsOpen(false);
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return <main className="container py-4">
|
return <main className="container py-4">
|
||||||
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2><p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden. Der Erstimport folgt in R5.</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>
|
||||||
{open ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isSaving={saving} onClose={() => setOpen(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}
|
{importStatus?.imported ? <RevitObjectTable projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : 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}
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertExternalInitialImportProjectCommand,
|
||||||
|
createExternalInitialImportProjectCommand,
|
||||||
|
isEmptyExternalModelState,
|
||||||
|
} from "../../domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalModelConfigurationSnapshot } from "../../domain/models/external-model-configuration-project-command.model.js";
|
||||||
|
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
|
export class ExternalInitialImportProjectCommandRepository implements ExternalInitialImportProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const currentStateResult = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
||||||
|
if (!currentStateResult.projectExists) throw new Error("Projekt nicht gefunden.");
|
||||||
|
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||||
|
const payload = input.command.payload;
|
||||||
|
if (!same(payload.expectedState, currentStateResult.state) || !same(payload.expectedConfiguration, currentConfiguration)) {
|
||||||
|
throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
if (!isEmptyExternalModelState(payload.targetState)) validateTarget(tx, input.projectId, input.expectedRevision, input.source, payload.targetConfiguration, payload.targetState);
|
||||||
|
replaceConfiguration(tx, input.projectId, payload.targetConfiguration);
|
||||||
|
replaceState(tx, input.projectId, payload.targetState);
|
||||||
|
const persistedState = new ExternalModelStateRepository(tx).getByProject(input.projectId).state;
|
||||||
|
const persistedConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||||
|
if (!same(payload.targetState, persistedState) || !same(payload.targetConfiguration, persistedConfiguration)) throw new Error("Gespeicherter Revit-Erstimport weicht vom Zielzustand ab.");
|
||||||
|
return createExternalInitialImportProjectCommand(payload.targetConfiguration, currentConfiguration, payload.targetState, currentStateResult.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfigurationSnapshot | null, state: ExternalModelStateSnapshot) {
|
||||||
|
if (!configuration || configuration.projectId !== projectId || state.source?.projectId !== projectId) throw new Error("Der Revit-Erstimport gehört zu einem anderen Projekt.");
|
||||||
|
const batch = state.importBatches[0];
|
||||||
|
if (source === "user" && batch.appliedProjectRevision !== expectedRevision + 1) throw new Error("Die Projektrevision des Revit-Erstimports ist ungültig.");
|
||||||
|
if (!same(batch.configurationSnapshot, configuration.configuration)) throw new Error("Die Konfigurationskopie des Importbatches ist ungültig.");
|
||||||
|
const bytes = decodeCanonicalBase64(batch.originalContentBase64!);
|
||||||
|
if (createHash("sha256").update(bytes).digest("hex") !== batch.sha256) throw new Error("Die Prüfsumme des Revit-Erstimports ist ungültig.");
|
||||||
|
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
|
||||||
|
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
|
||||||
|
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
|
||||||
|
const deviceTypeIds = new Set(configuration.configuration.deviceTypes.map((type) => type.id));
|
||||||
|
for (const mapping of state.roomMappings) {
|
||||||
|
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt.");
|
||||||
|
if (mapping.defaultDistributionBoardId !== null && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt.");
|
||||||
|
}
|
||||||
|
for (const object of state.objects) {
|
||||||
|
if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart.");
|
||||||
|
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler.");
|
||||||
|
if (object.linkedProjectDeviceId !== null && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceConfiguration(database: AppDatabase, projectId: string, target: ExternalModelConfigurationSnapshot | null) {
|
||||||
|
database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run();
|
||||||
|
if (target) database.insert(externalModelConfigurations).values(target).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) {
|
||||||
|
database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run();
|
||||||
|
if (!target.source) return;
|
||||||
|
database.insert(externalModelSources).values(target.source).run();
|
||||||
|
database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run();
|
||||||
|
if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||||
|
if (target.objects.length) database.insert(externalModelObjects).values(target.objects).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCanonicalBase64(value: string) { const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("Der XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
|
||||||
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
|
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
|
||||||
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { assertExternalObjectPlanningUpdateProjectCommand, invertExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
||||||
|
import type { ExternalObjectPlanningProjectCommandStore } from "../../domain/ports/external-object-planning-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalObjectPlanningProjectCommandRepository implements ExternalObjectPlanningProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalObjectPlanningProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const boardIds = new Set(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all().map((row) => row.id));
|
||||||
|
const deviceIds = new Set(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all().map((row) => row.id));
|
||||||
|
for (const { expected, target } of input.command.payload.objects) {
|
||||||
|
const current = tx.select().from(externalModelObjects).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).get();
|
||||||
|
if (!current || !snapshotsEqual(toExternalObjectSnapshot(current), expected)) throw new Error("Ein Revit-Objekt wurde zwischenzeitlich geändert.");
|
||||||
|
if (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) throw new Error("Der Verteiler gehört zu einem anderen Projekt.");
|
||||||
|
if (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)) throw new Error("Das Projektgerät gehört zu einem anderen Projekt.");
|
||||||
|
const updated = tx.update(externalModelObjects).set({ planningValues: target.planningValues, overriddenFields: target.overriddenFields, distributionBoardId: target.distributionBoardId, linkedProjectDeviceId: target.linkedProjectDeviceId }).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).run();
|
||||||
|
if (updated.changes !== 1) throw new Error("Revit-Objekt konnte nicht geändert werden.");
|
||||||
|
}
|
||||||
|
return invertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const externalInitialImportCommandType = "external-model.apply-initial-import" as const;
|
||||||
|
export const externalInitialImportCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ExternalInitialImportPayload {
|
||||||
|
expectedConfiguration: ExternalModelConfigurationSnapshot | null;
|
||||||
|
targetConfiguration: ExternalModelConfigurationSnapshot | null;
|
||||||
|
expectedState: ExternalModelStateSnapshot;
|
||||||
|
targetState: ExternalModelStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportProjectCommand extends SerializedProjectCommand<ExternalInitialImportPayload> {
|
||||||
|
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
|
||||||
|
type: typeof externalInitialImportCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalInitialImportProjectCommand(
|
||||||
|
expectedConfiguration: ExternalModelConfigurationSnapshot | null,
|
||||||
|
targetConfiguration: ExternalModelConfigurationSnapshot | null,
|
||||||
|
expectedState: ExternalModelStateSnapshot,
|
||||||
|
targetState: ExternalModelStateSnapshot
|
||||||
|
): ExternalInitialImportProjectCommand {
|
||||||
|
const command: ExternalInitialImportProjectCommand = {
|
||||||
|
schemaVersion: externalInitialImportCommandSchemaVersion,
|
||||||
|
type: externalInitialImportCommandType,
|
||||||
|
payload: { expectedConfiguration, targetConfiguration, expectedState, targetState },
|
||||||
|
};
|
||||||
|
assertExternalInitialImportProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalInitialImportProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ExternalInitialImportProjectCommand {
|
||||||
|
if (command.schemaVersion !== 1 || command.type !== externalInitialImportCommandType || !isRecord(command.payload)) {
|
||||||
|
throw new Error("Nicht unterstützter Revit-Erstimport-Command.");
|
||||||
|
}
|
||||||
|
const keys = Object.keys(command.payload).sort().join(",");
|
||||||
|
if (keys !== "expectedConfiguration,expectedState,targetConfiguration,targetState") {
|
||||||
|
throw new Error("Der Revit-Erstimport-Command ist unvollständig.");
|
||||||
|
}
|
||||||
|
assertConfigurationSnapshot(command.payload.expectedConfiguration);
|
||||||
|
assertConfigurationSnapshot(command.payload.targetConfiguration);
|
||||||
|
const expected = parseExternalModelStateSnapshot(command.payload.expectedState);
|
||||||
|
const target = parseExternalModelStateSnapshot(command.payload.targetState);
|
||||||
|
if (isEmptyExternalModelState(expected) === isEmptyExternalModelState(target)) {
|
||||||
|
throw new Error("Der Erstimport muss zwischen leerem und gefülltem Modellzustand wechseln.");
|
||||||
|
}
|
||||||
|
assertPopulatedInitialState(isEmptyExternalModelState(expected) ? target : expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
|
||||||
|
return { source: null, importBatches: [], roomMappings: [], objects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
|
||||||
|
return state.source === null && state.importBatches.length === 0 && state.roomMappings.length === 0 && state.objects.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConfigurationSnapshot(value: unknown): asserts value is ExternalModelConfigurationSnapshot | null {
|
||||||
|
if (value === null) return;
|
||||||
|
if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") {
|
||||||
|
throw new Error("Ungültiger Konfigurationszustand im Revit-Erstimport.");
|
||||||
|
}
|
||||||
|
assertExternalModelConfiguration(value.configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
|
||||||
|
if (!state.source || state.source.sourceType !== "revit_xlsx" || state.importBatches.length !== 1 || state.objects.length === 0) {
|
||||||
|
throw new Error("Der Erstimport benötigt eine Quelle, einen Batch und mindestens ein Objekt.");
|
||||||
|
}
|
||||||
|
const source = state.source;
|
||||||
|
const batch = state.importBatches[0];
|
||||||
|
if (batch.importKind !== "initial" || batch.projectId !== source.projectId || batch.sourceId !== source.id || batch.configurationSnapshot === null || batch.originalContentBase64 === null || batch.document === null || batch.objectCount !== state.objects.length) {
|
||||||
|
throw new Error("Der Erstimport-Batch ist unvollständig.");
|
||||||
|
}
|
||||||
|
const mappingIds = uniqueIds(state.roomMappings);
|
||||||
|
const roomKeys = new Set<string>();
|
||||||
|
for (const mapping of state.roomMappings) {
|
||||||
|
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id || roomKeys.has(mapping.normalizedSourceRoomKey)) throw new Error("Ungültige oder doppelte Quellraumzuordnung.");
|
||||||
|
roomKeys.add(mapping.normalizedSourceRoomKey);
|
||||||
|
}
|
||||||
|
uniqueIds(state.objects);
|
||||||
|
const worksharingIds = new Set<string>();
|
||||||
|
for (const object of state.objects) {
|
||||||
|
if (object.projectId !== source.projectId || object.sourceId !== source.id || object.lastSeenImportBatchId !== batch.id || object.lastAcceptedImportBatchId !== batch.id) throw new Error("Ungültige Objektidentität im Erstimport.");
|
||||||
|
if (worksharingIds.has(object.worksharingId)) throw new Error("Doppelte Worksharing-ID im Erstimport.");
|
||||||
|
worksharingIds.add(object.worksharingId);
|
||||||
|
if (object.externalRoomMappingId !== null && !mappingIds.has(object.externalRoomMappingId)) throw new Error("Ungültige Quellraumzuordnung am Revit-Objekt.");
|
||||||
|
if (object.circuitDeviceRowId !== null) throw new Error("Der Erstimport darf keine Gerätezeile zuordnen.");
|
||||||
|
if (object.presenceStatus !== "present") throw new Error("Erstimportobjekte müssen vorhanden sein.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIds(entries: ReadonlyArray<{ id: string }>) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const entry of entries) { if (ids.has(entry.id)) throw new Error("Doppelte ID im Revit-Erstimport."); ids.add(entry.id); }
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const externalObjectPlanningUpdateCommandType = "external-object.update-planning" as const;
|
||||||
|
export interface ExternalObjectPlanningUpdateProjectCommand extends SerializedProjectCommand<{ objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }> }> {
|
||||||
|
schemaVersion: 1;
|
||||||
|
type: typeof externalObjectPlanningUpdateCommandType;
|
||||||
|
}
|
||||||
|
export function createExternalObjectPlanningUpdateProjectCommand(objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>): ExternalObjectPlanningUpdateProjectCommand {
|
||||||
|
const command: ExternalObjectPlanningUpdateProjectCommand = { schemaVersion: 1, type: externalObjectPlanningUpdateCommandType, payload: { objects } };
|
||||||
|
assertExternalObjectPlanningUpdateProjectCommand(command); return command;
|
||||||
|
}
|
||||||
|
export function invertExternalObjectPlanningUpdateProjectCommand(command: ExternalObjectPlanningUpdateProjectCommand) { return createExternalObjectPlanningUpdateProjectCommand(command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected }))); }
|
||||||
|
export function assertExternalObjectPlanningUpdateProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectPlanningUpdateProjectCommand {
|
||||||
|
if (command.schemaVersion !== 1 || command.type !== externalObjectPlanningUpdateCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Revit-Objektplanungs-Command.");
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const transition of command.payload.objects) {
|
||||||
|
if (!isRecord(transition)) throw new Error("Ungültiger Revit-Objektübergang.");
|
||||||
|
const expected = parseObject(transition.expected); const target = parseObject(transition.target);
|
||||||
|
if (expected.id !== target.id || ids.has(expected.id)) throw new Error("Doppelte oder abweichende Revit-Objekt-ID.");
|
||||||
|
ids.add(expected.id);
|
||||||
|
if (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte werden über ihre Gerätezeile geplant.");
|
||||||
|
const left = { ...expected, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
|
||||||
|
const right = { ...target, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined };
|
||||||
|
if (JSON.stringify(left) !== JSON.stringify(right) || JSON.stringify(expected) === JSON.stringify(target)) throw new Error("Der Objektplanungs-Command ändert unzulässige oder keine Felder.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteExternalInitialImportCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ExternalInitialImportProjectCommand;
|
||||||
|
}
|
||||||
|
export interface ExternalInitialImportProjectCommandStore {
|
||||||
|
execute(input: ExecuteExternalInitialImportCommandInput): { revision: AppendedProjectRevision; inverse: ExternalInitialImportProjectCommand };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { ExternalObjectPlanningUpdateProjectCommand } from "../models/external-object-planning-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
export interface ExternalObjectPlanningProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectPlanningUpdateProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectPlanningUpdateProjectCommand }; }
|
||||||
|
|
@ -177,6 +177,13 @@ import {
|
||||||
externalModelConfigurationUpdateCommandType,
|
externalModelConfigurationUpdateCommandType,
|
||||||
} from "../models/external-model-configuration-project-command.model.js";
|
} from "../models/external-model-configuration-project-command.model.js";
|
||||||
import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js";
|
import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js";
|
||||||
|
import {
|
||||||
|
assertExternalInitialImportProjectCommand,
|
||||||
|
externalInitialImportCommandType,
|
||||||
|
} from "../models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
||||||
|
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
|
||||||
|
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
|
||||||
import {
|
import {
|
||||||
assertExternalObjectRowAssignmentProjectCommand,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
externalObjectRowAssignmentCommandType,
|
externalObjectRowAssignmentCommandType,
|
||||||
|
|
@ -242,7 +249,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
|
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
||||||
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
|
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
|
|
@ -631,6 +640,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case externalInitialImportCommandType: {
|
||||||
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
|
if (!this.externalInitialImportStore) {
|
||||||
|
throw new Error("External initial import store is not available.");
|
||||||
|
}
|
||||||
|
return this.externalInitialImportStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
|
case externalObjectPlanningUpdateCommandType: {
|
||||||
|
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
|
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
||||||
|
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
|
||||||
|
}
|
||||||
case externalObjectRowAssignmentCommandType: {
|
case externalObjectRowAssignmentCommandType: {
|
||||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||||
if (!this.externalObjectRowAssignmentStore) {
|
if (!this.externalObjectRowAssignmentStore) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
|
||||||
|
import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
import type { ExternalInitialImportPlan } from "./external-xlsx-initial-import.js";
|
||||||
|
|
||||||
|
export interface ExternalRoomDecision { sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null; }
|
||||||
|
export interface ExternalFamilyProjectDeviceDecision { familyAndType: string; projectDeviceId: string | null; }
|
||||||
|
|
||||||
|
export function buildExternalXlsxInitialImportTarget(input: {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
sourceName: string;
|
||||||
|
importedAtIso: string;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
originalContentBase64: string;
|
||||||
|
document: ExternalXlsxDocument;
|
||||||
|
plan: ExternalInitialImportPlan;
|
||||||
|
roomDecisions: ExternalRoomDecision[];
|
||||||
|
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: string | null;
|
||||||
|
createId: () => string;
|
||||||
|
}): ExternalModelStateSnapshot {
|
||||||
|
if (input.plan.familyGroups.some((group) => !group.classified) || input.plan.issueCounts.invalidQuantity > 0) {
|
||||||
|
throw new Error("Alle Familien müssen klassifiziert sein und gültige Mengen besitzen.");
|
||||||
|
}
|
||||||
|
const roomDecisions = exactDecisionMap(input.plan.sourceRooms.map((room) => room.sourceRoomKey), input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum");
|
||||||
|
const familyDecisions = exactDecisionMap(input.plan.familyGroups.map((group) => group.familyAndType), input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ");
|
||||||
|
const sourceId = input.createId();
|
||||||
|
const batchId = input.createId();
|
||||||
|
const mappingIds = new Map(input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()]));
|
||||||
|
const mappings = input.plan.sourceRooms.map((room) => {
|
||||||
|
const decision = roomDecisions.get(room.sourceRoomKey)!;
|
||||||
|
return { id: mappingIds.get(room.sourceRoomKey)!, projectId: input.projectId, sourceId, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId };
|
||||||
|
});
|
||||||
|
const mappingByKey = new Map(mappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping]));
|
||||||
|
const objects = input.plan.objects.map((object) => {
|
||||||
|
const roomMapping = object.sourceRoomKey === null ? null : mappingByKey.get(object.sourceRoomKey)!;
|
||||||
|
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === object.sourceValues.familyAndType)!;
|
||||||
|
return {
|
||||||
|
id: input.createId(), projectId: input.projectId, sourceId, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid,
|
||||||
|
deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId,
|
||||||
|
acceptedSourceValues: object.sourceValues, planningValues: object.suggestedPlanningValues, overriddenFields: [],
|
||||||
|
externalRoomMappingId: roomMapping?.id ?? null,
|
||||||
|
distributionBoardId: roomMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId,
|
||||||
|
linkedProjectDeviceId: familyDecisions.get(object.sourceValues.familyAndType)!.projectDeviceId,
|
||||||
|
circuitDeviceRowId: null, presenceStatus: "present" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source: { id: sourceId, projectId: input.projectId, name: input.sourceName.trim(), sourceType: "revit_xlsx" },
|
||||||
|
importBatches: [{ id: batchId, projectId: input.projectId, sourceId, importKind: "initial", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: objects.length, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }],
|
||||||
|
roomMappings: mappings,
|
||||||
|
objects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactDecisionMap<T>(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) {
|
||||||
|
const expected = new Set(expectedKeys); const result = new Map<string, T>();
|
||||||
|
for (const decision of decisions) { const key = keyOf(decision); if (!expected.has(key) || result.has(key)) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); result.set(key, decision); }
|
||||||
|
if (result.size !== expected.size) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
246
src/external-model/application/external-xlsx-initial-import.ts
Normal file
246
src/external-model/application/external-xlsx-initial-import.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
||||||
|
import type {
|
||||||
|
ExternalModelObjectPlanningValues,
|
||||||
|
ExternalModelObjectSourceValues,
|
||||||
|
} from "../domain/external-model-contracts.js";
|
||||||
|
import type {
|
||||||
|
ExternalXlsxDocument,
|
||||||
|
ExternalXlsxRow,
|
||||||
|
ExternalXlsxScalar,
|
||||||
|
} from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
import { parseExternalXlsx } from "../xlsx/external-xlsx-transport.js";
|
||||||
|
|
||||||
|
export interface ExternalInitialImportCatalogs {
|
||||||
|
floors: Array<{ id: string; name: string; sortOrder: number }>;
|
||||||
|
rooms: Array<{ id: string; floorId: string | null; roomNumber: string; roomName: string }>;
|
||||||
|
distributionBoards: Array<{ id: string; name: string; floorId: string | null }>;
|
||||||
|
projectDevices: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
category: string | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
powerPerUnit: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportProjectedObject {
|
||||||
|
worksharingId: string;
|
||||||
|
ifcGuid: string | null;
|
||||||
|
sourceRoomKey: string | null;
|
||||||
|
sourceValues: ExternalModelObjectSourceValues;
|
||||||
|
suggestedPlanningValues: ExternalModelObjectPlanningValues;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportPlan {
|
||||||
|
fileName: string;
|
||||||
|
sha256: string;
|
||||||
|
byteLength: number;
|
||||||
|
rowCount: number;
|
||||||
|
objectCount: number;
|
||||||
|
passthroughCount: number;
|
||||||
|
suspectObjectCount: number;
|
||||||
|
suspectRowNumbers: number[];
|
||||||
|
warnings: string[];
|
||||||
|
sourceRooms: Array<{
|
||||||
|
sourceRoomKey: string;
|
||||||
|
roomNumber: string;
|
||||||
|
roomName: string;
|
||||||
|
objectCount: number;
|
||||||
|
suggestedRoomId: string | null;
|
||||||
|
matchStatus: "exact-number" | "ambiguous-number" | "new";
|
||||||
|
candidateRoomIds: string[];
|
||||||
|
}>;
|
||||||
|
familyGroups: Array<{
|
||||||
|
familyAndType: string;
|
||||||
|
objectCount: number;
|
||||||
|
classified: boolean;
|
||||||
|
deviceTypeId: string | null;
|
||||||
|
deviceTypeName: string | null;
|
||||||
|
quantityRule: "one" | "socketCount" | null;
|
||||||
|
}>;
|
||||||
|
issueCounts: {
|
||||||
|
unknownFamilyAndType: number;
|
||||||
|
invalidPower: number;
|
||||||
|
invalidQuantity: number;
|
||||||
|
missingRoom: number;
|
||||||
|
};
|
||||||
|
objects: ExternalInitialImportProjectedObject[];
|
||||||
|
existing: ExternalInitialImportCatalogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExternalXlsxInitialImportPlan(input: {
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
catalogs: ExternalInitialImportCatalogs;
|
||||||
|
}): Promise<{ plan: ExternalInitialImportPlan; document: ExternalXlsxDocument }> {
|
||||||
|
const document = await parseExternalXlsx(input);
|
||||||
|
const columns = resolveColumns(document, input.configuration);
|
||||||
|
const rules = new Map(input.configuration.familyTypeRules.map((rule) => [rule.exactFamilyAndType, rule]));
|
||||||
|
const deviceTypes = new Map(input.configuration.deviceTypes.map((type) => [type.id, type]));
|
||||||
|
const objects = document.rows
|
||||||
|
.filter((row) => row.classification === "object")
|
||||||
|
.map((row) => projectObject(row, columns, rules, deviceTypes));
|
||||||
|
|
||||||
|
const roomCandidates = new Map<string, ExternalInitialImportCatalogs["rooms"]>();
|
||||||
|
for (const room of input.catalogs.rooms) {
|
||||||
|
const key = normalizeRoomPart(room.roomNumber);
|
||||||
|
if (!key) continue;
|
||||||
|
const entries = roomCandidates.get(key) ?? [];
|
||||||
|
entries.push(room);
|
||||||
|
roomCandidates.set(key, entries);
|
||||||
|
}
|
||||||
|
const sourceRoomMap = new Map<string, { sourceRoomKey: string; roomNumber: string; roomName: string; objectCount: number }>();
|
||||||
|
for (const object of objects) {
|
||||||
|
if (object.sourceRoomKey === null) continue;
|
||||||
|
const existing = sourceRoomMap.get(object.sourceRoomKey);
|
||||||
|
if (existing) existing.objectCount += 1;
|
||||||
|
else sourceRoomMap.set(object.sourceRoomKey, {
|
||||||
|
sourceRoomKey: object.sourceRoomKey,
|
||||||
|
roomNumber: scalarText(object.sourceValues.roomNumber),
|
||||||
|
roomName: scalarText(object.sourceValues.roomName),
|
||||||
|
objectCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sourceRooms = [...sourceRoomMap.values()].map((room) => {
|
||||||
|
const candidates = roomCandidates.get(normalizeRoomPart(room.roomNumber)) ?? [];
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
suggestedRoomId: candidates.length === 1 ? candidates[0].id : null,
|
||||||
|
matchStatus: candidates.length === 1 ? "exact-number" as const : candidates.length > 1 ? "ambiguous-number" as const : "new" as const,
|
||||||
|
candidateRoomIds: candidates.map((candidate) => candidate.id),
|
||||||
|
};
|
||||||
|
}).sort((a, b) => a.roomNumber.localeCompare(b.roomNumber, "de", { numeric: true }) || a.roomName.localeCompare(b.roomName, "de"));
|
||||||
|
|
||||||
|
const familyMap = new Map<string, ExternalInitialImportPlan["familyGroups"][number]>();
|
||||||
|
for (const object of objects) {
|
||||||
|
const name = object.sourceValues.familyAndType;
|
||||||
|
const existing = familyMap.get(name);
|
||||||
|
if (existing) { existing.objectCount += 1; continue; }
|
||||||
|
const rule = rules.get(name);
|
||||||
|
const type = rule ? deviceTypes.get(rule.deviceTypeId) : undefined;
|
||||||
|
familyMap.set(name, {
|
||||||
|
familyAndType: name,
|
||||||
|
objectCount: 1,
|
||||||
|
classified: Boolean(rule && type),
|
||||||
|
deviceTypeId: type?.id ?? null,
|
||||||
|
deviceTypeName: type?.name ?? null,
|
||||||
|
quantityRule: rule?.quantityRule ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const countIssue = (issue: string) => objects.filter((object) => object.issues.includes(issue)).length;
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
plan: {
|
||||||
|
fileName: input.fileName,
|
||||||
|
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||||
|
byteLength: input.bytes.byteLength,
|
||||||
|
rowCount: document.rows.length,
|
||||||
|
objectCount: objects.length,
|
||||||
|
passthroughCount: document.rows.filter((row) => row.classification === "passthrough").length,
|
||||||
|
suspectObjectCount: document.rows.filter((row) => row.classification === "suspect-object").length,
|
||||||
|
suspectRowNumbers: document.rows.filter((row) => row.classification === "suspect-object").map((row) => row.rowNumber),
|
||||||
|
warnings: document.issues.map((issue) => issue.message),
|
||||||
|
sourceRooms,
|
||||||
|
familyGroups: [...familyMap.values()].sort((a, b) => a.familyAndType.localeCompare(b.familyAndType, "de")),
|
||||||
|
issueCounts: {
|
||||||
|
unknownFamilyAndType: countIssue("unknown-family-and-type"),
|
||||||
|
invalidPower: countIssue("invalid-power"),
|
||||||
|
invalidQuantity: countIssue("invalid-quantity"),
|
||||||
|
missingRoom: countIssue("missing-room"),
|
||||||
|
},
|
||||||
|
objects,
|
||||||
|
existing: input.catalogs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColumns(document: ExternalXlsxDocument, configuration: ExternalModelConfiguration) {
|
||||||
|
const byHeader = new Map(document.columns.map((column) => [column.header, column.columnNumber]));
|
||||||
|
return Object.fromEntries(Object.entries(configuration.columns).map(([key, header]) => [key, header === null ? null : byHeader.get(header) ?? null])) as Record<keyof ExternalModelConfiguration["columns"], number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectObject(
|
||||||
|
row: ExternalXlsxRow,
|
||||||
|
columns: Record<keyof ExternalModelConfiguration["columns"], number | null>,
|
||||||
|
rules: Map<string, ExternalModelConfiguration["familyTypeRules"][number]>,
|
||||||
|
deviceTypes: Map<string, ExternalModelConfiguration["deviceTypes"][number]>
|
||||||
|
): ExternalInitialImportProjectedObject {
|
||||||
|
const value = (key: keyof typeof columns): ExternalXlsxScalar => {
|
||||||
|
const column = columns[key];
|
||||||
|
return column === null ? null : row.cells[column - 1]?.value ?? null;
|
||||||
|
};
|
||||||
|
const worksharingId = scalarText(value("worksharingId"));
|
||||||
|
const familyAndType = scalarText(value("familyAndType"));
|
||||||
|
const roomNumber = value("roomNumber");
|
||||||
|
const roomName = value("roomName");
|
||||||
|
const rule = rules.get(familyAndType);
|
||||||
|
const deviceType = rule ? deviceTypes.get(rule.deviceTypeId) : undefined;
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!rule || !deviceType) issues.push("unknown-family-and-type");
|
||||||
|
const sourcePower = nullableNumber(value("electricalPowerW"));
|
||||||
|
if (value("electricalPowerW") !== null && sourcePower === null) issues.push("invalid-power");
|
||||||
|
let quantity = 1;
|
||||||
|
if (rule?.quantityRule === "socketCount") {
|
||||||
|
const parsed = nullableNumber(value("socketCount"));
|
||||||
|
if (parsed === null || !Number.isSafeInteger(parsed) || parsed < 1) issues.push("invalid-quantity");
|
||||||
|
else quantity = parsed;
|
||||||
|
}
|
||||||
|
const sourceRoomKey = createExternalRoomKey(roomNumber, roomName);
|
||||||
|
if (sourceRoomKey === null) issues.push("missing-room");
|
||||||
|
const selectionMarker = value("selectionMarker");
|
||||||
|
const designation = value("designation");
|
||||||
|
const displayName = scalarText(designation) || scalarText(selectionMarker) || familyAndType || null;
|
||||||
|
return {
|
||||||
|
worksharingId,
|
||||||
|
ifcGuid: scalarText(value("ifcGuid")) || null,
|
||||||
|
sourceRoomKey,
|
||||||
|
sourceValues: {
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
roomNumber,
|
||||||
|
roomName,
|
||||||
|
familyAndType,
|
||||||
|
selectionMarker,
|
||||||
|
socketCount: value("socketCount"),
|
||||||
|
sourceRemark: value("sourceRemark"),
|
||||||
|
circuitCombined: value("circuitCombined"),
|
||||||
|
boardName: value("boardName"),
|
||||||
|
circuitIdentifier: value("circuitIdentifier"),
|
||||||
|
designation,
|
||||||
|
electricalPowerW: value("electricalPowerW"),
|
||||||
|
voltageV: value("voltageV"),
|
||||||
|
currentA: value("currentA"),
|
||||||
|
},
|
||||||
|
suggestedPlanningValues: {
|
||||||
|
displayName,
|
||||||
|
category: deviceType?.category ?? null,
|
||||||
|
connectionKind: null,
|
||||||
|
effectiveQuantity: quantity,
|
||||||
|
powerPerUnitW: sourcePower ?? deviceType?.defaults.powerPerUnitW ?? null,
|
||||||
|
simultaneityFactor: deviceType?.defaults.simultaneityFactor ?? 1,
|
||||||
|
cosPhi: deviceType?.defaults.cosPhi ?? null,
|
||||||
|
costGroup: deviceType?.defaults.costGroup ?? null,
|
||||||
|
remark: scalarText(value("sourceRemark")) || null,
|
||||||
|
},
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalRoomKey(roomNumber: ExternalXlsxScalar, roomName: ExternalXlsxScalar) {
|
||||||
|
const number = normalizeRoomPart(scalarText(roomNumber));
|
||||||
|
const name = normalizeRoomPart(scalarText(roomName));
|
||||||
|
return number || name ? `${number}\u0000${name}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoomPart(value: string) { return value.trim().toLocaleLowerCase("de-DE"); }
|
||||||
|
function scalarText(value: ExternalXlsxScalar) { return value === null ? "" : String(value).trim(); }
|
||||||
|
function nullableNumber(value: ExternalXlsxScalar) {
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
const normalized = value.trim().replace(/\s/g, "").replace(/,(?=\d+$)/, ".");
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
106
src/frontend/components/revit-initial-import-wizard.tsx
Normal file
106
src/frontend/components/revit-initial-import-wizard.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
||||||
|
import type { ExternalInitialImportPlanDto, ProjectHistoryStateDto } from "../types";
|
||||||
|
import { applyExternalInitialImport, planExternalInitialImport } from "../utils/api";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
const steps = ["Vorschau", "Räume", "Verteiler", "Familienregeln", "Projektgeräte", "Bestätigung"];
|
||||||
|
|
||||||
|
export function RevitInitialImportWizard({ projectId, expectedRevision, initialConfiguration, onClose, onImported }: {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
initialConfiguration: ExternalModelConfiguration;
|
||||||
|
onClose: () => void;
|
||||||
|
onImported: (result: { history: ProjectHistoryStateDto; objectCount: number }) => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration));
|
||||||
|
const [plan, setPlan] = useState<ExternalInitialImportPlanDto | null>(null);
|
||||||
|
const [roomIds, setRoomIds] = useState<Record<string, string>>( {} );
|
||||||
|
const [boardIds, setBoardIds] = useState<Record<string, string>>( {} );
|
||||||
|
const [deviceIds, setDeviceIds] = useState<Record<string, string>>( {} );
|
||||||
|
const [sourceName, setSourceName] = useState("Revit-Gesamtmodell");
|
||||||
|
const [noRoomBoardId, setNoRoomBoardId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadPlan(nextFile: File, nextConfiguration = configuration) {
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const loaded = await planExternalInitialImport(projectId, nextFile, nextConfiguration, expectedRevision);
|
||||||
|
setPlan(loaded);
|
||||||
|
setRoomIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? room.suggestedRoomId ?? ""])));
|
||||||
|
setBoardIds((current) => Object.fromEntries(loaded.sourceRooms.map((room) => [room.sourceRoomKey, current[room.sourceRoomKey] ?? ""])));
|
||||||
|
setDeviceIds((current) => Object.fromEntries(loaded.familyGroups.map((family) => [family.familyAndType, current[family.familyAndType] ?? ""])));
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "XLSX-Vorschau fehlgeschlagen."); setPlan(null); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classifyFamily(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount") {
|
||||||
|
if (!file) return;
|
||||||
|
const deviceTypeId = createClientId();
|
||||||
|
const next = structuredClone(configuration);
|
||||||
|
next.deviceTypes.push({ id: deviceTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } });
|
||||||
|
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule });
|
||||||
|
setConfiguration(next);
|
||||||
|
await loadPlan(file, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignExistingDeviceType(familyAndType: string, encoded: string) {
|
||||||
|
if (!file || !encoded) return;
|
||||||
|
const separator = encoded.lastIndexOf("|");
|
||||||
|
const deviceTypeId = encoded.slice(0, separator);
|
||||||
|
const quantityRule = encoded.slice(separator + 1) as "one" | "socketCount";
|
||||||
|
const next = structuredClone(configuration);
|
||||||
|
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule });
|
||||||
|
setConfiguration(next);
|
||||||
|
await loadPlan(file, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleQuantityRule(familyAndType: string) {
|
||||||
|
if (!file) return;
|
||||||
|
const next = structuredClone(configuration);
|
||||||
|
const rule = next.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === familyAndType);
|
||||||
|
if (!rule) return;
|
||||||
|
rule.quantityRule = rule.quantityRule === "one" ? "socketCount" : "one";
|
||||||
|
setConfiguration(next);
|
||||||
|
await loadPlan(file, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (step < steps.length - 1) { setStep(step + 1); return; }
|
||||||
|
if (!file || !plan) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const result = await applyExternalInitialImport(projectId, file, {
|
||||||
|
expectedRevision,
|
||||||
|
expectedSha256: plan.sha256,
|
||||||
|
sourceName,
|
||||||
|
configuration,
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null,
|
||||||
|
roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: roomIds[room.sourceRoomKey] || null, defaultDistributionBoardId: boardIds[room.sourceRoomKey] || null })),
|
||||||
|
familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: deviceIds[family.familyAndType] || null })),
|
||||||
|
});
|
||||||
|
onImported({ history: result.history, objectCount: result.import.objectCount });
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "Erstimport fehlgeschlagen."); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = !plan || (step >= 3 && (plan.issueCounts.unknownFamilyAndType > 0 || plan.issueCounts.invalidQuantity > 0));
|
||||||
|
return <FormModal dialogSize="viewport" title="Linear-XLSX-Erstimport" description={`Schritt ${step + 1} von ${steps.length}: ${steps[step]}`} isSaving={busy} onClose={onClose} onSubmit={(event) => void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Erstimport übernehmen" : "Weiter"}>
|
||||||
|
<div className="d-flex gap-2 flex-wrap mb-3">{steps.map((label, index) => <span className={`badge ${index === step ? "text-bg-primary" : index < step ? "text-bg-success" : "text-bg-secondary"}`} key={label}>{index + 1}. {label}</span>)}</div>
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
{step === 0 ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="initial-xlsx">Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="initial-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><div className="col-md-3"><strong>{plan.objectCount}</strong><div className="text-secondary small">Objekte</div></div><div className="col-md-3"><strong>{plan.sourceRooms.length}</strong><div className="text-secondary small">Quellräume</div></div><div className="col-md-3"><strong>{plan.familyGroups.length}</strong><div className="text-secondary small">Familien</div></div><div className="col-md-3"><strong>{plan.suspectObjectCount}</strong><div className="text-secondary small">übersprungene verdächtige Zeilen</div></div><div className="col-12"><div className="small text-secondary">Unbekannte Familien: {plan.issueCounts.unknownFamilyAndType} · Ungültige Leistungen: {plan.issueCounts.invalidPower} · Ungültige Mengen: {plan.issueCounts.invalidQuantity} · Objekte ohne Raum: {plan.issueCounts.missingRoom}</div></div>{plan.warnings.length ? <div className="col-12"><div className="alert alert-warning mb-0">{plan.warnings.map((warning) => <div key={warning}>{warning}</div>)}</div></div> : null}</> : null}</div> : null}
|
||||||
|
{step === 1 && plan ? <div><p className="text-secondary">Quellräume vorhandenen Projekträumen zuordnen. Neue Räume können vorher in der Gebäudestruktur angelegt werden.</p>{plan.sourceRooms.map((room) => <div className="row align-items-center g-2 mb-2" key={room.sourceRoomKey}><div className="col-md-5"><strong>{room.roomNumber || "–"}</strong> {room.roomName} <span className="badge text-bg-light">{room.objectCount}</span></div><div className="col-md-7"><select className="form-select" value={roomIds[room.sourceRoomKey] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [room.sourceRoomKey]: event.target.value })}><option value="">Nicht zuordnen</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div></div>)}</div> : null}
|
||||||
|
{step === 2 && plan ? <div><p className="text-secondary">Der Standardverteiler gilt beim Import nur für unzugeordnete Objekte.</p>{plan.sourceRooms.map((room) => <div className="row align-items-center g-2 mb-2" key={room.sourceRoomKey}><div className="col-md-5">{room.roomNumber || "–"} · {room.roomName}</div><div className="col-md-7"><select className="form-select" value={boardIds[room.sourceRoomKey] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [room.sourceRoomKey]: event.target.value })}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div>)}{plan.issueCounts.missingRoom > 0 ? <div className="row align-items-center g-2 mt-3"><div className="col-md-5"><strong>Objekte ohne Quellraum</strong> <span className="badge text-bg-light">{plan.issueCounts.missingRoom}</span></div><div className="col-md-7"><select className="form-select" value={noRoomBoardId} onChange={(event) => setNoRoomBoardId(event.target.value)}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div> : null}</div> : null}
|
||||||
|
{step === 3 && plan ? <div><p className="text-secondary">Exakte Familie-und-Typ-Werte werden einer Geräteart und Mengenregel zugeordnet. Ein fehlender GZF startet sichtbar mit 1.</p>{plan.familyGroups.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><div className="d-flex justify-content-between gap-2"><div><strong>{family.familyAndType}</strong> <span className="badge text-bg-light">{family.objectCount}</span><div className="small text-secondary">{family.classified ? `${family.deviceTypeName} · ${family.quantityRule === "socketCount" ? "Anzahl aus XLSX" : "Menge 1"} · GZF 1, falls nicht gepflegt` : "Noch nicht klassifiziert"}</div></div>{family.classified ? <button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => void toggleQuantityRule(family.familyAndType)}>Mengenregel wechseln</button> : <div className="d-flex gap-2 flex-wrap justify-content-end">{configuration.deviceTypes.length ? <select aria-label={`Vorhandene Geräteart für ${family.familyAndType}`} className="form-select form-select-sm" defaultValue="" onChange={(event) => void assignExistingDeviceType(family.familyAndType, event.target.value)}><option value="">Vorhandene Geräteart wählen …</option>{configuration.deviceTypes.flatMap((type) => [<option value={`${type.id}|one`} key={`${type.id}-one`}>{type.name} · Menge 1</option>, <option value={`${type.id}|socketCount`} key={`${type.id}-socket`}>{type.name} · XLSX-Anzahl</option>])}</select> : null}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "lighting", "one")}>Neue Beleuchtungsart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void classifyFamily(family.familyAndType, "three_phase", "one")}>Neue 3-phasige Geräteart</button></div>}</div></div>)}</div> : null}
|
||||||
|
{step === 4 && plan ? <div><p className="text-secondary">Optional kann jede Familie mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.familyGroups.map((family) => <div className="row align-items-center g-2 mb-2" key={family.familyAndType}><div className="col-md-5">{family.familyAndType}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family.familyAndType] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family.familyAndType]: event.target.value })}><option value="">Kein Projektgerät</option>{plan.existing.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name} · {device.displayName}</option>)}</select></div></div>)}</div> : null}
|
||||||
|
{step === 5 && plan ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="source-name">Quellenname</label><input className="form-control" id="source-name" value={sourceName} onChange={(event) => setSourceName(event.target.value)} /></div><div className="col-12"><div className="alert alert-info mb-0"><strong>{plan.objectCount} Revit-Objekte</strong> werden importiert. Es werden keine Gerätezeilen, Stromkreise oder BMKs erzeugt. Die Datei wird beim Übernehmen erneut übertragen und geprüft.</div></div></div> : null}
|
||||||
|
{step > 0 ? <button className="btn btn-link px-0 mt-3" disabled={busy} onClick={() => setStep(step - 1)} type="button">Zurück</button> : null}
|
||||||
|
</FormModal>;
|
||||||
|
}
|
||||||
73
src/frontend/components/revit-object-table.tsx
Normal file
73
src/frontend/components/revit-object-table.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ExternalProjectObjectsDto } from "../types";
|
||||||
|
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
||||||
|
|
||||||
|
const rowHeight = 44;
|
||||||
|
const viewportHeight = 520;
|
||||||
|
|
||||||
|
export function RevitObjectTable({ projectId, onRevision }: { projectId: string; onRevision: (revision: number) => void }) {
|
||||||
|
const [data, setData] = useState<ExternalProjectObjectsDto | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [boardFilter, setBoardFilter] = useState("all");
|
||||||
|
const [assignmentFilter, setAssignmentFilter] = useState("all");
|
||||||
|
const [sort, setSort] = useState<"family" | "room" | "worksharing">("family");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const [bulkBoard, setBulkBoard] = useState("");
|
||||||
|
const [bulkDevice, setBulkDevice] = useState("");
|
||||||
|
const [power, setPower] = useState("");
|
||||||
|
const [gzf, setGzf] = useState("");
|
||||||
|
const [planningField, setPlanningField] = useState("");
|
||||||
|
const [planningValue, setPlanningValue] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const needle = search.trim().toLocaleLowerCase("de-DE");
|
||||||
|
return data.objects.filter((object) => {
|
||||||
|
if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false;
|
||||||
|
if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false;
|
||||||
|
if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false;
|
||||||
|
return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle));
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId;
|
||||||
|
const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId;
|
||||||
|
return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId);
|
||||||
|
});
|
||||||
|
}, [data, search, boardFilter, assignmentFilter, sort]);
|
||||||
|
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
|
||||||
|
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
|
||||||
|
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
|
||||||
|
|
||||||
|
async function applyBulk() {
|
||||||
|
if (!data || selected.size === 0) return;
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (bulkBoard !== "") patch.distributionBoardId = bulkBoard === "none" ? null : bulkBoard;
|
||||||
|
if (bulkDevice !== "") patch.linkedProjectDeviceId = bulkDevice === "none" ? null : bulkDevice;
|
||||||
|
if (power !== "") patch.powerPerUnitW = Number(power.replace(",", "."));
|
||||||
|
if (gzf !== "") patch.simultaneityFactor = Number(gzf.replace(",", "."));
|
||||||
|
if (planningField) {
|
||||||
|
patch[planningField] = ["effectiveQuantity", "cosPhi"].includes(planningField)
|
||||||
|
? Number(planningValue.replace(",", "."))
|
||||||
|
: planningValue === "" ? null : planningValue;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) { setError("Mindestens eine Sammeländerung auswählen."); return; }
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
try { const result = await updateExternalProjectObjects(projectId, data.currentRevision, [...selected], patch); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
|
||||||
|
catch (value) { setError(value instanceof Error ? value.message : "Sammeländerung fehlgeschlagen."); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
|
||||||
|
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
<div className="border rounded 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="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></section>;
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,6 @@ import type {
|
||||||
ProtectionDeviceType,
|
ProtectionDeviceType,
|
||||||
RcdType,
|
RcdType,
|
||||||
} from "../shared/constants/protection-device";
|
} from "../shared/constants/protection-device";
|
||||||
import type {
|
|
||||||
ExternalCsvConfiguration,
|
|
||||||
ExternalCsvDialect,
|
|
||||||
} from "../external-model/csv/external-csv-contracts";
|
|
||||||
import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
|
import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
|
||||||
|
|
||||||
export interface ExternalModelConfigurationSnapshotDto {
|
export interface ExternalModelConfigurationSnapshotDto {
|
||||||
|
|
@ -22,44 +18,7 @@ export interface ExternalModelConfigurationSnapshotDto {
|
||||||
configuration: ExternalModelConfiguration;
|
configuration: ExternalModelConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationSnapshotDto {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
configurationVersion: number;
|
|
||||||
configuration: ExternalCsvConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvPreviewObjectDto {
|
|
||||||
rowNumber: number;
|
|
||||||
ifcGuid: string;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
familyAndType: string;
|
|
||||||
selectionMarker: string;
|
|
||||||
circuitIdentifier: string;
|
|
||||||
power: string;
|
|
||||||
quantity: string | null;
|
|
||||||
additionalSourceValues: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvPreviewDto {
|
|
||||||
fileName: string;
|
|
||||||
byteLength: number;
|
|
||||||
sha256: string;
|
|
||||||
dialect: ExternalCsvDialect;
|
|
||||||
headerRowNumber: number;
|
|
||||||
headerColumns: string[];
|
|
||||||
rowCount: number;
|
|
||||||
objectCount: number;
|
|
||||||
passthroughCount: number;
|
|
||||||
nonEmptyPassthroughCount: number;
|
|
||||||
suspectObjectCount: number;
|
|
||||||
objects: ExternalCsvPreviewObjectDto[];
|
|
||||||
suspectRowNumbers: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalInitialImportPlanDto {
|
export interface ExternalInitialImportPlanDto {
|
||||||
configurationVersion: number;
|
|
||||||
fileName: string;
|
fileName: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
byteLength: number;
|
byteLength: number;
|
||||||
|
|
@ -68,6 +27,7 @@ export interface ExternalInitialImportPlanDto {
|
||||||
passthroughCount: number;
|
passthroughCount: number;
|
||||||
suspectObjectCount: number;
|
suspectObjectCount: number;
|
||||||
suspectRowNumbers: number[];
|
suspectRowNumbers: number[];
|
||||||
|
warnings: string[];
|
||||||
sourceRooms: Array<{
|
sourceRooms: Array<{
|
||||||
sourceRoomKey: string;
|
sourceRoomKey: string;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
|
|
@ -81,9 +41,9 @@ export interface ExternalInitialImportPlanDto {
|
||||||
familyAndType: string;
|
familyAndType: string;
|
||||||
objectCount: number;
|
objectCount: number;
|
||||||
classified: boolean;
|
classified: boolean;
|
||||||
category: string | null;
|
deviceTypeId: string | null;
|
||||||
connectionKind: string | null;
|
deviceTypeName: string | null;
|
||||||
internalDeviceType: string | null;
|
quantityRule: "one" | "socketCount" | null;
|
||||||
}>;
|
}>;
|
||||||
issueCounts: {
|
issueCounts: {
|
||||||
unknownFamilyAndType: number;
|
unknownFamilyAndType: number;
|
||||||
|
|
@ -91,24 +51,7 @@ export interface ExternalInitialImportPlanDto {
|
||||||
invalidQuantity: number;
|
invalidQuantity: number;
|
||||||
missingRoom: number;
|
missingRoom: number;
|
||||||
};
|
};
|
||||||
objects: Array<{
|
objects: Array<{ worksharingId: string; ifcGuid: string | null; sourceRoomKey: string | null; sourceValues: { familyAndType: string }; suggestedPlanningValues: { simultaneityFactor: number; effectiveQuantity: number }; issues: string[] }>;
|
||||||
ifcGuid: string;
|
|
||||||
sourceRoomKey: string | null;
|
|
||||||
sourceValues: ExternalCsvPreviewObjectDto;
|
|
||||||
suggestedPlanningValues: {
|
|
||||||
displayName: string | null;
|
|
||||||
internalDeviceType: string | null;
|
|
||||||
category: CircuitGroupCategory | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
effectiveQuantity: number;
|
|
||||||
powerPerUnitW: number | null;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi: number | null;
|
|
||||||
costGroup: string | null;
|
|
||||||
remark: string | null;
|
|
||||||
};
|
|
||||||
issues: string[];
|
|
||||||
}>;
|
|
||||||
existing: {
|
existing: {
|
||||||
floors: Array<{ id: string; name: string; sortOrder: number }>;
|
floors: Array<{ id: string; name: string; sortOrder: number }>;
|
||||||
rooms: Array<{
|
rooms: Array<{
|
||||||
|
|
@ -135,11 +78,12 @@ export interface ExternalInitialImportPlanDto {
|
||||||
|
|
||||||
export interface ApplyExternalInitialImportInput {
|
export interface ApplyExternalInitialImportInput {
|
||||||
expectedRevision: number;
|
expectedRevision: number;
|
||||||
expectedConfigurationVersion: number;
|
|
||||||
expectedSha256: string;
|
expectedSha256: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
contentBase64: string;
|
contentBase64: string;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: string | null;
|
||||||
roomDecisions: Array<{
|
roomDecisions: Array<{
|
||||||
sourceRoomKey: string;
|
sourceRoomKey: string;
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
|
|
@ -151,6 +95,14 @@ export interface ApplyExternalInitialImportInput {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportStatusDto {
|
||||||
|
imported: boolean;
|
||||||
|
sourceName: string | null;
|
||||||
|
objectCount: number;
|
||||||
|
importedAtIso: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalInitialImportResultDto extends ProjectCommandResultDto {
|
export interface ExternalInitialImportResultDto extends ProjectCommandResultDto {
|
||||||
import: {
|
import: {
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
|
@ -159,6 +111,21 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalProjectObjectDto {
|
||||||
|
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
|
||||||
|
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
|
||||||
|
sourceRoomNumber: string; sourceRoomName: string; roomId: string | null;
|
||||||
|
distributionBoardId: string | null; linkedProjectDeviceId: string | null;
|
||||||
|
circuitDeviceRowId: string | null; presenceStatus: "present" | "missing";
|
||||||
|
planningValues: { displayName: string | null; category: CircuitGroupCategory | null; connectionKind: string | null; effectiveQuantity: number; powerPerUnitW: number | null; simultaneityFactor: number | null; cosPhi: number | null; costGroup: string | null; remark: string | null };
|
||||||
|
}
|
||||||
|
export interface ExternalProjectObjectsDto {
|
||||||
|
currentRevision: number; sourceName: string | null;
|
||||||
|
distributionBoards: Array<{ id: string; name: string }>;
|
||||||
|
projectDevices: Array<{ id: string; name: string; displayName: string }>;
|
||||||
|
objects: ExternalProjectObjectDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectDto {
|
export interface ProjectDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ import type {
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
ProjectCircuitTreesResponseDto,
|
ProjectCircuitTreesResponseDto,
|
||||||
ExternalModelConfigurationSnapshotDto,
|
ExternalModelConfigurationSnapshotDto,
|
||||||
|
ExternalInitialImportPlanDto,
|
||||||
|
ExternalInitialImportResultDto,
|
||||||
|
ExternalInitialImportStatusDto,
|
||||||
|
ApplyExternalInitialImportInput,
|
||||||
|
ExternalProjectObjectsDto,
|
||||||
CreateEstimateBalanceInput,
|
CreateEstimateBalanceInput,
|
||||||
EstimateBalanceCommandResultDto,
|
EstimateBalanceCommandResultDto,
|
||||||
EstimateBalanceDto,
|
EstimateBalanceDto,
|
||||||
|
|
@ -198,6 +203,48 @@ export async function detectExternalModelWorkbook(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExternalInitialImportStatus(projectId: string) {
|
||||||
|
return request<ExternalInitialImportStatusDto>(`/api/projects/${projectId}/external-model/import-status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function planExternalInitialImport(
|
||||||
|
projectId: string,
|
||||||
|
file: File,
|
||||||
|
configuration: ExternalModelConfiguration,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ExternalInitialImportPlanDto>(`/api/projects/${projectId}/external-model/initial-import/plan`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyExternalInitialImport(
|
||||||
|
projectId: string,
|
||||||
|
file: File,
|
||||||
|
input: Omit<ApplyExternalInitialImportInput, "fileName" | "contentBase64">
|
||||||
|
) {
|
||||||
|
return request<ExternalInitialImportResultDto>(`/api/projects/${projectId}/external-model/initial-import/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToBase64(file: File) {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExternalProjectObjects(projectId: string) {
|
||||||
|
return request<ExternalProjectObjectsDto>(`/api/projects/${projectId}/external-model/objects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[], patch: Record<string, unknown>) {
|
||||||
|
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
|
||||||
|
}
|
||||||
|
|
||||||
export function getProjectHistory(projectId: string) {
|
export function getProjectHistory(projectId: string) {
|
||||||
return request<ProjectHistoryStateDto>(
|
return request<ProjectHistoryStateDto>(
|
||||||
`/api/projects/${projectId}/history`
|
`/api/projects/${projectId}/history`
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ const commandTypeLabels: Record<string, string> = {
|
||||||
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
|
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
|
||||||
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
|
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
|
||||||
"external-import.apply-initial": "Revit-Erstimport übernommen",
|
"external-import.apply-initial": "Revit-Erstimport übernommen",
|
||||||
|
"external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen",
|
||||||
|
"external-object.update-planning": "Revit-Objekte bearbeitet",
|
||||||
"external-object.assign-to-new-circuit":
|
"external-object.assign-to-new-circuit":
|
||||||
"Externes Objekt in neuen Stromkreis übernommen",
|
"Externes Objekt in neuen Stromkreis übernommen",
|
||||||
"external-object.unassign-and-delete-created-circuit":
|
"external-object.unassign-and-delete-created-circuit":
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/p
|
||||||
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
|
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
|
||||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||||
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
||||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
|
|
@ -72,6 +74,10 @@ export const projectStateRestoreCommandStore =
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const externalModelConfigurationProjectCommandStore =
|
export const externalModelConfigurationProjectCommandStore =
|
||||||
new ExternalModelConfigurationProjectCommandRepository(db);
|
new ExternalModelConfigurationProjectCommandRepository(db);
|
||||||
|
export const externalInitialImportProjectCommandStore =
|
||||||
|
new ExternalInitialImportProjectCommandRepository(db);
|
||||||
|
export const externalObjectPlanningProjectCommandStore =
|
||||||
|
new ExternalObjectPlanningProjectCommandRepository(db);
|
||||||
export const externalObjectRowAssignmentProjectCommandStore =
|
export const externalObjectRowAssignmentProjectCommandStore =
|
||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
|
|
@ -107,5 +113,7 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore,
|
externalObjectNewRowProjectCommandStore,
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore,
|
||||||
estimateBalanceProjectCommandStore
|
estimateBalanceProjectCommandStore,
|
||||||
|
externalInitialImportProjectCommandStore,
|
||||||
|
externalObjectPlanningProjectCommandStore
|
||||||
);
|
);
|
||||||
|
|
|
||||||
68
src/server/controllers/external-initial-import.controller.ts
Normal file
68
src/server/controllers/external-initial-import.controller.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../../domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import { createExternalXlsxInitialImportPlan } from "../../external-model/application/external-xlsx-initial-import.js";
|
||||||
|
import { buildExternalXlsxInitialImportTarget } from "../../external-model/application/external-xlsx-initial-import-target.js";
|
||||||
|
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
|
export function getExternalInitialImportStatus(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
const result = externalModelStateRepository.getByProject(projectId);
|
||||||
|
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
const batch = result.state.importBatches.at(-1) ?? null;
|
||||||
|
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function planExternalInitialImport(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = req.body?.expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error("Ungültige Projektrevision.");
|
||||||
|
const stored = externalModelConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!stored.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
if (!stored.configuration) return res.status(409).json({ error: "Für das Projekt ist noch keine Revit-XLSX-Konfiguration gespeichert.", code: "EXTERNAL_XLSX_CONFIGURATION_REQUIRED" });
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project || project.currentRevision !== expectedRevision) return res.status(409).json({ error: "Der Erstimportplan ist wegen einer neueren Projektrevision ungültig.", code: "PROJECT_REVISION_CONFLICT", expectedRevision, currentRevision: project?.currentRevision ?? null });
|
||||||
|
if (externalModelStateRepository.getByProject(projectId).state.source) return res.status(409).json({ error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED" });
|
||||||
|
const configuration = req.body?.configuration ?? stored.configuration.configuration;
|
||||||
|
assertExternalModelConfiguration(configuration);
|
||||||
|
const { plan } = await createExternalXlsxInitialImportPlan({ fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration, catalogs: await loadCatalogs(projectId) });
|
||||||
|
return res.json(plan);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Erstimport-Vorschau fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyExternalInitialImport(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = req.body?.expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error("Ungültige Projektrevision.");
|
||||||
|
const stored = externalModelConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!stored.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
if (!stored.configuration) return res.status(409).json({ error: "Für das Projekt ist noch keine Revit-XLSX-Konfiguration gespeichert.", code: "EXTERNAL_XLSX_CONFIGURATION_REQUIRED" });
|
||||||
|
if (externalModelStateRepository.getByProject(projectId).state.source) return res.status(409).json({ error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.", code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED" });
|
||||||
|
assertExternalModelConfiguration(req.body?.configuration);
|
||||||
|
const bytes = decodeBase64(req.body?.contentBase64);
|
||||||
|
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: req.body.configuration, catalogs: await loadCatalogs(projectId) });
|
||||||
|
if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" });
|
||||||
|
const target = buildExternalXlsxInitialImportTarget({ projectId, expectedRevision, sourceName: requiredString(req.body?.sourceName, "Quellenname"), importedAtIso: new Date().toISOString(), configuration: req.body.configuration, originalContentBase64: req.body.contentBase64, document, plan, roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"), familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"), objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"), createId: randomUUID });
|
||||||
|
const targetConfiguration = { id: stored.configuration.id, projectId, configuration: req.body.configuration };
|
||||||
|
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `Revit-Erstimport ${plan.fileName}`, command: createExternalInitialImportProjectCommand(stored.configuration, targetConfiguration, createEmptyExternalModelState(), target) });
|
||||||
|
return res.status(201).json({ ...result, import: { sourceId: target.source!.id, importBatchId: target.importBatches[0].id, objectCount: target.objects.length } });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalogs(projectId: string) {
|
||||||
|
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]);
|
||||||
|
return { floors, rooms, distributionBoards, projectDevices };
|
||||||
|
}
|
||||||
|
function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
|
||||||
|
function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); }
|
||||||
|
function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; }
|
||||||
|
function requireArray<T>(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; }
|
||||||
56
src/server/controllers/external-objects.controller.ts
Normal file
56
src/server/controllers/external-objects.controller.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
||||||
|
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
|
export async function listExternalObjects(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
const [project, boards, devices] = await Promise.all([projectRepository.findById(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]);
|
||||||
|
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
const state = externalModelStateRepository.getByProject(projectId).state;
|
||||||
|
const mappings = new Map(state.roomMappings.map((mapping) => [mapping.id, mapping]));
|
||||||
|
return res.json({
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
|
sourceName: state.source?.name ?? null,
|
||||||
|
distributionBoards: boards.map(({ id, name }) => ({ id, name })),
|
||||||
|
projectDevices: devices.map(({ id, name, displayName }) => ({ id, name, displayName })),
|
||||||
|
objects: state.objects.map((object) => {
|
||||||
|
const mapping = object.externalRoomMappingId ? mappings.get(object.externalRoomMappingId) : null;
|
||||||
|
return { id: object.id, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid, deviceTypeId: object.deviceTypeId, familyAndType: object.acceptedSourceValues.familyAndType, selectionMarker: scalarText(object.acceptedSourceValues.selectionMarker), sourceCircuitIdentifier: scalarText(object.acceptedSourceValues.circuitCombined) || scalarText(object.acceptedSourceValues.circuitIdentifier), sourceRoomNumber: scalarText(object.acceptedSourceValues.roomNumber), sourceRoomName: scalarText(object.acceptedSourceValues.roomName), roomId: mapping?.roomId ?? null, distributionBoardId: object.distributionBoardId, linkedProjectDeviceId: object.linkedProjectDeviceId, circuitDeviceRowId: object.circuitDeviceRowId, presenceStatus: object.presenceStatus, planningValues: object.planningValues };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExternalObjects(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = req.body?.expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error("Ungültige Projektrevision.");
|
||||||
|
const objectIds = req.body?.objectIds;
|
||||||
|
const patch = req.body?.patch;
|
||||||
|
if (!Array.isArray(objectIds) || objectIds.length === 0 || new Set(objectIds).size !== objectIds.length || !isRecord(patch)) throw new Error("Objektauswahl oder Änderungen sind ungültig.");
|
||||||
|
const state = externalModelStateRepository.getByProject(projectId);
|
||||||
|
if (!state.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
const byId = new Map(state.state.objects.map((object) => [object.id, object]));
|
||||||
|
const transitions = objectIds.map((id: unknown) => {
|
||||||
|
if (typeof id !== "string") throw new Error("Ungültige Revit-Objekt-ID.");
|
||||||
|
const expected = byId.get(id);
|
||||||
|
if (!expected) throw new Error("Ein ausgewähltes Revit-Objekt wurde nicht gefunden.");
|
||||||
|
if (expected.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte folgen ihrer Gerätezeile und können hier nicht geändert werden.");
|
||||||
|
const planningValues = { ...expected.planningValues };
|
||||||
|
const overriddenFields = new Set(expected.overriddenFields);
|
||||||
|
for (const field of planningFields) if (hasOwn(patch, field)) { planningValues[field] = patch[field] as never; overriddenFields.add(field); }
|
||||||
|
return { expected, target: { ...expected, planningValues, overriddenFields: [...overriddenFields], distributionBoardId: hasOwn(patch, "distributionBoardId") ? nullableId(patch.distributionBoardId) : expected.distributionBoardId, linkedProjectDeviceId: hasOwn(patch, "linkedProjectDeviceId") ? nullableId(patch.linkedProjectDeviceId) : expected.linkedProjectDeviceId } };
|
||||||
|
});
|
||||||
|
const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte bearbeitet`, command: createExternalObjectPlanningUpdateProjectCommand(transitions) });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) { return respondWithProjectCommandError(error, res); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||||
|
function hasOwn(value: object, key: PropertyKey) { return Object.prototype.hasOwnProperty.call(value, key); }
|
||||||
|
function nullableId(value: unknown) { if (value === null || value === "") return null; if (typeof value !== "string") throw new Error("Ungültige Zuordnungs-ID."); return value; }
|
||||||
|
|
@ -58,6 +58,12 @@ import {
|
||||||
detectExternalModelWorkbook,
|
detectExternalModelWorkbook,
|
||||||
updateExternalModelConfiguration,
|
updateExternalModelConfiguration,
|
||||||
} from "../controllers/external-model-configuration.controller.js";
|
} from "../controllers/external-model-configuration.controller.js";
|
||||||
|
import {
|
||||||
|
applyExternalInitialImport,
|
||||||
|
getExternalInitialImportStatus,
|
||||||
|
planExternalInitialImport,
|
||||||
|
} from "../controllers/external-initial-import.controller.js";
|
||||||
|
import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
|
|
@ -82,6 +88,11 @@ projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration);
|
projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration);
|
||||||
projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration);
|
projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration);
|
||||||
projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook);
|
projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook);
|
||||||
|
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
|
||||||
|
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
|
||||||
|
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
|
||||||
|
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
|
||||||
|
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
|
||||||
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(
|
||||||
|
|
|
||||||
78
tests/external-xlsx-initial-import.test.ts
Normal file
78
tests/external-xlsx-initial-import.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||||
|
import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js";
|
||||||
|
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js";
|
||||||
|
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
|
||||||
|
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
|
||||||
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
|
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
|
||||||
|
|
||||||
|
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
|
||||||
|
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
|
||||||
|
|
||||||
|
function configured() {
|
||||||
|
return {
|
||||||
|
...createDefaultExternalModelConfiguration(),
|
||||||
|
...externalXlsxTestConfiguration,
|
||||||
|
deviceTypes: [
|
||||||
|
{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: null, cosPhi: 0.9, costGroup: "440" } },
|
||||||
|
{ id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } },
|
||||||
|
],
|
||||||
|
familyTypeRules: [
|
||||||
|
{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const },
|
||||||
|
{ id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Linear XLSX initial import", () => {
|
||||||
|
it("plans exact families, skips suspicious rows and defaults missing GZF to one", async () => {
|
||||||
|
const { plan } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes: await createRevitXlsxFixture(), configuration: configured(), catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } });
|
||||||
|
assert.equal(plan.objectCount, 2);
|
||||||
|
assert.equal(plan.suspectObjectCount, 1);
|
||||||
|
assert.equal(plan.issueCounts.unknownFamilyAndType, 0);
|
||||||
|
assert.equal(plan.objects[0].worksharingId, firstWorksharingId);
|
||||||
|
assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2);
|
||||||
|
assert.equal(plan.objects[0].suggestedPlanningValues.simultaneityFactor, 1);
|
||||||
|
assert.equal(plan.objects[1].suggestedPlanningValues.simultaneityFactor, 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits atomically and undo/redo uses stored states", async () => {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||||
|
const configuration = configured();
|
||||||
|
const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration };
|
||||||
|
context.db.insert(externalModelConfigurations).values(configurationSnapshot).run();
|
||||||
|
try {
|
||||||
|
const bytes = await createRevitXlsxFixture();
|
||||||
|
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "linear.xlsx", bytes, configuration, catalogs: { floors: [], rooms: [], distributionBoards: [], projectDevices: [] } });
|
||||||
|
let sequence = 0;
|
||||||
|
const target = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T10:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
const inserted = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), target) });
|
||||||
|
assert.equal(inserted.revision.revisionNumber, 1);
|
||||||
|
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.length, 2);
|
||||||
|
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse });
|
||||||
|
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.source, null);
|
||||||
|
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse });
|
||||||
|
const redone = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||||
|
assert.deepEqual(redone.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort());
|
||||||
|
const expectedObject = redone.objects[0];
|
||||||
|
const targetObject = { ...expectedObject, planningValues: { ...expectedObject.planningValues, simultaneityFactor: 0.5 }, overriddenFields: [...expectedObject.overriddenFields, "simultaneityFactor" as const] };
|
||||||
|
const planningRepository = new ExternalObjectPlanningProjectCommandRepository(context.db);
|
||||||
|
const planned = planningRepository.execute({ projectId: "project-1", expectedRevision: 3, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: expectedObject, target: targetObject }]) });
|
||||||
|
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 0.5);
|
||||||
|
planningRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "undo", historyTargetChangeSetId: planned.revision.changeSetId, command: planned.inverse });
|
||||||
|
assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 1);
|
||||||
|
} finally { context.close(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { detectExternalModelWorkbook, getExternalModelConfiguration, updateExternalModelConfiguration } 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 () => {
|
||||||
|
|
@ -25,14 +25,53 @@ describe("Revit XLSX settings", () => {
|
||||||
} finally { globalThis.fetch = originalFetch; }
|
} finally { globalThis.fetch = originalFetch; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses stateless preview and retransmits the workbook for apply", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: Record<string, unknown> | undefined }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||||
|
return new Response(JSON.stringify({ imported: false, familyGroups: [], sourceRooms: [], objects: [], warnings: [], issueCounts: {}, existing: {}, history: { currentRevision: 9 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const configuration = createDefaultExternalModelConfiguration();
|
||||||
|
const file = new File([new Uint8Array([1, 2])], "linear.xlsx");
|
||||||
|
await getExternalInitialImportStatus("project-1");
|
||||||
|
await planExternalInitialImport("project-1", file, configuration, 8);
|
||||||
|
await applyExternalInitialImport("project-1", file, { expectedRevision: 8, expectedSha256: "a".repeat(64), sourceName: "Revit", configuration, objectsWithoutRoomDefaultDistributionBoardId: null, roomDecisions: [], familyProjectDeviceDecisions: [] });
|
||||||
|
assert.equal(requests[0].url, "/api/projects/project-1/external-model/import-status");
|
||||||
|
assert.equal(requests[1].url, "/api/projects/project-1/external-model/initial-import/plan");
|
||||||
|
assert.equal(requests[2].url, "/api/projects/project-1/external-model/initial-import/apply");
|
||||||
|
assert.equal(requests[1].body?.contentBase64, "AQI=");
|
||||||
|
assert.equal(requests[1].body?.expectedRevision, 8);
|
||||||
|
assert.equal(requests[2].body?.contentBase64, "AQI=");
|
||||||
|
assert.equal(requests[2].body?.expectedRevision, 8);
|
||||||
|
} finally { globalThis.fetch = originalFetch; }
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes the Revit route and removes the old project modal", () => {
|
it("exposes the Revit route and removes the old project modal", () => {
|
||||||
const projectPage = readFileSync("src/app/projects/[projectId]/page.tsx", "utf8");
|
const projectPage = readFileSync("src/app/projects/[projectId]/page.tsx", "utf8");
|
||||||
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
||||||
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
|
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
|
||||||
|
const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8");
|
||||||
|
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
|
||||||
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
||||||
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
||||||
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(wizard, /Schritt.*von/);
|
||||||
|
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
|
||||||
|
assert.match(wizard, /GZF 1/);
|
||||||
|
assert.match(objectTable, /viewportHeight/);
|
||||||
|
assert.match(objectTable, /Sammeländerung anwenden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads one project object list and sends revision-safe bulk updates", 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({ currentRevision: 4, sourceName: null, distributionBoards: [], projectDevices: [], objects: [], history: { currentRevision: 5 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
|
||||||
|
try { await getExternalProjectObjects("project-1"); await updateExternalProjectObjects("project-1", 4, ["object-1"], { simultaneityFactor: 0.7 }); }
|
||||||
|
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 } } }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue