Add Linear XLSX follow-up import
This commit is contained in:
parent
1a4c8cad8c
commit
2581beee3c
28 changed files with 1114 additions and 31 deletions
|
|
@ -344,6 +344,13 @@ batch payloads. The Revit page filters and sorts it in the client and renders a
|
||||||
virtualized window. `external-object.update-planning` applies multi-object
|
virtualized window. `external-object.update-planning` applies multi-object
|
||||||
board, ProjectDevice and planning-value changes as one persistent command;
|
board, ProjectDevice and planning-value changes as one persistent command;
|
||||||
linked objects are excluded because they follow their CircuitDeviceRow board.
|
linked objects are excluded because they follow their CircuitDeviceRow board.
|
||||||
|
R9 adds the complete Linear-XLSX follow-up import. It matches Worksharing-IDs
|
||||||
|
exactly, separates source decisions from return deviations, updates accepted
|
||||||
|
linked quantities atomically, retains missing objects and compacts every older
|
||||||
|
batch to metadata. `external-import.apply-follow-up` stores complete inverse
|
||||||
|
states, so Undo/Redo never parses Excel. A separately confirmed
|
||||||
|
`external-model-object.delete-missing` command removes missing objects and
|
||||||
|
adjusts linked row quantities; Undo restores objects, links and quantities.
|
||||||
|
|
||||||
The target configuration has no independent version counter; `expectedRevision`
|
The target configuration has no independent version counter; `expectedRevision`
|
||||||
invalidates stale plans, and each import batch stores a configuration copy.
|
invalidates stale plans, and each import batch stores a configuration copy.
|
||||||
|
|
@ -597,7 +604,7 @@ Critical target rules:
|
||||||
selected combined/separate mode block export.
|
selected combined/separate mode block export.
|
||||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||||
- The corrected R2 return workbook was successfully imported into Linear on
|
- The corrected R2 return workbook was successfully imported into Linear on
|
||||||
2026-09-30; R3 through R6 are complete and R7 is the next package.
|
2026-09-30; R3 through R9 are complete and R10 is the next package.
|
||||||
- All confirmed writes use typed project commands with `expectedRevision`,
|
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||||
complete inverse data and the shared project-command transaction boundary.
|
complete inverse data and the shared project-command transaction boundary.
|
||||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte
|
||||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||||
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
||||||
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
||||||
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen;
|
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R9 sind abgeschlossen;
|
||||||
die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten
|
die eigene Revit-Seite unterstützt Einstellungen, Erst- und Folgeimport,
|
||||||
Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen.
|
virtuelle Objektbearbeitung, Editorzuordnung und strukturtreue Rückgabe.
|
||||||
Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
|
R10 härtet den Ablauf Ende zu Ende und für große Datenmengen.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -41,9 +41,9 @@ Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10.
|
||||||
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
||||||
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
||||||
übernehmen
|
übernehmen
|
||||||
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
|
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Erst- und Folgeimporte
|
||||||
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
|
prüfen, Revit-Objekte zuordnen und geplante Werte strukturerhaltend an
|
||||||
Phase-14-Plan in den nächsten Paketen angebunden
|
Linear zurückgeben
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- The current Revit/CSV foundation and confirmed initial import are a legacy
|
- Die Linear-XLSX-Anbindung bis R9 ist umgesetzt: Erst- und Folgeimport,
|
||||||
starting point. The active Phase-14 plan replaces them with the Linear XLSX
|
Objektplanung und Editorzuordnung, strukturtreue Rückgabe sowie bestätigtes
|
||||||
exchange and exact Worksharing-ID identity.
|
Löschen fehlender Objekte verwenden persistente Commands. Offen ist die
|
||||||
R2 must pass a real Linear return-import check before database cutover in R3.
|
R10-Härtung des gesamten Ablaufs mit mehreren Tausend synthetischen Objekten,
|
||||||
Existing-row assignment, move and detach plus creation of an external-only
|
Messungen sowie Snapshot-/Transfer- und Neustarttests.
|
||||||
row in an existing or newly created circuit have atomic persistent commands.
|
|
||||||
Their API planning and editor UI, follow-up import, conflict review and return
|
|
||||||
export are pending.
|
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
|
|
|
||||||
|
|
@ -843,9 +843,8 @@ Referenzen gesperrt.
|
||||||
## Noch nicht unterstützt
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die
|
- R10-Härtung des Linear-XLSX-Ablaufs für große Datenmengen sowie
|
||||||
Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung
|
Ende-zu-Ende-Nachweise über Snapshot, Transfer und Neustart
|
||||||
folgt dem R0–R10-Plan
|
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
- Produktionsdeployment
|
- Produktionsdeployment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,15 @@ Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
|
||||||
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
||||||
auf Metadaten reduziert.
|
auf Metadaten reduziert.
|
||||||
|
|
||||||
|
Umsetzungsstand R9: Der sechsstufige Folgeimportwizard, die erneute
|
||||||
|
Apply-Prüfung, die getrennte Rückgabeabweichungsanzeige und der atomare
|
||||||
|
`external-import.apply-follow-up`-Command sind umgesetzt. Akzeptierte
|
||||||
|
Mengenänderungen aktualisieren verknüpfte Row-Summen im selben Command.
|
||||||
|
Fehlende Objekte bleiben zunächst samt Link und Menge erhalten und erscheinen
|
||||||
|
in der virtuellen Objekttabelle als `fehlend`. Ihre gesondert bestätigte
|
||||||
|
Löschung verwendet `external-model-object.delete-missing`; Undo stellt
|
||||||
|
Objekte, Links und Row-Mengen exakt wieder her.
|
||||||
|
|
||||||
## 15. Commands, Persistenz und Sicherheit
|
## 15. Commands, Persistenz und Sicherheit
|
||||||
|
|
||||||
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
|
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
||||||
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
|
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
|
||||||
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
|
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
|
||||||
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
||||||
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
|
| R9 | abgeschlossen | Vollständiger Folgeimport nach exakter Worksharing-ID mit Quellentscheidungen, fehlenden Objekten, bestätigtem Löschen und persistentem Undo/Redo. |
|
||||||
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
|
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
|
||||||
|
|
||||||
## R0 – Audit
|
## R0 – Audit
|
||||||
|
|
@ -190,6 +190,17 @@ Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
|
||||||
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
||||||
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
|
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
|
||||||
|
|
||||||
|
Umsetzungsstand vom 30.09.2026: Vorschau und Apply klassifizieren die vollständige
|
||||||
|
XLSX nach exakter Worksharing-ID als neu, geändert, unverändert oder fehlend
|
||||||
|
und weisen Rückgabefeldabweichungen getrennt aus. Der Wizard fordert
|
||||||
|
Entscheidungen nur für fachliche Quelländerungen an. Apply überträgt und parst
|
||||||
|
die Datei erneut, aktualisiert akzeptierte Mengen samt verknüpfter Row-Summe
|
||||||
|
atomar und speichert den neuen Vollbatch; alle älteren Batches werden auf ihre
|
||||||
|
Metadaten reduziert. Fehlende Objekte behalten zunächst Link und Menge. Eine
|
||||||
|
gesondert bestätigte Aktion kann sie anschließend mit passender
|
||||||
|
Row-Mengenänderung löschen. Beide Commands besitzen vollständige Inversen;
|
||||||
|
Undo/Redo liest keine XLSX-Datei.
|
||||||
|
|
||||||
## R10 – Härtung
|
## R10 – Härtung
|
||||||
|
|
||||||
Ende-zu-Ende-Test:
|
Ende-zu-Ende-Test:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { IconLink } from "../../../../frontend/components/icon-link";
|
import { IconLink } from "../../../../frontend/components/icon-link";
|
||||||
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
|
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
|
||||||
|
import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard";
|
||||||
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
|
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
|
||||||
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
||||||
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
||||||
|
|
@ -19,6 +20,7 @@ export default function RevitPage() {
|
||||||
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
|
const [importStatus, setImportStatus] = useState<ExternalInitialImportStatusDto | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [followUpOpen, setFollowUpOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -41,11 +43,12 @@ export default function RevitPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return <main className="container py-4">
|
return <main className="container py-4">
|
||||||
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
|
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
|
||||||
{importStatus?.imported ? <RevitObjectTable projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
|
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
|
||||||
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
|
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
|
||||||
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
|
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
|
||||||
|
{followUpOpen && project && snapshot ? <RevitFollowUpImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setFollowUpOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setFollowUpOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
|
import { assertExternalFollowUpImportProjectCommand, invertExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
|
||||||
|
import type { ExternalFollowUpImportProjectCommandStore } from "../../domain/ports/external-follow-up-import-project-command.store.js";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalFollowUpImportProjectCommandRepository implements ExternalFollowUpImportProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalFollowUpImportProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalFollowUpImportProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const currentState = new ExternalModelStateRepository(tx).getByProject(input.projectId);
|
||||||
|
const currentConfiguration = tx.select().from(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, input.projectId)).get();
|
||||||
|
if (!currentState.projectExists || !currentConfiguration || !same(currentState.state, input.command.payload.expectedState) || !same(currentConfiguration, input.command.payload.expectedConfiguration)) throw new Error("Revit-Zustand oder Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
const rowIds = input.command.payload.rows.map(({ expected }) => expected.id);
|
||||||
|
const currentRows = rowIds.length ? tx.select().from(circuitDeviceRows).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
|
||||||
|
if (currentRows.length !== rowIds.length) throw new Error("Eine Gerätezeile des Folgeimports wurde nicht gefunden.");
|
||||||
|
const transitionById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
for (const row of currentRows) if (!same(toCircuitDeviceRowSnapshot(row), transitionById.get(row.id)!.expected)) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
|
||||||
|
validateTarget(tx, input.projectId, input.expectedRevision, input.source, input.command.payload.targetConfiguration.configuration, input.command.payload.expectedState, input.command.payload.targetState, input.command.payload.rows);
|
||||||
|
for (const transition of input.command.payload.rows) {
|
||||||
|
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Folgeimports geändert.");
|
||||||
|
}
|
||||||
|
replaceConfiguration(tx, input.projectId, input.command.payload.targetConfiguration);
|
||||||
|
replaceState(tx, input.projectId, input.command.payload.targetState);
|
||||||
|
return invertExternalFollowUpImportProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTarget(database: AppDatabase, projectId: string, expectedRevision: number, source: string, configuration: ExternalModelConfiguration, expectedState: ExternalModelStateSnapshot, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
|
||||||
|
if (target.source?.projectId !== projectId) throw new Error("Der Revit-Folgeimport gehört zu einem anderen Projekt.");
|
||||||
|
const growing = target.importBatches.length > expectedState.importBatches.length;
|
||||||
|
const latest = growing ? target.importBatches.at(-1)! : expectedState.importBatches.at(-1)!;
|
||||||
|
if (source === "user" && (!growing || latest.appliedProjectRevision !== expectedRevision + 1)) throw new Error("Die Projektrevision des Folgeimports ist ungültig.");
|
||||||
|
if (growing) {
|
||||||
|
if (!same(latest.configurationSnapshot, configuration)) throw new Error("Die Konfigurationskopie des Folgeimports ist ungültig.");
|
||||||
|
const bytes = Buffer.from(latest.originalContentBase64!, "base64");
|
||||||
|
if (bytes.toString("base64") !== latest.originalContentBase64 || createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("Die Prüfsumme des Folgeimports ist ungültig.");
|
||||||
|
}
|
||||||
|
const roomIds = ids(database.select({ id: rooms.id }).from(rooms).where(eq(rooms.projectId, projectId)).all());
|
||||||
|
const boardIds = ids(database.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, projectId)).all());
|
||||||
|
const projectDeviceIds = ids(database.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, projectId)).all());
|
||||||
|
const deviceTypeIds = new Set(configuration.deviceTypes.map((entry) => entry.id));
|
||||||
|
for (const mapping of target.roomMappings) { if (mapping.roomId && !roomIds.has(mapping.roomId)) throw new Error("Eine Raumzuordnung verweist auf ein anderes Projekt."); if (mapping.defaultDistributionBoardId && !boardIds.has(mapping.defaultDistributionBoardId)) throw new Error("Ein Standardverteiler verweist auf ein anderes Projekt."); }
|
||||||
|
for (const object of target.objects) { if (!deviceTypeIds.has(object.deviceTypeId)) throw new Error("Ein Revit-Objekt verweist auf eine unbekannte Geräteart."); if (object.distributionBoardId && !boardIds.has(object.distributionBoardId)) throw new Error("Ein Revit-Objekt verweist auf einen fremden Verteiler."); if (object.linkedProjectDeviceId && !projectDeviceIds.has(object.linkedProjectDeviceId)) throw new Error("Ein Revit-Objekt verweist auf ein fremdes Projektgerät."); }
|
||||||
|
assertRows(database, projectId, target, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRows(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot, rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>) {
|
||||||
|
const linkedRowIds = [...new Set(target.objects.flatMap((object) => object.circuitDeviceRowId ? [object.circuitDeviceRowId] : []))];
|
||||||
|
const rowsToLoad = [...new Set([...linkedRowIds, ...rows.map(({ expected }) => expected.id)])];
|
||||||
|
if (!rowsToLoad.length) return;
|
||||||
|
const contexts = database.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowsToLoad)).all();
|
||||||
|
if (contexts.length !== rowsToLoad.length || contexts.some((entry) => entry.projectId !== projectId)) throw new Error("Eine Gerätezeile gehört zu einem anderen Projekt.");
|
||||||
|
const transitionById = new Map(rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
for (const context of contexts) {
|
||||||
|
const current = toCircuitDeviceRowSnapshot(context.row);
|
||||||
|
const effective = transitionById.get(current.id)?.target ?? current;
|
||||||
|
assertCircuitDeviceRowQuantity({ quantity: effective.quantity, manualQuantity: effective.manualQuantity ?? effective.quantity, externalObjects: target.objects.filter((object) => object.circuitDeviceRowId === effective.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function replaceConfiguration(database: AppDatabase, projectId: string, target: typeof externalModelConfigurations.$inferInsert) { database.delete(externalModelConfigurations).where(eq(externalModelConfigurations.projectId, projectId)).run(); database.insert(externalModelConfigurations).values(target).run(); }
|
||||||
|
function replaceState(database: AppDatabase, projectId: string, target: ExternalModelStateSnapshot) { database.delete(externalModelSources).where(eq(externalModelSources.projectId, projectId)).run(); if (!target.source) return; database.insert(externalModelSources).values(target.source).run(); database.insert(externalImportBatches).values(target.importBatches.map(({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: originalContentBase64 === null ? null : Buffer.from(originalContentBase64, "base64") }))).run(); if (target.roomMappings.length) database.insert(externalRoomMappings).values(target.roomMappings).run(); if (target.objects.length) database.insert(externalModelObjects).values(target.objects).run(); }
|
||||||
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
|
function same(left: unknown, right: unknown) { return canonical(left) === canonical(right); }
|
||||||
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value !== null && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
|
import { assertExternalObjectDeleteMissingProjectCommand, invertExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
|
||||||
|
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../../domain/ports/external-object-delete-missing-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
|
import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalObjectDeleteMissingProjectCommandRepository implements ExternalObjectDeleteMissingProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
execute(input: Parameters<ExternalObjectDeleteMissingProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const rowById = new Map(input.command.payload.rows.map((transition) => [transition.expected.id, transition]));
|
||||||
|
const rowIds = [...rowById.keys()];
|
||||||
|
const rowContexts = rowIds.length ? tx.select({ row: circuitDeviceRows, projectId: circuitLists.projectId }).from(circuitDeviceRows).innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)).innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)).where(inArray(circuitDeviceRows.id, rowIds)).all() : [];
|
||||||
|
if (rowContexts.length !== rowIds.length || rowContexts.some((entry) => entry.projectId !== input.projectId || !snapshotsEqual(toCircuitDeviceRowSnapshot(entry.row), rowById.get(entry.row.id)!.expected))) throw new Error("Eine Gerätezeile wurde zwischenzeitlich geändert.");
|
||||||
|
const currentObjects = tx.select().from(externalModelObjects).where(eq(externalModelObjects.projectId, input.projectId)).all().map(toExternalObjectSnapshot);
|
||||||
|
const currentById = new Map(currentObjects.map((object) => [object.id, object]));
|
||||||
|
for (const transition of input.command.payload.objects) {
|
||||||
|
const current = currentById.get((transition.expected ?? transition.target)!.id) ?? null;
|
||||||
|
if (transition.expected === null ? current !== null : current === null || !snapshotsEqual(current, transition.expected)) throw new Error("Ein fehlendes Revit-Objekt wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
const sourceIds = ids(tx.select({ id: externalModelSources.id }).from(externalModelSources).where(eq(externalModelSources.projectId, input.projectId)).all());
|
||||||
|
const batchIds = ids(tx.select({ id: externalImportBatches.id }).from(externalImportBatches).where(eq(externalImportBatches.projectId, input.projectId)).all());
|
||||||
|
const mappingIds = ids(tx.select({ id: externalRoomMappings.id }).from(externalRoomMappings).where(eq(externalRoomMappings.projectId, input.projectId)).all());
|
||||||
|
const boardIds = ids(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all());
|
||||||
|
const deviceIds = ids(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all());
|
||||||
|
for (const { target } of input.command.payload.objects) if (target && (target.projectId !== input.projectId || !sourceIds.has(target.sourceId) || !batchIds.has(target.lastSeenImportBatchId) || !batchIds.has(target.lastAcceptedImportBatchId) || (target.externalRoomMappingId !== null && !mappingIds.has(target.externalRoomMappingId)) || (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) || (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)))) throw new Error("Ein wiederhergestelltes Revit-Objekt verweist auf ein anderes Projekt.");
|
||||||
|
const targetById = new Map(currentObjects.map((object) => [object.id, object]));
|
||||||
|
for (const transition of input.command.payload.objects) { const id = (transition.expected ?? transition.target)!.id; if (transition.target) targetById.set(id, transition.target); else targetById.delete(id); }
|
||||||
|
for (const transition of input.command.payload.rows) assertCircuitDeviceRowQuantity({ quantity: transition.target.quantity, manualQuantity: transition.target.manualQuantity ?? transition.target.quantity, externalObjects: [...targetById.values()].filter((object) => object.circuitDeviceRowId === transition.target.id).map((object) => ({ effectiveQuantity: object.planningValues.effectiveQuantity })) });
|
||||||
|
for (const transition of input.command.payload.rows) {
|
||||||
|
const result = tx.update(circuitDeviceRows).set({ quantity: transition.target.quantity }).where(and(eq(circuitDeviceRows.id, transition.expected.id), eq(circuitDeviceRows.quantity, transition.expected.quantity))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Eine Gerätezeile wurde während des Löschens geändert.");
|
||||||
|
}
|
||||||
|
for (const transition of input.command.payload.objects) {
|
||||||
|
const object = transition.expected ?? transition.target!;
|
||||||
|
if (transition.target === null) {
|
||||||
|
const result = tx.delete(externalModelObjects).where(and(eq(externalModelObjects.id, object.id), eq(externalModelObjects.projectId, input.projectId))).run();
|
||||||
|
if (result.changes !== 1) throw new Error("Ein fehlendes Revit-Objekt konnte nicht gelöscht werden.");
|
||||||
|
} else tx.insert(externalModelObjects).values(transition.target).run();
|
||||||
|
}
|
||||||
|
return invertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ids(entries: Array<{ id: string }>) { return new Set(entries.map((entry) => entry.id)); }
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js";
|
||||||
|
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export const externalFollowUpImportCommandType = "external-import.apply-follow-up" as const;
|
||||||
|
export const externalFollowUpImportCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ExternalFollowUpImportPayload {
|
||||||
|
expectedConfiguration: ExternalModelConfigurationSnapshot;
|
||||||
|
targetConfiguration: ExternalModelConfigurationSnapshot;
|
||||||
|
expectedState: ExternalModelStateSnapshot;
|
||||||
|
targetState: ExternalModelStateSnapshot;
|
||||||
|
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalFollowUpImportProjectCommand extends SerializedProjectCommand<ExternalFollowUpImportPayload> {
|
||||||
|
schemaVersion: typeof externalFollowUpImportCommandSchemaVersion;
|
||||||
|
type: typeof externalFollowUpImportCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalFollowUpImportProjectCommand(payload: ExternalFollowUpImportPayload): ExternalFollowUpImportProjectCommand {
|
||||||
|
const command: ExternalFollowUpImportProjectCommand = { schemaVersion: 1, type: externalFollowUpImportCommandType, payload: { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target) })) } };
|
||||||
|
assertExternalFollowUpImportProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertExternalFollowUpImportProjectCommand(command: ExternalFollowUpImportProjectCommand) {
|
||||||
|
return createExternalFollowUpImportProjectCommand({
|
||||||
|
expectedConfiguration: command.payload.targetConfiguration,
|
||||||
|
targetConfiguration: command.payload.expectedConfiguration,
|
||||||
|
expectedState: command.payload.targetState,
|
||||||
|
targetState: command.payload.expectedState,
|
||||||
|
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalFollowUpImportProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalFollowUpImportProjectCommand {
|
||||||
|
if (command.schemaVersion !== 1 || command.type !== externalFollowUpImportCommandType || !isRecord(command.payload)) throw new Error("Nicht unterstützter Revit-Folgeimport-Command.");
|
||||||
|
if (Object.keys(command.payload).sort().join(",") !== "expectedConfiguration,expectedState,rows,targetConfiguration,targetState" || !Array.isArray(command.payload.rows)) throw new Error("Der Revit-Folgeimport-Command ist unvollständig.");
|
||||||
|
const expectedConfiguration = parseConfiguration(command.payload.expectedConfiguration);
|
||||||
|
const targetConfiguration = parseConfiguration(command.payload.targetConfiguration);
|
||||||
|
if (expectedConfiguration.id !== targetConfiguration.id || expectedConfiguration.projectId !== targetConfiguration.projectId) throw new Error("Der Folgeimport darf die Konfigurationsidentität nicht ändern.");
|
||||||
|
const expectedState = parseExternalModelStateSnapshot(command.payload.expectedState);
|
||||||
|
const targetState = parseExternalModelStateSnapshot(command.payload.targetState);
|
||||||
|
if (!expectedState.source || !targetState.source || expectedState.source.id !== targetState.source.id || expectedState.source.projectId !== targetState.source.projectId) throw new Error("Der Folgeimport benötigt dieselbe Revit-Quelle auf beiden Seiten.");
|
||||||
|
const batchDelta = targetState.importBatches.length - expectedState.importBatches.length;
|
||||||
|
if (Math.abs(batchDelta) !== 1) throw new Error("Der Folgeimport muss genau einen Importbatch hinzufügen oder entfernen.");
|
||||||
|
const larger = batchDelta > 0 ? targetState : expectedState;
|
||||||
|
const smaller = batchDelta > 0 ? expectedState : targetState;
|
||||||
|
const largerConfiguration = batchDelta > 0 ? targetConfiguration : expectedConfiguration;
|
||||||
|
const addedBatch = larger.importBatches.at(-1)!;
|
||||||
|
if (addedBatch.importKind !== "follow_up" || addedBatch.configurationSnapshot === null || addedBatch.originalContentBase64 === null || addedBatch.document === null) throw new Error("Der Folgeimport-Batch ist unvollständig.");
|
||||||
|
if (canonical(addedBatch.configurationSnapshot) !== canonical(largerConfiguration.configuration)) throw new Error("Die Konfigurationskopie des Folgeimports stimmt nicht mit dem Ziel überein.");
|
||||||
|
if (larger.importBatches.length !== smaller.importBatches.length + 1 || smaller.importBatches.some((batch, index) => !sameBatchMetadata(batch, larger.importBatches[index]))) throw new Error("Der Folgeimport darf bestehende Importbatch-Metadaten nicht verändern.");
|
||||||
|
const largerObjectById = new Map(larger.objects.map((object) => [object.id, object]));
|
||||||
|
for (const object of smaller.objects) {
|
||||||
|
const next = largerObjectById.get(object.id);
|
||||||
|
if (!next || next.worksharingId !== object.worksharingId || next.sourceId !== object.sourceId || next.circuitDeviceRowId !== object.circuitDeviceRowId || next.linkedProjectDeviceId !== object.linkedProjectDeviceId) throw new Error("Der Folgeimport darf bestehende Objektidentitäten oder Links nicht verändern.");
|
||||||
|
}
|
||||||
|
const smallerIds = new Set(smaller.objects.map((object) => object.id));
|
||||||
|
if (larger.objects.some((object) => !smallerIds.has(object.id) && (object.circuitDeviceRowId !== null || object.presenceStatus !== "present" || object.lastSeenImportBatchId !== addedBatch.id))) throw new Error("Neue Folgeimportobjekte müssen unverknüpft und vorhanden sein.");
|
||||||
|
if (larger.objects.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === addedBatch.id).length !== addedBatch.objectCount) throw new Error("Die Objektzahl des Folgeimportbatches ist inkonsistent.");
|
||||||
|
const largerMappings = new Map(larger.roomMappings.map((mapping) => [mapping.id, mapping]));
|
||||||
|
if (smaller.roomMappings.some((mapping) => canonical(mapping) !== canonical(largerMappings.get(mapping.id)))) throw new Error("Der Folgeimport darf bestehende Raumzuordnungen nicht verändern.");
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const transition of command.payload.rows) {
|
||||||
|
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang im Folgeimport.");
|
||||||
|
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
|
||||||
|
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität im Folgeimport.");
|
||||||
|
const expectedRest = { ...expected, quantity: 0 }; const targetRest = { ...target, quantity: 0 };
|
||||||
|
if (canonical(expectedRest) !== canonical(targetRest) || expected.quantity === target.quantity) throw new Error("Der Folgeimport darf an Gerätezeilen nur die Gesamtmenge ändern.");
|
||||||
|
rowIds.add(expected.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfiguration(value: unknown): ExternalModelConfigurationSnapshot { if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") throw new Error("Ungültige Revit-Konfiguration im Folgeimport."); assertExternalModelConfiguration(value.configuration); return value as unknown as ExternalModelConfigurationSnapshot; }
|
||||||
|
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; }
|
||||||
|
function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||||
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
|
||||||
|
function sameBatchMetadata(left: ExternalModelStateSnapshot["importBatches"][number], right: ExternalModelStateSnapshot["importBatches"][number] | undefined) { if (!right) return false; return left.id === right.id && left.projectId === right.projectId && left.sourceId === right.sourceId && left.importKind === right.importKind && left.importedAtIso === right.importedAtIso && left.fileName === right.fileName && left.sha256 === right.sha256 && left.objectCount === right.objectCount; }
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
|
||||||
|
|
||||||
|
export const externalObjectDeleteMissingCommandType = "external-model-object.delete-missing" as const;
|
||||||
|
type ObjectTransition = { expected: ExternalModelObjectSnapshot | null; target: ExternalModelObjectSnapshot | null };
|
||||||
|
export interface ExternalObjectDeleteMissingProjectCommand extends SerializedProjectCommand<{ objects: ObjectTransition[]; rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> }> {
|
||||||
|
schemaVersion: 1;
|
||||||
|
type: typeof externalObjectDeleteMissingCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalObjectDeleteMissingProjectCommand(payload: ExternalObjectDeleteMissingProjectCommand["payload"]): ExternalObjectDeleteMissingProjectCommand {
|
||||||
|
const command: ExternalObjectDeleteMissingProjectCommand = { schemaVersion: 1, type: externalObjectDeleteMissingCommandType, payload };
|
||||||
|
assertExternalObjectDeleteMissingProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertExternalObjectDeleteMissingProjectCommand(command: ExternalObjectDeleteMissingProjectCommand) {
|
||||||
|
return createExternalObjectDeleteMissingProjectCommand({
|
||||||
|
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||||
|
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalObjectDeleteMissingProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectDeleteMissingProjectCommand {
|
||||||
|
if (command.schemaVersion !== 1 || command.type !== externalObjectDeleteMissingCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.rows) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Command zum Löschen fehlender Revit-Objekte.");
|
||||||
|
const objectIds = new Set<string>();
|
||||||
|
let deletion: boolean | null = null;
|
||||||
|
for (const transition of command.payload.objects) {
|
||||||
|
if (!isRecord(transition) || (transition.expected === null) === (transition.target === null)) throw new Error("Ungültiger Löschübergang für ein Revit-Objekt.");
|
||||||
|
const object = parseObject(transition.expected ?? transition.target);
|
||||||
|
if (object.presenceStatus !== "missing" || objectIds.has(object.id)) throw new Error("Nur eindeutig fehlende Revit-Objekte dürfen gelöscht werden.");
|
||||||
|
const nextDeletion = transition.target === null;
|
||||||
|
if (deletion !== null && deletion !== nextDeletion) throw new Error("Löschen und Wiederherstellen dürfen nicht gemischt werden.");
|
||||||
|
deletion = nextDeletion;
|
||||||
|
objectIds.add(object.id);
|
||||||
|
}
|
||||||
|
const affectedRowIds = new Set(command.payload.objects.flatMap(({ expected, target }) => (expected ?? target)!.circuitDeviceRowId ? [(expected ?? target)!.circuitDeviceRowId!] : []));
|
||||||
|
const rowIds = new Set<string>();
|
||||||
|
for (const transition of command.payload.rows) {
|
||||||
|
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang.");
|
||||||
|
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
|
||||||
|
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität.");
|
||||||
|
if (canonical({ ...expected, quantity: 0 }) !== canonical({ ...target, quantity: 0 }) || expected.quantity === target.quantity) throw new Error("Beim Löschen fehlender Objekte darf nur die Gesamtmenge geändert werden.");
|
||||||
|
rowIds.add(expected.id);
|
||||||
|
}
|
||||||
|
if (canonical([...affectedRowIds].sort()) !== canonical([...rowIds].sort())) throw new Error("Die Mengenübergänge der betroffenen Gerätezeilen sind unvollständig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
|
||||||
|
function parseRow(value: unknown) { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||||
|
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ExternalFollowUpImportProjectCommand } from "../models/external-follow-up-import-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExternalFollowUpImportProjectCommandStore {
|
||||||
|
execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalFollowUpImportProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalFollowUpImportProjectCommand };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { ExternalObjectDeleteMissingProjectCommand } from "../models/external-object-delete-missing-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
export interface ExternalObjectDeleteMissingProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectDeleteMissingProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectDeleteMissingProjectCommand }; }
|
||||||
|
|
@ -182,8 +182,12 @@ import {
|
||||||
externalInitialImportCommandType,
|
externalInitialImportCommandType,
|
||||||
} from "../models/external-initial-import-project-command.model.js";
|
} from "../models/external-initial-import-project-command.model.js";
|
||||||
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
||||||
|
import { assertExternalFollowUpImportProjectCommand, externalFollowUpImportCommandType } from "../models/external-follow-up-import-project-command.model.js";
|
||||||
|
import type { ExternalFollowUpImportProjectCommandStore } from "../ports/external-follow-up-import-project-command.store.js";
|
||||||
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
|
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
|
||||||
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
|
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
|
||||||
|
import { assertExternalObjectDeleteMissingProjectCommand, externalObjectDeleteMissingCommandType } from "../models/external-object-delete-missing-project-command.model.js";
|
||||||
|
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../ports/external-object-delete-missing-project-command.store.js";
|
||||||
import {
|
import {
|
||||||
assertExternalObjectRowAssignmentProjectCommand,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
externalObjectRowAssignmentCommandType,
|
externalObjectRowAssignmentCommandType,
|
||||||
|
|
@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
|
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
|
||||||
|
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
|
||||||
|
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
|
|
@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case externalFollowUpImportCommandType: {
|
||||||
|
assertExternalFollowUpImportProjectCommand(input.command);
|
||||||
|
if (!this.externalFollowUpImportStore) throw new Error("External follow-up import store is not available.");
|
||||||
|
return this.externalFollowUpImportStore.execute({ ...input, command: input.command }).revision;
|
||||||
|
}
|
||||||
case externalObjectPlanningUpdateCommandType: {
|
case externalObjectPlanningUpdateCommandType: {
|
||||||
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||||
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
||||||
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
|
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
|
||||||
}
|
}
|
||||||
|
case externalObjectDeleteMissingCommandType: {
|
||||||
|
assertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||||
|
if (!this.externalObjectDeleteMissingStore) throw new Error("External missing-object delete store is not available.");
|
||||||
|
return this.externalObjectDeleteMissingStore.execute({ ...input, command: input.command }).revision;
|
||||||
|
}
|
||||||
case externalObjectRowAssignmentCommandType: {
|
case externalObjectRowAssignmentCommandType: {
|
||||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||||
if (!this.externalObjectRowAssignmentStore) {
|
if (!this.externalObjectRowAssignmentStore) {
|
||||||
|
|
|
||||||
120
src/external-model/application/external-xlsx-follow-up-import.ts
Normal file
120
src/external-model/application/external-xlsx-follow-up-import.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
|
||||||
|
import type { ExternalXlsxReturnValues, ExternalXlsxScalar } from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
import { createExternalXlsxInitialImportPlan, type ExternalInitialImportCatalogs, type ExternalInitialImportPlan, type ExternalInitialImportProjectedObject } from "./external-xlsx-initial-import.js";
|
||||||
|
|
||||||
|
export const externalSourceDecisionFields = ["ifcGuid", "roomNumber", "roomName", "familyAndType", "selectionMarker", "socketCount", "sourceRemark"] as const;
|
||||||
|
export type ExternalSourceDecisionField = (typeof externalSourceDecisionFields)[number];
|
||||||
|
|
||||||
|
export interface ExternalFollowUpChange {
|
||||||
|
objectId: string;
|
||||||
|
worksharingId: string;
|
||||||
|
familyAndType: string;
|
||||||
|
changedFields: ExternalSourceDecisionField[];
|
||||||
|
previousQuantity: number;
|
||||||
|
suggestedQuantity: number;
|
||||||
|
circuitDeviceRowId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalXlsxFollowUpPlan extends ExternalInitialImportPlan {
|
||||||
|
newObjectCount: number;
|
||||||
|
changedObjectCount: number;
|
||||||
|
unchangedObjectCount: number;
|
||||||
|
missingObjectCount: number;
|
||||||
|
returnDeviationCount: number;
|
||||||
|
newObjectWorksharingIds: string[];
|
||||||
|
newSourceRoomKeys: string[];
|
||||||
|
newFamilyAndTypes: string[];
|
||||||
|
changes: ExternalFollowUpChange[];
|
||||||
|
missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>;
|
||||||
|
returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExternalXlsxFollowUpImportPlan(input: {
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
catalogs: ExternalInitialImportCatalogs;
|
||||||
|
currentState: ExternalModelStateSnapshot;
|
||||||
|
currentReturnValuesByObjectId: ReadonlyMap<string, ExternalXlsxReturnValues>;
|
||||||
|
}): Promise<{ plan: ExternalXlsxFollowUpPlan; document: Awaited<ReturnType<typeof createExternalXlsxInitialImportPlan>>["document"] }> {
|
||||||
|
if (!input.currentState.source || input.currentState.importBatches.length === 0) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import.");
|
||||||
|
const parsed = await createExternalXlsxInitialImportPlan(input);
|
||||||
|
const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object]));
|
||||||
|
const incomingByWorksharingId = new Map(parsed.plan.objects.map((object) => [object.worksharingId, object]));
|
||||||
|
const newObjects: ExternalInitialImportProjectedObject[] = [];
|
||||||
|
const changes: ExternalFollowUpChange[] = [];
|
||||||
|
let unchangedObjectCount = 0;
|
||||||
|
const returnDeviations: ExternalXlsxFollowUpPlan["returnDeviations"] = [];
|
||||||
|
|
||||||
|
for (const incoming of parsed.plan.objects) {
|
||||||
|
const existing = existingByWorksharingId.get(incoming.worksharingId);
|
||||||
|
if (!existing) { newObjects.push(incoming); continue; }
|
||||||
|
const changedFields = changedSourceFields(existing, incoming);
|
||||||
|
if (changedFields.length) {
|
||||||
|
changes.push({
|
||||||
|
objectId: existing.id,
|
||||||
|
worksharingId: existing.worksharingId,
|
||||||
|
familyAndType: incoming.sourceValues.familyAndType,
|
||||||
|
changedFields,
|
||||||
|
previousQuantity: existing.planningValues.effectiveQuantity,
|
||||||
|
suggestedQuantity: incoming.suggestedPlanningValues.effectiveQuantity,
|
||||||
|
circuitDeviceRowId: existing.circuitDeviceRowId,
|
||||||
|
});
|
||||||
|
} else unchangedObjectCount += 1;
|
||||||
|
const returnFields = changedReturnFields(input.currentReturnValuesByObjectId.get(existing.id), incoming.sourceValues);
|
||||||
|
if (returnFields.length) returnDeviations.push({ objectId: existing.id, worksharingId: existing.worksharingId, fields: returnFields });
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingObjects = input.currentState.objects
|
||||||
|
.filter((object) => !incomingByWorksharingId.has(object.worksharingId))
|
||||||
|
.map((object) => ({ objectId: object.id, worksharingId: object.worksharingId, familyAndType: object.acceptedSourceValues.familyAndType, circuitDeviceRowId: object.circuitDeviceRowId }));
|
||||||
|
const existingRoomKeys = new Set(input.currentState.roomMappings.map((mapping) => mapping.normalizedSourceRoomKey));
|
||||||
|
const newSourceRoomKeys = parsed.plan.sourceRooms.map((room) => room.sourceRoomKey).filter((key) => !existingRoomKeys.has(key));
|
||||||
|
const newFamilyAndTypes = [...new Set(newObjects.map((object) => object.sourceValues.familyAndType))].sort((a, b) => a.localeCompare(b, "de"));
|
||||||
|
return {
|
||||||
|
document: parsed.document,
|
||||||
|
plan: {
|
||||||
|
...parsed.plan,
|
||||||
|
newObjectCount: newObjects.length,
|
||||||
|
changedObjectCount: changes.length,
|
||||||
|
unchangedObjectCount,
|
||||||
|
missingObjectCount: missingObjects.length,
|
||||||
|
returnDeviationCount: returnDeviations.length,
|
||||||
|
newObjectWorksharingIds: newObjects.map((object) => object.worksharingId),
|
||||||
|
newSourceRoomKeys,
|
||||||
|
newFamilyAndTypes,
|
||||||
|
changes,
|
||||||
|
missingObjects,
|
||||||
|
returnDeviations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedSourceFields(existing: ExternalModelObjectSnapshot, incoming: ExternalInitialImportProjectedObject) {
|
||||||
|
const fields: ExternalSourceDecisionField[] = [];
|
||||||
|
if (!sameScalar(existing.ifcGuid, incoming.ifcGuid)) fields.push("ifcGuid");
|
||||||
|
for (const field of externalSourceDecisionFields.slice(1) as readonly (keyof ExternalModelObjectSourceValues)[]) {
|
||||||
|
if (!sameScalar(existing.acceptedSourceValues[field], incoming.sourceValues[field])) fields.push(field as ExternalSourceDecisionField);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedReturnFields(expected: ExternalXlsxReturnValues | undefined, incoming: ExternalModelObjectSourceValues) {
|
||||||
|
if (!expected) return [];
|
||||||
|
const fields = ["circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"] as const;
|
||||||
|
return fields.filter((field) => !sameScalar(expected[field], incoming[field]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameScalar(left: ExternalXlsxScalar | undefined, right: ExternalXlsxScalar | undefined) {
|
||||||
|
return normalize(left) === normalize(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: ExternalXlsxScalar | undefined): string | number | boolean {
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const numeric = Number(trimmed.replace(",", "."));
|
||||||
|
return Number.isFinite(numeric) ? numeric : trimmed;
|
||||||
|
}
|
||||||
138
src/external-model/application/external-xlsx-follow-up-target.ts
Normal file
138
src/external-model/application/external-xlsx-follow-up-target.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
|
||||||
|
import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
import type { ExternalFamilyProjectDeviceDecision, ExternalRoomDecision } from "./external-xlsx-initial-import-target.js";
|
||||||
|
import type { ExternalSourceDecisionField, ExternalXlsxFollowUpPlan } from "./external-xlsx-follow-up-import.js";
|
||||||
|
|
||||||
|
export interface ExternalSourceChangeDecision { objectId: string; accept: boolean; }
|
||||||
|
export interface ExternalRowQuantityTransition { expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot; }
|
||||||
|
|
||||||
|
export function buildExternalXlsxFollowUpTarget(input: {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
importedAtIso: string;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
originalContentBase64: string;
|
||||||
|
document: ExternalXlsxDocument;
|
||||||
|
plan: ExternalXlsxFollowUpPlan;
|
||||||
|
currentState: ExternalModelStateSnapshot;
|
||||||
|
sourceChangeDecisions: ExternalSourceChangeDecision[];
|
||||||
|
roomDecisions: ExternalRoomDecision[];
|
||||||
|
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: string | null;
|
||||||
|
rowsById: ReadonlyMap<string, CircuitDeviceRowSnapshot>;
|
||||||
|
createId: () => string;
|
||||||
|
}): { targetState: ExternalModelStateSnapshot; rowTransitions: ExternalRowQuantityTransition[] } {
|
||||||
|
const source = input.currentState.source;
|
||||||
|
if (!source || source.projectId !== input.projectId) throw new Error("Für den Folgeimport fehlt die Revit-Quelle.");
|
||||||
|
const decisions = exactDecisionMap(input.plan.changes.map((change) => change.objectId), input.sourceChangeDecisions, (decision) => decision.objectId, "Quelländerung");
|
||||||
|
const newRoomSet = new Set(input.plan.newSourceRoomKeys);
|
||||||
|
const roomDecisions = exactDecisionMap(input.plan.newSourceRoomKeys, input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum");
|
||||||
|
const newFamilySet = new Set(input.plan.newFamilyAndTypes);
|
||||||
|
const familyDecisions = exactDecisionMap(input.plan.newFamilyAndTypes, input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ");
|
||||||
|
const batchId = input.createId();
|
||||||
|
const oldMappings = input.currentState.roomMappings.map((mapping) => ({ ...mapping }));
|
||||||
|
const mappingByKey = new Map(oldMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping]));
|
||||||
|
const newObjectIds = new Set(input.plan.newObjectWorksharingIds);
|
||||||
|
const neededRoomKeys = new Set(input.plan.objects.filter((object) => newObjectIds.has(object.worksharingId)).map((object) => object.sourceRoomKey).filter((key): key is string => key !== null));
|
||||||
|
for (const change of input.plan.changes) {
|
||||||
|
if (!decisions.get(change.objectId)!.accept || !change.changedFields.some((field) => field === "roomNumber" || field === "roomName")) continue;
|
||||||
|
const key = input.plan.objects.find((object) => object.worksharingId === change.worksharingId)?.sourceRoomKey;
|
||||||
|
if (key) neededRoomKeys.add(key);
|
||||||
|
}
|
||||||
|
for (const room of input.plan.sourceRooms.filter((candidate) => newRoomSet.has(candidate.sourceRoomKey) && neededRoomKeys.has(candidate.sourceRoomKey))) {
|
||||||
|
const decision = roomDecisions.get(room.sourceRoomKey)!;
|
||||||
|
const mapping = { id: input.createId(), projectId: input.projectId, sourceId: source.id, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId };
|
||||||
|
oldMappings.push(mapping); mappingByKey.set(mapping.normalizedSourceRoomKey, mapping);
|
||||||
|
}
|
||||||
|
const incomingById = new Map(input.plan.objects.map((object) => [object.worksharingId, object]));
|
||||||
|
const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object]));
|
||||||
|
const changedById = new Map(input.plan.changes.map((change) => [change.objectId, change]));
|
||||||
|
const quantityDeltaByRow = new Map<string, number>();
|
||||||
|
const objects: ExternalModelObjectSnapshot[] = [];
|
||||||
|
|
||||||
|
for (const existing of input.currentState.objects) {
|
||||||
|
const incoming = incomingById.get(existing.worksharingId);
|
||||||
|
if (!incoming) { objects.push({ ...existing, presenceStatus: "missing" }); continue; }
|
||||||
|
const change = changedById.get(existing.id);
|
||||||
|
const accept = change ? decisions.get(existing.id)!.accept : false;
|
||||||
|
if (accept && incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Eine akzeptierte Quelländerung besitzt keine gültige Familien- oder Mengenregel.");
|
||||||
|
const planningValues = { ...existing.planningValues };
|
||||||
|
let deviceTypeId = existing.deviceTypeId;
|
||||||
|
let externalRoomMappingId = existing.externalRoomMappingId;
|
||||||
|
let distributionBoardId = existing.distributionBoardId;
|
||||||
|
let ifcGuid = existing.ifcGuid;
|
||||||
|
if (accept && change) {
|
||||||
|
const fields = new Set<ExternalSourceDecisionField>(change.changedFields);
|
||||||
|
if (fields.has("ifcGuid")) ifcGuid = incoming.ifcGuid;
|
||||||
|
if (fields.has("familyAndType")) {
|
||||||
|
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
|
||||||
|
if (!rule) throw new Error("Eine geänderte Familie ist nicht klassifiziert.");
|
||||||
|
deviceTypeId = rule.deviceTypeId;
|
||||||
|
const type = input.configuration.deviceTypes.find((candidate) => candidate.id === rule.deviceTypeId)!;
|
||||||
|
if (!existing.overriddenFields.includes("category")) planningValues.category = type.category;
|
||||||
|
}
|
||||||
|
if (fields.has("selectionMarker") && !existing.overriddenFields.includes("displayName")) planningValues.displayName = scalarText(incoming.sourceValues.selectionMarker) || incoming.sourceValues.familyAndType;
|
||||||
|
if (fields.has("sourceRemark") && !existing.overriddenFields.includes("remark")) planningValues.remark = scalarText(incoming.sourceValues.sourceRemark) || null;
|
||||||
|
if (fields.has("socketCount") && !existing.overriddenFields.includes("effectiveQuantity")) planningValues.effectiveQuantity = incoming.suggestedPlanningValues.effectiveQuantity;
|
||||||
|
if (fields.has("roomNumber") || fields.has("roomName")) {
|
||||||
|
const oldMapping = existing.externalRoomMappingId ? oldMappings.find((mapping) => mapping.id === existing.externalRoomMappingId) : null;
|
||||||
|
const nextMapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
|
||||||
|
externalRoomMappingId = nextMapping?.id ?? null;
|
||||||
|
if (!existing.circuitDeviceRowId && (existing.distributionBoardId === null || existing.distributionBoardId === oldMapping?.defaultDistributionBoardId)) distributionBoardId = nextMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing.circuitDeviceRowId && planningValues.effectiveQuantity !== existing.planningValues.effectiveQuantity) {
|
||||||
|
quantityDeltaByRow.set(existing.circuitDeviceRowId, (quantityDeltaByRow.get(existing.circuitDeviceRowId) ?? 0) + planningValues.effectiveQuantity - existing.planningValues.effectiveQuantity);
|
||||||
|
}
|
||||||
|
objects.push({
|
||||||
|
...existing,
|
||||||
|
ifcGuid, deviceTypeId, externalRoomMappingId, distributionBoardId,
|
||||||
|
lastSeenImportBatchId: batchId,
|
||||||
|
lastAcceptedImportBatchId: change && !accept ? existing.lastAcceptedImportBatchId : batchId,
|
||||||
|
acceptedSourceValues: mergeAcceptedSource(existing.acceptedSourceValues, incoming.sourceValues, accept),
|
||||||
|
planningValues,
|
||||||
|
presenceStatus: "present",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const incoming of input.plan.objects.filter((object) => !existingByWorksharingId.has(object.worksharingId))) {
|
||||||
|
if (incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Ein neues Revit-Objekt besitzt keine gültige Familien- oder Mengenregel.");
|
||||||
|
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
|
||||||
|
if (!rule) throw new Error("Eine neue Familie ist nicht klassifiziert.");
|
||||||
|
const mapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
|
||||||
|
objects.push({
|
||||||
|
id: input.createId(), projectId: input.projectId, sourceId: source.id, worksharingId: incoming.worksharingId, ifcGuid: incoming.ifcGuid,
|
||||||
|
deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId,
|
||||||
|
acceptedSourceValues: incoming.sourceValues, planningValues: incoming.suggestedPlanningValues, overriddenFields: [],
|
||||||
|
externalRoomMappingId: mapping?.id ?? null,
|
||||||
|
distributionBoardId: mapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId,
|
||||||
|
linkedProjectDeviceId: newFamilySet.has(incoming.sourceValues.familyAndType) ? familyDecisions.get(incoming.sourceValues.familyAndType)!.projectDeviceId : null,
|
||||||
|
circuitDeviceRowId: null, presenceStatus: "present",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rowTransitions = [...quantityDeltaByRow].map(([rowId, delta]) => {
|
||||||
|
const expected = input.rowsById.get(rowId);
|
||||||
|
if (!expected) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
|
||||||
|
return { expected, target: { ...expected, quantity: expected.quantity + delta } };
|
||||||
|
});
|
||||||
|
const previousBatches = input.currentState.importBatches.map((batch) => ({ ...batch, appliedProjectRevision: null, configurationSnapshot: null, originalContentBase64: null, document: null }));
|
||||||
|
return {
|
||||||
|
targetState: {
|
||||||
|
source: { ...source },
|
||||||
|
importBatches: [...previousBatches, { id: batchId, projectId: input.projectId, sourceId: source.id, importKind: "follow_up", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: input.plan.objectCount, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }],
|
||||||
|
roomMappings: oldMappings,
|
||||||
|
objects,
|
||||||
|
},
|
||||||
|
rowTransitions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnFields = new Set<keyof ExternalModelObjectSourceValues>(["rowNumber", "circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"]);
|
||||||
|
function mergeAcceptedSource(previous: ExternalModelObjectSourceValues, incoming: ExternalModelObjectSourceValues, acceptSourceChanges: boolean) {
|
||||||
|
if (acceptSourceChanges) return incoming;
|
||||||
|
return Object.fromEntries(Object.keys(previous).map((key) => [key, returnFields.has(key as keyof ExternalModelObjectSourceValues) ? incoming[key as keyof ExternalModelObjectSourceValues] : previous[key as keyof ExternalModelObjectSourceValues]])) as unknown as ExternalModelObjectSourceValues;
|
||||||
|
}
|
||||||
|
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value).trim(); }
|
||||||
|
function exactDecisionMap<T>(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { const expected = new Set(expectedKeys); const result = new Map<string, T>(); for (const decision of decisions) { const key = keyOf(decision); if (!expected.has(key) || result.has(key)) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); result.set(key, decision); } if (result.size !== expected.size) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); return result; }
|
||||||
91
src/frontend/components/revit-follow-up-import-wizard.tsx
Normal file
91
src/frontend/components/revit-follow-up-import-wizard.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
||||||
|
import type { ExternalFollowUpImportPlanDto, ProjectHistoryStateDto } from "../types";
|
||||||
|
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../utils/api";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
const steps = ["Vorschau", "Quelländerungen", "Neue Räume", "Familienregeln", "Projektgeräte", "Bestätigung"];
|
||||||
|
const fieldLabels: Record<string, string> = { ifcGuid: "IfcGUID", roomNumber: "Raumnummer", roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner", socketCount: "Anzahl", sourceRemark: "Bemerkung" };
|
||||||
|
|
||||||
|
export function RevitFollowUpImportWizard({ projectId, expectedRevision, initialConfiguration, onClose, onImported }: { projectId: string; expectedRevision: number; initialConfiguration: ExternalModelConfiguration; onClose: () => void; onImported: (result: { history: ProjectHistoryStateDto; objectCount: number }) => void }) {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [configuration, setConfiguration] = useState(() => structuredClone(initialConfiguration));
|
||||||
|
const [plan, setPlan] = useState<ExternalFollowUpImportPlanDto | null>(null);
|
||||||
|
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
|
||||||
|
const [roomIds, setRoomIds] = useState<Record<string, string>>({});
|
||||||
|
const [boardIds, setBoardIds] = useState<Record<string, string>>({});
|
||||||
|
const [deviceIds, setDeviceIds] = useState<Record<string, string>>({});
|
||||||
|
const [noRoomBoardId, setNoRoomBoardId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadPlan(nextFile: File, nextConfiguration = configuration) {
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const loaded = await planExternalFollowUpImport(projectId, nextFile, nextConfiguration, expectedRevision);
|
||||||
|
setPlan(loaded);
|
||||||
|
setAccepted((current) => Object.fromEntries(loaded.changes.map((change) => [change.objectId, current[change.objectId] ?? true])));
|
||||||
|
setRoomIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => { const room = loaded.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return [key, current[key] ?? room.suggestedRoomId ?? ""]; })));
|
||||||
|
setBoardIds((current) => Object.fromEntries(loaded.newSourceRoomKeys.map((key) => [key, current[key] ?? ""])));
|
||||||
|
setDeviceIds((current) => Object.fromEntries(loaded.newFamilyAndTypes.map((family) => [family, current[family] ?? ""])));
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport-Vorschau fehlgeschlagen."); setPlan(null); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRule(familyAndType: string, category: "lighting" | "single_phase" | "three_phase", quantityRule: "one" | "socketCount", deviceTypeId?: string) {
|
||||||
|
if (!file) return;
|
||||||
|
const next = structuredClone(configuration);
|
||||||
|
const targetTypeId = deviceTypeId ?? createClientId();
|
||||||
|
if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } });
|
||||||
|
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, quantityRule });
|
||||||
|
setConfiguration(next); await loadPlan(file, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleQuantityRule(familyAndType: string) {
|
||||||
|
if (!file) return;
|
||||||
|
const next = structuredClone(configuration);
|
||||||
|
const rule = next.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === familyAndType);
|
||||||
|
if (!rule) return;
|
||||||
|
rule.quantityRule = rule.quantityRule === "one" ? "socketCount" : "one";
|
||||||
|
setConfiguration(next); await loadPlan(file, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (step < steps.length - 1) { setStep(step + 1); return; }
|
||||||
|
if (!file || !plan) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const result = await applyExternalFollowUpImport(projectId, file, {
|
||||||
|
expectedRevision, expectedSha256: plan.sha256, configuration, objectsWithoutRoomDefaultDistributionBoardId: noRoomBoardId || null,
|
||||||
|
sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: accepted[change.objectId] ?? false })),
|
||||||
|
roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: roomIds[sourceRoomKey] || null, defaultDistributionBoardId: boardIds[sourceRoomKey] || null })),
|
||||||
|
familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: deviceIds[familyAndType] || null })),
|
||||||
|
});
|
||||||
|
onImported({ history: result.history, objectCount: result.import.objectCount });
|
||||||
|
} catch (value) { setError(value instanceof Error ? value.message : "Folgeimport fehlgeschlagen."); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const relevantUnknownFamilies = plan?.familyGroups.filter((family) => !family.classified && (plan.newFamilyAndTypes.includes(family.familyAndType) || plan.changes.some((change) => accepted[change.objectId] && change.familyAndType === family.familyAndType))) ?? [];
|
||||||
|
const relevantInvalidQuantity = plan?.objects.some((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))) ?? false;
|
||||||
|
const invalidQuantityFamilies = plan ? [...new Set(plan.objects.filter((object) => object.issues.includes("invalid-quantity") && (plan.newObjectWorksharingIds.includes(object.worksharingId) || plan.changes.some((change) => change.worksharingId === object.worksharingId && accepted[change.objectId]))).map((object) => object.sourceValues.familyAndType))] : [];
|
||||||
|
const blocked = !plan || relevantUnknownFamilies.length > 0 || relevantInvalidQuantity;
|
||||||
|
return <FormModal dialogSize="viewport" title="Linear-XLSX-Folgeimport" description={`Schritt ${step + 1} von ${steps.length}: ${steps[step]}`} isSaving={busy} onClose={onClose} onSubmit={(event) => void submit(event)} submitDisabled={blocked} submitLabel={step === steps.length - 1 ? "Folgeimport übernehmen" : "Weiter"}>
|
||||||
|
<div className="d-flex gap-2 flex-wrap mb-3">{steps.map((label, index) => <span className={`badge ${index === step ? "text-bg-primary" : index < step ? "text-bg-success" : "text-bg-secondary"}`} key={label}>{index + 1}. {label}</span>)}</div>
|
||||||
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
|
{step === 0 ? <div className="row g-3"><div className="col-12"><label className="form-label" htmlFor="follow-up-xlsx">Vollständige Linear-XLSX-Datei</label><input accept=".xlsx" className="form-control" id="follow-up-xlsx" type="file" onChange={(event) => { const selected = event.target.files?.[0] ?? null; setFile(selected); if (selected) void loadPlan(selected); }} /></div>{plan ? <><Count label="Neu" value={plan.newObjectCount} /><Count label="Geändert" value={plan.changedObjectCount} /><Count label="Unverändert" value={plan.unchangedObjectCount} /><Count label="Fehlend" value={plan.missingObjectCount} /><div className="col-12 text-secondary small">{plan.returnDeviationCount} Objekte enthalten Rückgabefelder, die in Revit noch nicht aktualisiert sind.</div>{plan.warnings.length ? <div className="col-12"><div className="alert alert-warning mb-0">{plan.warnings.map((warning) => <div key={warning}>{warning}</div>)}</div></div> : null}</> : null}</div> : null}
|
||||||
|
{step === 1 && plan ? <div><p className="text-secondary">Nur fachliche Quelländerungen benötigen eine Entscheidung. Abweichende Rückgabefelder werden lediglich als „in Revit noch nicht aktualisiert“ gemeldet.</p>{plan.changes.length ? plan.changes.map((change) => <label className="border rounded p-2 mb-2 d-flex gap-2" key={change.objectId}><input checked={accepted[change.objectId] ?? true} type="checkbox" onChange={(event) => setAccepted({ ...accepted, [change.objectId]: event.target.checked })} /><span><strong>{change.familyAndType}</strong> · {change.worksharingId}<br /><span className="small text-secondary">{change.changedFields.map((field) => fieldLabels[field] ?? field).join(", ")}{change.previousQuantity !== change.suggestedQuantity ? ` · Menge ${change.previousQuantity} → ${change.suggestedQuantity}` : ""}</span></span></label>) : <div className="alert alert-info">Keine fachlichen Quelländerungen.</div>}{plan.missingObjects.length ? <div className="alert alert-warning">{plan.missingObjects.length} fehlende Objekte werden markiert. Links und Mengen bleiben bestehen.</div> : null}</div> : null}
|
||||||
|
{step === 2 && plan ? <div><p className="text-secondary">Nur neue, tatsächlich übernommene Quellräume benötigen eine Zuordnung.</p>{plan.newSourceRoomKeys.map((key) => { const room = plan.sourceRooms.find((candidate) => candidate.sourceRoomKey === key)!; return <div className="row g-2 mb-2" key={key}><div className="col-md-4"><strong>{room.roomNumber || "–"}</strong> {room.roomName}</div><div className="col-md-4"><select className="form-select" value={roomIds[key] ?? ""} onChange={(event) => setRoomIds({ ...roomIds, [key]: event.target.value })}><option value="">Kein Projektraum</option>{plan.existing.rooms.map((candidate) => <option value={candidate.id} key={candidate.id}>{candidate.roomNumber} · {candidate.roomName}</option>)}</select></div><div className="col-md-4"><select className="form-select" value={boardIds[key] ?? ""} onChange={(event) => setBoardIds({ ...boardIds, [key]: event.target.value })}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></div></div>; })}<label className="form-label">Standardverteiler für neue Objekte ohne Raum<select className="form-select" value={noRoomBoardId} onChange={(event) => setNoRoomBoardId(event.target.value)}><option value="">Kein Standardverteiler</option>{plan.existing.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label></div> : null}
|
||||||
|
{step === 3 && plan ? <div><p className="text-secondary">Unbekannte Familien müssen nur für neue Objekte oder akzeptierte Familienänderungen klassifiziert werden.</p>{relevantUnknownFamilies.map((family) => <div className="border rounded p-2 mb-2" key={family.familyAndType}><strong>{family.familyAndType}</strong><div className="d-flex gap-2 flex-wrap mt-2">{configuration.deviceTypes.flatMap((type) => [<button className="btn btn-sm btn-outline-secondary" key={`${type.id}-one`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "one", type.id)}>{type.name} · Menge 1</button>, <button className="btn btn-sm btn-outline-secondary" key={`${type.id}-socket`} type="button" onClick={() => void addRule(family.familyAndType, type.category, "socketCount", type.id)}>{type.name} · XLSX-Anzahl</button>])}<button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "one")}>Neue 1-phasige Geräteart</button><button className="btn btn-sm btn-outline-primary" type="button" onClick={() => void addRule(family.familyAndType, "single_phase", "socketCount")}>Neu · XLSX-Anzahl</button></div></div>)}{!relevantUnknownFamilies.length ? <div className="alert alert-success">Alle benötigten Familien sind klassifiziert.</div> : null}{relevantInvalidQuantity ? <div className="alert alert-danger">Mindestens eine übernommene Mengenangabe ist ungültig.{invalidQuantityFamilies.map((family) => <button className="btn btn-sm btn-outline-danger ms-2" key={family} type="button" onClick={() => void toggleQuantityRule(family)}>Mengenregel für {family} wechseln</button>)}</div> : null}</div> : null}
|
||||||
|
{step === 4 && plan ? <div><p className="text-secondary">Neue Revit-Objekte können familienweise mit einem vorhandenen Projektgerät verknüpft werden.</p>{plan.newFamilyAndTypes.map((family) => <div className="row g-2 align-items-center mb-2" key={family}><div className="col-md-5">{family}</div><div className="col-md-7"><select className="form-select" value={deviceIds[family] ?? ""} onChange={(event) => setDeviceIds({ ...deviceIds, [family]: event.target.value })}><option value="">Kein Projektgerät</option>{plan.existing.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name} · {device.displayName}</option>)}</select></div></div>)}</div> : null}
|
||||||
|
{step === 5 && plan ? <div className="alert alert-info"><strong>{plan.objectCount} Objekte im vollständigen Import.</strong><br />Neu: {plan.newObjectCount}, fachlich geändert: {plan.changedObjectCount}, fehlend: {plan.missingObjectCount}. Fehlende Objekte werden nicht gelöst oder gelöscht. Apply liest die Datei erneut; Undo und Redo verwenden ausschließlich die gespeicherten Zustände.</div> : null}
|
||||||
|
{step > 0 ? <button className="btn btn-link px-0 mt-3" disabled={busy} onClick={() => setStep(step - 1)} type="button">Zurück</button> : null}
|
||||||
|
</FormModal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Count({ label, value }: { label: string; value: number }) { return <div className="col-6 col-md-3"><strong>{value}</strong><div className="text-secondary small">{label}</div></div>; }
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
|
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
|
||||||
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
import { createExternalReturnWorkbook, deleteMissingExternalProjectObjects, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
||||||
|
|
||||||
const rowHeight = 44;
|
const rowHeight = 44;
|
||||||
const viewportHeight = 520;
|
const viewportHeight = 520;
|
||||||
|
|
@ -48,7 +48,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
||||||
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
|
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5);
|
||||||
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
|
const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10);
|
||||||
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
|
const selectable = rows.filter((object) => object.circuitDeviceRowId === null);
|
||||||
|
const returnSelectable = rows.filter((object) => object.presenceStatus === "present");
|
||||||
const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
|
const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
|
||||||
|
const selectedMissing = data?.objects.filter((object) => selected.has(object.id) && object.presenceStatus === "missing") ?? [];
|
||||||
|
const selectionContainsPresentObject = data?.objects.some((object) => selected.has(object.id) && object.presenceStatus === "present") ?? false;
|
||||||
|
|
||||||
async function applyBulk() {
|
async function applyBulk() {
|
||||||
if (!data || selected.size === 0) return;
|
if (!data || selected.size === 0) return;
|
||||||
|
|
@ -84,12 +87,21 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteMissing() {
|
||||||
|
if (!data || selectedMissing.length === 0 || selectionContainsPresentObject) return;
|
||||||
|
if (!window.confirm(`${selectedMissing.length} fehlende Revit-Objekte wirklich löschen? Verknüpfte Mengen werden angepasst. Undo stellt Objekte, Links und Mengen wieder her.`)) return;
|
||||||
|
setSaving(true); setError(null);
|
||||||
|
try { const result = await deleteMissingExternalProjectObjects(projectId, data.currentRevision, selectedMissing.map((object) => object.id)); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); }
|
||||||
|
catch (value) { setError(value instanceof Error ? value.message : "Fehlende Revit-Objekte konnten nicht gelöscht werden."); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
|
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
|
||||||
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
|
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||||
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
|
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
|
||||||
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
|
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; const missing = object.presenceStatus === "missing"; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${missing ? "text-bg-warning" : linked ? "text-bg-success" : "text-bg-secondary"}`}>{missing ? "fehlend" : linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
|
||||||
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
|
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(returnSelectable.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><button className="btn btn-sm btn-danger" disabled={saving || selectedMissing.length === 0 || selectionContainsPresentObject} type="button" onClick={() => void deleteMissing()}>Ausgewählte fehlende löschen</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
|
||||||
</div></section>;
|
</div></section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export interface ExternalInitialImportStatusDto {
|
||||||
imported: boolean;
|
imported: boolean;
|
||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
objectCount: number;
|
objectCount: number;
|
||||||
|
missingObjectCount: number;
|
||||||
importedAtIso: string | null;
|
importedAtIso: string | null;
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +112,34 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalFollowUpImportPlanDto extends ExternalInitialImportPlanDto {
|
||||||
|
newObjectCount: number;
|
||||||
|
changedObjectCount: number;
|
||||||
|
unchangedObjectCount: number;
|
||||||
|
missingObjectCount: number;
|
||||||
|
returnDeviationCount: number;
|
||||||
|
newObjectWorksharingIds: string[];
|
||||||
|
newSourceRoomKeys: string[];
|
||||||
|
newFamilyAndTypes: string[];
|
||||||
|
changes: Array<{ objectId: string; worksharingId: string; familyAndType: string; changedFields: string[]; previousQuantity: number; suggestedQuantity: number; circuitDeviceRowId: string | null }>;
|
||||||
|
missingObjects: Array<{ objectId: string; worksharingId: string; familyAndType: string; circuitDeviceRowId: string | null }>;
|
||||||
|
returnDeviations: Array<{ objectId: string; worksharingId: string; fields: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyExternalFollowUpImportInput {
|
||||||
|
expectedRevision: number;
|
||||||
|
expectedSha256: string;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: string | null;
|
||||||
|
sourceChangeDecisions: Array<{ objectId: string; accept: boolean }>;
|
||||||
|
roomDecisions: Array<{ sourceRoomKey: string; roomId: string | null; defaultDistributionBoardId: string | null }>;
|
||||||
|
familyProjectDeviceDecisions: Array<{ familyAndType: string; projectDeviceId: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalFollowUpImportResultDto extends ProjectCommandResultDto {
|
||||||
|
import: { importBatchId: string; objectCount: number; newObjectCount: number; changedObjectCount: number; missingObjectCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalProjectObjectDto {
|
export interface ExternalProjectObjectDto {
|
||||||
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
|
id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string;
|
||||||
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
|
familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ import type {
|
||||||
ExternalModelConfigurationSnapshotDto,
|
ExternalModelConfigurationSnapshotDto,
|
||||||
ExternalInitialImportPlanDto,
|
ExternalInitialImportPlanDto,
|
||||||
ExternalInitialImportResultDto,
|
ExternalInitialImportResultDto,
|
||||||
|
ExternalFollowUpImportPlanDto,
|
||||||
|
ExternalFollowUpImportResultDto,
|
||||||
|
ApplyExternalFollowUpImportInput,
|
||||||
ExternalInitialImportStatusDto,
|
ExternalInitialImportStatusDto,
|
||||||
ApplyExternalInitialImportInput,
|
ApplyExternalInitialImportInput,
|
||||||
ExternalProjectObjectsDto,
|
ExternalProjectObjectsDto,
|
||||||
|
|
@ -232,6 +235,18 @@ export async function applyExternalInitialImport(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function planExternalFollowUpImport(projectId: string, file: File, configuration: ExternalModelConfiguration, expectedRevision: number) {
|
||||||
|
return request<ExternalFollowUpImportPlanDto>(`/api/projects/${projectId}/external-model/follow-up-import/plan`, {
|
||||||
|
method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: await fileToBase64(file), configuration, expectedRevision }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyExternalFollowUpImport(projectId: string, file: File, input: ApplyExternalFollowUpImportInput) {
|
||||||
|
return request<ExternalFollowUpImportResultDto>(`/api/projects/${projectId}/external-model/follow-up-import/apply`, {
|
||||||
|
method: "POST", body: JSON.stringify({ ...input, fileName: file.name, contentBase64: await fileToBase64(file) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fileToBase64(file: File) {
|
async function fileToBase64(file: File) {
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
let binary = "";
|
let binary = "";
|
||||||
|
|
@ -247,6 +262,10 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
|
||||||
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
|
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteMissingExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[]) {
|
||||||
|
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/delete-missing`, { method: "POST", body: JSON.stringify({ expectedRevision, objectIds, confirmed: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
export function createExternalReturnWorkbook(
|
export function createExternalReturnWorkbook(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
|
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
|
||||||
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalFollowUpImportProjectCommandRepository } from "../../db/repositories/external-follow-up-import-project-command.repository.js";
|
||||||
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
|
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
|
||||||
|
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/repositories/external-object-delete-missing-project-command.repository.js";
|
||||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||||
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
||||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
|
|
@ -76,8 +78,12 @@ export const externalModelConfigurationProjectCommandStore =
|
||||||
new ExternalModelConfigurationProjectCommandRepository(db);
|
new ExternalModelConfigurationProjectCommandRepository(db);
|
||||||
export const externalInitialImportProjectCommandStore =
|
export const externalInitialImportProjectCommandStore =
|
||||||
new ExternalInitialImportProjectCommandRepository(db);
|
new ExternalInitialImportProjectCommandRepository(db);
|
||||||
|
export const externalFollowUpImportProjectCommandStore =
|
||||||
|
new ExternalFollowUpImportProjectCommandRepository(db);
|
||||||
export const externalObjectPlanningProjectCommandStore =
|
export const externalObjectPlanningProjectCommandStore =
|
||||||
new ExternalObjectPlanningProjectCommandRepository(db);
|
new ExternalObjectPlanningProjectCommandRepository(db);
|
||||||
|
export const externalObjectDeleteMissingProjectCommandStore =
|
||||||
|
new ExternalObjectDeleteMissingProjectCommandRepository(db);
|
||||||
export const externalObjectRowAssignmentProjectCommandStore =
|
export const externalObjectRowAssignmentProjectCommandStore =
|
||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
|
|
@ -115,5 +121,7 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore,
|
||||||
estimateBalanceProjectCommandStore,
|
estimateBalanceProjectCommandStore,
|
||||||
externalInitialImportProjectCommandStore,
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectPlanningProjectCommandStore
|
externalObjectPlanningProjectCommandStore,
|
||||||
|
externalFollowUpImportProjectCommandStore,
|
||||||
|
externalObjectDeleteMissingProjectCommandStore
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
|
import { createExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
|
||||||
|
import { createExternalXlsxFollowUpImportPlan } from "../../external-model/application/external-xlsx-follow-up-import.js";
|
||||||
|
import { buildExternalXlsxFollowUpTarget } from "../../external-model/application/external-xlsx-follow-up-target.js";
|
||||||
|
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
|
||||||
|
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
|
export async function planExternalFollowUpImport(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
|
||||||
|
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
|
||||||
|
const { plan } = await createExternalXlsxFollowUpImportPlan({
|
||||||
|
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration: context.configuration,
|
||||||
|
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
|
||||||
|
});
|
||||||
|
return res.json(plan);
|
||||||
|
} catch (error) { return res.status(400).json({ error: error instanceof Error ? error.message : "Folgeimport-Vorschau fehlgeschlagen." }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyExternalFollowUpImport(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
|
||||||
|
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
|
||||||
|
const bytes = decodeBase64(req.body?.contentBase64);
|
||||||
|
const { plan, document } = await createExternalXlsxFollowUpImportPlan({
|
||||||
|
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: context.configuration,
|
||||||
|
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
|
||||||
|
});
|
||||||
|
if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" });
|
||||||
|
const built = buildExternalXlsxFollowUpTarget({
|
||||||
|
projectId, expectedRevision, importedAtIso: new Date().toISOString(), configuration: context.configuration,
|
||||||
|
originalContentBase64: String(req.body.contentBase64), document, plan, currentState: context.state,
|
||||||
|
sourceChangeDecisions: requireArray(req.body?.sourceChangeDecisions, "Quelländerungsentscheidungen"),
|
||||||
|
roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"),
|
||||||
|
familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"),
|
||||||
|
objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"),
|
||||||
|
rowsById: context.rowsById, createId: randomUUID,
|
||||||
|
});
|
||||||
|
const targetConfiguration = { ...context.configurationSnapshot, configuration: context.configuration };
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId, expectedRevision, description: `Revit-Folgeimport ${plan.fileName}`,
|
||||||
|
command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: context.configurationSnapshot, targetConfiguration, expectedState: context.state, targetState: built.targetState, rows: built.rowTransitions }),
|
||||||
|
});
|
||||||
|
return res.status(201).json({ ...result, import: { importBatchId: built.targetState.importBatches.at(-1)!.id, objectCount: plan.objectCount, newObjectCount: plan.newObjectCount, changedObjectCount: plan.changedObjectCount, missingObjectCount: plan.missingObjectCount } });
|
||||||
|
} catch (error) { return respondWithProjectCommandError(error, res); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContext(projectId: string, expectedRevision: number, configurationValue: unknown) {
|
||||||
|
const [project, treeResult] = await Promise.all([projectRepository.findById(projectId), buildProjectCircuitTrees(projectId)]);
|
||||||
|
if (!project || !treeResult) throw new Error("Projekt nicht gefunden.");
|
||||||
|
if (project.currentRevision !== expectedRevision || treeResult.currentRevision !== expectedRevision) throw new Error("Der Folgeimportplan ist wegen einer neueren Projektrevision ungültig.");
|
||||||
|
const stored = externalModelConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!stored.configuration) throw new Error("Für das Projekt ist keine Revit-XLSX-Konfiguration gespeichert.");
|
||||||
|
assertExternalModelConfiguration(configurationValue);
|
||||||
|
const stateResult = externalModelStateRepository.getByProject(projectId);
|
||||||
|
if (!stateResult.state.source) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import.");
|
||||||
|
const rowContexts = createRowContexts(treeResult);
|
||||||
|
const returnValues = new Map(stateResult.state.objects.map((object) => [object.id, projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? rowContexts.get(object.circuitDeviceRowId)?.returnContext ?? null : null, circuitMode: configurationValue.circuitMode, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV }).values]));
|
||||||
|
return { configuration: configurationValue, configurationSnapshot: stored.configuration, state: stateResult.state, returnValues, rowsById: new Map([...rowContexts].map(([id, value]) => [id, value.snapshot])) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRowContexts(treeResult: NonNullable<Awaited<ReturnType<typeof buildProjectCircuitTrees>>>) {
|
||||||
|
const result = new Map<string, { returnContext: ExternalReturnRowContext; snapshot: CircuitDeviceRowSnapshot }>();
|
||||||
|
for (const tree of treeResult.trees) for (const section of tree.sections) for (const circuit of section.circuits) for (const row of circuit.deviceRows) result.set(row.id, {
|
||||||
|
returnContext: { rowId: row.id, displayName: row.displayName, powerPerUnitKw: row.powerPerUnit, cosPhi: row.cosPhi ?? null, phaseType: row.phaseType ?? null, category: section.category ?? null, circuitIdentifier: circuit.equipmentIdentifier, circuitVoltageV: circuit.voltage ?? null, distributionBoardName: tree.distributionBoardName },
|
||||||
|
snapshot: { id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function loadCatalogs(projectId: string) { const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); return { floors, rooms, distributionBoards, projectDevices }; }
|
||||||
|
function parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; }
|
||||||
|
function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
|
||||||
|
function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); }
|
||||||
|
function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; }
|
||||||
|
function requireArray<T>(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; }
|
||||||
|
|
@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) {
|
||||||
const result = externalModelStateRepository.getByProject(projectId);
|
const result = externalModelStateRepository.getByProject(projectId);
|
||||||
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
const batch = result.state.importBatches.at(-1) ?? null;
|
const batch = result.state.importBatches.at(-1) ?? null;
|
||||||
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
|
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: batch?.objectCount ?? result.state.objects.length, missingObjectCount: result.state.objects.filter((object) => object.presenceStatus === "missing").length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function planExternalInitialImport(req: Request, res: Response) {
|
export async function planExternalInitialImport(req: Request, res: Response) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
|
||||||
|
import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
|
||||||
import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
|
import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
|
||||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||||
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
|
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
@ -57,6 +58,39 @@ export function updateExternalObjects(req: Request, res: Response) {
|
||||||
} catch (error) { return respondWithProjectCommandError(error, res); }
|
} catch (error) { return respondWithProjectCommandError(error, res); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteMissingExternalObjects(req: Request, res: Response) {
|
||||||
|
const projectId = String(req.params.projectId ?? "");
|
||||||
|
try {
|
||||||
|
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
|
||||||
|
const objectIds = parseObjectIds(req.body?.objectIds);
|
||||||
|
if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden.");
|
||||||
|
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
|
||||||
|
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||||
|
const byId = new Map(state.objects.map((object) => [object.id, object]));
|
||||||
|
const selected = objectIds.map((id) => {
|
||||||
|
const object = byId.get(id);
|
||||||
|
if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden.");
|
||||||
|
return object;
|
||||||
|
});
|
||||||
|
const treeResult = await buildProjectCircuitTrees(projectId);
|
||||||
|
const quantitiesByRowId = new Map<string, number>();
|
||||||
|
for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity);
|
||||||
|
const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => {
|
||||||
|
const location = findRow(treeResult, rowId);
|
||||||
|
if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
|
||||||
|
const expected = toRowSnapshot(location.row, location.circuitId);
|
||||||
|
return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } };
|
||||||
|
});
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision,
|
||||||
|
description: `${selected.length} fehlende Revit-Objekte gelöscht`,
|
||||||
|
command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }),
|
||||||
|
});
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) { return respondWithProjectCommandError(error, res); }
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignExternalObjectsToRow(req: Request, res: Response) {
|
export async function assignExternalObjectsToRow(req: Request, res: Response) {
|
||||||
const projectId = String(req.params.projectId ?? "");
|
const projectId = String(req.params.projectId ?? "");
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,9 @@ import {
|
||||||
getExternalInitialImportStatus,
|
getExternalInitialImportStatus,
|
||||||
planExternalInitialImport,
|
planExternalInitialImport,
|
||||||
} from "../controllers/external-initial-import.controller.js";
|
} from "../controllers/external-initial-import.controller.js";
|
||||||
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
|
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
|
||||||
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
|
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
|
||||||
|
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
|
|
@ -92,8 +93,11 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte
|
||||||
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
|
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
|
||||||
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
|
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
|
||||||
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
|
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
|
||||||
|
projectRouter.post("/:projectId/external-model/follow-up-import/plan", planExternalFollowUpImport);
|
||||||
|
projectRouter.post("/:projectId/external-model/follow-up-import/apply", applyExternalFollowUpImport);
|
||||||
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
|
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
|
||||||
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
|
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
|
||||||
|
projectRouter.post("/:projectId/external-model/objects/delete-missing", deleteMissingExternalObjects);
|
||||||
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
|
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
|
||||||
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
|
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
|
||||||
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
|
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||||
|
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
|
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
||||||
|
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js";
|
||||||
|
import { createExternalObjectDeleteMissingProjectCommand } from "../src/domain/models/external-object-delete-missing-project-command.model.js";
|
||||||
|
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../src/db/repositories/external-object-delete-missing-project-command.repository.js";
|
||||||
|
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
|
||||||
|
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
|
||||||
|
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
|
||||||
|
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
|
||||||
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
|
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
|
||||||
|
|
||||||
|
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
|
||||||
|
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
|
||||||
|
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
|
||||||
|
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
|
||||||
|
|
||||||
|
describe("external follow-up import project command", () => {
|
||||||
|
it("commits object and row quantities atomically and restores exact batches on undo and redo", async () => {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
try {
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||||
|
context.db.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run();
|
||||||
|
context.db.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run();
|
||||||
|
context.db.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
|
||||||
|
context.db.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run();
|
||||||
|
context.db.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
|
||||||
|
const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration };
|
||||||
|
context.db.insert(externalModelConfigurations).values(configurationSnapshot).run();
|
||||||
|
const initialBytes = await createRevitXlsxFixture();
|
||||||
|
const initialParsed = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs });
|
||||||
|
let sequence = 0;
|
||||||
|
const initialState = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initialParsed.document, plan: initialParsed.plan, roomDecisions: initialParsed.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: initialParsed.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) });
|
||||||
|
const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
|
||||||
|
context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
|
||||||
|
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
|
||||||
|
const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||||
|
const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }]));
|
||||||
|
const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues });
|
||||||
|
const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
|
||||||
|
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
const repository = new ExternalFollowUpImportProjectCommandRepository(context.db);
|
||||||
|
const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] });
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: commandWithoutRequiredRowTransition }),
|
||||||
|
/Materialized quantity 3 does not match manual and external quantity 4/,
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
|
||||||
|
const applied = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: built.rowTransitions }) });
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||||
|
let state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||||
|
assert.equal(state.importBatches.length, 2); assert.equal(state.importBatches[0]!.document, null); assert.equal(state.objects.find((object) => object.worksharingId === firstWorksharingId)!.planningValues.effectiveQuantity, 3);
|
||||||
|
|
||||||
|
const undone = repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse });
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
|
||||||
|
state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||||
|
assert.equal(state.importBatches.length, 1); assert.ok(state.importBatches[0]!.document);
|
||||||
|
repository.execute({ projectId: "project-1", expectedRevision: 3, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse });
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||||
|
|
||||||
|
context.db.update(externalModelObjects).set({ presenceStatus: "missing" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
|
||||||
|
const missingObject = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.id === importedFirst.id)!;
|
||||||
|
const rowBeforeDelete = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
|
||||||
|
const deleteRepository = new ExternalObjectDeleteMissingProjectCommandRepository(context.db);
|
||||||
|
const deleted = deleteRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "user", command: createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: missingObject, target: null }], rows: [{ expected: rowBeforeDelete, target: { ...rowBeforeDelete, quantity: 1 } }] }) });
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 1);
|
||||||
|
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get(), undefined);
|
||||||
|
const foreignRestore = createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: null, target: { ...missingObject, projectId: "project-2" } }], rows: deleted.inverse.payload.rows });
|
||||||
|
assert.throws(() => deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "user", command: foreignRestore }), /anderes Projekt/);
|
||||||
|
deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse });
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||||
|
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get()!.circuitDeviceRowId, "row-1");
|
||||||
|
} finally { context.close(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
82
tests/external-xlsx-follow-up-import.test.ts
Normal file
82
tests/external-xlsx-follow-up-import.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
|
||||||
|
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.js";
|
||||||
|
import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js";
|
||||||
|
import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js";
|
||||||
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
|
import type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.js";
|
||||||
|
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId, secondWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
|
||||||
|
|
||||||
|
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
|
||||||
|
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
|
||||||
|
const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003";
|
||||||
|
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
|
||||||
|
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
|
||||||
|
|
||||||
|
async function initialState() {
|
||||||
|
const bytes = await createRevitXlsxFixture();
|
||||||
|
const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs });
|
||||||
|
let sequence = 0;
|
||||||
|
const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
const missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099";
|
||||||
|
state.objects.push(missing);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changedWorkbook(firstId = firstWorksharingId) {
|
||||||
|
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture());
|
||||||
|
const sheet = workbook.worksheets[0]!;
|
||||||
|
sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3;
|
||||||
|
sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1;
|
||||||
|
return new Uint8Array(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentReturns(state: Awaited<ReturnType<typeof initialState>>) {
|
||||||
|
const map = new Map<string, ExternalXlsxReturnValues>();
|
||||||
|
for (const object of state.objects) map.set(object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA });
|
||||||
|
map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" });
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Linear XLSX follow-up import", () => {
|
||||||
|
it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => {
|
||||||
|
const state = await initialState();
|
||||||
|
const bytes = await changedWorkbook();
|
||||||
|
const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
|
||||||
|
assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 });
|
||||||
|
assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]);
|
||||||
|
assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true);
|
||||||
|
|
||||||
|
const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
|
||||||
|
assert.equal(caseChanged.plan.newObjectCount, 2);
|
||||||
|
assert.equal(caseChanged.plan.missingObjectCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => {
|
||||||
|
const state = await initialState();
|
||||||
|
const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
|
||||||
|
first.circuitDeviceRowId = "row-1";
|
||||||
|
const bytes = await changedWorkbook();
|
||||||
|
const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) });
|
||||||
|
let sequence = 0;
|
||||||
|
const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null };
|
||||||
|
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
assert.equal(built.rowTransitions[0]!.target.quantity, 4);
|
||||||
|
assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3);
|
||||||
|
const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!;
|
||||||
|
assert.equal(missing.presenceStatus, "missing");
|
||||||
|
assert.equal(missing.circuitDeviceRowId, null);
|
||||||
|
assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null);
|
||||||
|
assert.ok(built.targetState.importBatches[1]!.originalContentBase64);
|
||||||
|
|
||||||
|
const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||||
|
const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!;
|
||||||
|
assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity);
|
||||||
|
assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId);
|
||||||
|
assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId);
|
||||||
|
assert.equal(rejected.rowTransitions.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
import { applyExternalInitialImport, createExternalReturnWorkbook, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
|
import { applyExternalFollowUpImport, applyExternalInitialImport, createExternalReturnWorkbook, deleteMissingExternalProjectObjects, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalFollowUpImport, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
|
||||||
|
|
||||||
describe("Revit XLSX settings", () => {
|
describe("Revit XLSX settings", () => {
|
||||||
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
|
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
|
||||||
|
|
@ -53,6 +53,7 @@ describe("Revit XLSX settings", () => {
|
||||||
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
||||||
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
|
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
|
||||||
const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8");
|
const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8");
|
||||||
|
const followUpWizard = readFileSync("src/frontend/components/revit-follow-up-import-wizard.tsx", "utf8");
|
||||||
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
|
const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8");
|
||||||
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
||||||
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
||||||
|
|
@ -64,10 +65,23 @@ describe("Revit XLSX settings", () => {
|
||||||
assert.match(wizard, /Schritt.*von/);
|
assert.match(wizard, /Schritt.*von/);
|
||||||
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
|
assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/);
|
||||||
assert.match(wizard, /GZF 1/);
|
assert.match(wizard, /GZF 1/);
|
||||||
|
assert.match(revitPage, /Folgeimport/);
|
||||||
|
assert.match(followUpWizard, /in Revit noch nicht aktualisiert/);
|
||||||
|
assert.match(followUpWizard, /Fehlende Objekte werden nicht gelöst oder gelöscht/);
|
||||||
assert.match(objectTable, /viewportHeight/);
|
assert.match(objectTable, /viewportHeight/);
|
||||||
assert.match(objectTable, /Sammeländerung anwenden/);
|
assert.match(objectTable, /Sammeländerung anwenden/);
|
||||||
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
|
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
|
||||||
assert.match(objectTable, /Nur geänderte Objekte/);
|
assert.match(objectTable, /Nur geänderte Objekte/);
|
||||||
|
assert.match(objectTable, /Ausgewählte fehlende löschen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes missing objects only after explicit confirmation", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 6 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
|
||||||
|
try { await deleteMissingExternalProjectObjects("project-1", 5, ["object-1"]); }
|
||||||
|
finally { globalThis.fetch = originalFetch; }
|
||||||
|
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects/delete-missing", method: "POST", body: { expectedRevision: 5, objectIds: ["object-1"], confirmed: true } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads one project object list and sends revision-safe bulk updates", async () => {
|
it("loads one project object list and sends revision-safe bulk updates", async () => {
|
||||||
|
|
@ -90,4 +104,21 @@ describe("Revit XLSX settings", () => {
|
||||||
finally { globalThis.fetch = originalFetch; }
|
finally { globalThis.fetch = originalFetch; }
|
||||||
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
|
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("previews and retransmits the complete workbook for follow-up apply", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: Record<string, unknown> | undefined }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ history: { currentRevision: 10 }, import: { objectCount: 2 } }), { status: 200, headers: { "Content-Type": "application/json" } }); };
|
||||||
|
try {
|
||||||
|
const configuration = createDefaultExternalModelConfiguration();
|
||||||
|
const file = new File([new Uint8Array([1, 2])], "follow.xlsx");
|
||||||
|
await planExternalFollowUpImport("project-1", file, configuration, 9);
|
||||||
|
await applyExternalFollowUpImport("project-1", file, { expectedRevision: 9, expectedSha256: "a".repeat(64), configuration, objectsWithoutRoomDefaultDistributionBoardId: null, sourceChangeDecisions: [], roomDecisions: [], familyProjectDeviceDecisions: [] });
|
||||||
|
} finally { globalThis.fetch = originalFetch; }
|
||||||
|
assert.equal(requests[0].url, "/api/projects/project-1/external-model/follow-up-import/plan");
|
||||||
|
assert.equal(requests[1].url, "/api/projects/project-1/external-model/follow-up-import/apply");
|
||||||
|
assert.equal(requests[0].body?.contentBase64, "AQI=");
|
||||||
|
assert.equal(requests[1].body?.contentBase64, "AQI=");
|
||||||
|
assert.equal(requests[1].body?.expectedRevision, 9);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue