Compare commits

...

5 commits

93 changed files with 8497 additions and 7031 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@ dist/
data/*.db data/*.db
data/backups/*.db data/backups/*.db
.codex/*.log .codex/*.log
/docs/spec/kbp-Stromkreis.xlsx
/data/revit-reference/*.xlsx

192
AGENTS.md
View file

@ -295,7 +295,10 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 9 additionally stores Current snapshot schema version 10 stores the Linear-XLSX configuration and
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
versions containing legacy CSV configuration or external CSV state are rejected
with a German migration error. Version 9 additionally stores
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards; `feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
versions 1 to 8 are upgraded with both references set to `null`. Version 8 versions 1 to 8 are upgraded with both references set to `null`. Version 8
stores the optional net room area `rooms[].areaM2` (see stores the optional net room area `rooms[].areaM2` (see
@ -307,8 +310,8 @@ distribution-board info fields `boardNumber`, `location`, `fedFrom` and
`remark` and the project cover fields `siteAddress`, `workStage`, `remark` and the project cover fields `siteAddress`, `workStage`,
`projectLead` and `projectEditor`; all older supported versions are upgraded `projectLead` and `projectEditor`; all older supported versions are upgraded
with `null` for these fields. with `null` for these fields.
Stored named and automatic snapshots of every supported version (1 to 9) Stored named and automatic snapshots of versions 1 to 9 remain restorable when
remain restorable; they are upgraded while parsing. Version 5 stores they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed `manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3 external-model source, import batches, room mappings and objects. Version 3
@ -320,29 +323,21 @@ supported versions contain circuit-group identity, distribution-board components
separate circuit/component protection records. Portable duplicate imports separate circuit/component protection records. Portable duplicate imports
remap component ids and protection-owner references together with the existing remap component ids and protection-owner references together with the existing
project graph. project graph.
Project-wide Revit CSV configuration uses R3 replaced the runtime configuration and persistence foundation with Linear
`external-csv-configuration.update`. Its complete expected/target snapshot is XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
persisted through the shared project-command transaction, so create, update, the old development-only external CSV data after resetting linked row totals to
delete, Undo and Redo are revision-safe. Snapshot schema 5-9, restore and portable their manual share. Snapshot schema 10, restore and project transfer use the
project duplication preserve the configuration; duplication remaps its internal new state; non-empty legacy CSV snapshots and legacy CSV history entries are
id and project link. rejected with a German error. The backend CSV parser and routes are removed.
The project page exposes the Revit CSV modal. It edits the complete R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
transport/column/family-rule configuration through the revision-safe endpoint, server-side sample detection, and removes the old project-page CSV modal. See
requests a stateless preview and then shows the Phase 14.2 initial-import `docs/spec/revit-excel-integration-requirements.md` and
decisions. Preview responses include all mapped objects, while the UI renders `docs/spec/revit-excel-phase-14-plan.md`.
only the first 25. No preview or plan creates external objects, rows, revisions
or server-side drafts. The target configuration has no independent version counter; `expectedRevision`
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project, invalidates stale plans, and each import batch stores a configuration copy.
immutable import batches with original bytes plus the classified cell matrix, Only the newest import batch retains workbook bytes and the full matrix; older
normalized source-room mappings and external objects unique by batches retain date, filename, SHA-256 and object count.
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema versions 5-9
capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration
version introduced by additive migration `0004`; it is distinct from the CSV
configuration JSON schema version.
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
existing rows from `quantity` and keeps `quantity` as the materialized total. existing rows from `quantity` and keeps `quantity` as the materialized total.
Normal manual row inserts and quantity edits update both values together. Normal manual row inserts and quantity edits update both values together.
@ -363,37 +358,12 @@ protection and its external object links. Its history-only inverse requires the
complete unchanged circuit snapshot and exact link set. Redo never recalculates complete unchanged circuit snapshot and exact link set. Redo never recalculates
the BMK. All three external assignment stores share the BMK. All three external assignment stores share
`external-object-assignment.persistence.ts` for compatibility and link safety. `external-object-assignment.persistence.ts` for compatibility and link safety.
The read-only The current read-only
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects` `GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
projection resolves the board through the owned CircuitList and returns only projection resolves the board through the owned CircuitList and returns compact
that board's compact external objects, unassigned first. It exposes no original legacy external objects. The target Revit page will load one compact project
CSV bytes or command payloads and returns an empty list before optional import. list and filter/sort it client-side in a virtualized table, without server
Confirmed initial state is written only through pagination or batch blobs in the DTO.
`external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
planning overrides and internal project links in the shared transaction. It
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
unchanged external state, and Redo restores the same UUIDs and bytes.
`POST /api/projects/:projectId/external-csv/initial-import/plan` is a stateless
read path for the first wizard stage. It rejects projects with an existing
external source and returns source-room groups, exact room-number suggestions,
exact family/type groups, projected object values, issue counts and the current
floor/room/board/ProjectDevice catalogs. It creates no draft, revision or
domain row. The wide modal renders these groups and catalogs as explicit
decisions and identifies exact, ambiguous and new room matches. Missing rooms
and ProjectDevices are created through the reused project forms with mapped
Revit values as visible defaults. Each catalog creation remains its own project
command; the wizard adds and selects the result without discarding its plan.
The dedicated
`POST /api/projects/:projectId/external-csv/initial-import/apply` endpoint
retransmits and replans the file against the expected hash, configuration
version and project revision. It requires one explicit decision per source-room
and exact family/type group, blocks unclassified families, creates all stable
external UUIDs server-side and invokes `external-import.apply-initial`.
Room/default-board and optional ProjectDevice links are supported; CSV circuit
values remain source-only and every `circuitDeviceRowId` remains null. The UI
blocks unclassified families, requires a final confirmation and applies the
whole import as one persistent Undo/Redo step.
The central revision boundary creates an automatic logical snapshot after each The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per 25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy. project. Named snapshots are never removed by this retention policy.
@ -575,70 +545,57 @@ Required operations:
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
is served with `nosniff` and a restrictive CSP. is served with `nosniff` and a restrictive CSP.
## Revit / CSV External Model Work ## Revit / Linear Excel External Model Work
Status: further Revit work (editor assignment UI, follow-up import, return Phase 14 is active and follows
export) is deferred and will be tackled later. When it resumes, the exchange `docs/spec/revit-excel-integration-requirements.md` plus
stays CSV-based and uses the CSV export/import of the Linear plugin in Revit. `docs/spec/revit-excel-phase-14-plan.md`. The old CSV requirements and phases
The internal Revit/CAD steering committee prefers this over manipulating the 14.0–14.6 are superseded. Dynamo, Revit API and JSON transport remain excluded.
model directly through Dynamo. Do not start Dynamo, Revit API or JSON
transport work. The Linear CSV layout is not yet known: check a sample export
against the existing project CSV configuration before changing the parser.
Earlier Dynamo considerations are archived in
`docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md`; the
removed diagnostic scripts remain reachable through the git tag
`archive/dynamo-vorueberlegungen`.
When explicitly working on Phase 14, use Critical target rules:
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
truth together with the current architecture documents.
Critical rules: - Exchange uses only `.xlsx` files produced and consumed by the Linear plugin.
- Revit integration remains optional. Import never creates a CircuitDeviceRow,
Circuit or equipment identifier.
- Worksharing-ID is the external key and is compared exactly and
case-sensitively. It stays separate from internal UUIDs and circuit BMKs.
IfcGUID is optional redundancy, never a key, and duplicate values are only a
warning.
- A row without Worksharing-ID is suspicious only when `Familie und Typ` is
populated. Show a warning and skip it; do not block. Other ID-less rows,
including the sample summary in column B, are skipped silently.
- Source, planning and override values remain separate. Existing ProjectDevices
remain canonical and are never silently changed.
- Multi-socket objects remain indivisible. Row quantity stays
`manualQuantity + sum(effectiveQuantity)`.
- The project configuration has no independent version counter. Preview and
apply use `expectedRevision`; import batches store a configuration copy.
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
apply only stored before/after states.
- Only the newest import batch retains original bytes and the full matrix;
older batches retain date, filename, SHA-256 and object count.
- The Revit object table loads one compact list, filters/sorts in the client and
renders it virtually; do not add server pagination.
- Linked objects always follow their row's distribution board. Changing a
room's default board affects only unassigned objects and is not blocked.
- Deleting a row, circuit or group warns that X Revit objects become unassigned,
detaches them in the same command and restores links on Undo. Only deletion
of referenced rooms and distribution boards is blocked.
- Missing optional return columns for designation, power, voltage or current
produce warnings and are skipped. Only missing circuit columns for the
selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation.
- The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 and R4 are complete and R5 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
management, partial imports, PostgreSQL, background jobs or new permissions.
- Revit integration is optional. Do not make imported external data mandatory The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is
for projects, rooms, project devices, circuits or device rows. gitignored and must never be embedded in tests or committed. Use synthetic
- Parse imports into staging first. Never write CSV rows directly into circuits workbooks for automated tests and accept the real file only as an explicit
or device rows. local path in the reference checker.
- Keep external object identity separate from internal UUIDs and circuit BMKs.
`IfcGUID` identifies the external Revit object and must never replace an
internal UUID or `equipmentIdentifier`.
- One imported Revit object may exist without a `CircuitDeviceRow`.
Import, room assignment, distribution-board assignment, classification and
ProjectDevice linking do not create a row.
- Create or change a `CircuitDeviceRow` only after an explicit user action in
the circuit-list editor.
- Several external objects may link to one `CircuitDeviceRow`, while every
external object remains individually traceable for follow-up imports and
object-specific export.
- Multi-socket Revit objects are indivisible. Their effective quantity may be
greater than one, but one external object must never be split across circuits.
- Existing ProjectDevices are canonical project templates and must not be
overwritten from Revit values. When creating a new ProjectDevice, mapped
Revit values may prefill the creation form.
- Store imported source values separately from local planning values and local
overrides. Imports never silently overwrite local values.
- Repeated imports match deterministically by project/source and `IfcGUID`.
New, changed, missing and conflicting objects require preview and explicit
user decisions.
- The first implementation assumes one complete Revit model export per project.
Do not add visible multi-source, linked-model or partial-import management.
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
and unknown rows and columns for the return export.
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
Do not report Revit subtotal rows as missing-identity objects.
- Apply each confirmed import diff through a typed project command with
`expectedRevision`, complete inverse data and the shared project command
transaction boundary.
- External-model state must be added to logical project snapshots and portable
project transfer when it becomes part of the supported runtime.
- Keep CSV parsing/serialization in a transport adapter. Domain services must
not depend on CSV syntax, React, `better-sqlite3` or concrete repositories.
- Do not implement automatic circuit planning, Dynamo/API exchange,
PostgreSQL, background jobs or permissions as part of the initial CSV phase.
Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
domain objects and commands before adding migrations.
## Cable Sizing Module ## Cable Sizing Module
@ -733,9 +690,6 @@ for the early-phase power balance by DIN 276 cost group (see
## Current Deferred Work ## Current Deferred Work
- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never
Dynamo), except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing - full electrical sizing
- multi-user/PostgreSQL operation - multi-user/PostgreSQL operation
- supported production deployment - supported production deployment

View file

@ -9,11 +9,11 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Revit-CSV-Konfiguration, Vorschau vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen;
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport,
des Linear-Plugins in Revit. Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf
@ -40,9 +40,9 @@ des Linear-Plugins in Revit.
- 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
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von - Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
noch keine Stromkreise oder Gerätezeilen Phase-14-Plan in den nächsten Paketen angebunden
## Technik ## Technik
@ -177,6 +177,8 @@ vor der Baseline werden nicht unterstützt.
- [Anmeldung und Benutzerverwaltung](docs/authentication.md) - [Anmeldung und Benutzerverwaltung](docs/authentication.md)
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md)
- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md)
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen

View file

@ -31,13 +31,18 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Entwicklungs- und Contributor-Workflow](development-workflow.md) - [Entwicklungs- und Contributor-Workflow](development-workflow.md)
- [Deployment und Betrieb](deployment.md) - [Deployment und Betrieb](deployment.md)
## Zukunftsarchitektur ## Aktive Revit-Umstellung
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md) - [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) –
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) – verbindliche Anforderungen für XLSX und Worksharing-ID
zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins - [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status,
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md) Paketgrenzen und Linear-Freigabepunkt nach R2
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md)
## Zukunftsarchitektur
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md) - [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md) - [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md) - [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)

View file

@ -101,8 +101,8 @@ bleiben.
## Offen ## Offen
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die - Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die aktive Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten
Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg. Raumzuordnung im Importwizard nicht im Weg.
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer - Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
und ausdrücklicher Bestätigung einhalten. und ausdrücklicher Bestätigung einhalten.

View file

@ -9,9 +9,10 @@
- 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.
- Further Revit work is deferred; it will later use the CSV export/import of - The current Revit/CSV foundation and confirmed initial import are a legacy
the Linear plugin in Revit, not Dynamo. starting point. The active Phase-14 plan replaces them with the Linear XLSX
- The Revit/CSV foundation and confirmed initial import are implemented. exchange and exact Worksharing-ID identity.
R2 must pass a real Linear return-import check before database cutover in R3.
Existing-row assignment, move and detach plus creation of an external-only Existing-row assignment, move and detach plus creation of an external-only
row in an existing or newly created circuit have atomic persistent commands. 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 Their API planning and editor UI, follow-up import, conflict review and return

View file

@ -510,6 +510,9 @@ Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie. Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den
externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden
hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt.
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
@ -520,9 +523,9 @@ Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
Projekts; ältere Versionen werden mit `null` Projekts; ältere Versionen werden mit `null`
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand enthielt bereits den damaligen externen CSV-Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
1:1-Schutzgerätedaten. Capture, 1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
@ -640,11 +643,42 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage ## Revit-Anbindung: Linear-XLSX-Grundlage nach R3
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md). Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
Änderungen laufen als Projekt-Commands über `expectedRevision`.
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und
Projekttransfer verwenden diesen Zustand und remappen alle internen
Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9
werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem
CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
abgelehnt.
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte
Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch
die Route `/projects/:projectId/revit` ersetzt.
### Historische Ausgangslage vor R3
Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle
Laufzeitdokumentation mehr.
Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen
Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte
Ausgangslage für den schrittweisen Cutover; er darf nicht erweitert werden.
Das verbindliche Ziel ist der XLSX-Austausch des Linear-Plugins mit exakter,
case-sensitiver Worksharing-ID. Details:
[Excel-Anforderungen](spec/revit-excel-integration-requirements.md) und
[R0–R10-Plan](spec/revit-excel-phase-14-plan.md).
Der reine Adapter unter `src/external-model/csv` validiert die projektweite Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
@ -774,11 +808,33 @@ Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
ihn für die betroffene Gruppe. ihn für die betroffene Gruppe.
### Freigegebener Zielpfad ab R2
R2 ersetzt zunächst nur den Transportadapter und endet mit einem realen
Linear-Rückimporttest. R3 bleibt bis zu dessen Freigabe gesperrt. Scheitert der
ExcelJS-Roundtrip, wird vor R3 ein Adapter vorgeschlagen, der die Original-XLSX
direkt auf OOXML-Ebene ändert.
Der Zielzustand verwendet Worksharing-ID als exakten, case-sensitiven externen
Schlüssel; IfcGUID bleibt optional. Die Konfiguration hat keinen eigenen
Versionszähler. `expectedRevision` bindet Vorschau und Apply, Importbatches
speichern eine Konfigurationskopie. Nur der letzte Batch hält Workbook und
Matrix vollständig, ältere Batches nur Metadaten. Apply parst die erneut
übertragene Datei, Undo/Redo arbeitet nur auf persistierten Zuständen.
Die eigene Route `/projects/:projectId/revit` lädt eine kompakte Objektliste
einmal und stellt sie clientseitig gefiltert, sortiert und virtualisiert dar.
Verknüpfte Objekte folgen dem Verteiler ihrer Row. Row-, Circuit- und
Gruppenlöschungen lösen externe Links nach Warnung im selben Command; Undo
stellt sie wieder her. Raum- und Verteilerlöschungen bleiben bei externen
Referenzen gesperrt.
## Noch nicht unterstützt ## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung - Mehrbenutzerbetrieb und Konfliktauflösung
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und - Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die
strukturtreuer Revit-Rückexport Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung
folgt dem R0–R10-Plan
- vollständige elektrische Dimensionierung - vollständige elektrische Dimensionierung
- Produktionsdeployment - Produktionsdeployment

View file

@ -9,7 +9,8 @@ This document records architectural direction for future work. It is not a final
- persist undo/redo across page reloads and application restarts - persist undo/redo across page reloads and application restarts
- provide a project-wide version history with named restore points - provide a project-wide version history with named restore points
- preserve an auditable sequence of user, import, migration and restore operations - preserve an auditable sequence of user, import, migration and restore operations
- support future Revit/CSV round-trips using stable IFCGUID-based links - support the Revit/Linear-XLSX round-trip using stable, exact
Worksharing-ID-based external identity
- keep the persistence boundary replaceable so PostgreSQL can be adopted later - keep the persistence boundary replaceable so PostgreSQL can be adopted later
## Separate Concerns ## Separate Concerns
@ -111,11 +112,12 @@ Implemented automatic-snapshot policy:
- automatic capture and retention run inside the same transaction as the - automatic capture and retention run inside the same transaction as the
triggering project revision triggering project revision
## Revit / CSV / IFCGUID Round-Trip Direction ## Revit / Linear Excel Round-Trip Direction
**Deferred:** further Revit work will be tackled later, then through the Phase 14 is active and follows the
CSV export/import of the Linear plugin in Revit instead of Dynamo. Details: [Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
[Revit-CSV requirements, section 1](spec/revit-csv-integration-requirements.md). [R0–R10 plan](spec/revit-excel-phase-14-plan.md). The existing CSV/IFCGUID
runtime is a legacy starting point for the cutover, not the target model.
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
@ -124,9 +126,12 @@ Conceptual entities:
- `ExternalModelSource` - `ExternalModelSource`
- identifies a Revit/IFC model or another external source within a project - identifies a Revit/IFC model or another external source within a project
- `ExternalImportBatch` - `ExternalImportBatch`
- file name, checksum, import time, format/version and processing status - file name, checksum, import time, object count and processing status
- only the latest batch keeps original workbook bytes, matrix and the
configuration copy; older batches keep date, filename, checksum and count
- `ExternalModelObject` - `ExternalModelObject`
- stable IFCGUID and optional Revit element identifiers - exact case-sensitive Worksharing-ID as external identity
- optional IfcGUID as non-unique source redundancy
- source attributes and last-seen import batch - source attributes and last-seen import batch
- `ExternalObjectLink` - `ExternalObjectLink`
- explicit relationship to a project device, circuit device row or another future domain entity - explicit relationship to a project device, circuit device row or another future domain entity
@ -137,13 +142,27 @@ Conceptual entities:
Required behavior: Required behavior:
- IFCGUID uniqueness is scoped to project and external model source. - Worksharing-ID uniqueness is scoped to the external source and uses exact,
case-sensitive comparison. IfcGUID is not a matching key.
- Parsing writes to staging first. - Parsing writes to staging first.
- Users preview additions, changes, missing objects and conflicts before applying them. - Users preview additions, changes, missing objects and conflicts before applying them.
- Applying an import creates one project revision and one auditable change set. - Applying an import creates one project revision and one auditable change set.
- Imported source values and locally assigned planning values remain distinguishable. - Imported source values and locally assigned planning values remain distinguishable.
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically. - Export retains Worksharing-ID so Linear can match rows deterministically.
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned. - Missing objects and duplicate/conflicting Worksharing-IDs are reported,
never silently reassigned.
The project configuration has no separate revision counter. Stateless plans
and apply requests bind to `expectedRevision`; every applied import batch holds
its configuration copy. Apply reparses the retransmitted workbook. Undo and
Redo operate only on persisted complete before/after states and never parse an
Excel file.
Deleting a linked row, circuit or group detaches affected Revit objects in the
same typed command after an explicit warning; its inverse restores all links.
Only deleting referenced rooms and distribution boards is blocked. Linked
objects always derive their board from their row, while a room default-board
change affects only unassigned objects.
## SQLite and PostgreSQL Direction ## SQLite and PostgreSQL Direction
@ -292,7 +311,8 @@ Remaining constraints before completing project version history:
boundary; extend it whenever further project mutations are introduced boundary; extend it whenever further project mutations are introduced
- project creation remains initialization at revision zero, while global device - project creation remains initialization at revision zero, while global device
templates remain outside any single project history templates remain outside any single project history
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment
identifier or an internal relation id
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
## Deferred Decisions ## Deferred Decisions
@ -300,5 +320,6 @@ Remaining constraints before completing project version history:
- whether large snapshot payloads remain in PostgreSQL or move to object storage - whether large snapshot payloads remain in PostgreSQL or move to object storage
- branch visualization after undo followed by new edits - branch visualization after undo followed by new edits
- user/role model and actor attribution - user/role model and actor attribution
- supported Revit CSV dialects and parameter mapping UI - Linear-XLSX compatibility beyond the observed sample and any future
project-independent mapping profiles
- PostgreSQL deployment topology and migration date - PostgreSQL deployment topology and migration date

View file

@ -1,273 +1,15 @@
# Kontext für die Revit-Anforderungsplanung # LLM-Kontext für die Revit-Integration
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. abgelöst. Für Arbeiten an Phase 14 sind vollständig zu lesen:
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md).
## Zweck dieser Datei - [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
- [Aktuelle Systemarchitektur](current-architecture.md)
- [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md)
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der derzeit noch im
für einen zukünftigen Revit-Datenaustausch zu klären. Code vorhandene CSV-Stand ist ausschließlich die in R1 dokumentierte
Legacy-Ausgangslage für den schrittweisen Cutover.
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
## Produkt und fachliches Modell
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
Das unterstützte Laufzeitmodell ist:
```text
Project
├── Floor
│ └── Room
├── ProjectDevice
└── DistributionBoard
└── CircuitList
└── CircuitSection
└── Circuit
└── CircuitDeviceRow
```
Wichtige fachliche Grenzen:
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
und Kategorie gehören zur Gerätezeile.
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
lokale Werte und ausdrücklich erfasste Abweichungen.
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
erlaubt.
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
werden.
Für einen späteren Revit-Austausch besonders relevante Daten sind:
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
Kennzeichnung als öffentliches Gebäude und Projektspannungen.
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
Raumname.
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
aktuellen Produkt nicht vorgesehen.
## Aktuelle technische Architektur
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
`src/frontend`.
- API: Express und Zod unter `src/server`.
- Fachregeln und typisierte Commands: `src/domain`.
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
- Laufzeitkomposition konkreter Repositories:
`src/server/composition`.
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
SQLite-Datei unter `data/leistungsbilanz.db`.
- Docker Compose wird für die lokale Entwicklung unterstützt.
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
Der zentrale Editor liegt unter:
```text
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
src/frontend/components/circuit-tree-editor.tsx
```
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
## Revisionen, Undo/Redo und Sicherungspunkte
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
Command- und Transaktionsgrenze:
```text
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
```
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
geben die erwartete Revision mit, damit veraltete Clients keine neueren
Änderungen überschreiben.
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
Anwendungsneustart. Zusätzlich gibt es:
- eine unveränderliche Revisions-Timeline;
- benannte und automatische logische Projektsnapshots;
- wiederherstellbare Snapshots mit Prüfsumme;
- einen portablen, versionierten JSON-Projekttransfer.
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
revisioniert und rückgängig machbar sein.
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
Projekts innerhalb von Leistungsbilanz.
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
für CSV-Dateien oder Revit-Parameter verwendet werden.
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
zuweisen kann.
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
Implementierungsdesign:
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
Gerätezeilen.
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
widersprüchliche Objekte angezeigt.
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
unterscheidbar bleiben.
- Lokale Planungswerte dürfen niemals still überschrieben werden.
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
ausdrücklich modelliert werden.
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
automatisch einem anderen Objekt zugeordnet.
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
die Werte deterministisch zurückschreiben kann.
- Ein Export bezieht sich auf eine bekannte Projektrevision.
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
Serverbetrieb oder unabhängigen Hintergrundjobs.
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
- `ExternalModelSource`
- `ExternalImportBatch`
- `ExternalModelObject`
- `ExternalObjectLink`
- `ExternalParameterMapping`
- `ExternalExportBatch`
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
Tabellen behandeln.
## Auftrag an das andere LLM
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
realen Ablauf in Revit und kläre danach schrittweise mindestens:
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
Revit-Versionen)?
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
aktualisierte Revit-Dateien unterschieden?
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
Leistungsbilanz geplant?
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
Projektgerät, Raum oder einer Verteilung zugeordnet?
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
Einheiten und unter welchen Revit-Parameternamen?
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
Rückgabedatei erzeugt werden?
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
wiederverwendet und zwischen Projekten geteilt werden?
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
doppelte oder nicht mehr sichtbare Revit-Objekte?
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
eine Auswahl?
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
Anwender vor der Übernahme?
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
verhindern oder nur eine Warnung erzeugen?
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
16. Müssen Import und Export synchron im Browser laufen oder werden später
Hintergrundjobs benötigt?
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
Versionshistorie und Support gespeichert werden?
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
Abnahmekriterien?
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
Notizen ausdrücklich:
- vom Anwender bestätigte Anforderungen;
- sinnvolle Vorschläge;
- offene Entscheidungen;
- technische Folgerungen;
- bewusst nicht betrachtete spätere Ausbaustufen.
## Erwartetes Ergebnis der Anforderungsrunde
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
soll mindestens enthalten:
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
Rückexport;
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
- Identitäts-, Matching- und Quellmodell;
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
- Konflikt-, Validierungs- und Freigaberegeln;
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
Architekturregeln zu umgehen;
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
müssen.
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
nur Architekturvorschläge des LLM sind.
## Weiterführende Projektdokumente
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
Command-Pfad
- `docs/project-history-and-external-model-architecture.md` – bisherige
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
- `docs/spec/01-domain-context.md` – fachlicher Kontext
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
kontrollierte Synchronisierung
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
Funktionen

View file

@ -532,36 +532,34 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip ## Phase 14: External Model Round-Trip
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md). abgelöst und wird nicht fortgeschrieben.
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist Verbindliche Quellen:
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
und die verbindlichen
[Revit-CSV-Anforderungen](revit-csv-integration-requirements.md).
Goal: - [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md)
- [R0–R10-Plan](revit-excel-phase-14-plan.md)
- [R0-Bestandsaudit](revit-excel-r0-audit.md)
Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import). Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit
einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie
erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird
zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative
vorgelegt.
Tasks: Kernabnahme der Phase:
- model external sources, import batches and stable IFCGUID objects - Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
- stage and validate imports before applying them - XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
- show additions, changes, missing objects and conflicts Rückexport erhalten;
- link external objects explicitly to project-domain entities - Import erzeugt keine Rows und überschreibt keine lokale Planung still;
- apply an import as one auditable project revision - Zuordnung, Löschung, Folgeimport und Undo/Redo sind atomar und nach Neustart
- configure inbound and outbound parameter mappings reproduzierbar;
- export against a known project revision while preserving IFCGUID - Undo/Redo liest keine Excel-Datei;
- nur der letzte Importbatch enthält Workbook und Matrix vollständig;
Acceptance criteria: - mehrere Tausend Objekte bleiben über eine clientseitig gefilterte,
virtualisierte Liste bedienbar.
- repeated imports match objects deterministically by source and IFCGUID
- imports never silently overwrite local planning values
- users approve the diff before it changes the project
- exported data can be assigned back to the correct Revit objects
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration. PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.

View file

@ -162,7 +162,7 @@ ProjectDevices and device rows (#20).
3. [x] Snapshot-to-revision descriptions and history presentation. 3. [x] Snapshot-to-revision descriptions and history presentation.
4. [x] Distribution-board power summary and project column layout. 4. [x] Distribution-board power summary and project column layout.
The Revit/CSV/IFCGUID round-trip follows the distribution-board component The Revit/Linear-XLSX round-trip is now the separately planned active Phase 14.
foundation as a separate jointly planned phase and must not be inferred from It uses exact Worksharing-ID identity and must not be inferred from these
these tasks. It is currently deferred and will later use the CSV unrelated backlog tasks. See
export/import of the Linear plugin in Revit, not Dynamo. [the R0–R10 plan](revit-excel-phase-14-plan.md).

File diff suppressed because it is too large Load diff

View file

@ -1,406 +1,8 @@
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung # Abgelöst: alter Phase-14-CSV-Plan
## Status Die Phasen 14.0–14.6 und ihre Erledigt-Markierungen sind abgelöst. Für die
Umstellung auf Linear-Excel gelten der [R0–R10-Plan](revit-excel-phase-14-plan.md)
und die [neuen Anforderungen](revit-excel-integration-requirements.md).
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. `ae318a5` erhalten und darf nicht fortgeschrieben werden.
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md).
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
noch nicht.
Fachliche Quelle bleibt
[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die
Reihenfolge kleiner Arbeitspakete.
## Ergebnis des Bestandsaudits
### Beibehalten
- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis
viele `CircuitDeviceRow`-Datensätze.
- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf
diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht
ausdrücklich bestätigt.
- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung
zwischen einer Gerätezeile und genau einer Projektgerätevorlage.
- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte,
Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen
vierten, fachlich eigenen Pfad.
- Alle bestätigten Änderungen verwenden typisierte Commands mit
`expectedRevision`, vollständiger Gegenrichtung und
`project-command-transaction.persistence.ts` als gemeinsamer
Transaktionsgrenze.
- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit
fester Fremdschlüsselreihenfolge und UUID-Remapping.
- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte
Laufzeitadapter.
### Erweitern oder gezielt refaktorieren
- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den
bestätigten externen Projektzustand erweitert. Bereits veröffentlichte
Snapshot-Versionen werden nicht verändert.
- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration,
Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine
konkreten SQLite-Abhängigkeiten.
- Der Stromkreislisteneditor erhält später einen separaten Drawer für
unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht
dafür zweckentfremdet.
- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer
`CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“.
### Hinzufügen
- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain;
- projektweite, versionierte Revit-CSV-Konfiguration;
- eine zunächst implizite externe Modellquelle je Projekt;
- Importbatches einschließlich Originaltransport und akzeptiertem
Konfigurationsstand;
- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und
Planungswerten und einzelnem Lebenszyklus;
- Quellraum-Mappings auf interne Räume und Standardverteilungen;
- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler
abweichender Verteilung;
- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices;
- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und
End-to-End-Tests.
### Entfernen
Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14
kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt.
## Vorgeschlagenes Domänenmodell
Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die
jeweilige englische Pluralform in `snake_case`.
### `ExternalModelSource`
- stabile interne UUID;
- `projectId`;
- technischer Name und Quelltyp `revit_csv`;
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
abgeleitet und nicht als redundanter Zeiger gespeichert.
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an
`(sourceId, ifcGuid)`.
### `ExternalCsvConfiguration`
- stabile UUID und `projectId` mit genau einer aktiven Konfiguration;
- Konfigurationsschema-Version;
- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen,
Dezimalformat und Kopfzeilenerkennung;
- exakte Spaltenzuordnung;
- geordnete, exakte Familie-und-Typ-Regeln;
- monotoner Konfigurationsstand.
Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in
einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei
verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar,
ohne eine zweite veränderbare Historientabelle einzuführen.
### `ExternalImportBatch`
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
- Dateiname, SHA-256 und zugehörige Projektrevision;
- verwendete monotone Konfigurationsversion und vollständiger
Konfigurationssnapshot;
- Originaldatei als Bytes;
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes
belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten
Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten,
Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren.
### `ExternalRoomMapping`
- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel;
- Quellgeschoss-, Raumnummer- und Raumname-Snapshot;
- optionales `roomId`;
- optionale `defaultDistributionBoardId`.
Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält
das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung.
### `ExternalModelObject`
- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`;
- letzter gesehener und letzter akzeptierter Importbatch;
- akzeptierte Quellwerte getrennt als strukturierter Snapshot;
- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame
Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe
und Bemerkung;
- explizite Override-Metadaten;
- optionales `externalRoomMappingId`;
- optionale abweichende `distributionBoardId`;
- optionales `linkedProjectDeviceId`;
- optionales `circuitDeviceRowId`;
- Anwesenheitsstatus für bestätigte fehlende Objekte.
Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele
einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet
sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre
wirksame Menge größer eins.
Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem
Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter
Anwesenheits- oder Planungszustand wird persistiert.
## Mengenmodell der Gerätezeile
Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller
Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines
Objekts manuelle Mengen löschen oder doppelt zählen.
Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt:
```text
quantity = manualQuantity
+ Summe(effectiveQuantity aller verknüpften ExternalModelObjects)
```
- Bei der Migration erhält jede bestehende Row
`manualQuantity = quantity`; ihr Ergebnis bleibt unverändert.
- Eine ausschließlich aus externen Objekten erstellte Row startet mit
`manualQuantity = 0`.
- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende
Berechnungen, Filter und Exporte und wird im selben Command aktualisiert.
- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet
sein.
- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte
übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht
automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine
ausdrückliche Nutzerentscheidung.
- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim
Lösen bleibt der manuelle Anteil erhalten.
Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere
Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller
Row-Lebenszyklus ist ausgeschlossen.
## Command-Schnittstellen
Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte
Snapshots und werden mit der aktuellen Projektrevision geplant. Große
Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch
bleiben und dürfen spätere Quellstände nicht erneut auswerten.
- `external-csv-configuration.update`: vollständige projektweite
Konfiguration ändern.
- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe
Objekte und bestätigte Links atomar einfügen.
- `external-import.apply-follow-up`: neuen Batch und die vollständigen
erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte
atomar übernehmen.
- `external-model-object.update-planning`: lokale Werte und Overrides eines
oder mehrerer Objekte ändern.
- `external-model-object.update-project-device-link`: ProjectDevice-Links
explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
und alle betroffenen materialisierten Mengen atomar aktualisieren.
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
Command gespeichert und bei Redo nicht neu berechnet.
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
weiterhin unveränderte fehlende Objekte löschen.
Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen
`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare
Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und
Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern
keine Projektrevision und laufen über einen eigenen Audit-Store.
## Snapshot-, Transfer- und Migrationsfolgen
- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das
Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2
ergänzt `externalCsvConfiguration = null`.
- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und
Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt
leere externe Collections.
- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende
Werte werden einmalig aus `quantity` übernommen.
- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte
Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge.
- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links
remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte
bleiben unverändert.
- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene
Project-State-Schema-Version wird additiv erhöht.
- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert.
## Implementierungsfolge
### 14.1 – CSV-Konfiguration und reine Vorschau
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine
synthetische Fixtures ergänzen. **Erledigt.**
2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
synthetische Testdaten; die Referenzdatei steht noch aus.**
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
gegen die Vorschau geprüft.
**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme
erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
einen Commit aufgenommen.
### 14.2 – Persistente Objekte und Erstimport
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
weiterhin keinen Import-Write außerhalb eines Commands.
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
lassen IFCGUID und Quelltransport jedoch unverändert.
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
bleibt unverändert.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
schreibt weder Entwurf noch Projektzustand. Der dedizierte
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
Planung, vollständige Entscheidungen für alle Quellräume und exakten
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
### 14.3 – Editor und explizite Zuordnung
1. `manualQuantity` und Mengeninvarianten migrieren und testen.
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
**Teilweise erledigt:** `external-object.update-row-assignment` deckt
Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und
Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum,
Selektionskennzeichen und Planungswerte, verlangt für bestätigbare
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
Standardschutz und genau einer externen Row wird gemeinsam mit den
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
**Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene
GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers,
unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker,
Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden;
Drawer, Filter und Mengenwirkung folgen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen.
Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl,
Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt.
### 14.4 – Strukturtreuer Rückexport
1. Exportprojektion gegen eine bekannte Projektrevision ergänzen.
2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen.
3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten.
4. Download und append-only Exportnachweis ergänzen.
Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich
festgelegt.
### 14.5 – Folgeimport und Konflikte
1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen.
2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren.
3. Einzel- und Sammelentscheidungen im Wizard ergänzen.
4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo
übernehmen.
### 14.6 – Härtung und End-to-End-Abnahme
1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren.
2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport,
Snapshot/Restore und Projektduplikat prüfen.
3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen.
4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich
ausgelieferten Stand nachziehen.
## Risiken und Schutzmaßnahmen
- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile;
Commands ändern Links und materialisierte Menge gemeinsam.
- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind
getrennt; jede Übernahme benötigt eine Nutzerentscheidung.
- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage;
deren bestehende Commands bleiben alleinige Schreibgrenze.
- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix,
Dialekt, Konfiguration, Hash und Projektrevision.
- **Sehr große Undo-Payloads:** Importcommands speichern vollständige
erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen,
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
## Freigabepunkt
Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine
Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket
ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus.

View file

@ -0,0 +1,466 @@
# Revit-Integration über Linear-Excel – verbindliche Anforderungen
Stand: 29.09.2026. Dieses Dokument ist die fachliche und technische
Arbeitsgrundlage für die neue Phase 14. Es ersetzt die frühere CSV-/IfcGUID-
Spezifikation. Der Umsetzungsstand steht im
[R0–R10-Plan](revit-excel-phase-14-plan.md); der geprüfte Ausgangszustand im
[R0-Audit](revit-excel-r0-audit.md).
## 1. Ziel und Grenzen
Der Austausch mit Revit erfolgt ausschließlich über den XLSX-Export/-Import
des Linear-Plugins. Eine vollständige Revit-Bauteilliste wird importiert, in
Leistungsbilanz geplant und mit gleichem Tabellenaufbau an Linear
zurückgegeben. Linear ordnet enthaltene Zeilen anhand der Worksharing-ID zu.
Nicht Teil dieser Phase sind Dynamo, Revit-API, JSON-Transport, automatische
Stromkreisbildung, Übernahme vorhandener Revit-Stromkreiswerte,
Teilimporte, mehrere Modellquellen, PostgreSQL und neue Rollen/Rechte.
Die Integration bleibt optional. Manuelle Projekte, Räume, ProjectDevices,
Stromkreise und Gerätezeilen funktionieren ohne externes Modell. Der
Circuit-First-Aufbau, stabile interne UUIDs und BMKs, ausdrückliche
Neunummerierung sowie die gemeinsame Project-Command-Transaktionsgrenze
bleiben unverändert.
## 2. Verbindliche fachliche Regeln
- Import erzeugt keine `CircuitDeviceRow`, keinen `Circuit` und kein BMK.
- Quellwerte, lokale Planungswerte und Overrides bleiben getrennt.
- Ein Folgeimport überschreibt keine lokale Planung still.
- Fehlende Objekte werden markiert und erst nach ausdrücklicher Bestätigung
entfernt.
- Mehrfachsteckdosen sind unteilbare externe Objekte. Ihre wirksame Menge kann
größer als eins sein.
- Die Row-Menge ist
`manualQuantity + Summe(effectiveQuantity der verknüpften Objekte)`.
- Nach einer Row-Zuordnung ist die `CircuitDeviceRow` die Planungsebene für
alle verknüpften Objekte.
- Bestehende ProjectDevices bleiben kanonische Vorlagen und werden durch
Importe nie verändert.
- Sortieren, Filtern, Importieren, Zuordnen und Verschieben nummeriert keine
Stromkreise automatisch neu.
## 3. Linear-XLSX-Profil
Die lokale Referenzdatei `docs/spec/kbp-Stromkreis.xlsx` enthält vertrauliche
Projektdaten. Sie wird nicht committed und nicht in Tests eingebettet. Tests
erzeugen synthetische Workbooks. Der lokale Referenzprüfer erhält den Pfad als
Argument.
Beobachtetes Profil:
- genau ein Arbeitsblatt; Blattname nach Excel-Regel maximal 31 Zeichen;
- Kopfzeile in Zeile 1, Zeile 2 leer, Daten ab Zeile 3, erste Datenspalte A;
- Spalte A `Worksharing-ID` im Format `<GUID>-<8 Hex>`;
- Parameterbindungen stehen als klassische Excel-Kommentare in den übrigen
Kopfzellen;
- Zahlen sind typisierte Excel-Zahlen; importierte Werte bleiben unverändert,
fachlich verwendete Leistung wird auf 0,01 W projiziert;
- Objektzeilen dürfen keinen MEP-Raum besitzen;
- eine Schluss-/Summenzeile ohne ID kann vorkommen und wird weder importiert
noch zurückgegeben.
Die Referenzdatei enthält 487 Objektzeilen, eindeutige Worksharing-IDs und
keine doppelten nichtleeren IfcGUIDs bei exaktem, case-sensitivem Vergleich.
Ihre Summenzeile steht in Spalte B (`CAx_Auswahlkenner`), während
`Familie und Typ` leer ist.
### Zeilen ohne Worksharing-ID
Nur eine Zeile ohne Worksharing-ID, aber mit befülltem Feld
`Familie und Typ`, gilt als verdächtige Objektzeile. Sie erzeugt einen Hinweis
und wird übersprungen; sie blockiert den Import nicht. Andere Zeilen ohne ID,
einschließlich einer nur in Spalte B befüllten Summenzeile, werden ohne Hinweis
übersprungen. Eine Objektzeile ist für den Import eine Zeile mit nichtleerer
Worksharing-ID.
Worksharing-ID und IfcGUID werden stets exakt, ordinal und mit
Groß-/Kleinschreibung verglichen. Es findet kein Trimmen, Normalisieren oder
Case-Folding für die Identität statt. Eine nichtleere Worksharing-ID mit
unerlaubten äußeren Leerzeichen oder falschem Format blockiert. Doppelte
Worksharing-IDs blockieren. IfcGUID ist optional, kein Schlüssel und darf
doppelt vorkommen; Duplikate erzeugen lediglich einen Hinweis.
## 4. Upload und Transportadapter
Der Server akzeptiert ausschließlich Dateien mit Endung `.xlsx` bis 10 MiB.
`.xls` und `.xlsm` werden abgelehnt. Parsing findet serverseitig statt. Nach
dem Einlesen gelten feste Zeilen-, Spalten- und Zellgrenzen, die in R2 anhand
synthetischer Grenztests festgelegt und zentral definiert werden. Es gibt in
dieser Phase keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
Der Adapter unter `src/external-model/xlsx` liest eine transportneutrale
Zellmatrix. Eine Zelle ist `string`, `number`, `boolean` oder `empty`.
Zusätzlich werden Blattname, Originalzeilennummer, Kopfzeilenkommentare,
Spaltenbreiten und die für Hinweise nötigen Formelmetadaten erfasst.
Domain-Services kennen weder ExcelJS noch XLSX, React oder SQLite.
Formeln werden niemals ausgeführt. Ist ein gecachter skalarer Wert vorhanden,
wird er verwendet und als Hinweis ausgewiesen. Fehlt er oder ist er ein
Fehlerwert, bleibt das Feld leer; bei der Worksharing-ID oder einem anderen
Pflichtfeld blockiert dies die betreffende Objektzeile beziehungsweise den
Import.
Genau eine gepflegte Bibliothek wird ergänzt. Vorgesehen ist `exceljs`, sofern
R2 die Node-24-Verträglichkeit und den Roundtrip von Kopfkommentaren,
Blattname, Spaltenbreiten, Formaten und Zelltypen bestätigt.
## 5. Projektkonfiguration
Die Konfiguration ist projektweit, revisionssicher und besitzt keine eigene
Versionszählung. Jeder Plan und jedes Apply tragen `expectedRevision`.
Da die Projektrevision auch bei Undo/Redo strikt steigt, wird ein alter Plan
automatisch ungültig. Jeder Importbatch speichert eine vollständige Kopie der
beim Import verwendeten Konfiguration.
Das Einstellungsmodal auf der Revit-Seite erkennt aus einer zustandslos
hochgeladenen Musterdatei Kopfzeile, erste Datenzeile und erste Datenspalte.
Diese Werte bleiben editierbar; Standard ist 1 / 3 / A. Es gibt nur ein Feld
für die Identitätsspalte: `columns.worksharingId`. Ein separates
`layout.idColumn` existiert nicht.
Zielvertrag:
```ts
interface ExternalModelConfiguration {
schemaVersion: 1;
layout: {
headerRow: number;
firstDataRow: number;
firstDataColumn: number;
};
circuitMode: "combined" | "separate";
columns: {
worksharingId: string;
ifcGuid: string | null;
roomNumber: string | null;
roomName: string | null;
familyAndType: string;
selectionMarker: string | null;
socketCount: string | null;
sourceRemark: string | null;
circuitCombined: string | null;
boardName: string | null;
circuitIdentifier: string | null;
designation: string | null;
electricalPowerW: string | null;
voltageV: string | null;
currentA: string | null;
};
deviceTypes: DeviceTypeConfiguration[];
familyTypeRules: FamilyTypeRule[];
}
```
Standardzuordnung:
| Schlüssel | Bedeutung | Standardspalte |
| --- | --- | --- |
| `worksharingId` | externe Identität, Pflicht | `Worksharing-ID` |
| `ifcGuid` | optionale Redundanz | `IfcGUID` |
| `roomNumber` | Raumnummer | `MEP-Raum: Nummer` |
| `roomName` | Raumname | `MEP-Raum: Name` |
| `familyAndType` | Familie und Typ, Pflicht | `Familie und Typ` |
| `selectionMarker` | CAx-Auswahlkenner | `CAx_Auswahlkenner` |
| `socketCount` | Anzahl Einzelsteckdosen | `CAX_AnzahlObjekte` |
| `sourceRemark` | Anmerkung | `CAx_Anmerkung` |
| `circuitCombined` | Stromkreis gesamt | `kbp_Stromkreisnummer` |
| `boardName` | Verteiler | `kbp-E-Verteiler` |
| `circuitIdentifier` | Stromkreis-BMK | `kbp-E-Stromkreis` |
| `designation` | Bezeichnung | `kbp-E-Bezeichnung` |
| `electricalPowerW` | elektrische Leistung [W] | `kbp-E-Elektrische Leistung` |
| `voltageV` | Spannung [V] | `kbp-E-Spannung` |
| `currentA` | Stromstärke [A] | `kbp-E-Stromstärke` |
`worksharingId` und `familyAndType` sind Pflichtmappings. Im Modus
`combined` ist `circuitCombined` für den Rückexport Pflicht; im Modus
`separate` sind `boardName` und `circuitIdentifier` Pflicht. Fehlende optionale
Rückgabespalten `designation`, `electricalPowerW`, `voltageV` und `currentA`
werden mit Hinweis übersprungen und blockieren den Export nicht.
Jedes interne Feld besitzt im Modal ein Dropdown aus den Kopfzellen und eine
freie Eingabe. Exakte kbp-Standardnamen werden vorbelegt. Typ und Einheit aus
dem Linear-Kommentar werden angezeigt. Nicht gemappte Spalten bleiben beim
Rückexport unverändert.
## 6. Gerätearten und Familie-und-Typ-Regeln
Eine Geräteart besitzt stabile interne UUID, Name, Kategorie
`lighting | single_phase | three_phase` sowie optionale Standardwerte für
Leistung je Einheit in W, GZF, cosPhi und Kostengruppe. Jede exakte
`Familie und Typ`-Regel verweist auf genau eine Geräteart und verwendet die
Mengenregel `1` oder `socketCount`.
Mehrere Revit-Familien dürfen auf dieselbe Geräteart zeigen. Jedes Revit-
Objekt bleibt trotzdem einzeln über seine Worksharing-ID nachverfolgbar.
Unbekannte Familien müssen im Importwizard zugeordnet oder über eine neue
Geräteart klassifiziert werden; andernfalls blockiert Apply.
Planungsstartwerte folgen dieser Reihenfolge:
1. ausdrücklich verknüpftes ProjectDevice;
2. Geräteart-Standard;
3. leer.
Der Anzeigename wird aus CAx-Auswahlkenner, dann Geräteartname, dann
`Familie und Typ` vorgeschlagen. Ein fehlender GZF wird mit `1` vorbelegt,
ohne zusätzliche Bestätigung. Standardwerte sind Startwerte, keine dauerhafte
Synchronisierung.
## 7. Persistenter externer Zustand
Pro Projekt existiert genau eine implizite Revit-XLSX-Quelle. Ein externes
Objekt besitzt eine interne UUID und ist je `(sourceId, worksharingId)`
eindeutig. `worksharingId` ist NOT NULL; `ifcGuid` ist nullable und nicht
eindeutig. Interne Relationen verwenden ausschließlich interne UUIDs.
Gespeichert werden:
- akzeptierte Quellwerte einschließlich aller Rückgabefelder;
- lokale Planungswerte und Override-Felder;
- Geräteart;
- optionales Raum-Mapping, abweichender Verteiler, ProjectDevice und
`CircuitDeviceRow`;
- Anwesenheitsstatus und Verweis auf den zuletzt relevanten Import;
- effektive Menge als fachlicher Wert des unteilbaren Objekts.
Der effektive Verteiler eines verknüpften Objekts ist immer der Verteiler
seiner Row. Für ein unzugeordnetes Objekt gilt zuerst die explizite
Objektabweichung, danach der Standardverteiler seines Raum-Mappings. Ändert
sich der Standardverteiler eines Raums, betrifft das nur unzugeordnete
Objekte; verknüpfte Objekte bleiben über ihre Row unverändert und die Änderung
wird deshalb nicht gesperrt.
## 8. Importbatch-Aufbewahrung, Snapshot und Transfer
Der aktive Projektzustand enthält den letzten Importbatch vollständig:
Originalbytes, klassifizierte Matrix, Dateiname, SHA-256, Zeitpunkt,
Objektzahl, Importart, Projektrevision und Konfigurationskopie. Ältere Batches
werden zu Metadaten reduziert: Datum, Dateiname, SHA-256 und Objektzahl.
Sie enthalten keine Originalbytes, Matrix oder Konfigurationskopie mehr.
Snapshot-Schema 10 und portabler Projekttransfer übernehmen genau diese
Aufteilung. Beim Duplizieren werden alle internen UUIDs und Links remapped;
Worksharing-IDs, optionale IfcGUIDs, Quellwerte und das letzte Workbook bleiben
unverändert.
Versionen 1–9 ohne externe CSV-Daten werden verlustfrei auf Version 10
hochgestuft. Enthält ein alter Stand eine CSV-Konfiguration oder einen nicht
leeren CSV-Zustand, wird er mit klarer deutscher Fehlermeldung abgelehnt.
Alte CSV-History-Commands werden ebenfalls vor jedem Write abgelehnt und nie
stillschweigend übersprungen.
## 9. Erstimport
Der Ablauf ist Upload → zustandslose Vorschau/Planung → erneute
Dateiübertragung bei Apply. Apply prüft SHA-256, `expectedRevision`,
Konfigurationskopie, Matrix und sämtliche Entscheidungen erneut. Nur Apply
liest die Excel-Datei erneut ein.
Undo und Redo lesen keine Excel-Datei und parsen keine gespeicherten Bytes.
Sie arbeiten ausschließlich auf den im Command gespeicherten vollständigen
Vorher-/Nachher-Zuständen. Dadurch bleiben sie nach Neustart deterministisch
und unabhängig vom Parser.
Der Wizard umfasst:
1. Vorschau mit Objektzahl, übersprungenen und verdächtigen Zeilen,
doppelten Worksharing-IDs, doppelten IfcGUID-Hinweisen, unbekannten
Familien, nicht lesbaren Zahlen und Formeln;
2. Räume anlegen oder vorhandenen Räumen/Ebenen zuordnen;
3. Standardverteiler je Raum, Sammelzuordnung, Objektabweichungen und Objekte
ohne Raum;
4. Familien exakt Gerätearten und einer Mengenregel zuordnen;
5. optional vorhandene oder neu angelegte ProjectDevices verknüpfen;
6. Zusammenfassung und ausdrückliche Bestätigung.
Die Übernahme ist ein atomarer Command und ein Undo-Schritt. Im Import
entstehen keine Rows. Neu angelegte Räume und ProjectDevices verwenden ihre
vorhandenen Commands und bleiben eigene Undo-Schritte.
## 10. Objektplanung und Revit-Seite
Route: `/projects/:projectId/revit`, erreichbar aus der Projektnavigation.
Der Revit-Button und das CSV-Modal auf der Projektübersicht entfallen.
Die Seite besitzt:
- Statuskarte mit letztem Import, Objektzahl, ohne Verteiler, ohne
Stromkreis, fehlend und Abweichung zu Revit;
- Aktionen `Import`, `Rückgabe-Excel`, `Einstellungen`;
- einklappbare Geräteart-/Familienregel-Übersicht;
- kompakte Objekttabelle mit Suche, Spaltenfiltern, Sortierung,
Mehrfachauswahl und Sammelzuordnung für Verteiler, ProjectDevice und
Planungswerte.
Die kompakte Liste wird einmal geladen, vollständig im Client gefiltert und
sortiert und virtualisiert dargestellt. Es gibt keine Server-Pagination.
Originalbytes, Matrix und Command-Payloads gehören nicht in dieses DTO.
Vor Row-Zuordnung lassen sich Leistung je Einheit, GZF, cosPhi,
Kostengruppe, Anzeigename und Bemerkung einzeln oder gesammelt ändern.
Geänderte Felder werden Overrides. Nach Row-Zuordnung werden diese Werte im
Grid an der Row bearbeitet. Beim ausdrücklichen Lösen erhält das Objekt die
aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
## 11. Editor-Zuordnung und Löschen
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
ProjectDevice-Link. Die Anzeige nennt Summe der wirksamen Mengen und Zahl der
externen Objekte. Gruppen sind aufklappbar; Einzel- und Mehrfachauswahl sowie
Filter bleiben möglich.
Drag-and-drop:
- auf vorhandene Row: genau diese Row;
- auf Circuit: neue Row mit `manualQuantity = 0`;
- auf freien Bereich: neuer Circuit, Schutz und Row in einem Command.
Neue IDs und BMK werden einmal geplant; Redo rechnet sie nicht neu. Neue Rows
übernehmen den bestätigten Gruppenzustand und die Summe der effektiven Mengen.
An Rows zeigt ein Hinweis die Zahl verknüpfter Revit-Objekte; einzelne Objekte
können ausdrücklich gelöst werden.
Wird eine Row, ein Circuit oder eine Gruppe mit verknüpften Revit-Objekten
gelöscht, zeigt die UI vor Bestätigung:
`X Revit-Objekte werden wieder unzugeordnet`. Derselbe Löschcommand löst die
Objekte, entfernt die Struktur und speichert alle Links in seiner Inversen.
Undo stellt Struktur und Links exakt wieder her. Diese Löschungen werden nicht
gesperrt.
Das Löschen eines Verteilers oder Raums mit referenzierten externen Objekten
bleibt gesperrt. Der Nutzer muss die Verknüpfungen beziehungsweise Zuordnungen
zuvor ausdrücklich ändern. Die Regel gilt auch für Unterbaum- und History-
Pfade.
## 12. Rückgabe-Excel
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash
und Objektzahl.
Umfang:
- alle im letzten Import vorhandenen Objekte (Standard);
- nur Objekte mit Abweichung zum zuletzt importierten Revit-Wert;
- Auswahl aus der Objekttabelle.
Die Rückgabe basiert auf einer Kopie des zuletzt importierten Workbooks.
Zeilen oberhalb der ersten Datenzeile, Kopfkommentare, Formate,
Spaltenbreiten, Blattname und Spaltenreihenfolge bleiben erhalten. Danach
folgen nur die gewählten Objektzeilen in ihrer Originalreihenfolge. Alle
Spalten bleiben enthalten; Summenzeilen entfallen.
Stromkreiswerte:
- `combined`: `DistributionBoard.name + Circuit.equipmentIdentifier`, ohne
Trennzeichen;
- `separate`: Verteilername und unverändertes BMK in getrennten Spalten;
- ohne Circuit: Stromkreisfeld beziehungsweise beide Felder leer.
Die Wahl des Verteilerwerts liegt in genau einer Funktion und verwendet
zunächst `DistributionBoard.name`.
Weitere Rückgabewerte:
- Bezeichnung = Row-`displayName`, ohne Row Planungs-Anzeigename;
- Leistung [W] = effektive Menge × Einzelleistung, ohne GZF;
- Spannung = Circuit-Spannung, sonst Projektstandard der Kategorie;
- Strom = `P / (U × cosPhi)` beziehungsweise
`P / (sqrt(3) × U × cosPhi)`, auf 0,01 A;
- nicht berechenbar = leere Zelle plus Hinweis.
Zahlen werden als Excel-Zahlen geschrieben. Fehlende optionale Zielspalten
werden mit Hinweis ausgelassen. Nur fehlende Stromkreisspalten des gewählten
Modus blockieren. Dateiname:
`<Projektname>_Revit-Rueckgabe_<YYYY-MM-DD>.xlsx`.
## 13. Verbindliche R2-Abnahme mit Linear
R2 endet mit einer Rückgabedatei, die aus der lokalen realen Musterdatei
erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese
Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach
erfolgreichem Linear-Rückimport beginnen.
Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear
eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben.
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
Alternative bleibt hinter demselben transportneutralen Adaptervertrag.
## 14. Folgeimport
Jeder Import ist eine vollständige Projektbauteilliste. Matching erfolgt
ausschließlich über exakt und case-sensitiv verglichene Worksharing-ID.
- neu: Wizard-Schritte nur für neue Objekte und unbekannte Familien;
- geänderte Quellwerte: Einzel- oder Sammelentscheidung;
- akzeptierte Mengenänderung: verknüpfte Row-Menge im selben Command;
- fehlend: markieren, Link und Menge zunächst beibehalten;
- Löschen: erst nach gesonderter Bestätigung;
- abweichende Rückgabefelder: Hinweis `in Revit noch nicht aktualisiert`,
keine Übernahmeentscheidung.
`external-import.apply-follow-up` ist atomar und ein Undo-Schritt.
Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
auf Metadaten reduziert.
## 15. Commands, Persistenz und Sicherheit
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
Gegenrichtung, `expectedRevision`, Anmeldung, CSRF-Header und
`project-command-transaction.persistence.ts`. Dazu gehören mindestens:
- `external-model-configuration.update`;
- `external-import.apply-initial`;
- `external-import.apply-follow-up`;
- `external-model-object.update-planning`;
- `external-model-object.update-distribution-board`;
- `external-model-room-mapping.update`;
- `external-model-object.update-project-device-link`;
- `external-object.update-row-assignment`;
- `external-object.assign-to-new-row`;
- `external-object.assign-to-new-circuit`;
- `external-model-object.delete-missing`;
- die erweiterten Row-/Circuit-/Gruppen-Löschcommands mit Objekt-Link-
Transitionen.
Die Konfiguration selbst ist Teil der erwarteten/gewünschten Snapshots, aber
hat keinen eigenen monotonen Zähler. Importbatches halten eine unveränderliche
Konfigurationskopie. Preview/Plan sind zustandslos. Apply überträgt und parst
die Datei erneut; Undo/Redo führt nur gespeicherte Zustandsübergänge aus.
Neue Migrationen sind additiv. Vor Anwendung wird die Datenbank gesichert und
das generierte SQL geprüft. Da nur Entwicklungsdaten existieren, darf die
Migration alte externe CSV-Daten verwerfen. Vor dem Löschen externer Objekte
werden betroffene Row-Mengen auf `manualQuantity` zurückgesetzt. Der Verlust
des alten externen Entwicklungszustands wird dokumentiert.
## 16. Abnahme und Leistung
Die Oberfläche muss mehrere Tausend Objekte flüssig bedienen. R10 misst
Import, Command-/Snapshot-Größe, Speicherbedarf und Client-Interaktion mit
synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den
sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal
geladenen kompakten Liste arbeiten.
Jedes Paket R1–R10 führt fokussierte Tests sowie aus:
```text
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
Jedes Paket erhält einen eigenen knappen imperativen Commit. Die konkrete
Paketfolge und ihre Freigabepunkte stehen im
[R0–R10-Plan](revit-excel-phase-14-plan.md).

View file

@ -0,0 +1,191 @@
# Phase 14 neu – Plan für die Linear-Excel-Anbindung
Verbindliche Fachgrundlage:
[Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md).
Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
## Status
| Paket | Status | Ergebnis / Freigabepunkt |
| --- | --- | --- |
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
## R0 – Audit
Ergebnis: [R0-Audit](revit-excel-r0-audit.md). Die Freigabe vom 29.09.2026
enthält 13 Korrekturen. Bei Abweichungen zwischen dem historischen Audit und
der aktuellen Anforderung gilt das neue Anforderungsdokument.
## R1 – Dokumentation
Lieferumfang:
- neues verbindliches Excel-Anforderungsdokument;
- dieser R0–R10-Plan;
- alte CSV-Anforderungen und alter Plan als kurze Ablösungshinweise;
- LLM-Kontext auf die neue Quelle der Wahrheit kürzen;
- `AGENTS.md`, aktuelle Architektur, History-/External-Model-Architektur,
Roadmap, README, Dokumentationsindex und bekannte Einschränkungen anpassen;
- reale Musterdatei per `.gitignore` vom Commit ausschließen;
- alle internen Links auf die neuen Dokumente umstellen.
Keine Laufzeit-, Schema- oder UI-Änderung. Abnahme: keine aktive Dokumentation
nennt CSV/IfcGUID als künftigen Transport beziehungsweise Schlüssel; der
aktuell noch vorhandene Legacy-Code bleibt als solcher klar benannt.
## R2 – XLSX-Transportadapter
Dateien/Module: neues `src/external-model/xlsx`, synthetische Testfixtures und
ein auf XLSX umgestellter Referenzprüfer mit Dateipfadargument. Eine neue
direkte Bibliothek (`exceljs`, vorbehaltlich der Tests), keine DB-Änderung.
Abnahme:
- Kopfzeilenkommentare, Blattname, Spaltenbreiten, Formate und Zelltypen
überleben einen strukturellen Roundtrip;
- erste Datenzeile/-spalte und Mappings funktionieren ohne separates
`layout.idColumn`;
- Worksharing-ID/IfcGUID werden exakt und case-sensitiv behandelt;
- Zeile ohne ID und ohne `Familie und Typ` wird übersprungen, die Muster-
Summenzeile B490 blockiert nicht;
- Zeile ohne ID mit `Familie und Typ` erzeugt Hinweis und wird übersprungen;
- maximal 10 MiB, nur `.xlsx`, Zeilen-/Spaltengrenzen nach dem Einlesen;
- keine Formelausführung;
- lokale Referenzprüfung bestätigt 487 Objekte und keine ID-Duplikate;
- aus der realen Musterdatei entsteht lokal eine nicht versionierte
Rückgabedatei mit einigen geänderten Werten.
Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein
erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter
OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert.
Implementierungsstand vom 30.09.2026: Der Adapter liegt unter
`src/external-model/xlsx`, der Referenzprüfer unter
`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei
die ignorierte Datei
`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei
enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte
Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen,
Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte
ID-Vergleiche und Grenzen ab.
Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der
Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete
Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer;
ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten
Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete
Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im
fachlich begrenzten R2-Paket aktualisiert.
## R3 – Datenmodell und Backend-Cutover
- Konfiguration ohne eigene Versionszählung;
- `worksharing_id NOT NULL`, Unique je Quelle; `ifc_guid` nullable ohne Unique;
- additive Migration nach vorherigem DB-Backup; alte externe Entwicklungsdaten
verwerfen und Row-Mengen vorher auf `manualQuantity` zurücksetzen;
- Batch-Aufbewahrung: nur letzter Batch vollständig, ältere nur Metadaten;
- Snapshot 10, Restore und Transfer; alte CSV-Zustände/History klar ablehnen;
- Backend-CSV-Parser, Endpunkte und Verträge entfernen;
- Apply parst erneut; Undo/Redo verwenden ausschließlich gespeicherte
Vorher-/Nachher-Zustände.
Risiken: Migration mit verknüpften Row-Mengen, alte History-Spitzen und
vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
## R4 – Revit-Seite und Einstellungen
- Route `/projects/:projectId/revit` und Navigationseintrag;
- Status-/Aktionsgrundgerüst;
- Einstellungsmodal mit Musterdateierkennung, Layout und Feldzuordnung;
- Stromkreismodus `combined | separate`;
- Projektseiten-Button und `revit-csv-modal.tsx` entfernen;
- nur deutsche UI-Texte und bestehende Icon-/Kartenregeln.
## R5 – Erstimport
- Vorschau und Plan mit erneuter Dateiübertragung beim Apply;
- Räume, Verteiler, Gerätearten, exakte Familienregeln und optionale
ProjectDevices;
- fehlender GZF als sichtbarer Startwert `1`, ohne Zusatzbestätigung;
- Warn-/Überspringregel für ID-lose Zeilen;
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
## R6 – Objekttabelle und Sammelaktionen
- kompakte Objektliste einmal laden;
- clientseitig suchen, filtern und sortieren;
- virtualisierte Darstellung ohne Server-Pagination;
- stabile Mehrfachauswahl und Sammelaktionen für Verteiler, ProjectDevice und
Objektplanungswerte;
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
Objekte folgen unverändert ihrer Row.
## R7 – Stromkreislisteneditor
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
- Drop auf Row, Circuit oder freien Bereich über vorhandene, angepasste
Commands;
- Row-Indikator, Objektanzeige und Lösen mit Übernahme aktueller Row-Werte;
- Row-/Circuit-/Gruppen-Löschcommands lösen verknüpfte Objekte atomar und
zeigen `X Revit-Objekte werden wieder unzugeordnet`;
- Undo stellt Objektlinks exakt wieder her;
- nur Raum-/Verteilerlöschung mit externen Referenzen bleibt gesperrt.
## R8 – Rückgabe-Excel
- alle / abweichende / ausgewählte Objekte;
- zuletzt importiertes Workbook als strukturelle Grundlage;
- Stromkreisfelder des gewählten Modus sind Pflicht;
- fehlende optionale Bezeichnung-/Leistung-/Spannung-/Stromspalten nur als
Hinweis überspringen;
- typisierte Zahlen, gemeinsame Export-/Abweichungsprojektion;
- append-only Exportnachweis ohne Projektrevision.
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
## R9 – Folgeimport
- exaktes, case-sensitives Matching nach Worksharing-ID;
- neu, geändert, unverändert, fehlend und Rückgabefeldabweichungen;
- Entscheidungen nur für fachliche Quelländerungen;
- akzeptierte Mengenänderungen und Row-Summen atomar;
- fehlende Objekte nicht automatisch lösen oder löschen;
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
## R10 – Härtung
Ende-zu-Ende-Test:
```text
Import → Objektplanung → Row-Zuordnung → Rückgabe → Folgeimport
→ Snapshot/Restore → Projektduplikat → Undo/Redo nach Neustart
```
Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI,
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand.
## Pflichtprüfungen je Paket R1–R10
```text
fokussierte Tests
npm test
npm run build:api
npm run build:web
npm run typecheck:scripts
npx tsc --noEmit -p tsconfig.next.json
```
Jedes Paket wird mit einer knappen imperativen Commit-Message abgeschlossen.

View file

@ -0,0 +1,528 @@
# R0 – Audit der Linear-Excel-Anbindung
Stand: 29.09.2026. Geprüfter Code: `cf022b2`.
**Status: R0 abgeschlossen und mit Korrekturen für R1 freigegeben.** Dieser Bericht
ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten
Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und
die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0
ändert weder Laufzeitcode noch Datenbank noch Oberfläche.
Die 13 Freigabekorrekturen vom 29.09.2026 sind in den
[verbindlichen Anforderungen](revit-excel-integration-requirements.md) und im
[R0–R10-Plan](revit-excel-phase-14-plan.md) eingearbeitet. Bei Abweichungen
gelten diese beiden R1-Dokumente.
## 1. Geprüfter Bestand und Keep/Change/Remove
Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`,
`docs/project-history-and-external-model-architecture.md`,
`docs/spec/revit-csv-integration-requirements.md` und
`docs/spec/revit-csv-phase-14-audit-and-plan.md`. Der alte Plan widerspricht
sich bereits intern: Sein Status nennt die Erstübernahme noch nicht vorhanden,
während Implementierung und Tests sie nachweisen. Alte Erledigt-Markierungen
werden deshalb nicht übernommen.
| Bereich / Codebeleg | Entscheidung | Begründung und Änderung |
| --- | --- | --- |
| `src/db/repositories/project-command-transaction.persistence.ts` | Keep | Eine Grenze für Fachänderung, Inverse, Revision und History; auch automatische Snapshots bleiben darin. |
| `src/db/repositories/external-model-state.repository.ts`, `src/db/schema/external-*.ts` | Change | Quelle, Batches, Raum-Mappings und Objekte wiederverwenden; neue Identität, Konfiguration und typisierte Matrix. Schlanke Objekt-Reads von großen Batch-Blobs trennen. |
| `src/domain/models/external-initial-import-project-command.model.ts`, zugehöriger Store | Change | Vollständiger erwarteter/Zielzustand und Row-freier Import bleiben; Worksharing-ID, Gerätearten und atomare Regelübernahme ergänzen. |
| `src/external-model/application/external-initial-import-plan.ts`, `external-initial-import-target.ts` | Change | Raumvorschläge, Kataloge, vollständige Entscheidungen und stabile UUIDs behalten. Objektentscheidungen und Gerätearten ergänzen. |
| `src/db/repositories/external-object-assignment.persistence.ts` und drei Zuordnungs-Stores | Change | Mengeninvariante und exakte Inversen behalten; Geräteart, effektiven Verteiler, Anwesenheit und Übernahme der Row-Werte beim Lösen ergänzen. |
| `CircuitDeviceRow.manualQuantity`, bestehende Mengenprüfungen | Keep | `quantity = manualQuantity + Summe(externer effektiver Mengen)`; Mehrfachsteckdosen bleiben unteilbar. |
| `src/external-model/application/external-circuit-list-objects.ts` | Change | Listen-/Projektprüfung behalten; vollständige Planungswerte, Geräteart, effektiver Verteiler, Status und Row-Anzahlen liefern. |
| `src/domain/models/project-state-snapshot.model.ts`, Snapshot-Persistenz und Transfer | Change | Schema 10; historische CSV-Zustände ausdrücklich ablehnen; alle neuen internen Referenzen remappen. |
| `src/external-model/csv/*`, CSV-Preview, CSV-Validierungen und Controller | Remove/replace | XLSX ersetzt CSV vollständig; kein zweiter aktiver Transportpfad. |
| `src/frontend/components/revit-csv-modal.tsx`, Einbindung auf Projektseite | Remove/replace | Eigene Revit-Seite; Einstellungen nur im Modal, Import in eigenem Wizard. |
| `src/frontend/components/Sidebar.tsx`, `src/frontend/utils/api.ts`, `src/frontend/types.ts` | Change | Route `revit`, neue DTOs und Endpunkte, CSV-Imports entfernen. |
| `src/frontend/components/circuit-tree-editor.tsx`, Grid-Drop-/Projektionsmodule | Change | Separater externer Drag-Payload; Row-, Circuit- und Freiplatz-Ziele bewusst unterscheiden. Bestehende Grid-Bedienung behalten. |
| Raum-/ProjectDevice-Formulare und Commands | Keep | Sichtbar vorbelegte, ausdrücklich bestätigte Anlage als eigener Undo-Schritt. |
| `scripts/verify-revit-reference-csv.ts`, Transporttests | Replace | Pfadargument für lokalen XLSX-Prüfer; ausschließlich synthetische Workbooks in Tests. |
### Konkrete Lücken gegenüber dem Auftrag
- `external-model-contracts.ts` importiert heute CSV-Verträge. Die neutralen
Konfigurations-/Matrixverträge gehören künftig in `external-model/domain`;
nur `external-model/xlsx` darf ExcelJS kennen.
- `ifc_guid` ist derzeit NOT NULL und je Quelle eindeutig; Matching,
Snapshots, DTOs und Commands setzen diese Identität voraus.
- `internalDeviceType` ist derzeit ein freier String in Planungswerten.
Es gibt keinen Geräteart-Katalog mit stabilen IDs und Standardwerten.
- Der Erstimport setzt den ProjectDevice-Link, verwendet im Target aber
weiterhin `suggestedPlanningValues`. Die neue Priorität ProjectDevice vor
Geräteart muss ausdrücklich implementiert werden; ein Link allein genügt
nicht. Revit-Leistung wird künftig nicht automatisch zum Planungswert.
- Der Import-Target setzt `distributionBoardId = null`; die Raumvorgabe liegt
separat im Mapping. Projektion und Zuordnungsprüfung vergleichen heute
direkt das Objektfeld. Eine zentrale Auflösung des effektiven Verteilers
fehlt und ist vor der Editor-Nutzung erforderlich.
- Die bisherige Row-Zuordnung darf laut Validator am Objekt ausschließlich
`circuitDeviceRowId` ändern; der Store schreibt auch nur dieses Feld.
Lösen mit aktuellen Row-Planungswerten benötigt eine neue Command-Version.
- Die Kompatibilitätsprüfung vergleicht Raum, Kategorie, Auswahlkenner und
Planungswerte, aber keine stabile Geräteart. Unterschiedliche Gerätearten
dürfen nicht versehentlich in dieselbe Row aggregiert werden.
- Rows speichern `powerPerUnit` in **kW**, externe Planung
`powerPerUnitW` in **W**. Eine zentrale bidirektionale Konvertierung ist
nötig; Exportleistung darf nicht um Faktor 1000 falsch sein.
- Externe FKs auf Row, ProjectDevice, Raum-Mapping und Verteiler verwenden
teilweise `ON DELETE SET NULL`. Bestehende allgemeine Löschpfade sichern
externe Objektübergänge nicht durchgängig in ihren Inversen. Auch
Unterbaum-/History-Löschungen müssen abgesichert werden.
- Ein Editor-Drawer, projektweite Objekttabelle, Exportnachweis und
ausführbarer Folgeimport fehlen. `follow_up` als vorhandener Enumwert ist
kein implementierter Folgeimport.
- Konfigurationsversionen steigen heute nur bei normalen Updates gegenüber
dem aktuellen Wert; Undo setzt alte Versionen zurück. Für eine tatsächlich
monotone Generation muss diese Semantik korrigiert werden.
- XLSX-Parsing ist asynchron. Das heutige erneute synchrone CSV-Parsing
innerhalb der SQLite-Transaktion darf nicht einfach durch `await` ersetzt
werden.
## 2. Prüfung der lokalen Musterdatei
`docs/spec/kbp-Stromkreis.xlsx` wurde ausschließlich lesend auf ZIP-/OOXML-
Ebene untersucht; keine Inhalte wurden hochgeladen oder in Fixtures kopiert.
Die Datei ist unversioniert und darf nicht committed werden.
| Befund | Ergebnis |
| --- | --- |
| Dateigröße / entpackte ZIP-Einträge | 56.765 / 337.740 Bytes |
| Arbeitsblätter / Blattnamenlänge | 1 / 31 Zeichen |
| Zellbereich | A1:O490 |
| Kopfzeile / Leerzeile / Objekte / Abschluss | 1 / 2 / 3–489 / 490 |
| Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate |
| Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte |
| Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname |
| IfcGUID-Duplikate | Keine bei exaktem, case-sensitivem Vergleich |
| Formeln | Keine; synthetische Tests dafür erforderlich |
| Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind |
Das beobachtete Profil aus dem Auftrag wird damit bestätigt. Zusätzliche
Spaltenformatdefinitionen dürfen beim Export nicht auf die gemappten Spalten
gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und
die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie
dürfen nicht ohne Linear-Parameterbindung erfunden werden.
Die frühere Angabe von zwei IfcGUID-Duplikatgruppen war falsch: PowerShells
standardmäßiges Gruppieren verglich ohne Beachtung der Groß-/Kleinschreibung.
Ein erneuter ordinaler, case-sensitiver Vergleich ergab keine Duplikate. Die
Summenzeile hat nur in B490 einen Wert; `Familie und Typ` ist leer.
Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue
Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 endet
deshalb mit einer aus der Musterdatei erzeugten Rückgabedatei und einem
verbindlichen Linear-Test durch den Nutzer vor R3.
## 3. Bibliothek, Adapter und Uploadgrenzen
**Vorschlag: genau eine neue direkte Abhängigkeit `exceljs`.** Seine
öffentliche API unterstützt XLSX, Zellnotizen, Formate, Spaltenbreiten und
typisierte Werte. Das passt zum beobachteten klassischen Kommentarformat.
Version und transitive Abhängigkeiten werden in R2 festgeschrieben und
geprüft. Die veröffentlichte Version ist laut Paketregister 4.4.0; eine
aktuelle Releasepflege ist damit nicht bewiesen. Dies ist ein ausdrückliches
Wartungsrisiko, kein als erfüllt abgehakter Bibliotheksnachweis.
Quellen, am 29.09.2026 geprüft:
[ExcelJS-Projekt](https://github.com/exceljs/exceljs),
[offizielle Paketdokumentation](https://www.npmjs.com/package/exceljs).
Keine Bibliothek wird in R0 installiert. R2 muss vor Integration Kommentar-
Roundtrip, Node-24-Verträglichkeit und Abhängigkeitsrisiken prüfen; bei einem
Blocker wird die Bibliotheksentscheidung neu vorgelegt.
Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet:
- ausschließlich `.xlsx`, kein `.xls` oder `.xlsm`;
- maximal 10 MiB Upload;
- genau ein Arbeitsblatt und keine Formelausführung;
- feste Zeilen-, Spalten- und Zellgrenzen nach dem Einlesen.
Es gibt keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten
(Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete
Zeilen mit Originalzeilennummer und Klasse sowie Zellen als diskriminierte
Union `string | number | boolean | empty`. Formeltext und Cache-Status sind
optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert.
Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern
einen Blocker. Originalbytes bleiben unverändert gespeichert.
Eine Zeile ohne Worksharing-ID ist nur verdächtig, wenn `Familie und Typ`
gefüllt ist. Sie erzeugt einen Hinweis und wird übersprungen, ohne zu
blockieren. Andere ID-lose Zeilen, auch die Summenzeile mit Inhalt in B,
werden still übersprungen. Nicht passende nichtleere IDs blockieren.
Worksharing-ID und IfcGUID bleiben als Quelltext erhalten und werden exakt,
ordinal und case-sensitiv verglichen; ungültige äußere Leerzeichen werden
gemeldet statt still korrigiert. IfcGUID wird optional gespeichert, doppelte
Werte sind nur Hinweise.
Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte
Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein.
Rückgabe lädt eine Kopie der Originalbytes, bewahrt den Bereich oberhalb der
ersten Datenzeile samt Kommentaren, Formatierung und Spaltenbreiten und
ersetzt den Datenbereich durch gewählte Objektzeilen in Originalreihenfolge.
Alle Spalten bleiben enthalten; Summenzeilen entfallen. Unveränderte Zellen
behalten ihre importierten Werte und Typen. Formeln in Objektzeilen werden
als gecachte Werte ausgegeben, damit Zeilenauswahl keine falschen relativen
Bezüge erzeugt; dies wird vor Export als Hinweis ausgewiesen. Keine Formeln
werden berechnet. Roundtrip bedeutet strukturelle/semantische Gleichheit,
nicht identische ZIP-Bytes.
## 4. Konfigurationsschema
Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1:
```ts
{
schemaVersion: 1,
layout: {
headerRow: 1, firstDataRow: 3, firstDataColumn: 1
},
circuitMode: "combined" | "separate",
columns: {
worksharingId: string, familyAndType: string,
ifcGuid: string | null, roomNumber: string | null,
roomName: string | null, selectionMarker: string | null,
socketCount: string | null, sourceRemark: string | null,
circuitCombined: string | null, boardName: string | null,
circuitIdentifier: string | null, designation: string | null,
electricalPowerW: string | null, voltageV: string | null,
currentA: string | null
},
deviceTypes: [{
id: UUID, name: string,
category: "lighting" | "single_phase" | "three_phase",
defaults: {
powerPerUnitW: number | null, simultaneityFactor: number | null,
cosPhi: number | null, costGroup: string | null
}
}],
familyTypeRules: [{
id: UUID, exactFamilyAndType: string, deviceTypeId: UUID,
quantityRule: "one" | "socketCount"
}]
}
```
Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar.
Es gibt nur `columns.worksharingId` und kein separates `layout.idColumn`.
Jede gemappte Kopfzeile muss eindeutig auflösbar sein;
doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig,
aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten
Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem
Auftrag. Unbekannte Spalten bleiben ohne Mapping erhalten.
Geräteart-IDs sind interne UUIDs, keine Namen als Relationsschlüssel. Regeln
sind exakt und je Familien-/Typtext eindeutig; jede importierte Familie muss
eine Geräteart haben. Gerätearten und Regeln werden als ein versioniertes
Konfigurationsaggregat gespeichert. Keine zusätzliche Gerätearttabelle ist
für diesen Umfang nötig; Validatoren erzwingen referenzielle Integrität.
Referenzierte Gerätearten können nicht kommentarlos entfernt werden.
Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename:
Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt
Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung.
Fehlender GZF wird sichtbar mit `1` vorbelegt, ohne zusätzliche Bestätigung.
Geräteart-
Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um.
## 5. Konkretes Persistenzschema
Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten.
`JSON` bezeichnet validierten JSON-Text in SQLite, nicht ExcelJS-Objekte.
| Tabelle | Zielspalten / Constraints |
| --- | --- |
| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat; keine eigene Versionszählung. |
| `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. |
| `external_import_batches` | Letzter Batch vollständig mit IDs, Importart, Zeit, Dateiname, SHA-256, Objektzahl, `applied_project_revision`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; ältere Batches nur als Metadaten Datum, Dateiname, SHA-256 und Objektzahl. |
| `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. |
| `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. |
| Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. |
| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_snapshot/hash`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. |
`import_batch_id` des Exportnachweises ist absichtlich eine historische UUID
ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis
nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots
und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben.
Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1
dokumentiert. Nur der letzte Importbatch gehört mit Bytes und Matrix zu
Snapshot/Transfer; ältere Batches werden dort nur als Metadaten erhalten.
Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten;
zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`,
`linked_project_device_id`, Export `(project_id, exported_at_iso)`.
Cross-Project-Links und Geräteart-Verweise prüft jeder Command in der
Transaktion; bloße Existenz eines UUID-FK genügt nicht.
Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften
Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die
Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden;
bei fehlender Vorgabe ist das Objekt ohne Verteiler. Eine Änderung des
Raum-Standardverteilers betrifft nur unzugeordnete Objekte; verknüpfte Objekte
folgen immer der Row-Verteilung und blockieren diese Änderung nicht.
Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind
Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export
verwenden die Row. Beim Lösen werden aktuelle Row-Werte einschließlich
Einheitenkonvertierung in die Objektplanung übernommen und als explizite
Overrides markiert. Effektive Objektmenge wird nicht durch die Row-Gesamtmenge
ersetzt. Die Row und ihr manueller Anteil bleiben erhalten.
## 6. Commands und Transaktionsgrenze
Alle öffentlichen Fach-Writes verlangen `expectedRevision`, Anmeldung und
`X-Leistungsbilanz-Request: 1`. `expectedRevision` gehört wie bisher zum
Ausführungsumschlag. Jede Transition enthält vollständige erwartete/Ziel-
Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches.
| Command | Payload-Skizze und Gegenrichtung |
| --- | --- |
| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht den vollständigen Fachinhalt; keine eigene Konfigurationsversion. |
| `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. |
| `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. |
| `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. |
| `external-model-object.update-distribution-board` v1 | `{ objects: transitions }`; explizite Objektabweichung, Projekt-/Row-Konsistenz. |
| `external-model-room-mapping.update` v1 | `{ mappings: transitions }`; interner Raum/Standardverteiler; betroffene Objekt-/Row-Konsistenz mitprüfen. |
| `external-model-object.update-project-device-link` v1 | `{ objects: transitions, confirmedPlanningFields }`; keine Vorlagenänderung; neue Startwerte nur für ausdrücklich bestätigte Felder. Gebundene Objekte werden über ihre Row bearbeitet. |
| `external-object.update-row-assignment` v2 | `{ rows: transitions, objects: transitions, confirmedConflictObjectIds }`; Linkwechsel, materialisierte Mengen, beim Lösen aktuelle Row-Planungswerte; vollständige Inverse. |
| `external-object.assign-to-new-row` v2 | Bestehender vollständiger Row-/Objekt-/Reserveplan mit neuen Objektverträgen; history-only Inverse entfernt nur exakt unveränderte Erzeugnisse. |
| `external-object.assign-to-new-circuit` v2 | Bestehender Circuit-/Protection-/Row-/Objektplan; BMK einmal planen und festschreiben, gleiche UUIDs bei Redo. |
| `external-model-object.delete-missing` v1 | `{ objects: transitionsToNull, rows: quantityTransitions }`; ausschließlich ausdrücklich bestätigte weiterhin fehlende Objekte, kein Row-/Circuit-Löschen; Inverse stellt IDs, Links und Mengen wieder her. |
Bestehende `project-device.insert`, Room-/Floor-Commands bleiben eigenständige
Undo-Schritte. Familien-/Geräteartentscheidungen im Wizard sind lokale
Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater
Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu,
ohne lokale Entscheidungen zu verlieren.
Die Konfiguration hat keine eigene Versionszählung. Vorschau und Apply binden
sich an `expectedRevision`; weil auch Undo/Redo neue, strikt steigende
Projektrevisionen erzeugen, wird ein veralteter Plan eindeutig ungültig.
Importbatches speichern eine vollständige Konfigurationskopie. Prüfen:
Änderung → Undo → anderer Zweig darf niemals denselben Plan wieder gültig
machen.
XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen
Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und
Entscheidungen werden erneut validiert. Im Store werden Revision,
Konfigurationsinhalt, erwartete Objekte, Links und vollständiger
vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client
behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht
an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo lesen und
parsen keine Excel-Datei; sie wenden ausschließlich die gespeicherten
Vorher-/Nachher-Zustände an. Keine asynchronen Callbacks in
`better-sqlite3`-Transaktionen.
Row-/Circuit-/Gruppen-Löschcommands erfassen verknüpfte Objekte vollständig.
Nach der Warnung `X Revit-Objekte werden wieder unzugeordnet` lösen sie die
Links im selben Command; ihre Inverse stellt sie exakt wieder her. Sperren
bleiben nur für das Löschen referenzierter Verteiler und Räume und gelten auch
in Unterbaum- und History-only-Pfaden.
## 7. Migration, Snapshot 10, History und Transfer
Neue additive Migration nach `0013_users` (voraussichtlich `0014`, Nummer
beim Beginn von R3 erneut prüfen). Bestehende SQL-/Metadateien unverändert.
1. Vor R3-Anwendung API-Schreibzugriffe stoppen und `npm run db:backup`
ausführen; verifiziertes Backup samt Wiederherstellungsmöglichkeit sichern.
R0 verändert keine DB und benötigt daher kein Migrationsbackup.
2. Vorhandene externe CSV-Entwicklungsdaten dürfen gemäß Auftrag verworfen
werden. Zuerst je verknüpfter Row `quantity = manual_quantity` setzen;
sonst bliebe nach Entfernen der externen Objekte eine Phantomlast.
Rows, Circuits, manuelle Anteile, UUIDs und BMKs bleiben bestehen.
3. Externe Objekte → Mappings → Batches → Quellen → CSV-Konfiguration in
FK-sicherer Reihenfolge entfernen. Tabellen gezielt neu aufbauen, wo
SQLite NOT-NULL-/Unique-Änderungen dies erfordern. Neue Zieltabellen und
Indices erstellen. Keine leeren Worksharing-IDs als Backfill erfinden.
4. Generiertes SQL und Drizzle-Metadaten prüfen; saubere und bestehende
Test-DBs migrieren; FK-/Integritätsprüfung und Mengenabgleich ausführen.
Der Reset ist eine dokumentierte einmalige Entwicklungsmigration,
kein stiller Laufzeit-Import.
5. Alte Revisionsdaten nicht umschreiben, keine CSV-History heimlich
überspringen. An der Ausführungsgrenze alte CSV-Konfigurationscommands,
Initial-/Zuordnungscommands v1 einschließlich ihrer History-Inversen und
Restores mit CSV-Inhalt vor jedem Write ablehnen.
Fehlermeldung für History:
„Dieser Verlaufsschritt verwendet die frühere CSV-/IfcGUID-Anbindung und
kann mit der Excel-Anbindung nicht ausgeführt werden.“
Der History-Status sollte den blockierten obersten Schritt samt Grund zeigen;
keine automatisch geleerten Stacks und keine Änderung bei Ablehnung. Frühere
allgemeine Commands können nach dem Reset zusätzlich an ihren exakten
Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung.
Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle,
den letzten Batch mit Originalbytes/Matrix, ältere Batches nur als Metadaten
sowie Raum-Mappings, Objekte und Links.
Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn
`externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer
ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration
allein zählt als nicht leerer CSV-Zustand und wird nicht still verworfen.
Snapshot-Checksumme am gespeicherten Original vor Upgrade prüfen.
Fehlermeldung:
„Dieser Projektstand enthält Daten der früheren CSV-/IfcGUID-Anbindung.
Eine automatische Übernahme in die Excel-Anbindung ist nicht möglich.
Bitte verwenden Sie einen Projektstand ohne CSV-Daten.“
Der aktuelle portable Transfer ist bereits **Format 2** (Grobbilanzen),
nicht Format 1 wie im alten Audit. Envelope 2 und dessen Format-1-Lesepfad
bleiben; enthaltenes Projektzustandsschema wird 10. Duplizieren remappt
Projekt-, Konfigurations-, Geräteart-, Regel-, Quellen-, Batch-, Mapping-,
Objekt-UUIDs und alle internen Links, auch in Konfigurationssnapshots der
Batches. Worksharing-IDs, optionale IfcGUIDs und Originalbytes bleiben
unverändert. Grobbilanzen und globale Office-/Auth-Daten behalten ihre
bisherige Abgrenzung. Exportnachweise werden nicht als neue Projektexporte
dupliziert.
## 8. Read-Modelle, Oberfläche und Rückgabe
Vorgeschlagene API-Basis `/api/projects/:projectId/revit`:
`configuration` (GET/PUT), `profile/preview` (POST, zustandslos),
`initial-import/plan|apply`, `follow-up-import/plan|apply` (POST),
`status` und `objects` (GET), `return-workbook` (POST mit Export-Audit).
Der bestehende listenbezogene External-Objects-GET bleibt erhalten.
Objektmutationen verwenden den vorhandenen Project-Command-Endpunkt mit
neuen typisierten Verträgen. CSV-Endpunkte werden entfernt, keine Aliasse.
Export verwendet `expectedRevision` für einen konsistent gelesenen Stand;
die Datei-Erzeugung erfolgt außerhalb des kurzen Read-Snapshots. Danach
separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt.
Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung,
nicht einen tatsächlich abgeschlossenen Browserdownload.
Die Revit-Seite lädt einmal eine kompakte Objektliste. Filterung und Sortierung
laufen im Client; die Tabelle wird virtualisiert und verwendet keine Server-
Pagination. Filter-/Sortierzustand, UUID-Auswahl und Sammelaktionen sind
unabhängig vom DOM. „Alle gefilterten“ wird vor einer Mutation zu einer
expliziten ID-Auswahl an der aktuellen Revision aufgelöst. Große Auswahlmengen
werden nicht in getrennte Undo-Schritte aufgeteilt. Kein vollständiges
Batch-Blob im DTO.
Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen,
Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln
entstehen im Wizard und sind auf der Seite einklappbar nachbearbeitbar.
Fehlende Raumangaben bleiben zulässig, Board-Zuordnung ist auch ohne Raum
möglich. ProjectDevice-Zuordnung bleibt optional.
Rechter Editor-Drawer: nur vorhandene, ungebundene konfliktfreie Objekte des
effektiven Verteilers. Gruppierung nach internem Raum, Auswahlkenner,
Geräteart-ID, Kategorie und identischen Planungswerten; unterschiedliche
ProjectDevice-Links bleiben ebenfalls getrennt, da eine Row nur einen Link
haben kann. Effektive Einzelmengen sind kein Gleichheitsfeld: Einfach- und
Mehrfachsteckdosen sollen gemeinsam gruppierbar sein. Anzeige enthält
Mengensumme und Objektanzahl. Fachlich heterogene Auswahl darf nicht
willkürlich auf eine einzelne Row zusammenfallen; Konfliktanzeige verlangt
eine kompatible Auswahl. Row-Indikator öffnet Objekte und explizites Lösen.
Eine Funktion `getRevitBoardName(board)` liefert ausschließlich `board.name`.
Gemeinsam: Name + unverändertes `equipmentIdentifier`, ohne Separator.
Getrennt: Name und BMK in eigene Parameter. Ohne Circuit bleiben beide
Stromkreiswerte leer, auch wenn ein Planungsverteiler bekannt ist.
Exportprojektion und Abweichungsanzeige teilen dieselbe reine Funktion:
Row-Anzeigename/-Leistung bei Link, sonst Objektplanung; Objektleistung in W
= effektive Menge × Einzelleistung (Row-kW vorher × 1000), ohne GZF.
Spannung vom Circuit, sonst Projektstandard passend zur Kategorie.
Strom aus Objektleistung, U, cosPhi und Phasenart; Drehstrom mit Faktor √3,
auf 0,01 A. Fehlende/ungültige Werte ergeben leer plus Hinweis. Nullleistung
bleibt bei gültigem Nenner numerisch 0. Kein still angenommenes cosPhi.
Abweichungsvergleich normalisiert Zahlpräzision, Leere und skalare Typen
gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand.
Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite
Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden
nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene
Parameter behalten importierte Werte. Fehlende optionale Spalten für
Bezeichnung, Leistung, Spannung und Strom werden mit Hinweis übersprungen.
Nur fehlende Stromkreisspalten des gewählten Modus blockieren. Dateiname gemäß
Auftrag mit bereinigtem Projektnamen und Tagesdatum.
Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert
immer den vollständigen letzten Quellstand. Unveränderte Objekte werden
nicht allein zum Fortschreiben von `lastSeenImportBatchId` neu geschrieben;
aktuelle Sichtbarkeit/Quellwerte kommen aus Batch/Matrix. Akzeptierte
Planungsgrundlage und letzte importierte Beobachtung sind getrennt.
Raum-/Familien-/Marker-/Mengenänderungen brauchen Entscheidungen. Bei
unvereinbarer Row-Zuordnung blockiert die Übernahme bis zum ausdrücklichen
Lösen/Umordnen, statt Rows automatisch zu erzeugen. Übernommene Mengen
ändern alle betroffenen Row-Summen im selben Importcommand. Fehlend setzt
nur den Status, erhält vorerst Link und Menge. Rückgabefeld-Abweichungen
sind Hinweise, keine Übernahmeentscheidungen. Ein völlig unveränderter
Folgeimport erzeugt einen nachvollziehbaren Batch, aber keine unnötigen
Objekt-/Row-Updates.
## 9. Pakete, Verifikation und Risiken
R0 liefert nur diesen Bericht und einen eigenen Commit. R1–R10 starten erst
nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste.
| Paket | Arbeit und entscheidende Abnahme |
| --- | --- |
| R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. |
| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss. Aus der lokalen Musterdatei entsteht eine Rückgabedatei mit geänderten Werten. Danach Nutzerabnahme in Linear; R3 bleibt bis zum Erfolg gesperrt. Bei Fehlschlag vor R3 OOXML-Direktänderung als Alternative vorlegen. Keine DB-Änderung. |
| R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. |
| R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. |
| R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. |
| R6 | Kompakte Objekt-Reads, clientseitige Filter/Sortierung, Virtualisierung, Sammel-Commands und Overrides. |
| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele, atomare Objektlösung beim Row-/Circuit-/Gruppen-Löschen und Reload/Undo/Redo. |
| R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. |
| R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. |
| R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. |
Die temporäre Grenze zwischen R3 und R4 wird ausdrücklich behandelt:
Buildfähige, nicht aktive Kompatibilitätstypen für das alte Modal dürfen bis
R4 bestehen, aber kein aktiver CSV-Backendpfad. Veraltete Projektaktion bis
zur neuen Seite deaktivieren/entfernen, keinen funktionslosen Upload anbieten.
Die Dokumentation darf Zwischenstände nicht als fertigen Workflow ausweisen.
Pflicht je R1–R10: fokussierte Tests, `npm test`, `npm run build:api`,
`npm run build:web`, `npm run typecheck:scripts`,
`npx tsc --noEmit -p tsconfig.next.json`, je ein knapper imperativer Commit.
R0: 31 vorhandene Tests aus sechs Dateien erfolgreich ausgeführt:
`external-model-foundation`, `external-initial-import-plan`,
`external-initial-import-project-command.repository`,
`external-object-row-assignment-project-command.repository`,
`external-circuit-list-objects`,
`external-csv-configuration-project-command.repository` (je `.test.ts`).
Diese decken vorhandene Commit-/Rollback-, Undo-/Redo-, Snapshot-/Transfer-
Grundlagen ab, nicht die neue Excel-Funktion. Der erste Start scheiterte an
Sandbox-`spawn EPERM`; derselbe Lauf außerhalb der Sandbox bestand.
Vollständige Builds/Tests sind für diesen reinen R0-Bericht nicht ausgeführt.
Zusätzlich erforderliche neue Prüfungen:
- Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs.
- v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen
und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen.
- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne über `expectedRevision`; Apply mit anderem
Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig
zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück.
- Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte.
- Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische
Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung.
- Row-/Circuit-/Gruppen-Löschen löst externe Links im Command und stellt sie
per Undo wieder her. Raum-/Verteilerlöschen mit Referenzen blockiert.
- Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt
den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise.
- 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße,
Command-/Snapshot-Dauer und UI-Reaktionszeit messen; nur sichtbare Zeilen im
DOM, stabile Auswahl über virtuelle Bereiche.
Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des
Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu
belegen. Große Originaldateien vervielfachen sich in History/Snapshots;
Limits und Messungen müssen reale Speichergrößen berücksichtigen. Die
CSV-Entwicklungsbereinigung ist absichtlich verlustbehaftet, aber auf den
externen Zustand begrenzt und nur nach verifiziertem Backup zulässig.
Fehlende Zielparameter im Muster verlangen einen neuen vollständigen
Linear-Export, keine geratenen GUID-Kommentare.
**Freigabepunkt:** R0 wurde am 29.09.2026 mit den in R1 dokumentierten
Korrekturen freigegeben. R2 besitzt einen neuen verbindlichen Freigabepunkt:
Vor Beginn von R3 muss der Nutzer die in R2 erzeugte Rückgabedatei erfolgreich
in Linear getestet haben.

811
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,10 +24,10 @@
"test": "node scripts/run-tests.mjs", "test": "node scripts/run-tests.mjs",
"test:watch": "node scripts/run-tests.mjs --watch", "test:watch": "node scripts/run-tests.mjs --watch",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "node scripts/run-migrations.js",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts" "revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -36,6 +36,7 @@
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "^16.2.4", "next": "^16.2.4",

View file

@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const dataDir = path.resolve('data'); const dataDir = path.resolve(
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db')); const databasePath = path.join(dataDir, 'leistungsbilanz.db');
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') }); async function run() {
sqlite.close(); const sqlite = new Database(databasePath);
try {
if (requiresLinearXlsxBackup(sqlite)) {
const backupDir = path.join(dataDir, 'backups');
fs.mkdirSync(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(
backupDir,
`leistungsbilanz-before-linear-xlsx-${timestamp}.db`
);
await sqlite.backup(backupPath);
verifyBackup(backupPath);
console.log(`Verified pre-migration backup created: ${backupPath}`);
}
console.log('Migrations applied'); migrate(drizzle(sqlite), {
migrationsFolder: path.resolve('src/db/migrations'),
});
} finally {
sqlite.close();
}
console.log('Migrations applied');
}
function requiresLinearXlsxBackup(sqlite) {
const tables = new Set(
sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((row) => row.name)
);
return tables.has('external_csv_configurations') &&
!tables.has('external_model_configurations');
}
function verifyBackup(backupPath) {
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
try {
const integrity = backup.pragma('integrity_check', { simple: true });
const foreignKeyViolations = backup.pragma('foreign_key_check');
if (integrity !== 'ok' || foreignKeyViolations.length > 0) {
throw new Error(
`Pre-migration backup verification failed: integrity=${integrity}, ` +
`foreign-key violations=${foreignKeyViolations.length}`
);
}
} finally {
backup.close();
}
}
run().catch((error) => {
console.error('Migration failed:', error instanceof Error ? error.message : error);
process.exit(1);
});

View file

@ -1,81 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import {
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
const filePath = resolve(
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
);
const source = readFileSync(filePath);
const configuration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
configuration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const document = parseExternalCsv(source, configuration);
const counts = Object.fromEntries(
["metadata", "header", "passthrough", "object", "suspect-object"].map(
(classification) => [
classification,
document.rows.filter((row) => row.classification === classification).length,
]
)
);
const nonEmptyPassthroughRows = document.rows.filter(
(row) =>
row.classification === "passthrough" &&
row.cells.some((cell) => cell.value !== "")
).length;
const serialized = serializeExternalCsv(document);
const preview = createExternalCsvPreview({
fileName: filePath,
bytes: source,
configuration,
});
assert.equal(counts.object, 897, "Expected 897 object rows.");
assert.equal(preview.objectCount, 897, "Expected 897 preview objects.");
assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
assert.equal(
Buffer.compare(Buffer.from(serialized), source),
0,
"Expected a byte-identical CSV round-trip."
);
const sha256 = createHash("sha256").update(source).digest("hex");
process.stdout.write(
`${JSON.stringify(
{
file: filePath,
bytes: source.length,
sha256,
dialect: document.dialect,
rows: counts,
nonEmptyPassthroughRows,
roundTripByteIdentical: true,
previewObjectCount: preview.objectCount,
},
null,
2
)}\n`
);

View file

@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxScalar,
} from "../src/external-model/xlsx/external-xlsx-contracts.js";
import {
createExternalXlsxReturn,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
async function main() {
const inputPath = resolve(
process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx"
);
const outputPath = resolve(
process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx"
);
assert.notEqual(
inputPath,
outputPath,
"Eingabe- und Ausgabedatei müssen verschieden sein."
);
const source = readFileSync(inputPath);
const configuration = createDefaultExternalXlsxTransportConfiguration();
const document = await parseExternalXlsx({
fileName: inputPath,
bytes: source,
configuration,
});
const objectRows = document.rows.filter((row) => row.classification === "object");
const suspectRows = document.rows.filter(
(row) => row.classification === "suspect-object"
);
const worksharingIdColumn = document.columns.find(
(column) => column.header === configuration.columns.worksharingId
)?.columnNumber;
assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst.");
assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet.");
assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet.");
assert.equal(
document.columns.filter((column) => column.comment !== null).length,
14,
"Es werden 14 Kopfzeilenkommentare erwartet."
);
assert.equal(
document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length,
0,
"Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten."
);
const testValues = [
{
circuitCombined: "UV_R2_TEST-2F1",
electricalPowerW: 111,
voltageV: 230,
currentA: 0.48,
},
{
circuitCombined: "UV_R2_TEST-2F2",
electricalPowerW: 222,
voltageV: 230,
currentA: 0.97,
},
{
circuitCombined: "UV_R2_TEST-3F1",
electricalPowerW: 333,
voltageV: 400,
currentA: 0.48,
},
] satisfies Record<string, ExternalXlsxScalar>[];
const updates = testValues.map((values, index) => {
const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value;
if (typeof value !== "string") {
throw new Error("Worksharing-ID muss Text sein.");
}
return { worksharingId: value, values };
});
const result = await createExternalXlsxReturn({
fileName: inputPath,
bytes: source,
configuration,
updates,
});
assert.equal(result.exportedObjectCount, 487);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, result.bytes);
const outputDocument = await parseExternalXlsx({
fileName: outputPath,
bytes: result.bytes,
configuration,
});
assert.equal(
outputDocument.rows.filter((row) => row.classification === "object").length,
487
);
assert.equal(
outputDocument.rows.some((row) => row.classification !== "object"),
false,
"Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten."
);
assert.equal(outputDocument.sheetName, document.sheetName);
assert.deepEqual(
outputDocument.columns,
document.columns,
"Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben."
);
assert.deepEqual(
outputDocument.rows.slice(updates.length).map((row) => row.cells),
objectRows.slice(updates.length).map((row) => row.cells),
"Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten."
);
const outputWorkbook = new ExcelJS.Workbook();
await outputWorkbook.xlsx.load(result.bytes as never);
const commentedCells: string[] = [];
outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.equal(
commentedCells.length,
14,
"Nur die 14 Kopfzellen dürfen Kommentare enthalten."
);
assert.equal(
commentedCells.every((address) => address.endsWith("1")),
true,
"Datenzellen dürfen keine Kommentare enthalten."
);
process.stdout.write(
`${JSON.stringify(
{
input: inputPath,
output: outputPath,
inputBytes: source.length,
outputBytes: result.bytes.length,
inputSha256: createHash("sha256").update(source).digest("hex"),
outputSha256: createHash("sha256").update(result.bytes).digest("hex"),
sheetName: document.sheetName,
objectCount: objectRows.length,
headerCommentCount: document.columns.filter(
(column) => column.comment !== null
).length,
exactDuplicateIfcGuidCount: 0,
changedObjectCount: updates.length,
},
null,
2
)}\n`
);
}
void main();

View file

@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
FileUp, FileSpreadsheet,
Library, Library,
Link2, Link2,
ListTree, ListTree,
@ -51,7 +51,6 @@ import {
copyProjectDeviceToGlobal, copyProjectDeviceToGlobal,
createDistributionBoard, createDistributionBoard,
createProjectDevice, createProjectDevice,
createRoom,
deleteProjectDevice, deleteProjectDevice,
deleteDistributionBoard, deleteDistributionBoard,
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
@ -83,7 +82,6 @@ import type {
BuildingDto, BuildingDto,
CircuitListDto, CircuitListDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateRoomInput,
CreateProjectDeviceInput, CreateProjectDeviceInput,
DistributionBoardDto, DistributionBoardDto,
FloorDto, FloorDto,
@ -115,7 +113,6 @@ import {
} from "../../../frontend/components/project-settings-modal"; } from "../../../frontend/components/project-settings-modal";
import { FormModal } from "../../../frontend/components/form-modal"; import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
@ -200,7 +197,6 @@ export default function ProjectDetailPage() {
}) })
); );
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState<"board" | null>(null); const [structureModal, setStructureModal] = useState<"board" | null>(null);
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
useState(false); useState(false);
@ -679,22 +675,6 @@ export default function ProjectDetailPage() {
} }
} }
async function handleCreateRevitRoom(input: CreateRoomInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createRoom(projectId, input, project.currentRevision);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createProjectDevice(projectId, input, project.currentRevision);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCopyBoard() { async function handleCopyBoard() {
if ( if (
!projectId || !projectId ||
@ -997,15 +977,13 @@ export default function ProjectDetailPage() {
<Printer aria-hidden="true" size={16} /> <Printer aria-hidden="true" size={16} />
Seitenansicht Seitenansicht
</Link> </Link>
<button <Link
className="btn btn-outline-primary btn-with-icon" className="btn btn-outline-primary btn-with-icon"
disabled={!project} href={`/projects/${projectId}/revit`}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
> >
<FileUp aria-hidden="true" size={16} strokeWidth={2} /> <FileSpreadsheet aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV Revit
</button> </Link>
<button <button
className="btn btn-outline-primary btn-with-icon" className="btn btn-outline-primary btn-with-icon"
disabled={!project} disabled={!project}
@ -1897,16 +1875,6 @@ export default function ProjectDetailPage() {
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes} usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/> />
) : null} ) : null}
{project && isRevitCsvOpen ? (
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onCreateProjectDevice={handleCreateRevitProjectDevice}
onCreateRoom={handleCreateRevitRoom}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>
) : null}
</main> </main>
); );
} }

View file

@ -0,0 +1,34 @@
"use client";
import { ArrowLeft, FileSpreadsheet, Settings } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
import { detectExternalModelWorkbook, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
import type { ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
export default function RevitPage() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<ProjectDto | null>(null);
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { Promise.all([getProject(projectId), getExternalModelConfiguration(projectId)]).then(([loadedProject, result]) => { setProject(loadedProject); setSnapshot(result.configuration); }).catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); }, [projectId]);
async function save(configuration: ExternalModelConfiguration) {
if (!project) return;
setSaving(true); setError(null);
try { const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); setSnapshot(result.configuration); setProject({ ...project, currentRevision: result.history.currentRevision }); setOpen(false); }
catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
finally { setSaving(false); }
}
return <main className="container py-4">
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2><p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden. Der Erstimport folgt in R5.</p></div></section></div></div>
{open ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isSaving={saving} onClose={() => setOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
</main>;
}

View file

@ -0,0 +1,97 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
UPDATE `circuit_device_rows`
SET `quantity` = `manual_quantity`
WHERE EXISTS (
SELECT 1
FROM `external_model_objects`
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
);--> statement-breakpoint
DROP TABLE `external_model_objects`;--> statement-breakpoint
DROP TABLE `external_room_mappings`;--> statement-breakpoint
DROP TABLE `external_import_batches`;--> statement-breakpoint
DROP TABLE `external_model_sources`;--> statement-breakpoint
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
CREATE TABLE `external_model_configurations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`configuration` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_model_sources` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`source_type` text DEFAULT 'revit_xlsx' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
CREATE TABLE `external_import_batches` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`import_kind` text NOT NULL,
`imported_at_iso` text NOT NULL,
`file_name` text NOT NULL,
`sha256` text NOT NULL,
`object_count` integer NOT NULL,
`applied_project_revision` integer,
`configuration_snapshot` text,
`original_bytes` blob,
`document` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
CREATE TABLE `external_room_mappings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`normalized_source_room_key` text NOT NULL,
`source_floor_name` text,
`source_room_number` text NOT NULL,
`source_room_name` text NOT NULL,
`room_id` text,
`default_distribution_board_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint
CREATE TABLE `external_model_objects` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_id` text NOT NULL,
`worksharing_id` text NOT NULL,
`ifc_guid` text,
`device_type_id` text NOT NULL,
`last_seen_import_batch_id` text NOT NULL,
`last_accepted_import_batch_id` text NOT NULL,
`accepted_source_values` text NOT NULL,
`planning_values` text NOT NULL,
`overridden_fields` text NOT NULL,
`external_room_mapping_id` text,
`distribution_board_id` text,
`linked_project_device_id` text,
`circuit_device_row_id` text,
`presence_status` text DEFAULT 'present' NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,13 @@
"when": 1790624623732, "when": 1790624623732,
"tag": "0013_users", "tag": "0013_users",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790748107566,
"tag": "0014_linear_xlsx_external_model",
"breakpoints": true
} }
] ]
} }

View file

@ -1,105 +0,0 @@
import { eq } from "drizzle-orm";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
createExternalCsvConfigurationUpdateProjectCommand,
type ExternalCsvConfigurationSnapshot,
} from "../../domain/models/external-csv-configuration-project-command.model.js";
import type {
ExecuteExternalCsvConfigurationCommandInput,
ExternalCsvConfigurationProjectCommandStore,
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalCsvConfigurationProjectCommandRepository
implements ExternalCsvConfigurationProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const current = tx
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("External CSV configuration belongs to a different project.");
}
if (sameSnapshot(current, target)) {
throw new Error("External CSV configuration did not change.");
}
if (
input.source === "user" &&
target !== null &&
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
) {
throw new Error("External CSV configuration version must advance by one.");
}
if (target === null) {
tx.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.run();
} else if (current === null) {
tx.insert(externalCsvConfigurations).values(target).run();
} else {
const updated = tx
.update(externalCsvConfigurations)
.set({
configurationVersion: target.configurationVersion,
configuration: target.configuration,
})
.where(eq(externalCsvConfigurations.id, current.id))
.run();
if (updated.changes !== 1) {
throw new Error("External CSV configuration changed before update.");
}
}
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalCsvConfigurationSnapshot | null,
expected: ExternalCsvConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("External CSV configuration changed before update.");
}
}
function sameSnapshot(
left: ExternalCsvConfigurationSnapshot | null,
right: ExternalCsvConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -1,252 +0,0 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import {
assertExternalInitialImportProjectCommand,
createExternalInitialImportProjectCommand,
isEmptyExternalModelState,
} from "../../domain/models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../../external-model/domain/external-model-matching.js";
import type { AppDatabase } from "../database-context.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export class ExternalInitialImportProjectCommandRepository
implements ExternalInitialImportProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
assertExternalInitialImportProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const currentResult = new ExternalModelStateRepository(tx)
.getByProject(input.projectId);
if (!currentResult.projectExists) throw new Error("Project not found.");
const expected = input.command.payload.expected;
if (!sameState(currentResult.state, expected)) {
throw new Error("External model state changed before initial import.");
}
const target = input.command.payload.target;
if (!isEmptyExternalModelState(target)) {
validatePopulatedTarget(tx, input, target);
}
replaceExternalState(tx, input.projectId, target);
const persisted = new ExternalModelStateRepository(tx)
.getByProject(input.projectId).state;
if (!sameState(persisted, target)) {
throw new Error("Persisted external initial import differs from its target.");
}
return createExternalInitialImportProjectCommand(target, currentResult.state);
});
}
}
function validatePopulatedTarget(
database: AppDatabase,
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
target: ExternalModelStateSnapshot
) {
const source = target.source!;
const batch = target.importBatches[0];
if (source.projectId !== input.projectId) {
throw new Error("External initial import belongs to a different project.");
}
if (
input.source === "user" &&
batch.appliedProjectRevision !== input.expectedRevision + 1
) {
throw new Error("External initial import revision is invalid.");
}
const configuration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get();
if (
!configuration ||
configuration.configurationVersion !== batch.configurationVersion ||
canonicalJson(configuration.configuration) !==
canonicalJson(batch.configurationSnapshot)
) {
throw new Error("External initial import configuration changed before apply.");
}
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
if (sha256 !== batch.sha256) {
throw new Error("External initial import checksum is invalid.");
}
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
throw new Error("External initial import document differs from the original bytes.");
}
const preview = createExternalCsvPreview({
fileName: batch.fileName,
bytes,
configuration: batch.configurationSnapshot,
});
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
if (targetByIfcGuid.size !== preview.objects.length) {
throw new Error("External initial import must contain every CSV object exactly once.");
}
const mappingsById = new Map(
target.roomMappings.map((mapping) => [mapping.id, mapping])
);
const expectedRoomKeys = new Set<string>();
for (const candidate of preview.objects) {
const object = targetByIfcGuid.get(candidate.ifcGuid);
if (!object) {
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
}
const projected = projectInitialExternalObject(
candidate,
batch.configurationSnapshot
);
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
throw new Error("External initial import source values differ from the CSV.");
}
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
if (
!object.overriddenFields.includes(field) &&
canonicalJson(object.planningValues[field]) !==
canonicalJson(projected.planningValues[field])
) {
throw new Error(`External initial planning field ${field} changed without an override.`);
}
}
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
if (roomKey === null) {
if (object.externalRoomMappingId !== null) {
throw new Error("External object without a source room must not use a room mapping.");
}
} else {
expectedRoomKeys.add(roomKey);
const mapping = object.externalRoomMappingId === null
? null
: mappingsById.get(object.externalRoomMappingId);
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
throw new Error("External object source room mapping is incomplete.");
}
}
}
if (
target.roomMappings.length !== expectedRoomKeys.size ||
target.roomMappings.some(
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
)
) {
throw new Error("External initial import room mappings do not match CSV rooms.");
}
validateInternalLinks(database, input.projectId, target);
}
function validateInternalLinks(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
const roomIds = new Set(
database.select({ id: rooms.id }).from(rooms)
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
);
const boardIds = new Set(
database.select({ id: distributionBoards.id }).from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
);
const deviceIds = new Set(
database.select({ id: projectDevices.id }).from(projectDevices)
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
);
for (const mapping of target.roomMappings) {
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
throw new Error("External room mapping references another project.");
}
if (
mapping.defaultDistributionBoardId !== null &&
!boardIds.has(mapping.defaultDistributionBoardId)
) {
throw new Error("External room mapping distribution board belongs to another project.");
}
}
for (const object of target.objects) {
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
throw new Error("External object distribution board belongs to another project.");
}
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
throw new Error("External object project device belongs to another project.");
}
}
}
function replaceExternalState(
database: AppDatabase,
projectId: string,
target: ExternalModelStateSnapshot
) {
database.delete(externalModelSources)
.where(eq(externalModelSources.projectId, projectId)).run();
if (target.source === null) return;
database.insert(externalModelSources).values(target.source).run();
database.insert(externalImportBatches).values(
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
}))
).run();
if (target.roomMappings.length) {
database.insert(externalRoomMappings).values(target.roomMappings).run();
}
if (target.objects.length) {
database.insert(externalModelObjects).values(target.objects).run();
}
}
function decodeCanonicalBase64(value: string) {
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("External initial import bytes are not canonical base64.");
}
return bytes;
}
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
}
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
const byId = <T extends { id: string }>(entries: readonly T[]) =>
[...entries].sort((left, right) => left.id.localeCompare(right.id));
return {
source: state.source,
importBatches: byId(state.importBatches),
roomMappings: byId(state.roomMappings),
objects: byId(state.objects),
};
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -0,0 +1,79 @@
import { eq } from "drizzle-orm";
import {
assertExternalModelConfigurationUpdateProjectCommand,
createExternalModelConfigurationUpdateProjectCommand,
type ExternalModelConfigurationSnapshot,
} from "../../domain/models/external-model-configuration-project-command.model.js";
import type {
ExecuteExternalModelConfigurationCommandInput,
ExternalModelConfigurationProjectCommandStore,
} from "../../domain/ports/external-model-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalModelConfigurationProjectCommandRepository
implements ExternalModelConfigurationProjectCommandStore {
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalModelConfigurationCommandInput) {
assertExternalModelConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx.select({ id: projects.id }).from(projects)
.where(eq(projects.id, input.projectId)).get();
if (!project) throw new Error("Project not found.");
const current = tx.select().from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
}
if (sameSnapshot(current, target)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
}
if (target === null) {
tx.delete(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
} else if (current === null) {
tx.insert(externalModelConfigurations).values(target).run();
} else {
const updated = tx.update(externalModelConfigurations)
.set({ configuration: target.configuration })
.where(eq(externalModelConfigurations.id, current.id)).run();
if (updated.changes !== 1) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
return createExternalModelConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalModelConfigurationSnapshot | null,
expected: ExternalModelConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
}
}
function sameSnapshot(
left: ExternalModelConfigurationSnapshot | null,
right: ExternalModelConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map(
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}

View file

@ -1,12 +1,11 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js"; import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
export class ExternalCsvConfigurationRepository export class ExternalModelConfigurationRepository
implements ExternalCsvConfigurationReader implements ExternalModelConfigurationReader {
{
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
getByProject(projectId: string) { getByProject(projectId: string) {
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
.from(projects) .from(projects)
.where(eq(projects.id, projectId)) .where(eq(projects.id, projectId))
.get(); .get();
if (!project) { if (!project) return { projectExists: false, configuration: null };
return { projectExists: false, configuration: null };
}
const configuration = this.database const configuration = this.database
.select() .select()
.from(externalCsvConfigurations) .from(externalModelConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId)) .where(eq(externalModelConfigurations.projectId, projectId))
.get() ?? null; .get() ?? null;
return { projectExists: true, configuration }; return { projectExists: true, configuration };
} }

View file

@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
.select() .select()
.from(externalModelObjects) .from(externalModelObjects)
.where(eq(externalModelObjects.sourceId, source.id)) .where(eq(externalModelObjects.sourceId, source.id))
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id)) .orderBy(
asc(externalModelObjects.worksharingId),
asc(externalModelObjects.id)
)
.all(); .all();
return { return {
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
source, source,
importBatches: batches.map(({ originalBytes, ...batch }) => ({ importBatches: batches.map(({ originalBytes, ...batch }) => ({
...batch, ...batch,
originalContentBase64: Buffer.from(originalBytes).toString("base64"), originalContentBase64:
originalBytes === null
? null
: Buffer.from(originalBytes).toString("base64"),
})), })),
roomMappings, roomMappings,
objects, objects,

View file

@ -51,7 +51,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId])); const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
const markers = new Set( const markers = new Set(
input.allLinkedObjects.map((object) => input.allLinkedObjects.map((object) =>
object.acceptedSourceValues.selectionMarker.trim() scalarText(object.acceptedSourceValues.selectionMarker).trim()
) )
); );
if (markers.size > 1) { if (markers.size > 1) {
@ -118,13 +118,17 @@ function matchesRowPlanningValues(
row.category === planning.category && row.category === planning.category &&
row.connectionKind === planning.connectionKind && row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) && (planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor && row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
row.cosPhi === planning.cosPhi && row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup && row.costGroup === planning.costGroup &&
row.remark === planning.remark row.remark === planning.remark
); );
} }
function scalarText(value: string | number | boolean | null) {
return value === null ? "" : String(value);
}
function canonicalJson(value: unknown): string { function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value !== null && typeof value === "object") { if (value !== null && typeof value === "object") {

View file

@ -31,7 +31,7 @@ import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js"; import { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js"; import { externalModelSources } from "../schema/external-model-sources.js";
@ -241,7 +241,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
buildings: byId(state.buildings), buildings: byId(state.buildings),
floors: byId(state.floors), floors: byId(state.floors),
rooms: byId(state.rooms), rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration, externalModelConfiguration: state.externalModelConfiguration,
externalModel: { externalModel: {
source: state.externalModel.source, source: state.externalModel.source,
importBatches: byId(state.externalModel.importBatches), importBatches: byId(state.externalModel.importBatches),
@ -283,8 +283,8 @@ function replaceProjectState(
.where(eq(externalModelSources.projectId, state.project.id)) .where(eq(externalModelSources.projectId, state.project.id))
.run(); .run();
database database
.delete(externalCsvConfigurations) .delete(externalModelConfigurations)
.where(eq(externalCsvConfigurations.projectId, state.project.id)) .where(eq(externalModelConfigurations.projectId, state.project.id))
.run(); .run();
database database
.delete(distributionBoards) .delete(distributionBoards)
@ -330,10 +330,10 @@ function replaceProjectState(
insertMany(database, buildings, state.buildings); insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors); insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) { if (state.externalModelConfiguration !== null) {
database database
.insert(externalCsvConfigurations) .insert(externalModelConfigurations)
.values(state.externalCsvConfiguration) .values(state.externalModelConfiguration)
.run(); .run();
} }
insertMany(database, rooms, state.rooms); insertMany(database, rooms, state.rooms);
@ -387,7 +387,10 @@ function replaceProjectState(
state.externalModel.importBatches.map( state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({ ({ originalContentBase64, ...batch }) => ({
...batch, ...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"), originalBytes:
originalContentBase64 === null
? null
: Buffer.from(originalContentBase64, "base64"),
}) })
) )
); );

View file

@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js"; import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export interface PersistedProjectStateSnapshot { export interface PersistedProjectStateSnapshot {
@ -66,10 +66,10 @@ export function readProjectStateSnapshot(
.where(eq(distributionBoards.projectId, projectId)) .where(eq(distributionBoards.projectId, projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id)) .orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all(); .all();
const externalCsvConfiguration = database const externalModelConfiguration = database
.select() .select()
.from(externalCsvConfigurations) .from(externalModelConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId)) .where(eq(externalModelConfigurations.projectId, projectId))
.get() ?? null; .get() ?? null;
const externalModel = new ExternalModelStateRepository(database) const externalModel = new ExternalModelStateRepository(database)
.getByProject(projectId).state; .getByProject(projectId).state;
@ -266,7 +266,7 @@ export function readProjectStateSnapshot(
projectEditor: project.projectEditor, projectEditor: project.projectEditor,
}, },
distributionBoards: boardRows, distributionBoards: boardRows,
externalCsvConfiguration, externalModelConfiguration,
externalModel, externalModel,
circuitLists: listRows, circuitLists: listRows,
circuitSections: sectionRows, circuitSections: sectionRows,

View file

@ -23,7 +23,7 @@ import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalModelConfigurations } from "../schema/external-model-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js"; import { externalModelSources } from "../schema/external-model-sources.js";
@ -163,10 +163,10 @@ function insertProjectState(
.insert(projects) .insert(projects)
.values({ ...state.project, currentRevision: 0 }) .values({ ...state.project, currentRevision: 0 })
.run(); .run();
if (state.externalCsvConfiguration !== null) { if (state.externalModelConfiguration !== null) {
database database
.insert(externalCsvConfigurations) .insert(externalModelConfigurations)
.values(state.externalCsvConfiguration) .values(state.externalModelConfiguration)
.run(); .run();
} }
insertMany(database, buildings, state.buildings); insertMany(database, buildings, state.buildings);
@ -240,7 +240,10 @@ function insertProjectState(
state.externalModel.importBatches.map( state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({ ({ originalContentBase64, ...batch }) => ({
...batch, ...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"), originalBytes:
originalContentBase64 === null
? null
: Buffer.from(originalContentBase64, "base64"),
}) })
) )
); );

View file

@ -1,22 +0,0 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import { projects } from "./projects.js";
export const externalCsvConfigurations = sqliteTable(
"external_csv_configurations",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
configurationVersion: integer("configuration_version").notNull(),
configuration: text("configuration", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.notNull(),
},
(table) => [
uniqueIndex("external_csv_configurations_project_id_unique").on(
table.projectId
),
]
);

View file

@ -1,9 +1,7 @@
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
ExternalCsvConfiguration,
ExternalCsvDocument,
} from "../../external-model/csv/external-csv-contracts.js";
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js"; import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
import type { ExternalXlsxDocument } from "../../external-model/xlsx/external-xlsx-contracts.js";
import { externalModelSources } from "./external-model-sources.js"; import { externalModelSources } from "./external-model-sources.js";
import { projects } from "./projects.js"; import { projects } from "./projects.js";
@ -21,15 +19,13 @@ export const externalImportBatches = sqliteTable(
importedAtIso: text("imported_at_iso").notNull(), importedAtIso: text("imported_at_iso").notNull(),
fileName: text("file_name").notNull(), fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(), sha256: text("sha256").notNull(),
appliedProjectRevision: integer("applied_project_revision").notNull(), objectCount: integer("object_count").notNull(),
configurationVersion: integer("configuration_version").notNull(), appliedProjectRevision: integer("applied_project_revision"),
configurationSnapshot: text("configuration_snapshot", { mode: "json" }) configurationSnapshot: text("configuration_snapshot", { mode: "json" })
.$type<ExternalCsvConfiguration>() .$type<ExternalModelConfiguration>(),
.notNull(), originalBytes: blob("original_bytes", { mode: "buffer" }),
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
document: text("document", { mode: "json" }) document: text("document", { mode: "json" })
.$type<ExternalCsvDocument>() .$type<ExternalXlsxDocument>(),
.notNull(),
}, },
(table) => [ (table) => [
index("external_import_batches_project_revision_idx").on( index("external_import_batches_project_revision_idx").on(

View file

@ -0,0 +1,21 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { projects } from "./projects.js";
export const externalModelConfigurations = sqliteTable(
"external_model_configurations",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
configuration: text("configuration", { mode: "json" })
.$type<ExternalModelConfiguration>()
.notNull(),
},
(table) => [
uniqueIndex("external_model_configurations_project_id_unique").on(
table.projectId
),
]
);

View file

@ -23,7 +23,9 @@ export const externalModelObjects = sqliteTable(
sourceId: text("source_id") sourceId: text("source_id")
.notNull() .notNull()
.references(() => externalModelSources.id, { onDelete: "cascade" }), .references(() => externalModelSources.id, { onDelete: "cascade" }),
ifcGuid: text("ifc_guid").notNull(), worksharingId: text("worksharing_id").notNull(),
ifcGuid: text("ifc_guid"),
deviceTypeId: text("device_type_id").notNull(),
lastSeenImportBatchId: text("last_seen_import_batch_id") lastSeenImportBatchId: text("last_seen_import_batch_id")
.notNull() .notNull()
.references(() => externalImportBatches.id, { onDelete: "restrict" }), .references(() => externalImportBatches.id, { onDelete: "restrict" }),
@ -62,17 +64,27 @@ export const externalModelObjects = sqliteTable(
.default("present"), .default("present"),
}, },
(table) => [ (table) => [
uniqueIndex("external_model_objects_source_ifc_guid_unique").on( uniqueIndex("external_model_objects_source_worksharing_id_unique").on(
table.sourceId, table.sourceId,
table.ifcGuid table.worksharingId
), ),
index("external_model_objects_project_presence_idx").on( index("external_model_objects_project_presence_idx").on(
table.projectId, table.projectId,
table.presenceStatus table.presenceStatus
), ),
index("external_model_objects_project_device_type_idx").on(
table.projectId,
table.deviceTypeId
),
index("external_model_objects_external_room_mapping_idx").on(
table.externalRoomMappingId
),
index("external_model_objects_distribution_board_idx").on( index("external_model_objects_distribution_board_idx").on(
table.distributionBoardId table.distributionBoardId
), ),
index("external_model_objects_linked_project_device_idx").on(
table.linkedProjectDeviceId
),
index("external_model_objects_circuit_device_row_idx").on( index("external_model_objects_circuit_device_row_idx").on(
table.circuitDeviceRowId table.circuitDeviceRowId
), ),

View file

@ -12,12 +12,9 @@ export const externalModelSources = sqliteTable(
name: text("name").notNull(), name: text("name").notNull(),
sourceType: text("source_type", { enum: externalModelSourceTypes }) sourceType: text("source_type", { enum: externalModelSourceTypes })
.notNull() .notNull()
.default("revit_csv"), .default("revit_xlsx"),
}, },
(table) => [ (table) => [
uniqueIndex("external_model_sources_project_type_unique").on( uniqueIndex("external_model_sources_project_unique").on(table.projectId),
table.projectId,
table.sourceType
),
] ]
); );

View file

@ -1,94 +0,0 @@
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalCsvConfigurationUpdateCommandType =
"external-csv-configuration.update" as const;
export const externalCsvConfigurationCommandSchemaVersion = 1 as const;
export interface ExternalCsvConfigurationSnapshot {
id: string;
projectId: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
}
export interface ExternalCsvConfigurationUpdatePayload {
expected: ExternalCsvConfigurationSnapshot | null;
target: ExternalCsvConfigurationSnapshot | null;
}
export interface ExternalCsvConfigurationUpdateProjectCommand
extends SerializedProjectCommand<ExternalCsvConfigurationUpdatePayload> {
schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion;
type: typeof externalCsvConfigurationUpdateCommandType;
}
export function createExternalCsvConfigurationUpdateProjectCommand(
expected: ExternalCsvConfigurationSnapshot | null,
target: ExternalCsvConfigurationSnapshot | null
): ExternalCsvConfigurationUpdateProjectCommand {
const command: ExternalCsvConfigurationUpdateProjectCommand = {
schemaVersion: externalCsvConfigurationCommandSchemaVersion,
type: externalCsvConfigurationUpdateCommandType,
payload: { expected, target },
};
assertExternalCsvConfigurationUpdateProjectCommand(command);
return command;
}
export function assertExternalCsvConfigurationUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalCsvConfigurationUpdateProjectCommand {
if (
command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion ||
command.type !== externalCsvConfigurationUpdateCommandType ||
!isRecord(command.payload)
) {
throw new Error("Unsupported external CSV configuration update command.");
}
assertSnapshot(command.payload.expected, "expected");
assertSnapshot(command.payload.target, "target");
if (command.payload.expected === null && command.payload.target === null) {
throw new Error("External CSV configuration command must change state.");
}
if (
command.payload.expected !== null &&
command.payload.target !== null &&
(command.payload.expected.id !== command.payload.target.id ||
command.payload.expected.projectId !== command.payload.target.projectId)
) {
throw new Error("External CSV configuration identity must remain stable.");
}
}
function assertSnapshot(
value: unknown,
field: string
): asserts value is ExternalCsvConfigurationSnapshot | null {
if (value === null) {
return;
}
if (!isRecord(value)) {
throw new Error(`${field} external CSV configuration must be an object or null.`);
}
assertId(value.id, `${field}.id`);
assertId(value.projectId, `${field}.projectId`);
if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) {
throw new Error(`${field}.configurationVersion must be a positive integer.`);
}
assertExternalCsvConfiguration(value.configuration);
if (Object.keys(value).length !== 4) {
throw new Error(`${field} external CSV configuration contains unknown fields.`);
}
}
function assertId(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,134 +0,0 @@
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalInitialImportCommandType =
"external-import.apply-initial" as const;
export const externalInitialImportCommandSchemaVersion = 1 as const;
export interface ExternalInitialImportPayload {
expected: ExternalModelStateSnapshot;
target: ExternalModelStateSnapshot;
}
export interface ExternalInitialImportProjectCommand
extends SerializedProjectCommand<ExternalInitialImportPayload> {
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
type: typeof externalInitialImportCommandType;
}
export function createExternalInitialImportProjectCommand(
expected: ExternalModelStateSnapshot,
target: ExternalModelStateSnapshot
): ExternalInitialImportProjectCommand {
const command: ExternalInitialImportProjectCommand = {
schemaVersion: externalInitialImportCommandSchemaVersion,
type: externalInitialImportCommandType,
payload: { expected, target },
};
assertExternalInitialImportProjectCommand(command);
return command;
}
export function assertExternalInitialImportProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalInitialImportProjectCommand {
if (
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
command.type !== externalInitialImportCommandType ||
!isRecord(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported external initial import command.");
}
const expected = parseExternalModelStateSnapshot(command.payload.expected);
const target = parseExternalModelStateSnapshot(command.payload.target);
const expectedIsEmpty = isEmptyExternalModelState(expected);
const targetIsEmpty = isEmptyExternalModelState(target);
if (expectedIsEmpty === targetIsEmpty) {
throw new Error("External initial import must transition between empty and populated state.");
}
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
}
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
return { source: null, importBatches: [], roomMappings: [], objects: [] };
}
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
return (
state.source === null &&
state.importBatches.length === 0 &&
state.roomMappings.length === 0 &&
state.objects.length === 0
);
}
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
throw new Error("External initial import requires one source, one batch and at least one object.");
}
const source = state.source;
const batch = state.importBatches[0];
if (
batch.importKind !== "initial" ||
batch.projectId !== source.projectId ||
batch.sourceId !== source.id
) {
throw new Error("External initial import batch identity is invalid.");
}
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
const roomKeys = new Set<string>();
for (const mapping of state.roomMappings) {
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
throw new Error("External initial room mapping identity is invalid.");
}
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
throw new Error("External initial import contains duplicate room keys.");
}
roomKeys.add(mapping.normalizedSourceRoomKey);
}
uniqueIds(state.objects, "object");
const ifcGuids = new Set<string>();
for (const object of state.objects) {
if (
object.projectId !== source.projectId ||
object.sourceId !== source.id ||
object.lastSeenImportBatchId !== batch.id ||
object.lastAcceptedImportBatchId !== batch.id
) {
throw new Error("External initial object identity is invalid.");
}
if (ifcGuids.has(object.ifcGuid)) {
throw new Error("External initial import contains duplicate IFCGUIDs.");
}
ifcGuids.add(object.ifcGuid);
if (
object.externalRoomMappingId !== null &&
!mappingIds.has(object.externalRoomMappingId)
) {
throw new Error("External initial object room mapping is invalid.");
}
if (object.circuitDeviceRowId !== null) {
throw new Error("External initial import must not assign circuit device rows.");
}
if (object.presenceStatus !== "present") {
throw new Error("External initial import objects must be present.");
}
}
}
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`External initial import contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -0,0 +1,88 @@
import {
assertExternalModelConfiguration,
type ExternalModelConfiguration,
} from "../../external-model/configuration/external-model-configuration.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalModelConfigurationUpdateCommandType =
"external-model-configuration.update" as const;
export const externalModelConfigurationCommandSchemaVersion = 1 as const;
export interface ExternalModelConfigurationSnapshot {
id: string;
projectId: string;
configuration: ExternalModelConfiguration;
}
export interface ExternalModelConfigurationUpdatePayload {
expected: ExternalModelConfigurationSnapshot | null;
target: ExternalModelConfigurationSnapshot | null;
}
export interface ExternalModelConfigurationUpdateProjectCommand
extends SerializedProjectCommand<ExternalModelConfigurationUpdatePayload> {
schemaVersion: typeof externalModelConfigurationCommandSchemaVersion;
type: typeof externalModelConfigurationUpdateCommandType;
}
export function createExternalModelConfigurationUpdateProjectCommand(
expected: ExternalModelConfigurationSnapshot | null,
target: ExternalModelConfigurationSnapshot | null
): ExternalModelConfigurationUpdateProjectCommand {
const command: ExternalModelConfigurationUpdateProjectCommand = {
schemaVersion: externalModelConfigurationCommandSchemaVersion,
type: externalModelConfigurationUpdateCommandType,
payload: { expected, target },
};
assertExternalModelConfigurationUpdateProjectCommand(command);
return command;
}
export function assertExternalModelConfigurationUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalModelConfigurationUpdateProjectCommand {
if (
command.schemaVersion !== externalModelConfigurationCommandSchemaVersion ||
command.type !== externalModelConfigurationUpdateCommandType ||
!isRecord(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Nicht unterstützter Revit-XLSX-Konfigurationscommand.");
}
assertSnapshot(command.payload.expected, "expected");
assertSnapshot(command.payload.target, "target");
if (command.payload.expected === null && command.payload.target === null) {
throw new Error("Der Konfigurationscommand muss den Zustand ändern.");
}
if (
command.payload.expected !== null &&
command.payload.target !== null &&
(command.payload.expected.id !== command.payload.target.id ||
command.payload.expected.projectId !== command.payload.target.projectId)
) {
throw new Error("Die Identität der Revit-XLSX-Konfiguration muss stabil bleiben.");
}
}
function assertSnapshot(
value: unknown,
field: string
): asserts value is ExternalModelConfigurationSnapshot | null {
if (value === null) return;
if (!isRecord(value) || Object.keys(value).length !== 3) {
throw new Error(`${field} muss eine vollständige Konfiguration oder null sein.`);
}
assertId(value.id, `${field}.id`);
assertId(value.projectId, `${field}.projectId`);
assertExternalModelConfiguration(value.configuration);
}
function assertId(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} muss eine nichtleere ID sein.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -28,6 +28,10 @@ import {
} from "../services/project-voltage.service.js"; } from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import {
validateExternalModelConfiguration,
type ExternalModelConfiguration,
} from "../../external-model/configuration/external-model-configuration.js";
import { import {
externalImportKinds, externalImportKinds,
externalModelSourceTypes, externalModelSourceTypes,
@ -35,7 +39,8 @@ import {
type ExternalModelStateSnapshot, type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js"; } from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 9 as const; export const projectStateSnapshotSchemaVersion = 10 as const;
const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const;
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const; const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
const buildingProjectStateSnapshotSchemaVersion = 7 as const; const buildingProjectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const; const coverProjectStateSnapshotSchemaVersion = 6 as const;
@ -423,14 +428,14 @@ const externalCsvDocumentSchema = z.object({
}); });
}); });
const externalModelSourceSchema = z.object({ const legacyExternalModelSourceSchema = z.object({
id: idSchema, id: idSchema,
projectId: idSchema, projectId: idSchema,
name: z.string().trim().min(1), name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes), sourceType: z.literal("revit_csv"),
}).strict(); }).strict();
const externalImportBatchSchema = z.object({ const legacyExternalImportBatchSchema = z.object({
id: idSchema, id: idSchema,
projectId: idSchema, projectId: idSchema,
sourceId: idSchema, sourceId: idSchema,
@ -461,7 +466,7 @@ const externalRoomMappingSchema = z.object({
defaultDistributionBoardId: idSchema.nullable(), defaultDistributionBoardId: idSchema.nullable(),
}).strict(); }).strict();
const externalPlanningFieldNames = [ const legacyExternalPlanningFieldNames = [
"displayName", "displayName",
"internalDeviceType", "internalDeviceType",
"category", "category",
@ -474,7 +479,7 @@ const externalPlanningFieldNames = [
"remark", "remark",
] as const; ] as const;
const externalModelObjectSchema = z.object({ const legacyExternalModelObjectSchema = z.object({
id: idSchema, id: idSchema,
projectId: idSchema, projectId: idSchema,
sourceId: idSchema, sourceId: idSchema,
@ -504,6 +509,170 @@ const externalModelObjectSchema = z.object({
costGroup: nullableStringSchema, costGroup: nullableStringSchema,
remark: nullableStringSchema, remark: nullableStringSchema,
}).strict(), }).strict(),
overriddenFields: z.array(z.enum(legacyExternalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length
),
externalRoomMappingId: idSchema.nullable(),
distributionBoardId: idSchema.nullable(),
linkedProjectDeviceId: idSchema.nullable(),
circuitDeviceRowId: idSchema.nullable(),
presenceStatus: z.enum(externalObjectPresenceStatuses),
}).strict();
const legacyExternalModelStateSchema = z.object({
source: legacyExternalModelSourceSchema.nullable(),
importBatches: z.array(legacyExternalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(legacyExternalModelObjectSchema),
}).strict();
const externalModelConfigurationSnapshotSchema = z.object({
id: idSchema,
projectId: idSchema,
configuration: z.custom<ExternalModelConfiguration>(
(value) => validateExternalModelConfiguration(value).success
),
}).strict();
const externalXlsxScalarSchema = z.union([
z.string(),
finiteNumberSchema,
z.boolean(),
z.null(),
]);
const externalXlsxDocumentSchema = z.object({
sheetName: z.string().min(1).max(31),
headerRowNumber: z.number().int().positive(),
firstDataRowNumber: z.number().int().positive(),
firstDataColumnNumber: z.number().int().positive(),
columns: z.array(z.object({
columnNumber: z.number().int().positive(),
header: z.string(),
comment: z.string().nullable(),
width: finiteNumberSchema.positive().nullable(),
}).strict()),
rows: z.array(z.object({
rowNumber: z.number().int().positive(),
classification: z.enum(["object", "suspect-object", "passthrough"]),
cells: z.array(z.object({
columnNumber: z.number().int().positive(),
value: externalXlsxScalarSchema,
valueType: z.enum(["empty", "string", "number", "boolean"]),
numberFormat: z.string().nullable(),
formula: z.string().nullable(),
usedCachedFormulaValue: z.boolean(),
}).strict()),
}).strict()),
issues: z.array(z.object({
code: z.enum([
"cached-formula-value-used",
"formula-without-cached-value",
"suspect-object-skipped",
"duplicate-ifc-guid",
"optional-return-column-missing",
]),
message: z.string().min(1),
rowNumber: z.number().int().positive().optional(),
columnNumber: z.number().int().positive().optional(),
field: z.enum([
"worksharingId", "ifcGuid", "roomNumber", "roomName",
"familyAndType", "selectionMarker", "socketCount", "sourceRemark",
"circuitCombined", "boardName", "circuitIdentifier", "designation",
"electricalPowerW", "voltageV", "currentA",
]).optional(),
}).strict()),
}).strict();
const externalModelSourceSchema = z.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
}).strict();
const externalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
importKind: z.enum(externalImportKinds),
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
objectCount: z.number().int().nonnegative(),
appliedProjectRevision: z.number().int().nonnegative().nullable(),
configurationSnapshot: z.custom<ExternalModelConfiguration>(
(value) => value === null || validateExternalModelConfiguration(value).success
).nullable(),
originalContentBase64: z.string().min(1).regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
).nullable(),
document: externalXlsxDocumentSchema.nullable(),
}).strict().superRefine((batch, context) => {
const complete = [
batch.appliedProjectRevision,
batch.configurationSnapshot,
batch.originalContentBase64,
batch.document,
].map((value) => value !== null);
if (!complete.every((value) => value === complete[0])) {
context.addIssue({
code: "custom",
message: "Importbatch muss vollständig oder auf Metadaten reduziert sein.",
});
}
});
const externalPlanningFieldNames = [
"displayName",
"category",
"connectionKind",
"effectiveQuantity",
"powerPerUnitW",
"simultaneityFactor",
"cosPhi",
"costGroup",
"remark",
] as const;
const externalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
worksharingId: z.string().regex(
/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/
),
ifcGuid: externalIfcGuidSchema.nullable(),
deviceTypeId: idSchema,
lastSeenImportBatchId: idSchema,
lastAcceptedImportBatchId: idSchema,
acceptedSourceValues: z.object({
rowNumber: z.number().int().positive(),
roomNumber: externalXlsxScalarSchema,
roomName: externalXlsxScalarSchema,
familyAndType: z.string().min(1),
selectionMarker: externalXlsxScalarSchema,
socketCount: externalXlsxScalarSchema,
sourceRemark: externalXlsxScalarSchema,
circuitCombined: externalXlsxScalarSchema,
boardName: externalXlsxScalarSchema,
circuitIdentifier: externalXlsxScalarSchema,
designation: externalXlsxScalarSchema,
electricalPowerW: externalXlsxScalarSchema,
voltageV: externalXlsxScalarSchema,
currentA: externalXlsxScalarSchema,
}).strict(),
planningValues: z.object({
displayName: nullableStringSchema,
category: z.enum(circuitGroupCategories).nullable(),
connectionKind: nullableStringSchema,
effectiveQuantity: finiteNumberSchema.positive(),
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
simultaneityFactor: finiteNumberSchema.min(0).max(1).nullable(),
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).strict(),
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine( overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length (values) => new Set(values).size === values.length
), ),
@ -519,7 +688,21 @@ const externalModelStateSchema = z.object({
importBatches: z.array(externalImportBatchSchema), importBatches: z.array(externalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema), roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(externalModelObjectSchema), objects: z.array(externalModelObjectSchema),
}).strict(); }).strict().superRefine((state, context) => {
const fullBatchIndexes = state.importBatches.flatMap((batch, index) =>
batch.originalContentBase64 === null ? [] : [index]
);
if (
state.importBatches.length > 0 &&
(fullBatchIndexes.length !== 1 ||
fullBatchIndexes[0] !== state.importBatches.length - 1)
) {
context.addIssue({
code: "custom",
message: "Nur der letzte Importbatch darf vollständig gespeichert sein.",
});
}
});
export function parseExternalModelStateSnapshot( export function parseExternalModelStateSnapshot(
value: unknown value: unknown
@ -565,19 +748,30 @@ export const projectStateSnapshotSchema = z
schemaVersion: z.literal(projectStateSnapshotSchemaVersion), schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: externalModelStateSchema,
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
.strict(); .strict();
const legacyCsvProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const roomAreaProjectStateSnapshotSchema = z const roomAreaProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: legacyExternalModelStateSchema,
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
.strict(); .strict();
@ -588,7 +782,7 @@ const buildingProjectStateSnapshotSchema = z
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: legacyExternalModelStateSchema,
...buildingProjectStateSnapshotContents, ...buildingProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -599,7 +793,7 @@ const coverProjectStateSnapshotSchema = z
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -610,7 +804,7 @@ const boardInfoProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -621,7 +815,7 @@ const previousProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -684,6 +878,39 @@ export function parseProjectStateSnapshot(
} }
function upgradePreviousProjectStateSnapshot(value: unknown): unknown { function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value);
if (
legacyCsv !== null &&
typeof legacyCsv === "object" &&
!Array.isArray(legacyCsv) &&
(legacyCsv as { schemaVersion?: unknown }).schemaVersion ===
legacyCsvProjectStateSnapshotSchemaVersion
) {
const previous = legacyCsvProjectStateSnapshotSchema.parse(legacyCsv);
if (
previous.externalCsvConfiguration !== null ||
!isEmptyExternalModelState(previous.externalModel)
) {
throw new Error(
"Dieser alte Projektstand enthält Revit-CSV-Daten und kann nicht auf die Revit-XLSX-Struktur aktualisiert werden."
);
}
const {
externalCsvConfiguration: _externalCsvConfiguration,
externalModel: _legacyExternalModel,
...remaining
} = previous;
return {
...remaining,
schemaVersion: projectStateSnapshotSchemaVersion,
externalModelConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
return legacyCsv;
}
function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown {
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value); const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
if ( if (
roomArea !== null && roomArea !== null &&
@ -695,7 +922,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea); const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardFeeding( distributionBoards: withEmptyDistributionBoardFeeding(
previous.distributionBoards previous.distributionBoards
), ),
@ -830,6 +1057,20 @@ function emptyExternalModelState() {
}; };
} }
function isEmptyExternalModelState(state: {
source: unknown;
importBatches: unknown[];
roomMappings: unknown[];
objects: unknown[];
}) {
return (
state.source === null &&
state.importBatches.length === 0 &&
state.roomMappings.length === 0 &&
state.objects.length === 0
);
}
function normalizeSnapshotPhaseTypes( function normalizeSnapshotPhaseTypes(
snapshot: ProjectStateSnapshot snapshot: ProjectStateSnapshot
): ProjectStateSnapshot { ): ProjectStateSnapshot {
@ -891,10 +1132,10 @@ function assertProjectStateSnapshotRelations(
) { ) {
const projectId = snapshot.project.id; const projectId = snapshot.project.id;
if ( if (
snapshot.externalCsvConfiguration !== null && snapshot.externalModelConfiguration !== null &&
snapshot.externalCsvConfiguration.projectId !== projectId snapshot.externalModelConfiguration.projectId !== projectId
) { ) {
throw new Error("Snapshot external CSV configuration belongs to a different project."); throw new Error("Snapshot Revit-XLSX configuration belongs to a different project.");
} }
const boardIds = uniqueIds( const boardIds = uniqueIds(
snapshot.distributionBoards, snapshot.distributionBoards,
@ -1235,6 +1476,12 @@ function assertExternalModelRelations(
} }
return; return;
} }
if (snapshot.externalModelConfiguration === null) {
throw new Error("Snapshot external model requires a Revit-XLSX configuration.");
}
if (importBatches.length === 0) {
throw new Error("Snapshot external model source requires an import batch.");
}
assertProjectOwnership(source.projectId, references.projectId, "external model source"); assertProjectOwnership(source.projectId, references.projectId, "external model source");
const batchIds = uniqueIds(importBatches, "external import batch"); const batchIds = uniqueIds(importBatches, "external import batch");
const mappingIds = uniqueIds(roomMappings, "external room mapping"); const mappingIds = uniqueIds(roomMappings, "external room mapping");
@ -1270,17 +1517,23 @@ function assertExternalModelRelations(
} }
} }
const ifcGuids = new Set<string>(); const worksharingIds = new Set<string>();
const deviceTypeIds = new Set(
snapshot.externalModelConfiguration.configuration.deviceTypes.map(
(deviceType) => deviceType.id
)
);
for (const object of objects) { for (const object of objects) {
assertProjectOwnership(object.projectId, references.projectId, "external model object"); assertProjectOwnership(object.projectId, references.projectId, "external model object");
if (object.sourceId !== source.id) { if (object.sourceId !== source.id) {
throw new Error("Snapshot external model object belongs to a different source."); throw new Error("Snapshot external model object belongs to a different source.");
} }
assertUniqueKey( assertUniqueKey(
ifcGuids, worksharingIds,
object.ifcGuid, object.worksharingId,
"Snapshot contains duplicate external IFCGUIDs." "Snapshot contains duplicate external Worksharing-IDs."
); );
assertReference(deviceTypeIds, object.deviceTypeId, "external object device type");
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch"); assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch"); assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
if (object.externalRoomMappingId !== null) { if (object.externalRoomMappingId !== null) {

View file

@ -146,16 +146,35 @@ export function remapProjectState(
createId createId
); );
const externalObjectIds = idMap(source.externalModel.objects, createId); const externalObjectIds = idMap(source.externalModel.objects, createId);
const configurations = [
source.externalModelConfiguration?.configuration ?? null,
...source.externalModel.importBatches.map(
(batch) => batch.configurationSnapshot
),
].filter((configuration) => configuration !== null);
const deviceTypeIds = uniqueIdMap(
configurations.flatMap((configuration) => configuration.deviceTypes),
createId
);
const familyRuleIds = uniqueIdMap(
configurations.flatMap((configuration) => configuration.familyTypeRules),
createId
);
return parseProjectStateSnapshot({ return parseProjectStateSnapshot({
...source, ...source,
project: { ...source.project, id: targetProjectId, name }, project: { ...source.project, id: targetProjectId, name },
externalCsvConfiguration: externalModelConfiguration:
source.externalCsvConfiguration === null source.externalModelConfiguration === null
? null ? null
: { : {
...source.externalCsvConfiguration, ...source.externalModelConfiguration,
id: createId(), id: createId(),
projectId: targetProjectId, projectId: targetProjectId,
configuration: remapExternalModelConfiguration(
source.externalModelConfiguration.configuration,
deviceTypeIds,
familyRuleIds
),
}, },
externalModel: { externalModel: {
source: source.externalModel.source === null source: source.externalModel.source === null
@ -170,6 +189,14 @@ export function remapProjectState(
id: requiredId(externalBatchIds, batch.id), id: requiredId(externalBatchIds, batch.id),
projectId: targetProjectId, projectId: targetProjectId,
sourceId: externalSourceId!, sourceId: externalSourceId!,
configurationSnapshot:
batch.configurationSnapshot === null
? null
: remapExternalModelConfiguration(
batch.configurationSnapshot,
deviceTypeIds,
familyRuleIds
),
})), })),
roomMappings: source.externalModel.roomMappings.map((mapping) => ({ roomMappings: source.externalModel.roomMappings.map((mapping) => ({
...mapping, ...mapping,
@ -190,6 +217,7 @@ export function remapProjectState(
id: requiredId(externalObjectIds, object.id), id: requiredId(externalObjectIds, object.id),
projectId: targetProjectId, projectId: targetProjectId,
sourceId: externalSourceId!, sourceId: externalSourceId!,
deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId),
lastSeenImportBatchId: requiredId( lastSeenImportBatchId: requiredId(
externalBatchIds, externalBatchIds,
object.lastSeenImportBatchId object.lastSeenImportBatchId
@ -322,6 +350,36 @@ export function remapProjectState(
}); });
} }
function remapExternalModelConfiguration(
configuration: NonNullable<
ProjectStateSnapshot["externalModelConfiguration"]
>["configuration"],
deviceTypeIds: ReadonlyMap<string, string>,
familyRuleIds: ReadonlyMap<string, string>
) {
return {
...configuration,
deviceTypes: configuration.deviceTypes.map((deviceType) => ({
...deviceType,
id: requiredId(deviceTypeIds, deviceType.id),
})),
familyTypeRules: configuration.familyTypeRules.map((rule) => ({
...rule,
id: requiredId(familyRuleIds, rule.id),
deviceTypeId: requiredId(deviceTypeIds, rule.deviceTypeId),
})),
};
}
function uniqueIdMap(
entries: ReadonlyArray<{ id: string }>,
createId: () => string
) {
return new Map(
[...new Set(entries.map((entry) => entry.id))].map((id) => [id, createId()])
);
}
function idMap( function idMap(
entries: ReadonlyArray<{ id: string }>, entries: ReadonlyArray<{ id: string }>,
createId: () => string createId: () => string

View file

@ -1,19 +0,0 @@
import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalCsvConfigurationCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalCsvConfigurationUpdateProjectCommand;
}
export interface ExternalCsvConfigurationProjectCommandStore {
execute(input: ExecuteExternalCsvConfigurationCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalCsvConfigurationUpdateProjectCommand;
};
}

View file

@ -1,10 +0,0 @@
import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js";
export interface ExternalCsvConfigurationReadResult {
projectExists: boolean;
configuration: ExternalCsvConfigurationSnapshot | null;
}
export interface ExternalCsvConfigurationReader {
getByProject(projectId: string): ExternalCsvConfigurationReadResult;
}

View file

@ -1,19 +0,0 @@
import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalInitialImportCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalInitialImportProjectCommand;
}
export interface ExternalInitialImportProjectCommandStore {
execute(input: ExecuteExternalInitialImportCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalInitialImportProjectCommand;
};
}

View file

@ -0,0 +1,19 @@
import type { ExternalModelConfigurationUpdateProjectCommand } from "../models/external-model-configuration-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalModelConfigurationCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalModelConfigurationUpdateProjectCommand;
}
export interface ExternalModelConfigurationProjectCommandStore {
execute(input: ExecuteExternalModelConfigurationCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalModelConfigurationUpdateProjectCommand;
};
}

View file

@ -0,0 +1,10 @@
import type { ExternalModelConfigurationSnapshot } from "../models/external-model-configuration-project-command.model.js";
export interface ExternalModelConfigurationReadResult {
projectExists: boolean;
configuration: ExternalModelConfigurationSnapshot | null;
}
export interface ExternalModelConfigurationReader {
getByProject(projectId: string): ExternalModelConfigurationReadResult;
}

View file

@ -173,15 +173,10 @@ import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js"; import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js"; import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
import { import {
assertExternalCsvConfigurationUpdateProjectCommand, assertExternalModelConfigurationUpdateProjectCommand,
externalCsvConfigurationUpdateCommandType, externalModelConfigurationUpdateCommandType,
} from "../models/external-csv-configuration-project-command.model.js"; } from "../models/external-model-configuration-project-command.model.js";
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js"; import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js";
import {
assertExternalInitialImportProjectCommand,
externalInitialImportCommandType,
} from "../models/external-initial-import-project-command.model.js";
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
import { import {
assertExternalObjectRowAssignmentProjectCommand, assertExternalObjectRowAssignmentProjectCommand,
externalObjectRowAssignmentCommandType, externalObjectRowAssignmentCommandType,
@ -243,8 +238,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore, private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, private readonly externalModelConfigurationStore?: ExternalModelConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
@ -308,6 +302,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
); );
} }
assertNoLegacyExternalCsvHistoryCommand(step.command);
return this.dispatch({ return this.dispatch({
...input, ...input,
source: direction, source: direction,
@ -625,22 +621,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case externalCsvConfigurationUpdateCommandType: { case externalModelConfigurationUpdateCommandType: {
assertExternalCsvConfigurationUpdateProjectCommand(input.command); assertExternalModelConfigurationUpdateProjectCommand(input.command);
if (!this.externalCsvConfigurationStore) { if (!this.externalModelConfigurationStore) {
throw new Error("External CSV configuration store is not available."); throw new Error("External model configuration store is not available.");
} }
return this.externalCsvConfigurationStore.execute({ return this.externalModelConfigurationStore.execute({
...input,
command: input.command,
}).revision;
}
case externalInitialImportCommandType: {
assertExternalInitialImportProjectCommand(input.command);
if (!this.externalInitialImportStore) {
throw new Error("External initial import store is not available.");
}
return this.externalInitialImportStore.execute({
...input, ...input,
command: input.command, command: input.command,
}).revision; }).revision;
@ -720,3 +706,16 @@ function assertExpectedRevision(expectedRevision: number) {
); );
} }
} }
function assertNoLegacyExternalCsvHistoryCommand(
command: SerializedProjectCommand<unknown>
) {
if (
command.type === "external-csv-configuration.update" ||
command.type === "external-import.apply-initial"
) {
throw new Error(
"Dieser Verlaufseintrag stammt aus der alten Revit-CSV-Anbindung und kann nach der Umstellung auf Linear-XLSX nicht mehr ausgef\u00fchrt werden."
);
}
}

View file

@ -1,88 +0,0 @@
import type {
ExternalModelStateSnapshot,
} from "../domain/external-model-contracts.js";
export interface ExternalCircuitListObjectDto {
id: string;
ifcGuid: string;
selectionMarker: string;
familyAndType: string;
sourceCircuitIdentifier: string;
displayName: string | null;
category: string | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
sourceRoomNumber: string;
sourceRoomName: string;
roomId: string | null;
linkedProjectDeviceId: string | null;
circuitDeviceRowId: string | null;
presenceStatus: "present" | "missing";
}
export interface ExternalCircuitListObjectsDto {
sourceName: string | null;
objectCount: number;
unassignedObjectCount: number;
objects: ExternalCircuitListObjectDto[];
}
export function createExternalCircuitListObjects(input: {
state: ExternalModelStateSnapshot;
distributionBoardId: string;
}): ExternalCircuitListObjectsDto {
const mappingById = new Map(
input.state.roomMappings.map((mapping) => [mapping.id, mapping])
);
const objects = input.state.objects
.filter((object) => object.distributionBoardId === input.distributionBoardId)
.map((object) => {
const mapping = object.externalRoomMappingId === null
? null
: mappingById.get(object.externalRoomMappingId) ?? null;
return {
id: object.id,
ifcGuid: object.ifcGuid,
selectionMarker: object.acceptedSourceValues.selectionMarker,
familyAndType: object.acceptedSourceValues.familyAndType,
sourceCircuitIdentifier: object.acceptedSourceValues.circuitIdentifier,
displayName: object.planningValues.displayName,
category: object.planningValues.category,
connectionKind: object.planningValues.connectionKind,
effectiveQuantity: object.planningValues.effectiveQuantity,
powerPerUnitW: object.planningValues.powerPerUnitW,
sourceRoomNumber:
mapping?.sourceRoomNumber ?? object.acceptedSourceValues.roomNumber,
sourceRoomName:
mapping?.sourceRoomName ?? object.acceptedSourceValues.roomName,
roomId: mapping?.roomId ?? null,
linkedProjectDeviceId: object.linkedProjectDeviceId,
circuitDeviceRowId: object.circuitDeviceRowId,
presenceStatus: object.presenceStatus,
} satisfies ExternalCircuitListObjectDto;
})
.sort(compareExternalObjects);
return {
sourceName: input.state.source?.name ?? null,
objectCount: objects.length,
unassignedObjectCount: objects.filter(
(object) => object.circuitDeviceRowId === null
).length,
objects,
};
}
function compareExternalObjects(
left: ExternalCircuitListObjectDto,
right: ExternalCircuitListObjectDto
) {
const assignmentOrder = Number(left.circuitDeviceRowId !== null) -
Number(right.circuitDeviceRowId !== null);
if (assignmentOrder !== 0) return assignmentOrder;
return left.sourceRoomNumber.localeCompare(right.sourceRoomNumber, "de", {
numeric: true,
}) || left.selectionMarker.localeCompare(right.selectionMarker, "de", {
numeric: true,
}) || left.ifcGuid.localeCompare(right.ifcGuid);
}

View file

@ -1,91 +0,0 @@
import { createHash } from "node:crypto";
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import { parseExternalCsv } from "../csv/external-csv-transport.js";
export interface ExternalCsvPreviewObject {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export interface ExternalCsvPreview {
fileName: string;
byteLength: number;
sha256: string;
dialect: ReturnType<typeof parseExternalCsv>["dialect"];
headerRowNumber: number;
headerColumns: string[];
rowCount: number;
objectCount: number;
passthroughCount: number;
nonEmptyPassthroughCount: number;
suspectObjectCount: number;
objects: ExternalCsvPreviewObject[];
suspectRowNumbers: number[];
}
export function createExternalCsvPreview(input: {
fileName: string;
bytes: Uint8Array;
configuration: ExternalCsvConfiguration;
}): ExternalCsvPreview {
const document = parseExternalCsv(input.bytes, input.configuration);
const header = document.rows[document.headerRowIndex];
const headerIndex = new Map(
header.cells.map((cell, index) => [cell.value, index])
);
const valueAt = (row: (typeof document.rows)[number], column: string) =>
row.cells[headerIndex.get(column)!]?.value ?? "";
const objects = document.rows
.filter((row) => row.classification === "object")
.map((row): ExternalCsvPreviewObject => ({
rowNumber: row.index + 1,
ifcGuid: valueAt(row, input.configuration.columns.ifcGuid),
roomNumber: valueAt(row, input.configuration.columns.roomNumber),
roomName: valueAt(row, input.configuration.columns.roomName),
familyAndType: valueAt(row, input.configuration.columns.familyAndType),
selectionMarker: valueAt(row, input.configuration.columns.selectionMarker),
circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier),
power: valueAt(row, input.configuration.columns.power),
quantity:
input.configuration.columns.quantity === null
? null
: valueAt(row, input.configuration.columns.quantity),
additionalSourceValues: Object.fromEntries(
input.configuration.additionalSourceMappings.map((mapping) => [
mapping.targetField,
valueAt(row, mapping.sourceColumn),
])
),
}));
const passthroughRows = document.rows.filter(
(row) => row.classification === "passthrough"
);
const suspectRows = document.rows.filter(
(row) => row.classification === "suspect-object"
);
return {
fileName: input.fileName,
byteLength: input.bytes.byteLength,
sha256: createHash("sha256").update(input.bytes).digest("hex"),
dialect: document.dialect,
headerRowNumber: document.headerRowIndex + 1,
headerColumns: header.cells.map((cell) => cell.value),
rowCount: document.rows.length,
objectCount: objects.length,
passthroughCount: passthroughRows.length,
nonEmptyPassthroughCount: passthroughRows.filter((row) =>
row.cells.some((cell) => cell.value !== "")
).length,
suspectObjectCount: suspectRows.length,
objects,
suspectRowNumbers: suspectRows.map((row) => row.index + 1),
};
}

View file

@ -1,164 +0,0 @@
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import {
createExternalRoomKey,
normalizeExternalRoomPart,
projectInitialExternalObject,
} from "../domain/external-model-matching.js";
import { createExternalCsvPreview } from "./external-csv-preview.js";
interface ExistingRoom {
id: string;
floorId: string | null;
roomNumber: string;
roomName: string;
}
interface ExistingFloor {
id: string;
name: string;
sortOrder: number;
}
interface ExistingDistributionBoard {
id: string;
name: string;
floorId: string | null;
}
interface ExistingProjectDevice {
id: string;
name: string;
displayName: string;
category: string | null;
connectionKind: string | null;
powerPerUnit: number;
}
export function createExternalInitialImportPlan(input: {
fileName: string;
bytes: Uint8Array;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
rooms: ExistingRoom[];
floors: ExistingFloor[];
distributionBoards: ExistingDistributionBoard[];
projectDevices: ExistingProjectDevice[];
}) {
const preview = createExternalCsvPreview(input);
const roomCandidatesByNumber = new Map<string, ExistingRoom[]>();
for (const room of input.rooms) {
const key = normalizeExternalRoomPart(room.roomNumber);
if (!key) continue;
const candidates = roomCandidatesByNumber.get(key) ?? [];
candidates.push(room);
roomCandidatesByNumber.set(key, candidates);
}
const objects = preview.objects.map((object) => {
const projected = projectInitialExternalObject(object, input.configuration);
return {
ifcGuid: projected.ifcGuid,
sourceRoomKey: createExternalRoomKey(object.roomNumber, object.roomName),
sourceValues: projected.sourceValues,
suggestedPlanningValues: projected.planningValues,
issues: projected.issues,
};
});
const sourceRooms = new Map<string, {
sourceRoomKey: string;
roomNumber: string;
roomName: string;
objectCount: number;
}>();
for (const object of objects) {
if (object.sourceRoomKey === null) continue;
const existing = sourceRooms.get(object.sourceRoomKey);
if (existing) {
existing.objectCount += 1;
} else {
sourceRooms.set(object.sourceRoomKey, {
sourceRoomKey: object.sourceRoomKey,
roomNumber: object.sourceValues.roomNumber,
roomName: object.sourceValues.roomName,
objectCount: 1,
});
}
}
const roomPlans = [...sourceRooms.values()]
.map((room) => {
const candidates = roomCandidatesByNumber.get(
normalizeExternalRoomPart(room.roomNumber)
) ?? [];
return {
...room,
suggestedRoomId: candidates.length === 1 ? candidates[0].id : null,
matchStatus:
candidates.length === 1
? "exact-number"
: candidates.length > 1
? "ambiguous-number"
: "new",
candidateRoomIds: candidates.map((candidate) => candidate.id),
} as const;
})
.sort((left, right) =>
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
left.roomName.localeCompare(right.roomName, "de")
);
const familyGroups = new Map<string, {
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
}>();
for (const object of objects) {
const key = object.sourceValues.familyAndType;
const group = familyGroups.get(key);
if (group) {
group.objectCount += 1;
} else {
familyGroups.set(key, {
familyAndType: key,
objectCount: 1,
classified: !object.issues.includes("unknown-family-and-type"),
category: object.suggestedPlanningValues.category,
connectionKind: object.suggestedPlanningValues.connectionKind,
internalDeviceType: object.suggestedPlanningValues.internalDeviceType,
});
}
}
return {
configurationVersion: input.configurationVersion,
fileName: preview.fileName,
sha256: preview.sha256,
byteLength: preview.byteLength,
rowCount: preview.rowCount,
objectCount: preview.objectCount,
passthroughCount: preview.passthroughCount,
suspectObjectCount: preview.suspectObjectCount,
suspectRowNumbers: preview.suspectRowNumbers,
sourceRooms: roomPlans,
familyGroups: [...familyGroups.values()].sort((left, right) =>
left.familyAndType.localeCompare(right.familyAndType, "de")
),
issueCounts: {
unknownFamilyAndType: objects.filter((object) =>
object.issues.includes("unknown-family-and-type")
).length,
invalidPower: objects.filter((object) => object.issues.includes("invalid-power")).length,
invalidQuantity: objects.filter((object) => object.issues.includes("invalid-quantity")).length,
missingRoom: objects.filter((object) => object.issues.includes("missing-room")).length,
},
objects,
existing: {
floors: input.floors,
rooms: input.rooms,
distributionBoards: input.distributionBoards,
projectDevices: input.projectDevices,
},
};
}

View file

@ -1,130 +0,0 @@
import type { ExternalCsvConfiguration, ExternalCsvDocument } from "../csv/external-csv-contracts.js";
import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { createExternalInitialImportPlan } from "./external-initial-import-plan.js";
type ExternalInitialImportPlan = ReturnType<typeof createExternalInitialImportPlan>;
export interface ExternalRoomDecision {
sourceRoomKey: string;
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export interface ExternalFamilyProjectDeviceDecision {
familyAndType: string;
projectDeviceId: string | null;
}
export function buildExternalInitialImportTarget(input: {
projectId: string;
expectedRevision: number;
sourceName: string;
importedAtIso: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
plan: ExternalInitialImportPlan;
roomDecisions: ExternalRoomDecision[];
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
createId: () => string;
}): ExternalModelStateSnapshot {
if (input.plan.familyGroups.some((group) => !group.classified)) {
throw new Error("Alle Familie-und-Typ-Werte müssen vor dem Erstimport klassifiziert sein.");
}
const roomDecisionByKey = exactDecisionMap(
input.plan.sourceRooms.map((room) => room.sourceRoomKey),
input.roomDecisions,
(decision) => decision.sourceRoomKey,
"Quellraum"
);
const familyDecisionByName = exactDecisionMap(
input.plan.familyGroups.map((group) => group.familyAndType),
input.familyProjectDeviceDecisions,
(decision) => decision.familyAndType,
"Familie und Typ"
);
const sourceId = input.createId();
const batchId = input.createId();
const roomMappingIdByKey = new Map(
input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()])
);
return {
source: {
id: sourceId,
projectId: input.projectId,
name: input.sourceName.trim(),
sourceType: "revit_csv",
},
importBatches: [{
id: batchId,
projectId: input.projectId,
sourceId,
importKind: "initial",
importedAtIso: input.importedAtIso,
fileName: input.plan.fileName,
sha256: input.plan.sha256,
appliedProjectRevision: input.expectedRevision + 1,
configurationVersion: input.configurationVersion,
configurationSnapshot: input.configuration,
originalContentBase64: input.originalContentBase64,
document: input.document,
}],
roomMappings: input.plan.sourceRooms.map((room) => {
const decision = roomDecisionByKey.get(room.sourceRoomKey)!;
return {
id: roomMappingIdByKey.get(room.sourceRoomKey)!,
projectId: input.projectId,
sourceId,
normalizedSourceRoomKey: room.sourceRoomKey,
sourceFloorName: null,
sourceRoomNumber: room.roomNumber,
sourceRoomName: room.roomName,
roomId: decision.roomId,
defaultDistributionBoardId: decision.defaultDistributionBoardId,
};
}),
objects: input.plan.objects.map((object) => ({
id: input.createId(),
projectId: input.projectId,
sourceId,
ifcGuid: object.ifcGuid,
lastSeenImportBatchId: batchId,
lastAcceptedImportBatchId: batchId,
acceptedSourceValues: object.sourceValues,
planningValues: object.suggestedPlanningValues,
overriddenFields: [],
externalRoomMappingId:
object.sourceRoomKey === null
? null
: roomMappingIdByKey.get(object.sourceRoomKey)!,
distributionBoardId: null,
linkedProjectDeviceId:
familyDecisionByName.get(object.sourceValues.familyAndType)!
.projectDeviceId,
circuitDeviceRowId: null,
presenceStatus: "present",
})),
};
}
function exactDecisionMap<T>(
expectedKeys: string[],
decisions: T[],
keyOf: (decision: T) => string,
label: string
) {
const expected = new Set(expectedKeys);
const result = new Map<string, T>();
for (const decision of decisions) {
const key = keyOf(decision);
if (!expected.has(key) || result.has(key)) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
result.set(key, decision);
}
if (result.size !== expected.size) {
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
}
return result;
}

View file

@ -0,0 +1,246 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxColumnConfiguration,
type ExternalXlsxTransportConfiguration,
} from "../xlsx/external-xlsx-contracts.js";
export const externalModelConfigurationSchemaVersion = 1 as const;
export const externalQuantityRules = ["one", "socketCount"] as const;
export type ExternalQuantityRule = (typeof externalQuantityRules)[number];
export interface ExternalDeviceTypeDefaults {
powerPerUnitW: number | null;
simultaneityFactor: number | null;
cosPhi: number | null;
costGroup: string | null;
}
export interface ExternalDeviceTypeConfiguration {
id: string;
name: string;
category: CircuitGroupCategory;
defaults: ExternalDeviceTypeDefaults;
}
export interface ExternalFamilyTypeRule {
id: string;
exactFamilyAndType: string;
deviceTypeId: string;
quantityRule: ExternalQuantityRule;
}
export interface ExternalModelConfiguration
extends ExternalXlsxTransportConfiguration {
schemaVersion: typeof externalModelConfigurationSchemaVersion;
deviceTypes: ExternalDeviceTypeConfiguration[];
familyTypeRules: ExternalFamilyTypeRule[];
}
export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration {
return {
schemaVersion: externalModelConfigurationSchemaVersion,
...createDefaultExternalXlsxTransportConfiguration(),
deviceTypes: [],
familyTypeRules: [],
};
}
export function assertExternalModelConfiguration(
value: unknown
): asserts value is ExternalModelConfiguration {
if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) {
throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration.");
}
assertExactKeys(value, [
"schemaVersion",
"layout",
"circuitMode",
"columns",
"deviceTypes",
"familyTypeRules",
], "Revit-XLSX-Konfiguration");
assertLayout(value.layout);
if (value.circuitMode !== "combined" && value.circuitMode !== "separate") {
throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig.");
}
assertColumns(value.columns, value.circuitMode);
if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) {
throw new Error("Gerätearten und Familienregeln müssen Listen sein.");
}
const deviceTypeIds = new Set<string>();
const deviceTypeNames = new Set<string>();
for (const [index, deviceType] of value.deviceTypes.entries()) {
assertDeviceType(deviceType, index);
assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID");
assertUnique(deviceTypeNames, deviceType.name, "Geräteartname");
}
const ruleIds = new Set<string>();
const familyNames = new Set<string>();
for (const [index, rule] of value.familyTypeRules.entries()) {
assertFamilyRule(rule, index);
assertUnique(ruleIds, rule.id, "Familienregel-ID");
assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ");
if (!deviceTypeIds.has(rule.deviceTypeId)) {
throw new Error(
`Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.`
);
}
}
}
export function validateExternalModelConfiguration(value: unknown):
| { success: true; data: ExternalModelConfiguration }
| { success: false; error: string } {
try {
assertExternalModelConfiguration(value);
return { success: true, data: value };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Ungültige Konfiguration.",
};
}
}
function assertLayout(value: unknown) {
if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt.");
assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout");
assertPositiveInteger(value.headerRow, "Kopfzeile");
assertPositiveInteger(value.firstDataRow, "erste Datenzeile");
assertPositiveInteger(value.firstDataColumn, "erste Datenspalte");
if ((value.firstDataRow as number) <= (value.headerRow as number)) {
throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen.");
}
}
function assertColumns(
value: unknown,
circuitMode: ExternalModelConfiguration["circuitMode"]
) {
if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt.");
const keys = [
"worksharingId",
"ifcGuid",
"roomNumber",
"roomName",
"familyAndType",
"selectionMarker",
"socketCount",
"sourceRemark",
"circuitCombined",
"boardName",
"circuitIdentifier",
"designation",
"electricalPowerW",
"voltageV",
"currentA",
] satisfies (keyof ExternalXlsxColumnConfiguration)[];
assertExactKeys(value, keys, "XLSX-Spaltenzuordnung");
const mappedNames = new Set<string>();
for (const key of keys) {
const columnName = value[key];
const required =
key === "worksharingId" ||
key === "familyAndType" ||
(circuitMode === "combined" && key === "circuitCombined") ||
(circuitMode === "separate" &&
(key === "boardName" || key === "circuitIdentifier"));
if (columnName === null && !required) continue;
assertNonEmptyString(columnName, `Spalte ${key}`);
assertUnique(mappedNames, columnName, "Spaltenzuordnung");
}
}
function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration {
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`);
assertUuid(value.id, `Geräteart ${index + 1}.id`);
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {
throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`);
}
if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`);
assertExactKeys(
value.defaults,
["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"],
`Geräteart ${index + 1}.defaults`
);
assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung");
assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF");
assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi");
if (value.defaults.costGroup !== null) {
assertNonEmptyString(value.defaults.costGroup, "Kostengruppe");
}
}
function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule {
if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`);
assertExactKeys(
value,
["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"],
`Familienregel ${index + 1}`
);
assertUuid(value.id, `Familienregel ${index + 1}.id`);
assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`);
assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`);
if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) {
throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`);
}
}
function assertPositiveInteger(value: unknown, label: string) {
if (!Number.isSafeInteger(value) || (value as number) < 1) {
throw new Error(`${label} muss eine positive Ganzzahl sein.`);
}
}
function assertNullableNumber(
value: unknown,
minimum: number,
maximum: number,
label: string
) {
if (value === null) return;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
throw new Error(`${label} ist ungültig.`);
}
}
function assertNonEmptyString(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`);
}
}
function assertUuid(value: unknown, label: string): asserts value is string {
if (
typeof value !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
) {
throw new Error(`${label} muss eine UUID sein.`);
}
}
function assertUnique(values: Set<string>, value: string, label: string) {
if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`);
values.add(value);
}
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string) {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error(`${label} enthält unbekannte oder fehlende Felder.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,356 +0,0 @@
import { assertExternalCsvConfiguration } from "./external-csv-configuration.js";
import type {
ExternalCsvCell,
ExternalCsvConfiguration,
ExternalCsvDocument,
ExternalCsvLineEnding,
ExternalCsvRow,
} from "./external-csv-contracts.js";
export type ExternalCsvParseErrorCode =
| "invalid-encoding"
| "invalid-csv"
| "header-not-found"
| "ambiguous-header"
| "invalid-ifc-guid"
| "duplicate-ifc-guid";
export class ExternalCsvParseError extends Error {
constructor(
public readonly code: ExternalCsvParseErrorCode,
message: string
) {
super(message);
this.name = "ExternalCsvParseError";
}
}
export function parseExternalCsv(
input: Uint8Array,
configuration: ExternalCsvConfiguration
): ExternalCsvDocument {
assertExternalCsvConfiguration(configuration);
const hasBom = hasUtf8Bom(input);
const contentBytes = hasBom ? input.subarray(3) : input;
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(contentBytes);
} catch {
throw new ExternalCsvParseError(
"invalid-encoding",
"CSV is not valid UTF-8."
);
}
const parsed = parseRows(text, configuration.delimiter);
const headerMatches = findHeaderRows(parsed.rows, configuration);
if (headerMatches.length === 0) {
throw new ExternalCsvParseError(
"header-not-found",
"CSV does not contain exactly the configured required columns in one header row."
);
}
if (headerMatches.length > 1) {
throw new ExternalCsvParseError(
"ambiguous-header",
"CSV contains more than one possible header row."
);
}
const headerRowIndex = headerMatches[0];
const configuredColumnNames = [
...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
];
const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value);
if (
configuredColumnNames.some(
(columnName) => headerValues.filter((value) => value === columnName).length !== 1
)
) {
throw new ExternalCsvParseError(
"ambiguous-header",
"CSV header contains a configured column more than once."
);
}
const headerLookup = createHeaderLookup(parsed.rows[headerRowIndex]);
const ifcGuidColumnIndex = headerLookup.get(configuration.columns.ifcGuid)!;
const objectBearingColumnIndexes = [
configuration.columns.roomNumber,
configuration.columns.roomName,
configuration.columns.familyAndType,
configuration.columns.selectionMarker,
configuration.columns.power,
...(configuration.columns.quantity === null
? []
: [configuration.columns.quantity]),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
].map((column) => headerLookup.get(column)!);
const seenIfcGuids = new Set<string>();
const rows: ExternalCsvRow[] = parsed.rows.map((cells, index) => {
if (index < headerRowIndex) {
return { index, cells, classification: "metadata" };
}
if (index === headerRowIndex) {
return { index, cells, classification: "header" };
}
const rawIfcGuid = cells[ifcGuidColumnIndex]?.value ?? "";
const ifcGuid = rawIfcGuid.trim();
if (ifcGuid) {
if (ifcGuid !== rawIfcGuid || !isIfcGuid(ifcGuid)) {
throw new ExternalCsvParseError(
"invalid-ifc-guid",
`CSV row ${index + 1} contains an invalid IfcGUID.`
);
}
if (seenIfcGuids.has(ifcGuid)) {
throw new ExternalCsvParseError(
"duplicate-ifc-guid",
`CSV contains the IfcGUID more than once: ${ifcGuid}`
);
}
seenIfcGuids.add(ifcGuid);
return { index, cells, classification: "object" };
}
const looksLikeObject = objectBearingColumnIndexes.some(
(columnIndex) => (cells[columnIndex]?.value.trim() ?? "") !== ""
);
return {
index,
cells,
classification: looksLikeObject ? "suspect-object" : "passthrough",
};
});
return {
dialect: {
encoding: "utf-8",
hasBom,
delimiter: configuration.delimiter,
lineEnding: resolveLineEnding(parsed.lineEndings),
quoteCharacter: '"',
quoteAllFields:
rows.flatMap((row) => row.cells).length > 0 &&
rows.flatMap((row) => row.cells).every((cell) => cell.wasQuoted),
hasTrailingLineEnding: parsed.hasTrailingLineEnding,
},
headerRowIndex,
rows,
};
}
export function serializeExternalCsv(document: ExternalCsvDocument): Uint8Array {
assertSerializableDocument(document);
const serializedRows = document.rows.map((row) =>
row.cells
.map((cell) => serializeCell(cell, document.dialect.delimiter))
.join(document.dialect.delimiter)
);
let text = serializedRows.join(document.dialect.lineEnding);
if (document.dialect.hasTrailingLineEnding && document.rows.length > 0) {
text += document.dialect.lineEnding;
}
const encoded = new TextEncoder().encode(text);
if (!document.dialect.hasBom) {
return encoded;
}
const result = new Uint8Array(encoded.length + 3);
result.set([0xef, 0xbb, 0xbf]);
result.set(encoded, 3);
return result;
}
function parseRows(text: string, delimiter: string) {
const rows: ExternalCsvCell[][] = [];
const lineEndings: ExternalCsvLineEnding[] = [];
let cells: ExternalCsvCell[] = [];
let value = "";
let wasQuoted = false;
let inQuotes = false;
let afterClosingQuote = false;
let fieldStarted = false;
let hasTrailingLineEnding = false;
const finishCell = () => {
cells.push({ value, wasQuoted });
value = "";
wasQuoted = false;
inQuotes = false;
afterClosingQuote = false;
fieldStarted = false;
};
const finishRow = (lineEnding: ExternalCsvLineEnding) => {
finishCell();
rows.push(cells);
cells = [];
lineEndings.push(lineEnding);
hasTrailingLineEnding = true;
};
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (inQuotes) {
if (character === '"') {
if (text[index + 1] === '"') {
value += '"';
index += 1;
} else {
inQuotes = false;
afterClosingQuote = true;
}
} else {
value += character;
}
hasTrailingLineEnding = false;
continue;
}
if (afterClosingQuote) {
if (character !== delimiter && character !== "\r" && character !== "\n") {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains characters after a closing quote."
);
}
}
if (character === delimiter) {
finishCell();
hasTrailingLineEnding = false;
continue;
}
if (character === "\r" || character === "\n") {
const lineEnding: ExternalCsvLineEnding =
character === "\r" && text[index + 1] === "\n"
? "\r\n"
: character;
if (lineEnding === "\r\n") {
index += 1;
}
finishRow(lineEnding);
continue;
}
if (character === '"') {
if (fieldStarted || value.length > 0 || afterClosingQuote) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains a quote inside an unquoted field."
);
}
wasQuoted = true;
inQuotes = true;
fieldStarted = true;
hasTrailingLineEnding = false;
continue;
}
if (afterClosingQuote) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains invalid data after a quoted field."
);
}
value += character;
fieldStarted = true;
hasTrailingLineEnding = false;
}
if (inQuotes) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains an unterminated quoted field."
);
}
if (!hasTrailingLineEnding || cells.length > 0 || fieldStarted || value.length > 0) {
finishCell();
rows.push(cells);
}
return { rows, lineEndings, hasTrailingLineEnding };
}
function findHeaderRows(
rows: ExternalCsvCell[][],
configuration: ExternalCsvConfiguration
) {
const requiredColumns = new Set([
...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
]);
const matches: number[] = [];
rows.forEach((row, index) => {
const values = new Set(row.map((cell) => cell.value));
if ([...requiredColumns].every((column) => values.has(column))) {
matches.push(index);
}
});
return matches;
}
function createHeaderLookup(cells: ExternalCsvCell[]) {
return new Map(cells.map((cell, index) => [cell.value, index]));
}
function isIfcGuid(value: string) {
return /^[0-9A-Za-z_$]{22}$/.test(value);
}
function resolveLineEnding(lineEndings: ExternalCsvLineEnding[]): ExternalCsvLineEnding {
if (lineEndings.length === 0) {
return "\r\n";
}
const unique = new Set(lineEndings);
if (unique.size !== 1) {
throw new ExternalCsvParseError(
"invalid-csv",
"CSV contains mixed line endings that cannot be preserved reliably."
);
}
return lineEndings[0];
}
function serializeCell(cell: ExternalCsvCell, delimiter: string) {
const mustQuote =
cell.wasQuoted ||
cell.value.includes(delimiter) ||
cell.value.includes('"') ||
cell.value.includes("\r") ||
cell.value.includes("\n");
if (!mustQuote) {
return cell.value;
}
return `"${cell.value.replaceAll('"', '""')}"`;
}
function assertSerializableDocument(document: ExternalCsvDocument) {
if (document.dialect.encoding !== "utf-8") {
throw new Error("Only UTF-8 external CSV documents can be serialized.");
}
if (
document.headerRowIndex < 0 ||
document.headerRowIndex >= document.rows.length ||
document.rows[document.headerRowIndex]?.classification !== "header"
) {
throw new Error("External CSV document has an invalid header row.");
}
document.rows.forEach((row, index) => {
if (row.index !== index) {
throw new Error("External CSV row indexes must be contiguous and stable.");
}
for (const cell of row.cells) {
if (typeof cell.value !== "string" || typeof cell.wasQuoted !== "boolean") {
throw new Error("External CSV document contains an invalid cell.");
}
}
});
}
function hasUtf8Bom(input: Uint8Array) {
return input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf;
}

View file

@ -1,10 +1,11 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
import type { import type {
ExternalCsvConfiguration, ExternalXlsxDocument,
ExternalCsvDocument, ExternalXlsxScalar,
} from "../csv/external-csv-contracts.js"; } from "../xlsx/external-xlsx-contracts.js";
export const externalModelSourceTypes = ["revit_csv"] as const; export const externalModelSourceTypes = ["revit_xlsx"] as const;
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number]; export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
export const externalImportKinds = ["initial", "follow_up"] as const; export const externalImportKinds = ["initial", "follow_up"] as const;
@ -29,11 +30,11 @@ export interface ExternalImportBatchSnapshot {
importedAtIso: string; importedAtIso: string;
fileName: string; fileName: string;
sha256: string; sha256: string;
appliedProjectRevision: number; objectCount: number;
configurationVersion: number; appliedProjectRevision: number | null;
configurationSnapshot: ExternalCsvConfiguration; configurationSnapshot: ExternalModelConfiguration | null;
originalContentBase64: string; originalContentBase64: string | null;
document: ExternalCsvDocument; document: ExternalXlsxDocument | null;
} }
export interface ExternalRoomMappingSnapshot { export interface ExternalRoomMappingSnapshot {
@ -50,24 +51,28 @@ export interface ExternalRoomMappingSnapshot {
export interface ExternalModelObjectSourceValues { export interface ExternalModelObjectSourceValues {
rowNumber: number; rowNumber: number;
roomNumber: string; roomNumber: ExternalXlsxScalar;
roomName: string; roomName: ExternalXlsxScalar;
familyAndType: string; familyAndType: string;
selectionMarker: string; selectionMarker: ExternalXlsxScalar;
circuitIdentifier: string; socketCount: ExternalXlsxScalar;
power: string; sourceRemark: ExternalXlsxScalar;
quantity: string | null; circuitCombined: ExternalXlsxScalar;
additionalSourceValues: Record<string, string>; boardName: ExternalXlsxScalar;
circuitIdentifier: ExternalXlsxScalar;
designation: ExternalXlsxScalar;
electricalPowerW: ExternalXlsxScalar;
voltageV: ExternalXlsxScalar;
currentA: ExternalXlsxScalar;
} }
export interface ExternalModelObjectPlanningValues { export interface ExternalModelObjectPlanningValues {
displayName: string | null; displayName: string | null;
internalDeviceType: string | null;
category: CircuitGroupCategory | null; category: CircuitGroupCategory | null;
connectionKind: string | null; connectionKind: string | null;
effectiveQuantity: number; effectiveQuantity: number;
powerPerUnitW: number | null; powerPerUnitW: number | null;
simultaneityFactor: number; simultaneityFactor: number | null;
cosPhi: number | null; cosPhi: number | null;
costGroup: string | null; costGroup: string | null;
remark: string | null; remark: string | null;
@ -80,7 +85,9 @@ export interface ExternalModelObjectSnapshot {
id: string; id: string;
projectId: string; projectId: string;
sourceId: string; sourceId: string;
ifcGuid: string; worksharingId: string;
ifcGuid: string | null;
deviceTypeId: string;
lastSeenImportBatchId: string; lastSeenImportBatchId: string;
lastAcceptedImportBatchId: string; lastAcceptedImportBatchId: string;
acceptedSourceValues: ExternalModelObjectSourceValues; acceptedSourceValues: ExternalModelObjectSourceValues;

View file

@ -1,147 +0,0 @@
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
import type {
ExternalModelObjectPlanningValues,
ExternalModelObjectSourceValues,
} from "./external-model-contracts.js";
export type InitialExternalObjectIssue =
| "unknown-family-and-type"
| "invalid-power"
| "invalid-quantity"
| "missing-room";
export interface InitialExternalObjectProjection {
ifcGuid: string;
sourceValues: ExternalModelObjectSourceValues;
planningValues: ExternalModelObjectPlanningValues;
issues: InitialExternalObjectIssue[];
}
export interface ExternalObjectImportCandidate {
rowNumber: number;
ifcGuid: string;
roomNumber: string;
roomName: string;
familyAndType: string;
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string | null;
additionalSourceValues: Record<string, string>;
}
export function normalizeExternalRoomPart(value: string): string {
return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE");
}
export function createExternalRoomKey(
roomNumber: string,
roomName: string
): string | null {
const normalizedNumber = normalizeExternalRoomPart(roomNumber);
if (normalizedNumber) return `number:${normalizedNumber}`;
const normalizedName = normalizeExternalRoomPart(roomName);
return normalizedName ? `name:${normalizedName}` : null;
}
export function indexExternalObjectsByIfcGuid<T extends { ifcGuid: string }>(
objects: readonly T[]
): Map<string, T> {
const index = new Map<string, T>();
for (const object of objects) {
if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) {
throw new Error("IfcGUID must be non-empty and must not contain outer whitespace.");
}
if (index.has(object.ifcGuid)) {
throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`);
}
index.set(object.ifcGuid, object);
}
return index;
}
export function projectInitialExternalObject(
object: ExternalObjectImportCandidate,
configuration: ExternalCsvConfiguration
): InitialExternalObjectProjection {
const rule = configuration.familyTypeRules.find(
(candidate) => candidate.exactFamilyAndType === object.familyAndType
);
const issues: InitialExternalObjectIssue[] = [];
if (!rule) issues.push("unknown-family-and-type");
if (!createExternalRoomKey(object.roomNumber, object.roomName)) {
issues.push("missing-room");
}
const parsedPower = parseConfiguredNumber(object.power, configuration);
if (object.power.trim() && parsedPower === null) issues.push("invalid-power");
let effectiveQuantity = 1;
if (rule?.quantityRule.kind === "fixed") {
effectiveQuantity = rule.quantityRule.quantity;
} else if (rule?.quantityRule.kind === "mapped-column") {
const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration);
if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) {
issues.push("invalid-quantity");
} else {
effectiveQuantity = parsedQuantity;
}
}
return {
ifcGuid: object.ifcGuid,
sourceValues: {
rowNumber: object.rowNumber,
roomNumber: object.roomNumber,
roomName: object.roomName,
familyAndType: object.familyAndType,
selectionMarker: object.selectionMarker,
circuitIdentifier: object.circuitIdentifier,
power: object.power,
quantity: object.quantity,
additionalSourceValues: { ...object.additionalSourceValues },
},
planningValues: {
displayName: rule
? resolveDisplayName(rule.displayNameSuggestion, object)
: null,
internalDeviceType: rule?.internalDeviceType ?? null,
category: rule?.category ?? null,
connectionKind: rule?.connectionKind ?? null,
effectiveQuantity,
powerPerUnitW:
parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
issues,
};
}
function parseConfiguredNumber(
value: string,
configuration: ExternalCsvConfiguration
): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const normalized = configuration.decimalSeparator === ","
? trimmed.replace(",", ".")
: trimmed;
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function resolveDisplayName(
suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"],
object: ExternalObjectImportCandidate
): string | null {
if (suggestion === null) return null;
if (suggestion.kind === "fixed") return suggestion.value;
if (suggestion.kind === "selection-marker") {
return object.selectionMarker.trim() || null;
}
return object.familyAndType.trim() || null;
}

View file

@ -0,0 +1,153 @@
export const MAX_EXTERNAL_XLSX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_EXTERNAL_XLSX_LIMITS = {
maxRows: 50_000,
maxColumns: 256,
maxCells: 2_000_000,
} as const;
export type ExternalXlsxScalar = string | number | boolean | null;
export interface ExternalXlsxCell {
columnNumber: number;
value: ExternalXlsxScalar;
valueType: "empty" | "string" | "number" | "boolean";
numberFormat: string | null;
formula: string | null;
usedCachedFormulaValue: boolean;
}
export type ExternalXlsxRowClassification =
| "object"
| "suspect-object"
| "passthrough";
export interface ExternalXlsxRow {
rowNumber: number;
classification: ExternalXlsxRowClassification;
cells: ExternalXlsxCell[];
}
export interface ExternalXlsxColumn {
columnNumber: number;
header: string;
comment: string | null;
width: number | null;
}
export type ExternalXlsxIssueCode =
| "cached-formula-value-used"
| "formula-without-cached-value"
| "suspect-object-skipped"
| "duplicate-ifc-guid"
| "optional-return-column-missing";
export interface ExternalXlsxIssue {
code: ExternalXlsxIssueCode;
message: string;
rowNumber?: number;
columnNumber?: number;
field?: ExternalXlsxColumnKey;
}
export interface ExternalXlsxDocument {
sheetName: string;
headerRowNumber: number;
firstDataRowNumber: number;
firstDataColumnNumber: number;
columns: ExternalXlsxColumn[];
rows: ExternalXlsxRow[];
issues: ExternalXlsxIssue[];
}
export interface ExternalXlsxLayoutConfiguration {
headerRow: number;
firstDataRow: number;
firstDataColumn: number;
}
export type ExternalXlsxCircuitMode = "combined" | "separate";
export interface ExternalXlsxColumnConfiguration {
worksharingId: string;
ifcGuid: string | null;
roomNumber: string | null;
roomName: string | null;
familyAndType: string;
selectionMarker: string | null;
socketCount: string | null;
sourceRemark: string | null;
circuitCombined: string | null;
boardName: string | null;
circuitIdentifier: string | null;
designation: string | null;
electricalPowerW: string | null;
voltageV: string | null;
currentA: string | null;
}
export type ExternalXlsxColumnKey = keyof ExternalXlsxColumnConfiguration;
export interface ExternalXlsxTransportConfiguration {
layout: ExternalXlsxLayoutConfiguration;
circuitMode: ExternalXlsxCircuitMode;
columns: ExternalXlsxColumnConfiguration;
}
export interface ExternalXlsxLimits {
maxRows: number;
maxColumns: number;
maxCells: number;
}
export type ExternalXlsxReturnValues = Partial<
Record<
| "circuitCombined"
| "boardName"
| "circuitIdentifier"
| "designation"
| "electricalPowerW"
| "voltageV"
| "currentA",
ExternalXlsxScalar
>
>;
export interface ExternalXlsxReturnUpdate {
worksharingId: string;
values: ExternalXlsxReturnValues;
}
export interface ExternalXlsxReturnResult {
bytes: Uint8Array;
exportedObjectCount: number;
issues: ExternalXlsxIssue[];
}
export function createDefaultExternalXlsxTransportConfiguration(): ExternalXlsxTransportConfiguration {
return {
layout: {
headerRow: 1,
firstDataRow: 3,
firstDataColumn: 1,
},
circuitMode: "combined",
columns: {
worksharingId: "Worksharing-ID",
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
socketCount: "CAX_AnzahlObjekte",
sourceRemark: "CAx_Anmerkung",
circuitCombined: "kbp_Stromkreisnummer",
boardName: "kbp-E-Verteiler",
circuitIdentifier: "kbp-E-Stromkreis",
designation: "kbp-E-Bezeichnung",
electricalPowerW: "kbp-E-Elektrische Leistung",
voltageV: "kbp-E-Spannung",
currentA: "kbp-E-Stromstärke",
},
};
}

View file

@ -0,0 +1,582 @@
import ExcelJS from "exceljs";
import type { CellValue, Worksheet } from "exceljs";
import {
DEFAULT_EXTERNAL_XLSX_LIMITS,
MAX_EXTERNAL_XLSX_BYTES,
type ExternalXlsxCell,
type ExternalXlsxColumnKey,
type ExternalXlsxDocument,
type ExternalXlsxIssue,
type ExternalXlsxLimits,
type ExternalXlsxReturnResult,
type ExternalXlsxReturnUpdate,
type ExternalXlsxScalar,
type ExternalXlsxTransportConfiguration,
} from "./external-xlsx-contracts.js";
export type ExternalXlsxErrorCode =
| "invalid-file-extension"
| "file-too-large"
| "invalid-workbook"
| "worksheet-count"
| "worksheet-too-large"
| "invalid-layout"
| "header-not-found"
| "ambiguous-header"
| "required-formula-result-missing"
| "invalid-worksharing-id"
| "duplicate-worksharing-id"
| "return-circuit-column-missing"
| "unknown-worksharing-id";
export class ExternalXlsxError extends Error {
constructor(
public readonly code: ExternalXlsxErrorCode,
message: string
) {
super(message);
this.name = "ExternalXlsxError";
}
}
export interface ParseExternalXlsxInput {
fileName: string;
bytes: Uint8Array;
configuration: ExternalXlsxTransportConfiguration;
limits?: ExternalXlsxLimits;
}
export interface CreateExternalXlsxReturnInput extends ParseExternalXlsxInput {
selectedWorksharingIds?: readonly string[];
updates?: readonly ExternalXlsxReturnUpdate[];
}
export async function parseExternalXlsx(
input: ParseExternalXlsxInput
): Promise<ExternalXlsxDocument> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
return parseWorksheet(worksheet, input.configuration);
}
export async function createExternalXlsxReturn(
input: CreateExternalXlsxReturnInput
): Promise<ExternalXlsxReturnResult> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
const document = parseWorksheet(worksheet, input.configuration);
const headerLookup = createHeaderLookup(worksheet, input.configuration);
const issues = [...document.issues];
const returnColumns = resolveReturnColumns(
headerLookup,
input.configuration,
issues
);
const worksharingIdColumn = headerLookup.get(
input.configuration.columns.worksharingId
)!;
const objectRows = document.rows.filter((row) => row.classification === "object");
const objectRowById = new Map(
objectRows.map((row) => [
scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null),
row.rowNumber,
])
);
const selectedIds = input.selectedWorksharingIds
? new Set(input.selectedWorksharingIds)
: new Set(objectRowById.keys());
for (const worksharingId of selectedIds) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const updates = new Map(
(input.updates ?? []).map((update) => [update.worksharingId, update.values])
);
for (const worksharingId of updates.keys()) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const selectedRows = objectRows.filter((row) => {
const id = scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null);
return selectedIds.has(id);
});
const lastColumn = worksheet.columnCount;
const snapshots = selectedRows.map((row) =>
captureRow(worksheet, row.rowNumber, lastColumn)
);
snapshots.forEach((snapshot, index) => {
restoreRow(
worksheet,
input.configuration.layout.firstDataRow + index,
snapshot,
lastColumn
);
const destinationRow = worksheet.getRow(
input.configuration.layout.firstDataRow + index
);
const id = scalarToIdentity(destinationRow.getCell(worksharingIdColumn).value);
const values = updates.get(id);
if (!values) return;
for (const [field, value] of Object.entries(values) as [
keyof typeof values,
ExternalXlsxScalar,
][]) {
const columnNumber = returnColumns.get(field);
if (columnNumber !== undefined) {
destinationRow.getCell(columnNumber).value = value;
}
}
});
const firstRemovedRow =
input.configuration.layout.firstDataRow + snapshots.length;
const removeCount = Math.max(0, worksheet.rowCount - firstRemovedRow + 1);
// ExcelJS 4.4 does not shrink its internal row array when a multi-row
// splice removes the complete tail. Removing the tail one row at a time
// produces an actual OOXML deletion instead of serialized empty rows.
for (let index = 0; index < removeCount; index += 1) {
worksheet.spliceRows(worksheet.rowCount, 1);
}
const buffer = await workbook.xlsx.writeBuffer();
return {
bytes: new Uint8Array(buffer),
exportedObjectCount: snapshots.length,
issues,
};
}
function parseWorksheet(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
): ExternalXlsxDocument {
const headerLookup = createHeaderLookup(worksheet, configuration);
const issues: ExternalXlsxIssue[] = [];
const worksharingIdColumn = headerLookup.get(configuration.columns.worksharingId)!;
const familyAndTypeColumn = headerLookup.get(configuration.columns.familyAndType)!;
const ifcGuidColumn = configuration.columns.ifcGuid
? headerLookup.get(configuration.columns.ifcGuid)
: undefined;
const seenWorksharingIds = new Set<string>();
const seenIfcGuids = new Set<string>();
const rows = [] as ExternalXlsxDocument["rows"];
for (
let rowNumber = configuration.layout.firstDataRow;
rowNumber <= worksheet.rowCount;
rowNumber += 1
) {
const row = worksheet.getRow(rowNumber);
const cells: ExternalXlsxCell[] = [];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
cells.push(
toExternalCell(row.getCell(columnNumber), rowNumber, columnNumber, issues)
);
}
const worksharingIdCell = cells[worksharingIdColumn - 1];
const familyAndTypeCell = cells[familyAndTypeColumn - 1];
for (const [field, requiredCell] of [
["Worksharing-ID", worksharingIdCell],
["Familie und Typ", familyAndTypeCell],
] as const) {
if (requiredCell?.formula != null && !requiredCell.usedCachedFormulaValue) {
throw new ExternalXlsxError(
"required-formula-result-missing",
`Das Pflichtfeld ${field} hat in Zeile ${rowNumber} kein gespeichertes Formelergebnis.`
);
}
}
const worksharingId = scalarToIdentity(worksharingIdCell?.value ?? null);
const familyAndType = scalarToIdentity(familyAndTypeCell?.value ?? null);
if (!worksharingId) {
const classification = familyAndType ? "suspect-object" : "passthrough";
if (classification === "suspect-object") {
issues.push({
code: "suspect-object-skipped",
message: `Zeile ${rowNumber} enthält Familie und Typ, aber keine Worksharing-ID, und wird übersprungen.`,
rowNumber,
});
}
rows.push({ rowNumber, classification, cells });
continue;
}
if (!isWorksharingId(worksharingId)) {
throw new ExternalXlsxError(
"invalid-worksharing-id",
`Zeile ${rowNumber} enthält eine ungültige Worksharing-ID.`
);
}
if (seenWorksharingIds.has(worksharingId)) {
throw new ExternalXlsxError(
"duplicate-worksharing-id",
`Die Worksharing-ID kommt mehrfach vor: ${worksharingId}`
);
}
seenWorksharingIds.add(worksharingId);
if (ifcGuidColumn !== undefined) {
const ifcGuid = scalarToIdentity(cells[ifcGuidColumn - 1]?.value ?? null);
if (ifcGuid) {
if (seenIfcGuids.has(ifcGuid)) {
issues.push({
code: "duplicate-ifc-guid",
message: `IfcGUID kommt mehrfach vor: ${ifcGuid}`,
rowNumber,
columnNumber: ifcGuidColumn,
field: "ifcGuid",
});
}
seenIfcGuids.add(ifcGuid);
}
}
rows.push({ rowNumber, classification: "object", cells });
}
const headerRow = worksheet.getRow(configuration.layout.headerRow);
const columns = [] as ExternalXlsxDocument["columns"];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
const cell = headerRow.getCell(columnNumber);
columns.push({
columnNumber,
header: scalarToIdentity(toScalar(cell.value).value),
comment: getCommentText(cell.note),
width: worksheet.getColumn(columnNumber).width ?? null,
});
}
return {
sheetName: worksheet.name,
headerRowNumber: configuration.layout.headerRow,
firstDataRowNumber: configuration.layout.firstDataRow,
firstDataColumnNumber: configuration.layout.firstDataColumn,
columns,
rows,
issues,
};
}
function createHeaderLookup(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
) {
const headers = new Map<string, number[]>();
const row = worksheet.getRow(configuration.layout.headerRow);
for (
let columnNumber = configuration.layout.firstDataColumn;
columnNumber <= worksheet.columnCount;
columnNumber += 1
) {
const header = scalarToIdentity(toScalar(row.getCell(columnNumber).value).value);
if (!header) continue;
headers.set(header, [...(headers.get(header) ?? []), columnNumber]);
}
const configured = Object.entries(configuration.columns) as [
ExternalXlsxColumnKey,
string | null,
][];
const required = new Set<ExternalXlsxColumnKey>(["worksharingId", "familyAndType"]);
const lookup = new Map<string, number>();
for (const [field, header] of configured) {
if (header === null) continue;
const matches = headers.get(header) ?? [];
if (matches.length > 1) {
throw new ExternalXlsxError(
"ambiguous-header",
`Die konfigurierte Kopfzelle ist nicht eindeutig: ${header}`
);
}
if (matches.length === 0 && required.has(field)) {
throw new ExternalXlsxError(
"header-not-found",
`Die konfigurierte Pflichtspalte fehlt: ${header}`
);
}
if (matches.length === 1) lookup.set(header, matches[0]);
}
return lookup;
}
function resolveReturnColumns(
headerLookup: Map<string, number>,
configuration: ExternalXlsxTransportConfiguration,
issues: ExternalXlsxIssue[]
) {
const result = new Map<keyof ExternalXlsxReturnUpdate["values"], number>();
const circuitFields: ("circuitCombined" | "boardName" | "circuitIdentifier")[] =
configuration.circuitMode === "combined"
? ["circuitCombined"]
: ["boardName", "circuitIdentifier"];
for (const field of circuitFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
throw new ExternalXlsxError(
"return-circuit-column-missing",
`Die Stromkreisspalte des gewählten Modus fehlt: ${field}`
);
}
result.set(field, columnNumber);
}
const optionalFields = [
"designation",
"electricalPowerW",
"voltageV",
"currentA",
] as const;
for (const field of optionalFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
issues.push({
code: "optional-return-column-missing",
message: `Die optionale Rückgabespalte fehlt und wird übersprungen: ${field}`,
field,
});
} else {
result.set(field, columnNumber);
}
}
return result;
}
function toExternalCell(
cell: ExcelJS.Cell,
rowNumber: number,
columnNumber: number,
issues: ExternalXlsxIssue[]
): ExternalXlsxCell {
const converted = toScalar(cell.value);
if (converted.formula !== null) {
issues.push({
code: converted.usedCachedFormulaValue
? "cached-formula-value-used"
: "formula-without-cached-value",
message: converted.usedCachedFormulaValue
? `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} wird mit ihrem gespeicherten Ergebnis gelesen.`
: `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} hat kein nutzbares gespeichertes Ergebnis und bleibt leer.`,
rowNumber,
columnNumber,
});
}
return {
columnNumber,
value: converted.value,
valueType: getScalarValueType(converted.value),
numberFormat: cell.numFmt || null,
formula: converted.formula,
usedCachedFormulaValue: converted.usedCachedFormulaValue,
};
}
function toScalar(value: CellValue): {
value: ExternalXlsxScalar;
formula: string | null;
usedCachedFormulaValue: boolean;
} {
if (value === null || value === undefined) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return { value, formula: null, usedCachedFormulaValue: false };
}
if (value instanceof Date) {
return { value: value.toISOString(), formula: null, usedCachedFormulaValue: false };
}
if ("formula" in value || "sharedFormula" in value) {
const formula =
("formula" in value ? value.formula : value.sharedFormula) ?? "";
const result = "result" in value ? value.result : undefined;
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean"
) {
return { value: result, formula, usedCachedFormulaValue: true };
}
return { value: null, formula, usedCachedFormulaValue: false };
}
if ("error" in value) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if ("richText" in value) {
return {
value: value.richText.map((part) => part.text).join(""),
formula: null,
usedCachedFormulaValue: false,
};
}
if ("text" in value) {
return { value: value.text, formula: null, usedCachedFormulaValue: false };
}
return { value: null, formula: null, usedCachedFormulaValue: false };
}
function captureRow(worksheet: Worksheet, rowNumber: number, lastColumn: number) {
const row = worksheet.getRow(rowNumber);
return {
height: row.height,
cells: Array.from({ length: lastColumn }, (_, index) => {
const cell = row.getCell(index + 1);
const converted = toScalar(cell.value);
return {
value: converted.formula === null ? cloneValue(cell.value) : converted.value,
style: structuredClone(cell.style),
note: cell.note === undefined ? undefined : structuredClone(cell.note),
};
}),
};
}
function restoreRow(
worksheet: Worksheet,
rowNumber: number,
snapshot: ReturnType<typeof captureRow>,
lastColumn: number
) {
const row = worksheet.getRow(rowNumber);
row.height = snapshot.height;
for (let columnNumber = 1; columnNumber <= lastColumn; columnNumber += 1) {
const cell = row.getCell(columnNumber);
const source = snapshot.cells[columnNumber - 1];
cell.value = source.value;
cell.style = structuredClone(source.style);
if (source.note === undefined) {
// ExcelJS has no public comment-removal API. Its note setter creates a
// Note even for undefined, which would add an empty comment to the cell.
(cell as unknown as { _comment?: unknown })._comment = undefined;
} else {
cell.note = source.note;
}
}
}
function cloneValue(value: CellValue): CellValue {
if (value instanceof Date) return new Date(value);
if (value && typeof value === "object") return structuredClone(value);
return value;
}
function getCommentText(note: ExcelJS.Cell["note"]): string | null {
if (note === undefined) return null;
if (typeof note === "string") return note;
return note.texts?.map((part) => part.text).join("") ?? null;
}
function getScalarValueType(value: ExternalXlsxScalar): ExternalXlsxCell["valueType"] {
if (value === null) return "empty";
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
return "boolean";
}
function scalarToIdentity(value: ExternalXlsxScalar | CellValue): string {
const scalar =
typeof value === "object" && value !== null ? toScalar(value).value : value;
return typeof scalar === "string" ? scalar : scalar === null ? "" : String(scalar);
}
function isWorksharingId(value: string) {
return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/.test(
value
);
}
function assertUpload(fileName: string, bytes: Uint8Array) {
if (!fileName.toLocaleLowerCase("en-US").endsWith(".xlsx")) {
throw new ExternalXlsxError(
"invalid-file-extension",
"Es werden ausschließlich .xlsx-Dateien unterstützt."
);
}
if (bytes.byteLength > MAX_EXTERNAL_XLSX_BYTES) {
throw new ExternalXlsxError(
"file-too-large",
"Die XLSX-Datei darf höchstens 10 MiB groß sein."
);
}
}
async function loadWorkbook(bytes: Uint8Array) {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(
Buffer.from(bytes) as unknown as Parameters<typeof workbook.xlsx.load>[0]
);
} catch (error) {
throw new ExternalXlsxError(
"invalid-workbook",
`Die XLSX-Datei konnte nicht gelesen werden: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
);
}
return workbook;
}
function getOnlyWorksheet(workbook: ExcelJS.Workbook) {
if (workbook.worksheets.length !== 1) {
throw new ExternalXlsxError(
"worksheet-count",
"Die XLSX-Datei muss genau ein Arbeitsblatt enthalten."
);
}
return workbook.worksheets[0];
}
function assertWorksheetLimits(worksheet: Worksheet, limits: ExternalXlsxLimits) {
const rowCount = worksheet.rowCount;
const columnCount = worksheet.columnCount;
if (
rowCount > limits.maxRows ||
columnCount > limits.maxColumns ||
rowCount * columnCount > limits.maxCells
) {
throw new ExternalXlsxError(
"worksheet-too-large",
`Das Arbeitsblatt überschreitet die Grenzen (${rowCount} Zeilen, ${columnCount} Spalten).`
);
}
}
function assertConfiguration(configuration: ExternalXlsxTransportConfiguration) {
const { headerRow, firstDataRow, firstDataColumn } = configuration.layout;
if (
!Number.isInteger(headerRow) ||
headerRow < 1 ||
!Number.isInteger(firstDataRow) ||
firstDataRow <= headerRow ||
!Number.isInteger(firstDataColumn) ||
firstDataColumn < 1
) {
throw new ExternalXlsxError(
"invalid-layout",
"Kopfzeile, erste Datenzeile oder erste Datenspalte sind ungültig."
);
}
if (!configuration.columns.worksharingId || !configuration.columns.familyAndType) {
throw new ExternalXlsxError(
"invalid-layout",
"Worksharing-ID und Familie und Typ müssen konfiguriert sein."
);
}
}

View file

@ -1,666 +0,0 @@
"use client";
import React, { useEffect, useMemo, useState, type FormEvent } from "react";
import {
createDefaultExternalCsvConfiguration,
type ExternalCsvConfiguration,
type ExternalCsvFamilyTypeRule,
} from "../../external-model/csv/external-csv-contracts";
import type {
CreateProjectDeviceInput,
CreateRoomInput,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ProjectDeviceCommandResultDto,
ProjectRoomCommandResultDto,
} from "../types";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
planExternalInitialImport,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../utils/api";
import { FormModal } from "./form-modal";
import { ProjectDeviceModal } from "./project-device-modal";
import { RoomModal } from "./room-modal";
interface RevitCsvModalProps {
currentRevision: number;
onClose: () => void;
onCreateProjectDevice: (
input: CreateProjectDeviceInput
) => Promise<ProjectDeviceCommandResultDto>;
onCreateRoom: (input: CreateRoomInput) => Promise<ProjectRoomCommandResultDto>;
onRevisionChange: (currentRevision: number) => void;
projectId: string;
}
const defaultConfiguration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
defaultConfiguration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const columnFields = [
["ifcGuid", "IFC-GUID"],
["roomNumber", "Raumnummer"],
["roomName", "Raumname"],
["familyAndType", "Familie und Typ"],
["selectionMarker", "CAx-Auswahlkenner"],
["circuitIdentifier", "Stromkreiskennzeichnung"],
["power", "Elektrische Leistung"],
["quantity", "Menge (optional)"],
] as const;
export function RevitCsvModal({
currentRevision,
onClose,
onCreateProjectDevice,
onCreateRoom,
onRevisionChange,
projectId,
}: RevitCsvModalProps) {
const [configuration, setConfiguration] = useState<ExternalCsvConfiguration>(
structuredClone(defaultConfiguration)
);
const [savedConfiguration, setSavedConfiguration] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ExternalCsvPreviewDto | null>(null);
const [importPlan, setImportPlan] = useState<ExternalInitialImportPlanDto | null>(null);
const [roomDecisions, setRoomDecisions] = useState<Record<string, { roomId: string | null; defaultDistributionBoardId: string | null }>>({});
const [familyLinks, setFamilyLinks] = useState<Record<string, string | null>>({});
const [sourceName, setSourceName] = useState("Revit-Gesamtmodell");
const [success, setSuccess] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isPlanning, setIsPlanning] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [isCreatingCatalogEntry, setIsCreatingCatalogEntry] = useState(false);
const [newRoomSourceKey, setNewRoomSourceKey] = useState<string | null>(null);
const [newProjectDeviceFamily, setNewProjectDeviceFamily] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getExternalCsvConfiguration(projectId)
.then(({ configuration: stored }) => {
const next = stored?.configuration ?? structuredClone(defaultConfiguration);
setConfiguration(next);
setSavedConfiguration(stored ? JSON.stringify(next) : null);
})
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht geladen werden.")
)
.finally(() => setIsLoading(false));
}, [projectId]);
const isDirty = savedConfiguration !== JSON.stringify(configuration);
function replaceConfiguration(next: ExternalCsvConfiguration) {
setConfiguration(next);
setPreview(null);
setImportPlan(null);
setSuccess(null);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSaving(true);
setError(null);
try {
const result = await updateExternalCsvConfiguration(
projectId,
currentRevision,
configuration
);
setSavedConfiguration(JSON.stringify(result.configuration.configuration));
setImportPlan(null);
onRevisionChange(result.history.currentRevision);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Konfiguration konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handlePreview() {
if (!file || isDirty || savedConfiguration === null) return;
setIsPreviewing(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
setPreview(await previewExternalCsv(projectId, file.name, contentBase64));
setImportPlan(null);
setSuccess(null);
} catch (reason) {
setPreview(null);
setError(reason instanceof Error ? reason.message : "CSV-Vorschau konnte nicht erstellt werden.");
} finally {
setIsPreviewing(false);
}
}
async function handlePlanImport() {
if (!file || !preview || isDirty || savedConfiguration === null) return;
setIsPlanning(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
const plan = await planExternalInitialImport(projectId, file.name, contentBase64);
if (plan.sha256 !== preview.sha256) {
throw new Error("Die Datei stimmt nicht mehr mit der Vorschau überein.");
}
setImportPlan(plan);
setRoomDecisions(Object.fromEntries(plan.sourceRooms.map((room) => [
room.sourceRoomKey,
{ roomId: room.suggestedRoomId, defaultDistributionBoardId: null },
])));
setFamilyLinks(Object.fromEntries(plan.familyGroups.map((group) => [
group.familyAndType,
null,
])));
} catch (reason) {
setImportPlan(null);
setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht geplant werden.");
} finally {
setIsPlanning(false);
}
}
async function handleApplyImport() {
if (!file || !importPlan) return;
if (!window.confirm(
`${importPlan.objectCount} Revit-Objekte jetzt als einen rückgängig machbaren Import übernehmen?`
)) return;
setIsApplying(true);
setError(null);
try {
const contentBase64 = await fileToBase64(file);
const result = await applyExternalInitialImport(projectId, {
expectedRevision: currentRevision,
expectedConfigurationVersion: importPlan.configurationVersion,
expectedSha256: importPlan.sha256,
fileName: file.name,
contentBase64,
sourceName,
roomDecisions: importPlan.sourceRooms.map((room) => ({
sourceRoomKey: room.sourceRoomKey,
roomId: roomDecisions[room.sourceRoomKey]?.roomId ?? null,
defaultDistributionBoardId:
roomDecisions[room.sourceRoomKey]?.defaultDistributionBoardId ?? null,
})),
familyProjectDeviceDecisions: importPlan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: familyLinks[group.familyAndType] ?? null,
})),
});
onRevisionChange(result.history.currentRevision);
setSuccess(`${result.import.objectCount} Revit-Objekte wurden übernommen.`);
setImportPlan(null);
setPreview(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Erstimport konnte nicht übernommen werden.");
} finally {
setIsApplying(false);
}
}
function updateColumn(key: (typeof columnFields)[number][0], value: string) {
setConfiguration((current) => ({
...current,
columns: { ...current.columns, [key]: key === "quantity" && !value.trim() ? null : value },
}));
setPreview(null);
setImportPlan(null);
}
async function handleCreateRoom(input: CreateRoomInput) {
if (!newRoomSourceKey) return;
setIsCreatingCatalogEntry(true);
setError(null);
try {
const result = await onCreateRoom(input);
setImportPlan((current) => current ? {
...current,
existing: {
...current.existing,
rooms: [...current.existing.rooms, result.room],
},
} : current);
setRoomDecisions((current) => ({
...current,
[newRoomSourceKey]: {
roomId: result.room.id,
defaultDistributionBoardId:
current[newRoomSourceKey]?.defaultDistributionBoardId ?? null,
},
}));
setNewRoomSourceKey(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Raum konnte nicht angelegt werden.");
setNewRoomSourceKey(null);
} finally {
setIsCreatingCatalogEntry(false);
}
}
async function handleCreateProjectDevice(input: CreateProjectDeviceInput) {
if (!newProjectDeviceFamily) return;
setIsCreatingCatalogEntry(true);
setError(null);
try {
const result = await onCreateProjectDevice(input);
setImportPlan((current) => current ? {
...current,
existing: {
...current.existing,
projectDevices: [...current.existing.projectDevices, result.projectDevice],
},
} : current);
setFamilyLinks((current) => ({
...current,
[newProjectDeviceFamily]: result.projectDevice.id,
}));
setNewProjectDeviceFamily(null);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "ProjectDevice konnte nicht angelegt werden.");
setNewProjectDeviceFamily(null);
} finally {
setIsCreatingCatalogEntry(false);
}
}
if (newRoomSourceKey && importPlan) {
const sourceRoom = importPlan.sourceRooms.find((room) => room.sourceRoomKey === newRoomSourceKey);
if (sourceRoom) {
return (
<RoomModal
floors={importPlan.existing.floors.map((floor) => ({ ...floor, projectId }))}
initialValues={{ roomNumber: sourceRoom.roomNumber, roomName: sourceRoom.roomName }}
isSaving={isCreatingCatalogEntry}
onClose={() => setNewRoomSourceKey(null)}
onSave={handleCreateRoom}
/>
);
}
}
if (newProjectDeviceFamily && importPlan) {
const group = importPlan.familyGroups.find((entry) => entry.familyAndType === newProjectDeviceFamily);
if (group) {
return (
<ProjectDeviceModal
globalDevices={[]}
initialValues={createRevitProjectDevicePrefill(importPlan, group.familyAndType)}
isSaving={isCreatingCatalogEntry}
onClose={() => setNewProjectDeviceFamily(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
);
}
}
return (
<FormModal
description="Projektweites Mapping speichern und Revit-CSV ohne Datenübernahme prüfen."
dialogSize="viewport"
isSaving={isSaving || isApplying}
onClose={onClose}
onSubmit={handleSave}
submitDisabled={isLoading || isPreviewing || isPlanning || isApplying || !isDirty}
submitLabel="Konfiguration speichern"
title="Revit-CSV"
>
{error ? <div className="alert alert-warning">{error}</div> : null}
{success ? <div className="alert alert-success">{success}</div> : null}
{isLoading ? <p>Konfiguration wird geladen …</p> : (
<div className="d-grid gap-4">
<section>
<h3 className="h6">Transport</h3>
<div className="row g-3">
<Field label="Encoding"><select className="form-select" disabled value={configuration.encoding}><option value="utf-8">UTF-8</option></select></Field>
<Field label="Trennzeichen"><select className="form-select" value={configuration.delimiter} onChange={(event) => replaceConfiguration({ ...configuration, delimiter: event.target.value as ExternalCsvConfiguration["delimiter"] })}><option value=";">Semikolon (;)</option><option value=",">Komma (,)</option><option value="\t">Tabulator</option></select></Field>
<Field label="Dezimaltrennzeichen"><select className="form-select" value={configuration.decimalSeparator} onChange={(event) => replaceConfiguration({ ...configuration, decimalSeparator: event.target.value as "," | "." })}><option value=",">Komma</option><option value=".">Punkt</option></select></Field>
<Field label="Leistungseinheit"><select className="form-select" value={configuration.powerUnit} onChange={(event) => replaceConfiguration({ ...configuration, powerUnit: event.target.value as "W" | "kW", wattsPerSourceUnit: event.target.value === "kW" ? 1000 : 1 })}><option value="W">Watt</option><option value="kW">Kilowatt</option></select></Field>
<Field label="Umrechnung in Watt"><input className="form-control" min="0.000001" step="any" type="number" value={configuration.wattsPerSourceUnit} onChange={(event) => replaceConfiguration({ ...configuration, wattsPerSourceUnit: Number(event.target.value) })} /></Field>
</div>
</section>
<section>
<h3 className="h6">Spaltenzuordnung</h3>
<div className="row g-3">
{columnFields.map(([key, label]) => (
<Field key={key} label={label}>
<input className="form-control" value={configuration.columns[key] ?? ""} onChange={(event) => updateColumn(key, event.target.value)} />
</Field>
))}
</div>
</section>
<AdditionalMappings configuration={configuration} onChange={(additionalSourceMappings) => replaceConfiguration({ ...configuration, additionalSourceMappings })} />
<FamilyRules configuration={configuration} onChange={(familyTypeRules) => replaceConfiguration({ ...configuration, familyTypeRules })} />
<section className="border-top pt-3">
<h3 className="h6">CSV prüfen</h3>
<p className="small text-secondary">Die Vorschau speichert keine Datei und verändert keine Projektdaten.</p>
<div className="d-flex flex-wrap gap-2 align-items-center">
<input accept=".csv,text/csv" className="form-control" onChange={(event) => { setFile(event.target.files?.[0] ?? null); setPreview(null); setImportPlan(null); setSuccess(null); }} style={{ maxWidth: 480 }} type="file" />
<button className="btn btn-outline-primary" disabled={!file || isDirty || savedConfiguration === null || isPreviewing || isSaving} onClick={handlePreview} type="button">{isPreviewing ? "Wird geprüft …" : "Vorschau erstellen"}</button>
<button className="btn btn-primary" disabled={!preview || isDirty || isPlanning || isPreviewing || isSaving} onClick={handlePlanImport} type="button">{isPlanning ? "Wird geplant …" : "Erstimport planen"}</button>
</div>
{isDirty ? <div className="form-text">Vor der Vorschau muss die Konfiguration gespeichert werden.</div> : null}
</section>
{preview ? <PreviewResult preview={preview} /> : null}
{importPlan ? (
<RevitCsvImportPlan
familyLinks={familyLinks}
isApplying={isApplying}
onApply={handleApplyImport}
onCreateProjectDevice={setNewProjectDeviceFamily}
onCreateRoom={setNewRoomSourceKey}
onFamilyLinkChange={(familyAndType, projectDeviceId) => setFamilyLinks((current) => ({ ...current, [familyAndType]: projectDeviceId }))}
onRoomDecisionChange={(sourceRoomKey, decision) => setRoomDecisions((current) => ({ ...current, [sourceRoomKey]: decision }))}
plan={importPlan}
roomDecisions={roomDecisions}
setSourceName={setSourceName}
sourceName={sourceName}
/>
) : null}
</div>
)}
</FormModal>
);
}
function Field({ children, label }: { children: React.ReactNode; label: string }) {
return <label className="col-12 col-md-6"><span className="form-label">{label}</span>{children}</label>;
}
function AdditionalMappings({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvConfiguration["additionalSourceMappings"]) => void }) {
return <section><div className="d-flex justify-content-between align-items-center"><h3 className="h6 mb-0">Weitere Quellfelder</h3><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...configuration.additionalSourceMappings, { sourceColumn: "", targetField: "" }])} type="button">Zuordnung hinzufügen</button></div><div className="d-grid gap-2 mt-2">{configuration.additionalSourceMappings.map((mapping, index) => <div className="row g-2" key={index}><div className="col"><input aria-label={`Quellspalte ${index + 1}`} className="form-control" placeholder="CSV-Spalte" value={mapping.sourceColumn} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, sourceColumn: event.target.value } : entry))} /></div><div className="col"><input aria-label={`Zielfeld ${index + 1}`} className="form-control" placeholder="Internes Zielfeld" value={mapping.targetField} onChange={(event) => onChange(configuration.additionalSourceMappings.map((entry, entryIndex) => entryIndex === index ? { ...entry, targetField: event.target.value } : entry))} /></div><div className="col-auto"><button aria-label={`Zuordnung ${index + 1} entfernen`} className="btn btn-outline-danger" onClick={() => onChange(configuration.additionalSourceMappings.filter((_, entryIndex) => entryIndex !== index))} type="button">Entfernen</button></div></div>)}</div></section>;
}
function FamilyRules({ configuration, onChange }: { configuration: ExternalCsvConfiguration; onChange: (value: ExternalCsvFamilyTypeRule[]) => void }) {
const rules = configuration.familyTypeRules;
function change(index: number, patch: Partial<ExternalCsvFamilyTypeRule>) { onChange(rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, ...patch } : rule)); }
return <section><div className="d-flex justify-content-between align-items-center"><div><h3 className="h6 mb-0">Familie-und-Typ-Regeln</h3><div className="form-text">Exakter Textvergleich; Reihenfolge wird beibehalten.</div></div><button className="btn btn-sm btn-outline-secondary" onClick={() => onChange([...rules, { exactFamilyAndType: "", internalDeviceType: "", connectionKind: null, category: "single_phase", quantityRule: { kind: "fixed", quantity: 1 }, displayNameSuggestion: null }])} type="button">Regel hinzufügen</button></div><div className="d-grid gap-3 mt-2">{rules.map((rule, index) => <div className="border rounded p-3" key={index}><div className="row g-2"><Field label="Exakter Familie-und-Typ-Wert"><input className="form-control" value={rule.exactFamilyAndType} onChange={(event) => change(index, { exactFamilyAndType: event.target.value })} /></Field><Field label="Interner Gerätetyp"><input className="form-control" value={rule.internalDeviceType} onChange={(event) => change(index, { internalDeviceType: event.target.value })} /></Field><Field label="Anschlussart (optional)"><input className="form-control" value={rule.connectionKind ?? ""} onChange={(event) => change(index, { connectionKind: event.target.value || null })} /></Field><Field label="Kategorie"><select className="form-select" value={rule.category} onChange={(event) => change(index, { category: event.target.value as ExternalCsvFamilyTypeRule["category"] })}><option value="lighting">Beleuchtung</option><option value="single_phase">1-phasig</option><option value="three_phase">3-phasig</option></select></Field><Field label="Mengenregel"><select className="form-select" value={rule.quantityRule.kind} onChange={(event) => change(index, { quantityRule: event.target.value === "mapped-column" ? { kind: "mapped-column" } : { kind: "fixed", quantity: 1 } })}><option value="fixed">Feste Menge</option><option disabled={configuration.columns.quantity === null} value="mapped-column">Aus Mengenspalte</option></select></Field>{rule.quantityRule.kind === "fixed" ? <Field label="Feste Menge"><input className="form-control" min="0.001" step="any" type="number" value={rule.quantityRule.quantity} onChange={(event) => change(index, { quantityRule: { kind: "fixed", quantity: Number(event.target.value) } })} /></Field> : null}<Field label="Anzeigename vorschlagen aus"><select className="form-select" value={rule.displayNameSuggestion?.kind ?? "none"} onChange={(event) => change(index, { displayNameSuggestion: event.target.value === "none" ? null : event.target.value === "fixed" ? { kind: "fixed", value: "" } : { kind: event.target.value as "selection-marker" | "family-and-type" } })}><option value="none">Kein Vorschlag</option><option value="selection-marker">CAx-Auswahlkenner</option><option value="family-and-type">Familie und Typ</option><option value="fixed">Fester Text</option></select></Field>{rule.displayNameSuggestion?.kind === "fixed" ? <Field label="Fester Anzeigename"><input className="form-control" value={rule.displayNameSuggestion.value} onChange={(event) => change(index, { displayNameSuggestion: { kind: "fixed", value: event.target.value } })} /></Field> : null}</div><button className="btn btn-sm btn-outline-danger mt-2" onClick={() => onChange(rules.filter((_, ruleIndex) => ruleIndex !== index))} type="button">Regel entfernen</button></div>)}</div></section>;
}
function PreviewResult({ preview }: { preview: ExternalCsvPreviewDto }) {
const visibleObjects = useMemo(() => preview.objects.slice(0, 25), [preview]);
return <section className="border-top pt-3"><h3 className="h6">Prüfergebnis</h3><div className="row g-2 mb-3"><Stat label="Objekte" value={preview.objectCount} /><Stat label="Passthrough-Zeilen" value={preview.passthroughCount} /><Stat label="Verdachtszeilen" value={preview.suspectObjectCount} /><Stat label="Kopfzeile" value={preview.headerRowNumber} /></div><p className="small text-secondary text-break mb-2">SHA-256: {preview.sha256}</p><div className="table-responsive"><table className="table table-sm"><thead><tr><th>Zeile</th><th>IFC-GUID</th><th>Raum</th><th>Familie und Typ</th><th>Auswahlkenner</th><th>Stromkreis</th></tr></thead><tbody>{visibleObjects.map((object) => <tr key={object.ifcGuid}><td>{object.rowNumber}</td><td><code>{object.ifcGuid}</code></td><td>{[object.roomNumber, object.roomName].filter(Boolean).join(" · ") || "–"}</td><td>{object.familyAndType || "–"}</td><td>{object.selectionMarker || "–"}</td><td>{object.circuitIdentifier || "–"}</td></tr>)}</tbody></table></div>{preview.objects.length > visibleObjects.length ? <p className="small text-secondary">Es werden die ersten {visibleObjects.length} von {preview.objects.length} Objekten angezeigt.</p> : null}</section>;
}
interface RoomDecision {
roomId: string | null;
defaultDistributionBoardId: string | null;
}
export function RevitCsvImportPlan({
familyLinks,
isApplying,
onApply,
onCreateProjectDevice,
onCreateRoom,
onFamilyLinkChange,
onRoomDecisionChange,
plan,
roomDecisions,
setSourceName,
sourceName,
}: {
familyLinks: Record<string, string | null>;
isApplying: boolean;
onApply: () => void;
onCreateProjectDevice: (familyAndType: string) => void;
onCreateRoom: (sourceRoomKey: string) => void;
onFamilyLinkChange: (familyAndType: string, projectDeviceId: string | null) => void;
onRoomDecisionChange: (sourceRoomKey: string, decision: RoomDecision) => void;
plan: ExternalInitialImportPlanDto;
roomDecisions: Record<string, RoomDecision>;
setSourceName: (value: string) => void;
sourceName: string;
}) {
const hasBlockingIssues = plan.issueCounts.unknownFamilyAndType > 0;
const warningCount =
plan.issueCounts.invalidPower +
plan.issueCounts.invalidQuantity +
plan.issueCounts.missingRoom +
plan.suspectObjectCount;
const floorsById = new Map(plan.existing.floors.map((floor) => [floor.id, floor]));
return (
<section className="border-top pt-3">
<div className="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div>
<h3 className="h6 mb-1">Erstimport vorbereiten</h3>
<p className="small text-secondary mb-0">
Raum- und Gerätekatalog-Zuordnungen werden zusammen mit {plan.objectCount} Revit-Objekten gespeichert.
Stromkreise und Gerätezeilen entstehen dabei noch nicht.
</p>
</div>
<span className="badge text-bg-secondary">Konfiguration {plan.configurationVersion}</span>
</div>
<div className="row g-2 my-2">
<Stat label="Quellräume" value={plan.sourceRooms.length} />
<Stat label="Familien/Typen" value={plan.familyGroups.length} />
<Stat label="Objekte" value={plan.objectCount} />
<Stat label="Hinweise" value={warningCount} />
</div>
{hasBlockingIssues ? (
<div className="alert alert-danger">
{plan.issueCounts.unknownFamilyAndType} Objekte besitzen noch keine exakte Familie-und-Typ-Regel.
Ergänze die Regeln oben, speichere die Konfiguration und plane den Import erneut.
</div>
) : null}
{plan.issueCounts.invalidPower > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidPower} Objekten ist die Leistung ungültig.</div> : null}
{plan.issueCounts.invalidQuantity > 0 ? <div className="alert alert-warning py-2">Bei {plan.issueCounts.invalidQuantity} Objekten ist die Menge ungültig.</div> : null}
{plan.issueCounts.missingRoom > 0 ? <div className="alert alert-warning py-2">{plan.issueCounts.missingRoom} Objekte besitzen keine Raumangabe.</div> : null}
{plan.suspectObjectCount > 0 ? <div className="alert alert-warning py-2">{plan.suspectObjectCount} Objektzeilen wurden als verdächtig erkannt.</div> : null}
<label className="form-label mt-2" style={{ maxWidth: 520 }}>
<span>Bezeichnung des externen Modells</span>
<input
className="form-control"
onChange={(event) => setSourceName(event.target.value)}
value={sourceName}
/>
</label>
<h4 className="h6 mt-4">Räume zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Quellraum</th><th>Objekte</th><th>Treffer</th><th>Interner Raum</th><th>Standardverteilung</th></tr></thead>
<tbody>
{plan.sourceRooms.map((sourceRoom) => {
const decision = roomDecisions[sourceRoom.sourceRoomKey] ?? {
roomId: sourceRoom.suggestedRoomId,
defaultDistributionBoardId: null,
};
return (
<tr key={sourceRoom.sourceRoomKey}>
<td>{[sourceRoom.roomNumber, sourceRoom.roomName].filter(Boolean).join(" · ")}</td>
<td>{sourceRoom.objectCount}</td>
<td><RoomMatchBadge status={sourceRoom.matchStatus} /></td>
<td>
<div className="input-group input-group-sm">
<select
aria-label={`Interner Raum für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
roomId: event.target.value || null,
})}
value={decision.roomId ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.rooms.map((room) => {
const floor = room.floorId ? floorsById.get(room.floorId) : null;
return <option key={room.id} value={room.id}>{room.roomNumber} · {room.roomName}{floor ? ` (${floor.name})` : ""}</option>;
})}
</select>
<button
aria-label={`Raum ${sourceRoom.roomNumber || sourceRoom.roomName} neu anlegen`}
className="btn btn-outline-secondary"
onClick={() => onCreateRoom(sourceRoom.sourceRoomKey)}
type="button"
>
Neu
</button>
</div>
</td>
<td>
<select
aria-label={`Standardverteilung für ${sourceRoom.roomNumber || sourceRoom.roomName}`}
className="form-select form-select-sm"
onChange={(event) => onRoomDecisionChange(sourceRoom.sourceRoomKey, {
...decision,
defaultDistributionBoardId: event.target.value || null,
})}
value={decision.defaultDistributionBoardId ?? ""}
>
<option value="">Keine Standardverteilung</option>
{plan.existing.distributionBoards.map((board) => <option key={board.id} value={board.id}>{board.name}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<h4 className="h6 mt-4">Familien und Typen zuordnen</h4>
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead><tr><th>Familie und Typ</th><th>Objekte</th><th>Klassifizierung</th><th>ProjectDevice (optional)</th></tr></thead>
<tbody>
{plan.familyGroups.map((group) => (
<tr key={group.familyAndType}>
<td>{group.familyAndType || "–"}</td>
<td>{group.objectCount}</td>
<td>
{group.classified
? `${group.internalDeviceType ?? "–"} · ${categoryLabel(group.category)}`
: <span className="badge text-bg-danger">Regel fehlt</span>}
</td>
<td>
<div className="input-group input-group-sm">
<select
aria-label={`ProjectDevice für ${group.familyAndType}`}
className="form-select"
onChange={(event) => onFamilyLinkChange(group.familyAndType, event.target.value || null)}
value={familyLinks[group.familyAndType] ?? ""}
>
<option value="">Nicht verknüpfen</option>
{plan.existing.projectDevices.map((device) => (
<option key={device.id} value={device.id}>{device.displayName || device.name} · {categoryLabel(device.category)}</option>
))}
</select>
<button
aria-label={`ProjectDevice für ${group.familyAndType} neu anlegen`}
className="btn btn-outline-secondary"
disabled={!group.classified}
onClick={() => onCreateProjectDevice(group.familyAndType)}
type="button"
>
Neu
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="d-flex justify-content-end mt-3">
<button
className="btn btn-primary"
disabled={hasBlockingIssues || isApplying || !sourceName.trim()}
onClick={onApply}
type="button"
>
{isApplying ? "Import wird übernommen …" : "Erstimport übernehmen"}
</button>
</div>
</section>
);
}
function RoomMatchBadge({ status }: { status: ExternalInitialImportPlanDto["sourceRooms"][number]["matchStatus"] }) {
if (status === "exact-number") return <span className="badge text-bg-success">Eindeutig</span>;
if (status === "ambiguous-number") return <span className="badge text-bg-warning">Mehrdeutig</span>;
return <span className="badge text-bg-secondary">Neu</span>;
}
function categoryLabel(category: string | null) {
if (category === "lighting") return "Beleuchtung";
if (category === "single_phase") return "1-phasig";
if (category === "three_phase") return "3-phasig";
return "Ohne Kategorie";
}
export function createRevitProjectDevicePrefill(
plan: ExternalInitialImportPlanDto,
familyAndType: string
): Partial<CreateProjectDeviceInput> {
const group = plan.familyGroups.find((entry) => entry.familyAndType === familyAndType);
const firstObject = plan.objects.find(
(object) => object.sourceValues.familyAndType === familyAndType
);
const category = group?.category;
return {
name: group?.internalDeviceType || familyAndType || "Revit-Gerät",
displayName: firstObject?.suggestedPlanningValues.displayName || familyAndType,
connectionKind: group?.connectionKind ?? undefined,
category:
category === "lighting" || category === "three_phase"
? category
: "single_phase",
quantity: firstObject?.suggestedPlanningValues.effectiveQuantity ?? 1,
powerPerUnit:
firstObject?.suggestedPlanningValues.powerPerUnitW === null ||
firstObject?.suggestedPlanningValues.powerPerUnitW === undefined
? 0.1
: firstObject.suggestedPlanningValues.powerPerUnitW / 1000,
simultaneityFactor:
firstObject?.suggestedPlanningValues.simultaneityFactor ?? 1,
cosPhi: firstObject?.suggestedPlanningValues.cosPhi ?? undefined,
costGroup: firstObject?.suggestedPlanningValues.costGroup ?? undefined,
remark: firstObject?.suggestedPlanningValues.remark ?? undefined,
};
}
function Stat({ label, value }: { label: string; value: number }) { return <div className="col-6 col-lg-3"><div className="border rounded p-2"><div className="small text-secondary">{label}</div><strong>{value}</strong></div></div>; }
function fileToBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("CSV-Datei konnte nicht gelesen werden."));
reader.onload = () => {
const result = String(reader.result ?? "");
const commaIndex = result.indexOf(",");
if (commaIndex < 0) reject(new Error("CSV-Datei konnte nicht kodiert werden."));
else resolve(result.slice(commaIndex + 1));
};
reader.readAsDataURL(file);
});
}

View file

@ -0,0 +1,45 @@
"use client";
import { useState, type FormEvent } from "react";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ExternalXlsxColumnKey } from "../../external-model/xlsx/external-xlsx-contracts";
import { FormModal } from "./form-modal";
const labels: Record<ExternalXlsxColumnKey, string> = {
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "Raumnummer",
roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner",
socketCount: "Anzahl Objekte", sourceRemark: "Quellbemerkung", circuitCombined: "Stromkreis kombiniert",
boardName: "Verteiler", circuitIdentifier: "Stromkreis", designation: "Bezeichnung",
electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke",
};
export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: {
configuration: ExternalModelConfiguration;
isSaving: boolean;
onClose: () => void;
onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise<string>;
onSave: (configuration: ExternalModelConfiguration) => Promise<void>;
}) {
const [draft, setDraft] = useState(() => structuredClone(configuration));
const [sampleStatus, setSampleStatus] = useState<string | null>(null);
const columnKeys = Object.keys(draft.columns) as ExternalXlsxColumnKey[];
async function inspectSample(file: File | undefined) {
if (!file) return;
try {
setSampleStatus(await onInspectSample(file, draft));
} catch (error) {
setSampleStatus(error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden.");
}
}
return <FormModal title="Revit-XLSX-Einstellungen" description="Layout und Spalten der Linear-Bauteilliste" isSaving={isSaving} onClose={onClose} submitDisabled={JSON.stringify(draft) === JSON.stringify(configuration)} submitLabel="Einstellungen speichern" onSubmit={(event: FormEvent) => { event.preventDefault(); void onSave(draft); }}>
<div className="row g-3">
<div className="col-12"><label className="form-label" htmlFor="revit-sample">Musterdatei prüfen</label><input id="revit-sample" className="form-control" type="file" accept=".xlsx" onChange={(event) => void inspectSample(event.target.files?.[0])} />{sampleStatus ? <div className="form-text">{sampleStatus}</div> : null}</div>
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) => <div className="col-md-4" key={key}><label className="form-label" htmlFor={`layout-${key}`}>{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"}</label><input id={`layout-${key}`} className="form-control" min={1} type="number" value={draft.layout[key]} onChange={(event) => setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} /></div>)}
<div className="col-12"><label className="form-label" htmlFor="circuit-mode">Stromkreisspalten</label><select id="circuit-mode" className="form-select" value={draft.circuitMode} onChange={(event) => setDraft({ ...draft, circuitMode: event.target.value as ExternalModelConfiguration["circuitMode"] })}><option value="combined">Kombinierte Stromkreisspalte</option><option value="separate">Verteiler und Stromkreis getrennt</option></select></div>
<div className="col-12"><h3 className="fs-6 mb-0">Spaltenzuordnung</h3></div>
{columnKeys.map((key) => <div className="col-md-6" key={key}><label className="form-label" htmlFor={`column-${key}`}>{labels[key]}</label><input id={`column-${key}`} className="form-control" value={draft.columns[key] ?? ""} onChange={(event) => setDraft({ ...draft, columns: { ...draft.columns, [key]: event.target.value.trim() ? event.target.value : null } })} /></div>)}
</div>
</FormModal>;
}

View file

@ -14,7 +14,13 @@ import type {
ExternalCsvConfiguration, ExternalCsvConfiguration,
ExternalCsvDialect, ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts"; } from "../external-model/csv/external-csv-contracts";
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan"; import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
export interface ExternalModelConfigurationSnapshotDto {
id: string;
projectId: string;
configuration: ExternalModelConfiguration;
}
export interface ExternalCsvConfigurationSnapshotDto { export interface ExternalCsvConfigurationSnapshotDto {
id: string; id: string;
@ -52,9 +58,80 @@ export interface ExternalCsvPreviewDto {
suspectRowNumbers: number[]; suspectRowNumbers: number[];
} }
export type ExternalInitialImportPlanDto = ReturnType< export interface ExternalInitialImportPlanDto {
typeof createExternalInitialImportPlan configurationVersion: number;
>; fileName: string;
sha256: string;
byteLength: number;
rowCount: number;
objectCount: number;
passthroughCount: number;
suspectObjectCount: number;
suspectRowNumbers: number[];
sourceRooms: Array<{
sourceRoomKey: string;
roomNumber: string;
roomName: string;
objectCount: number;
suggestedRoomId: string | null;
matchStatus: "exact-number" | "ambiguous-number" | "new";
candidateRoomIds: string[];
}>;
familyGroups: Array<{
familyAndType: string;
objectCount: number;
classified: boolean;
category: string | null;
connectionKind: string | null;
internalDeviceType: string | null;
}>;
issueCounts: {
unknownFamilyAndType: number;
invalidPower: number;
invalidQuantity: number;
missingRoom: number;
};
objects: Array<{
ifcGuid: string;
sourceRoomKey: string | null;
sourceValues: ExternalCsvPreviewObjectDto;
suggestedPlanningValues: {
displayName: string | null;
internalDeviceType: string | null;
category: CircuitGroupCategory | null;
connectionKind: string | null;
effectiveQuantity: number;
powerPerUnitW: number | null;
simultaneityFactor: number;
cosPhi: number | null;
costGroup: string | null;
remark: string | null;
};
issues: string[];
}>;
existing: {
floors: Array<{ id: string; name: string; sortOrder: number }>;
rooms: Array<{
id: string;
floorId: string | null;
roomNumber: string;
roomName: string;
}>;
distributionBoards: Array<{
id: string;
name: string;
floorId: string | null;
}>;
projectDevices: Array<{
id: string;
name: string;
displayName: string;
category: string | null;
connectionKind: string | null;
powerPerUnit: number;
}>;
};
}
export interface ApplyExternalInitialImportInput { export interface ApplyExternalInitialImportInput {
expectedRevision: number; expectedRevision: number;

View file

@ -38,18 +38,14 @@ import type {
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
ProjectCircuitTreesResponseDto, ProjectCircuitTreesResponseDto,
ExternalCsvConfigurationSnapshotDto, ExternalModelConfigurationSnapshotDto,
ExternalCsvPreviewDto,
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
CreateEstimateBalanceInput, CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto, EstimateBalanceCommandResultDto,
EstimateBalanceDto, EstimateBalanceDto,
EstimateBalanceUpdateResultDto, EstimateBalanceUpdateResultDto,
EstimateComparisonDto, EstimateComparisonDto,
} from "../types"; } from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import { import {
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
@ -168,6 +164,40 @@ export function getProject(projectId: string) {
return request<ProjectDto>(`/api/projects/${projectId}`); return request<ProjectDto>(`/api/projects/${projectId}`);
} }
export function getExternalModelConfiguration(projectId: string) {
return request<{ configuration: ExternalModelConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-model/configuration`
);
}
export function updateExternalModelConfiguration(
projectId: string,
expectedRevision: number,
configuration: ExternalModelConfiguration
) {
return request<{
configuration: ExternalModelConfigurationSnapshotDto;
history: ProjectHistoryStateDto;
}>(`/api/projects/${projectId}/external-model/configuration`, {
method: "PUT",
body: JSON.stringify({ expectedRevision, configuration }),
});
}
export async function detectExternalModelWorkbook(
projectId: string,
file: File,
configuration: ExternalModelConfiguration
) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return request<{ sheetName: string; columnCount: number; objectCount: number }>(
`/api/projects/${projectId}/external-model/configuration/detect`,
{ method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: btoa(binary), configuration }) }
);
}
export function getProjectHistory(projectId: string) { export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>( return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history` `/api/projects/${projectId}/history`
@ -469,12 +499,6 @@ export function deleteDocumentLogo() {
return request<void>(documentLogoUrl, { method: "DELETE" }); return request<void>(documentLogoUrl, { method: "DELETE" });
} }
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`
);
}
export function getExternalCircuitListObjects( export function getExternalCircuitListObjects(
projectId: string, projectId: string,
circuitListId: string circuitListId: string
@ -484,57 +508,6 @@ export function getExternalCircuitListObjects(
); );
} }
export function updateExternalCsvConfiguration(
projectId: string,
expectedRevision: number,
configuration: ExternalCsvConfiguration
) {
return request<
ProjectCommandResultDto & { configuration: ExternalCsvConfigurationSnapshotDto }
>(`/api/projects/${projectId}/external-csv/configuration`, {
method: "PUT",
body: JSON.stringify({ expectedRevision, configuration }),
});
}
export function previewExternalCsv(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalCsvPreviewDto>(
`/api/projects/${projectId}/external-csv/preview`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function planExternalInitialImport(
projectId: string,
fileName: string,
contentBase64: string
) {
return request<ExternalInitialImportPlanDto>(
`/api/projects/${projectId}/external-csv/initial-import/plan`,
{
method: "POST",
body: JSON.stringify({ fileName, contentBase64 }),
}
);
}
export function applyExternalInitialImport(
projectId: string,
input: ApplyExternalInitialImportInput
) {
return request<ExternalInitialImportResultDto>(
`/api/projects/${projectId}/external-csv/initial-import/apply`,
{ method: "POST", body: JSON.stringify(input) }
);
}
export function copyDistributionBoard( export function copyDistributionBoard(
projectId: string, projectId: string,
distributionBoardId: string, distributionBoardId: string,

View file

@ -62,6 +62,7 @@ const commandTypeLabels: Record<string, string> = {
"project.restore-state": "Projektstand wiederhergestellt", "project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet", "circuit-protection.update": "Stromkreisschutz bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet", "external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen", "external-import.apply-initial": "Revit-Erstimport übernommen",
"external-object.assign-to-new-circuit": "external-object.assign-to-new-circuit":
"Externes Objekt in neuen Stromkreis übernommen", "Externes Objekt in neuen Stromkreis übernommen",

View file

@ -0,0 +1,17 @@
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
export function createDefaultRevitXlsxConfiguration(): ExternalModelConfiguration {
return {
schemaVersion: 1,
layout: { headerRow: 1, firstDataRow: 3, firstDataColumn: 1 },
circuitMode: "combined",
columns: {
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name", familyAndType: "Familie und Typ", selectionMarker: "CAx_Auswahlkenner",
socketCount: "CAX_AnzahlObjekte", sourceRemark: "CAx_Anmerkung", circuitCombined: "kbp_Stromkreisnummer",
boardName: "kbp-E-Verteiler", circuitIdentifier: "kbp-E-Stromkreis", designation: "kbp-E-Bezeichnung",
electricalPowerW: "kbp-E-Elektrische Leistung", voltageV: "kbp-E-Spannung", currentA: "kbp-E-Stromstärke",
},
deviceTypes: [], familyTypeRules: [],
};
}

View file

@ -13,7 +13,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js"; import { UserRepository } from "../../db/repositories/user.repository.js";
@ -36,8 +36,8 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db);
export const projectRepository = new ProjectRepository(db); export const projectRepository = new ProjectRepository(db);
export const roomRepository = new RoomRepository(db); export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository = export const externalModelConfigurationRepository =
new ExternalCsvConfigurationRepository(db); new ExternalModelConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db); export const userRepository = new UserRepository(db);

View file

@ -22,8 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-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 { 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";
@ -71,10 +70,8 @@ export const projectSettingsProjectCommandStore =
export const projectStateRestoreCommandStore = export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db); new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
export const externalCsvConfigurationProjectCommandStore = export const externalModelConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db); new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore = export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db); new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore = export const externalObjectNewRowProjectCommandStore =
@ -106,8 +103,7 @@ export const projectCommandService = new ProjectCommandService(
circuitGroupSubtreeProjectCommandStore, circuitGroupSubtreeProjectCommandStore,
circuitProtectionProjectCommandStore, circuitProtectionProjectCommandStore,
projectHistoryStore, projectHistoryStore,
externalCsvConfigurationProjectCommandStore, externalModelConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore, externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore, externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore, externalObjectNewCircuitProjectCommandStore,

View file

@ -1,298 +0,0 @@
import crypto from "node:crypto";
import type { Request, Response } from "express";
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../../external-model/application/external-initial-import-target.js";
import { createExternalCircuitListObjects } from "../../external-model/application/external-circuit-list-objects.js";
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
import {
createEmptyExternalModelState,
createExternalInitialImportProjectCommand,
} from "../../domain/models/external-initial-import-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
import {
previewExternalCsvSchema,
planExternalInitialImportSchema,
applyExternalInitialImportSchema,
updateExternalCsvConfigurationSchema,
} from "../../shared/validation/external-csv.schemas.js";
import {
distributionBoardRepository,
externalCsvConfigurationRepository,
externalModelStateRepository,
floorRepository,
projectDeviceRepository,
roomRepository,
circuitListRepository,
} from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export function getExternalCsvConfiguration(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const result = externalCsvConfigurationRepository.getByProject(projectId);
if (!result.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
return res.json({ configuration: result.configuration });
}
export async function getExternalCircuitListObjects(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const { circuitListId } = req.params;
if (typeof circuitListId !== "string" || !circuitListId.trim()) {
return res.status(400).json({ error: "Invalid circuitListId" });
}
const circuitList = await circuitListRepository.findById(projectId, circuitListId);
if (!circuitList) {
return res.status(404).json({ error: "Circuit list not found" });
}
const state = externalModelStateRepository.getByProject(projectId);
if (!state.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(createExternalCircuitListObjects({
state: state.state,
distributionBoardId: circuitList.distributionBoardId,
}));
}
export function updateExternalCsvConfiguration(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
assertExternalCsvConfiguration(parsed.data.configuration);
const current = externalCsvConfigurationRepository.getByProject(projectId);
if (!current.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
const target = {
id: current.configuration?.id ?? crypto.randomUUID(),
projectId,
configurationVersion:
(current.configuration?.configurationVersion ?? 0) + 1,
configuration: parsed.data.configuration,
};
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Revit-CSV-Konfiguration bearbeiten",
command: createExternalCsvConfigurationUpdateProjectCommand(
current.configuration,
target
),
});
return res.json({ ...result, configuration: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function previewExternalCsv(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = previewExternalCsvSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
return res.json(
createExternalCsvPreview({
fileName: parsed.data.fileName,
bytes,
configuration: stored.configuration.configuration,
})
);
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return res.status(400).json({
error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.",
});
}
}
export async function planExternalInitialImport(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = planExternalInitialImportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
const externalState = externalModelStateRepository.getByProject(projectId);
if (externalState.state.source !== null) {
return res.status(409).json({
error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.",
code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
floorRepository.listByProject(projectId),
roomRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
projectDeviceRepository.listByProject(projectId),
]);
return res.json(createExternalInitialImportPlan({
fileName: parsed.data.fileName,
bytes,
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
floors,
rooms,
distributionBoards,
projectDevices,
}));
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return res.status(400).json({
error: error instanceof Error ? error.message : "Erstimport-Planung fehlgeschlagen.",
});
}
}
export async function applyExternalInitialImport(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) return;
const parsed = applyExternalInitialImportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const stored = externalCsvConfigurationRepository.getByProject(projectId);
if (!stored.projectExists) {
return res.status(404).json({ error: "Project not found" });
}
if (!stored.configuration) {
return res.status(409).json({
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
});
}
if (
stored.configuration.configurationVersion !==
parsed.data.expectedConfigurationVersion
) {
return res.status(409).json({
error: "Die Revit-CSV-Konfiguration wurde seit der Planung geändert.",
code: "EXTERNAL_CSV_CONFIGURATION_CHANGED",
});
}
try {
const bytes = decodeBase64(parsed.data.contentBase64);
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
floorRepository.listByProject(projectId),
roomRepository.listByProject(projectId),
distributionBoardRepository.listByProject(projectId),
projectDeviceRepository.listByProject(projectId),
]);
const plan = createExternalInitialImportPlan({
fileName: parsed.data.fileName,
bytes,
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
floors,
rooms,
distributionBoards,
projectDevices,
});
if (plan.sha256 !== parsed.data.expectedSha256) {
return res.status(409).json({
error: "Die CSV-Datei stimmt nicht mehr mit der geprüften Planung überein.",
code: "EXTERNAL_CSV_FILE_CHANGED",
});
}
const target = buildExternalInitialImportTarget({
projectId,
expectedRevision: parsed.data.expectedRevision,
sourceName: parsed.data.sourceName,
importedAtIso: new Date().toISOString(),
configurationVersion: stored.configuration.configurationVersion,
configuration: stored.configuration.configuration,
originalContentBase64: parsed.data.contentBase64,
document: parseExternalCsv(bytes, stored.configuration.configuration),
plan,
roomDecisions: parsed.data.roomDecisions,
familyProjectDeviceDecisions:
parsed.data.familyProjectDeviceDecisions,
createId: () => crypto.randomUUID(),
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: `Revit-Erstimport ${parsed.data.fileName}`,
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
});
return res.status(201).json({
...result,
import: {
sourceId: target.source!.id,
importBatchId: target.importBatches[0].id,
objectCount: target.objects.length,
},
});
} catch (error) {
if (error instanceof ExternalCsvParseError) {
return res.status(400).json({ error: error.message, code: error.code });
}
return respondWithProjectCommandError(error, res);
}
}
function decodeBase64(value: string) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
}
const bytes = Buffer.from(value, "base64");
if (bytes.toString("base64") !== value) {
throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert.");
}
if (bytes.length > 18 * 1024 * 1024) {
throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB.");
}
return bytes;
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}

View file

@ -0,0 +1,60 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.js";
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { parseExternalXlsx } from "../../external-model/xlsx/external-xlsx-transport.js";
import { externalModelConfigurationRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export function getExternalModelConfiguration(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const result = externalModelConfigurationRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
return res.json({ configuration: result.configuration });
}
export function updateExternalModelConfiguration(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
const expectedRevision = req.body?.expectedRevision;
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
return res.status(400).json({ error: "Ungültige Projektrevision." });
}
try {
assertExternalModelConfiguration(req.body?.configuration);
const current = externalModelConfigurationRepository.getByProject(projectId);
if (!current.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const target = {
id: current.configuration?.id ?? randomUUID(),
projectId,
configuration: req.body.configuration,
};
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Revit-XLSX-Einstellungen gespeichert",
command: createExternalModelConfigurationUpdateProjectCommand(current.configuration, target),
});
return res.json({ configuration: target, history: result.history });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function detectExternalModelWorkbook(req: Request, res: Response) {
try {
assertExternalModelConfiguration(req.body?.configuration);
const document = await parseExternalXlsx({
fileName: req.body?.fileName,
bytes: Buffer.from(req.body?.contentBase64 ?? "", "base64"),
configuration: req.body.configuration,
});
return res.json({
sheetName: document.sheetName,
columnCount: document.columns.length,
objectCount: document.rows.filter((row) => row.classification === "object").length,
});
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden." });
}
}

View file

@ -54,13 +54,10 @@ import {
importProjectTransferAsNewProject, importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js"; } from "../controllers/project-transfer.controller.js";
import { import {
getExternalCsvConfiguration, getExternalModelConfiguration,
applyExternalInitialImport, detectExternalModelWorkbook,
previewExternalCsv, updateExternalModelConfiguration,
planExternalInitialImport, } from "../controllers/external-model-configuration.controller.js";
updateExternalCsvConfiguration,
getExternalCircuitListObjects,
} from "../controllers/external-csv.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@ -70,21 +67,6 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/export", exportProjectTransfer); projectRouter.get("/:projectId/export", exportProjectTransfer);
projectRouter.post("/:projectId/import", importProjectTransfer); projectRouter.post("/:projectId/import", importProjectTransfer);
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
projectRouter.post(
"/:projectId/external-csv/initial-import/plan",
planExternalInitialImport
);
projectRouter.get(
"/:projectId/circuit-lists/:circuitListId/external-objects",
getExternalCircuitListObjects
);
projectRouter.post(
"/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport
);
projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions); projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/commands", executeProjectCommand);
@ -97,6 +79,9 @@ projectRouter.post(
restoreProjectSnapshot restoreProjectSnapshot
); );
projectRouter.put("/:projectId", updateProjectSettings); projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration);
projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration);
projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
projectRouter.post( projectRouter.post(

View file

@ -1,40 +0,0 @@
import { z } from "zod";
// Base64 length of the 18 MiB transport limit documented for CSV uploads
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
// padding). Keep in sync with the controller's raw byte-length check.
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
export const updateExternalCsvConfigurationSchema = z
.object({
expectedRevision: z.number().int().nonnegative(),
configuration: z.unknown(),
})
.strict();
export const previewExternalCsvSchema = z
.object({
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
})
.strict();
export const planExternalInitialImportSchema = previewExternalCsvSchema;
export const applyExternalInitialImportSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
expectedConfigurationVersion: z.number().int().positive(),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
sourceName: z.string().trim().min(1).max(200),
roomDecisions: z.array(z.object({
sourceRoomKey: z.string().trim().min(1),
roomId: z.string().trim().min(1).nullable(),
defaultDistributionBoardId: z.string().trim().min(1).nullable(),
}).strict()),
familyProjectDeviceDecisions: z.array(z.object({
familyAndType: z.string(),
projectDeviceId: z.string().trim().min(1).nullable(),
}).strict()),
}).strict();

View file

@ -1,105 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalCircuitListObjects } from "../src/external-model/application/external-circuit-list-objects.js";
import type {
ExternalModelObjectSnapshot,
ExternalModelStateSnapshot,
} from "../src/external-model/domain/external-model-contracts.js";
function object(input: {
id: string;
distributionBoardId: string;
circuitDeviceRowId: string | null;
roomNumber: string;
}): ExternalModelObjectSnapshot {
return {
id: input.id,
projectId: "project-1",
sourceId: "source-1",
ifcGuid: `ifc-${input.id}`,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: input.roomNumber,
roomName: "Büro",
familyAndType: "Steckdose: Standard",
selectionMarker: `S-${input.id}`,
circuitIdentifier: "-2F1.1",
power: "120",
quantity: "1",
additionalSourceValues: {},
},
planningValues: {
displayName: "Steckdose",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "socket",
effectiveQuantity: 1,
powerPerUnitW: 120,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: input.distributionBoardId === "board-1" ? "mapping-1" : null,
distributionBoardId: input.distributionBoardId,
linkedProjectDeviceId: null,
circuitDeviceRowId: input.circuitDeviceRowId,
presenceStatus: "present",
};
}
describe("external circuit-list object projection", () => {
it("returns only the board objects with unassigned objects first", () => {
const state: ExternalModelStateSnapshot = {
source: {
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
},
importBatches: [],
roomMappings: [{
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:101",
sourceFloorName: "EG",
sourceRoomNumber: "101",
sourceRoomName: "Besprechung",
roomId: "room-1",
defaultDistributionBoardId: "board-1",
}],
objects: [
object({ id: "assigned", distributionBoardId: "board-1", circuitDeviceRowId: "row-1", roomNumber: "102" }),
object({ id: "unassigned", distributionBoardId: "board-1", circuitDeviceRowId: null, roomNumber: "101" }),
object({ id: "other-board", distributionBoardId: "board-2", circuitDeviceRowId: null, roomNumber: "001" }),
],
};
const result = createExternalCircuitListObjects({
state,
distributionBoardId: "board-1",
});
assert.equal(result.sourceName, "Revit-Gesamtmodell");
assert.equal(result.objectCount, 2);
assert.equal(result.unassignedObjectCount, 1);
assert.deepEqual(result.objects.map(({ id }) => id), ["unassigned", "assigned"]);
assert.equal(result.objects[0].sourceRoomName, "Besprechung");
assert.equal(result.objects[0].roomId, "room-1");
assert.equal(result.objects[0].sourceCircuitIdentifier, "-2F1.1");
});
it("returns an empty projection before the optional Revit import", () => {
assert.deepEqual(
createExternalCircuitListObjects({
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
distributionBoardId: "board-1",
}),
{ sourceName: null, objectCount: 0, unassignedObjectCount: 0, objects: [] }
);
});
});

View file

@ -1,83 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
applyExternalInitialImportSchema,
MAX_CSV_CONTENT_BASE64_LENGTH,
planExternalInitialImportSchema,
previewExternalCsvSchema,
updateExternalCsvConfigurationSchema,
} from "../src/shared/validation/external-csv.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("external CSV API contracts", () => {
it("accepts strict update and preview request envelopes", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: 4,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
}).success,
true
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
assert.equal(
applyExternalInitialImportSchema.safeParse({
expectedRevision: 5,
expectedConfigurationVersion: 2,
expectedSha256: "a".repeat(64),
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [],
familyProjectDeviceDecisions: [],
}).success,
true
);
});
it("rejects stale-shaped, oversized and unknown request fields", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: -1,
configuration: {},
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
apply: true,
}).success,
false
);
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
decisions: [],
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
}).success,
false
);
});
});

View file

@ -1,244 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
return context;
}
function snapshot(version = 1) {
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Standard",
internalDeviceType: "Steckdose",
connectionKind: null,
category: "single_phase",
quantityRule: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
return {
id: "external-config-1",
projectId: "project-1",
configurationVersion: version,
configuration,
};
}
describe("external CSV configuration project command", () => {
it("persists insert and update with exact inverse commands", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Revit-CSV-Konfiguration anlegen",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.deepEqual(readConfiguration(context), initial);
assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null });
const target = structuredClone(initial);
target.configurationVersion = 2;
target.configuration.decimalSeparator = ".";
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, target),
});
assert.equal(updated.revision.revisionNumber, 2);
assert.deepEqual(readConfiguration(context), target);
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
} finally {
context.close();
}
});
it("rejects stale state and cross-project targets without a partial revision", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)),
}),
/changed before update/
);
const foreign = snapshot(2);
foreign.projectId = "project-2";
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign),
}),
/identity must remain stable|different project/
);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
const skippedVersion = snapshot(3);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion),
}),
/version must advance by one/
);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial),
}),
/did not change/
);
} finally {
context.close();
}
});
it("includes configuration in snapshots and remaps it for project duplicates", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const state = readProjectStateSnapshot(context.db, "project-1")?.state;
assert.deepEqual(state?.externalCsvConfiguration, initial);
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const duplicatedConfiguration = context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, duplicate.projectId))
.get();
assert.ok(duplicatedConfiguration);
assert.notEqual(duplicatedConfiguration.id, initial.id);
assert.equal(duplicatedConfiguration.projectId, duplicate.projectId);
assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration);
} finally {
context.close();
}
});
it("distinguishes projects without configuration from unknown projects", () => {
const context = createTestDatabase();
try {
const reader = new ExternalCsvConfigurationRepository(context.db);
assert.deepEqual(reader.getByProject("project-1"), {
projectExists: true,
configuration: null,
});
assert.deepEqual(reader.getByProject("missing"), {
projectExists: false,
configuration: null,
});
} finally {
context.close();
}
});
it("restores and undoes the complete configuration through snapshot history", () => {
const context = createTestDatabase();
try {
const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const configuredState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(configuredState);
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, null),
});
const emptyState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(emptyState);
const restoreRepository = new ProjectStateRestoreCommandRepository(context.db);
const restored = restoreRepository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createProjectStateRestoreCommand(
emptyState.payloadSha256,
configuredState.state
),
});
assert.deepEqual(readConfiguration(context), initial);
restoreRepository.execute({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.equal(readConfiguration(context), null);
} finally {
context.close();
}
});
});
function readConfiguration(context: DatabaseContext) {
return context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, "project-1"))
.get() ?? null;
}

View file

@ -1,56 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external CSV preview", () => {
it("projects transport statistics and mapped object source values without writes", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.additionalSourceMappings.push({
sourceColumn: "Raumname",
targetField: "sourceRoomLabel",
});
// The additional mapping intentionally needs a distinct source column in
// persisted configuration, so use a separately named synthetic header.
configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert";
const csv = quotedRevitCsv
.replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"')
.replaceAll(';"2";"0Ab', ';"2";"A";"0Ab')
.replaceAll(';"1";"1Ab', ';"1";"B";"1Ab')
.replaceAll(';"1";""', ';"1";"C";""')
.replaceAll(';"";""\r\n', ';"";"";""\r\n');
const preview = createExternalCsvPreview({
fileName: "export.csv",
bytes: utf8Bytes(csv, true),
configuration,
});
assert.equal(preview.fileName, "export.csv");
assert.equal(preview.headerRowNumber, 2);
assert.equal(preview.rowCount, 7);
assert.equal(preview.objectCount, 2);
assert.equal(preview.passthroughCount, 2);
assert.equal(preview.nonEmptyPassthroughCount, 1);
assert.equal(preview.suspectObjectCount, 1);
assert.deepEqual(preview.suspectRowNumbers, [6]);
assert.match(preview.sha256, /^[a-f0-9]{64}$/);
assert.deepEqual(preview.objects[0], {
rowNumber: 4,
ifcGuid: firstIfcGuid,
roomNumber: "01/101",
roomName: "Technik",
familyAndType: "Steckdose: Doppelsteckdose",
selectionMarker: "Arbeitsplatz",
circuitIdentifier: "UV_AV_01-2F1",
power: "120,5",
quantity: "2",
additionalSourceValues: { sourceRoomLabel: "A" },
});
});
});

View file

@ -1,146 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ExternalCsvParseError,
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external CSV transport", () => {
it("detects the observed Revit dialect and classifies every row", () => {
const document = parseExternalCsv(
utf8Bytes(quotedRevitCsv, true),
externalCsvTestConfiguration
);
assert.deepEqual(document.dialect, {
encoding: "utf-8",
hasBom: true,
delimiter: ";",
lineEnding: "\r\n",
quoteCharacter: '"',
quoteAllFields: true,
hasTrailingLineEnding: true,
});
assert.equal(document.headerRowIndex, 1);
assert.deepEqual(
document.rows.map((row) => row.classification),
[
"metadata",
"header",
"passthrough",
"object",
"passthrough",
"suspect-object",
"object",
]
);
assert.equal(document.rows[6].cells[2].value, 'Büro "Nord"');
});
it("round-trips BOM, CRLF, quotation, order and cell values byte for byte", () => {
const source = utf8Bytes(quotedRevitCsv, true);
const document = parseExternalCsv(source, externalCsvTestConfiguration);
assert.deepEqual(serializeExternalCsv(document), source);
});
it("parses exports without an optional quantity column", () => {
const configuration = {
...externalCsvTestConfiguration,
columns: {
...externalCsvTestConfiguration.columns,
quantity: null,
},
};
const csv = [
'"Projekt-Export";"";"";"";"";"";""',
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"IfcGUID"',
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Standard";"Arbeitsplatz";"100";"${firstIfcGuid}"`,
].join("\r\n") + "\r\n";
const document = parseExternalCsv(utf8Bytes(csv, true), configuration);
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
1
);
});
it("preserves embedded delimiters and line endings inside quoted cells", () => {
const csv = quotedRevitCsv.replace(
'"Steckdose: Doppelsteckdose"',
'"Steckdose; Doppel\r\nsteckdose"'
);
const source = utf8Bytes(csv, true);
const document = parseExternalCsv(source, externalCsvTestConfiguration);
assert.equal(
document.rows[3].cells[3].value,
"Steckdose; Doppel\r\nsteckdose"
);
assert.deepEqual(serializeExternalCsv(document), source);
});
it("blocks missing and ambiguous configured headers", () => {
assertParseError(
utf8Bytes(quotedRevitCsv.replace("IfcGUID", "Andere ID")),
"header-not-found"
);
const duplicatedHeader = quotedRevitCsv.replace(
'"";"";"";"";"";"";"";""',
quotedRevitCsv.split("\r\n")[1]
);
assertParseError(utf8Bytes(duplicatedHeader), "ambiguous-header");
const duplicateConfiguredColumn = quotedRevitCsv.replace(
'"Stromkreis";"Raumnummer"',
'"Stromkreis";"Raumnummer";"Raumnummer"'
);
assertParseError(
utf8Bytes(duplicateConfiguredColumn),
"ambiguous-header"
);
});
it("blocks invalid and duplicate non-empty IfcGUIDs", () => {
assertParseError(
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, "not-an-ifc-guid")),
"invalid-ifc-guid"
);
assertParseError(
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, ` ${firstIfcGuid}`)),
"invalid-ifc-guid"
);
const duplicate = quotedRevitCsv.replace(
"1AbCdEfGhIjKlMnOpQrStu",
firstIfcGuid
);
assertParseError(utf8Bytes(duplicate), "duplicate-ifc-guid");
});
it("blocks invalid UTF-8, unterminated quotes and mixed row endings", () => {
assertParseError(new Uint8Array([0xff, 0xfe, 0xfd]), "invalid-encoding");
assertParseError(
utf8Bytes(quotedRevitCsv.replace(/"\r\n$/, "\r\n")),
"invalid-csv"
);
assertParseError(
utf8Bytes(quotedRevitCsv.replace("\r\n", "\n")),
"invalid-csv"
);
});
});
function assertParseError(input: Uint8Array, code: ExternalCsvParseError["code"]) {
assert.throws(
() => parseExternalCsv(input, externalCsvTestConfiguration),
(error: unknown) => error instanceof ExternalCsvParseError && error.code === code
);
}

View file

@ -1,149 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../src/external-model/application/external-initial-import-target.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external initial import plan", () => {
it("groups source rooms and exposes exact room and classification suggestions", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Doppelsteckdose",
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: { kind: "mapped-column" },
displayNameSuggestion: { kind: "selection-marker" },
});
const plan = createExternalInitialImportPlan({
fileName: "revit.csv",
bytes: utf8Bytes(quotedRevitCsv, true),
configurationVersion: 4,
configuration,
floors: [{ id: "floor-1", name: "EG", sortOrder: 10 }],
rooms: [{
id: "room-1",
floorId: "floor-1",
roomNumber: "01/101",
roomName: "Technik Bestand",
}],
distributionBoards: [{ id: "board-1", name: "UV 1", floorId: "floor-1" }],
projectDevices: [{
id: "device-1",
name: "socket",
displayName: "Steckdose",
category: "single_phase",
connectionKind: "socket",
powerPerUnit: 0.12,
}],
});
assert.equal(plan.configurationVersion, 4);
assert.equal(plan.objectCount, 2);
assert.equal(plan.sourceRooms.length, 2);
assert.deepEqual(
plan.sourceRooms.map((room) => ({
number: room.roomNumber,
status: room.matchStatus,
suggested: room.suggestedRoomId,
})),
[
{ number: "01/101", status: "exact-number", suggested: "room-1" },
{ number: "01/103", status: "new", suggested: null },
]
);
assert.equal(plan.familyGroups.length, 2);
assert.equal(plan.issueCounts.unknownFamilyAndType, 1);
assert.equal(plan.issueCounts.invalidPower, 0);
assert.equal(plan.issueCounts.invalidQuantity, 0);
assert.equal(plan.objects[0].ifcGuid, firstIfcGuid);
assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2);
assert.equal(plan.existing.distributionBoards[0].id, "board-1");
assert.equal(plan.existing.projectDevices[0].id, "device-1");
});
it("builds a stable row-free target only after every family is classified", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
for (const exactFamilyAndType of [
"Steckdose: Doppelsteckdose",
"Steckdose: Standard",
]) {
configuration.familyTypeRules.push({
exactFamilyAndType,
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: exactFamilyAndType.includes("Doppel")
? { kind: "mapped-column" }
: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
}
const bytes = utf8Bytes(quotedRevitCsv, true);
const plan = createExternalInitialImportPlan({
fileName: "revit.csv",
bytes,
configurationVersion: 2,
configuration,
floors: [],
rooms: [],
distributionBoards: [],
projectDevices: [],
});
let id = 0;
const target = buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: plan.sourceRooms.map((room) => ({
sourceRoomKey: room.sourceRoomKey,
roomId: null,
defaultDistributionBoardId: null,
})),
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => `generated-${++id}`,
});
assert.equal(target.importBatches[0].appliedProjectRevision, 8);
assert.equal(target.importBatches[0].configurationVersion, 2);
assert.equal(target.objects.length, 2);
assert.ok(target.objects.every((object) => object.circuitDeviceRowId === null));
assert.ok(target.objects.every((object) => object.overriddenFields.length === 0));
assert.throws(
() => buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: [],
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => "unused",
}),
/Quellraum-Entscheidungen/
);
});
});

View file

@ -1,297 +0,0 @@
import crypto from "node:crypto";
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import {
createEmptyExternalModelState,
createExternalInitialImportProjectCommand,
} from "../src/domain/models/external-initial-import-project-command.model.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import {
createExternalRoomKey,
projectInitialExternalObject,
} from "../src/external-model/domain/external-model-matching.js";
import {
externalCsvTestConfiguration,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
function createTestContext() {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
const configuration = configuredCsv();
context.db.insert(externalCsvConfigurations).values({
id: "config-1",
projectId: "project-1",
configurationVersion: 1,
configuration,
}).run();
return { context, configuration };
}
function configuredCsv() {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.familyTypeRules.push(
{
exactFamilyAndType: "Steckdose: Doppelsteckdose",
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: { kind: "mapped-column" },
displayNameSuggestion: { kind: "selection-marker" },
},
{
exactFamilyAndType: "Steckdose: Standard",
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
}
);
return configuration;
}
function initialState(
configuration: ReturnType<typeof configuredCsv>
): ExternalModelStateSnapshot {
const bytes = utf8Bytes(quotedRevitCsv, true);
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes,
configuration,
});
const roomKeys = [...new Set(preview.objects.map((object) =>
createExternalRoomKey(object.roomNumber, object.roomName)
).filter((key): key is string => key !== null))];
const roomMappings = roomKeys.map((key, index) => {
const object = preview.objects.find(
(candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key
)!;
return {
id: `mapping-${index + 1}`,
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: key,
sourceFloorName: null,
sourceRoomNumber: object.roomNumber,
sourceRoomName: object.roomName,
roomId: null,
defaultDistributionBoardId: null,
};
});
const mappingByKey = new Map(
roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id])
);
return {
source: {
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
},
importBatches: [{
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
appliedProjectRevision: 1,
configurationVersion: 1,
configurationSnapshot: configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
}],
roomMappings,
objects: preview.objects.map((candidate, index) => {
const projected = projectInitialExternalObject(candidate, configuration);
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
return {
id: `object-${index + 1}`,
projectId: "project-1",
sourceId: "source-1",
ifcGuid: projected.ifcGuid,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: projected.sourceValues,
planningValues: projected.planningValues,
overriddenFields: [],
externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!,
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: null,
presenceStatus: "present",
};
}),
};
}
describe("external initial import project command", () => {
it("commits the complete import and supports persisted undo and redo", () => {
const { context, configuration } = createTestContext();
try {
const target = initialState(configuration);
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Revit-Erstimport übernehmen",
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.objects.length,
2
);
assert.ok(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.objects.every((object) => object.circuitDeviceRowId === null)
);
const undone = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(undone.revision.revisionNumber, 2);
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.source,
null
);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: undone.inverse,
});
const redone = new ExternalModelStateRepository(context.db)
.getByProject("project-1").state;
assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]);
assert.equal(redone.importBatches[0].configurationVersion, 1);
} finally {
context.close();
}
});
it("rejects a target state with zero classified objects before writing anything", () => {
// assertPopulatedInitialState requires at least one object, so a CSV
// that classified none must be rejected at command construction, not
// reach the persistence layer's bulk insert.
const { context, configuration } = createTestContext();
try {
const target: ExternalModelStateSnapshot = {
...initialState(configuration),
roomMappings: [],
objects: [],
};
assert.throws(
() =>
createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
/at least one object/
);
} finally {
context.close();
}
});
it("rejects changed state, checksum drift and row assignment", () => {
const { context, configuration } = createTestContext();
try {
const target = initialState(configuration);
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
const assigned = structuredClone(target);
assigned.objects[0].circuitDeviceRowId = "row-1";
assert.throws(
() => createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
assigned
),
/must not assign circuit device rows/
);
const invalidHash = structuredClone(target);
invalidHash.importBatches[0].sha256 = "b".repeat(64);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
invalidHash
),
}),
/checksum is invalid/
);
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.source,
null
);
} finally {
context.close();
}
});
it("rolls back all external rows when late history persistence fails", () => {
const { context, configuration } = createTestContext();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_external_import_history
BEFORE INSERT ON project_change_sets
BEGIN
SELECT RAISE(ABORT, 'late history failure');
END;
`);
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
initialState(configuration)
),
}),
/late history failure/
);
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.source,
null
);
assert.equal(
context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision,
0
);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,132 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalModelConfigurationRepository } from "../src/db/repositories/external-model-configuration.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
import {
assertExternalModelConfiguration,
createDefaultExternalModelConfiguration,
} from "../src/external-model/configuration/external-model-configuration.js";
const deviceTypeId = "10000000-0000-4000-8000-000000000001";
const ruleId = "20000000-0000-4000-8000-000000000001";
function configuration() {
const value = createDefaultExternalModelConfiguration();
value.deviceTypes.push({
id: deviceTypeId,
name: "Steckdose",
category: "single_phase",
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
});
value.familyTypeRules.push({
id: ruleId,
exactFamilyAndType: "Steckdose: Standard",
deviceTypeId,
quantityRule: "socketCount",
});
return value;
}
function snapshot(projectId = "project-1") {
return { id: "config-1", projectId, configuration: configuration() };
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
return context;
}
describe("external model configuration", () => {
it("validates the single Worksharing-ID mapping and exact family rules", () => {
const value = configuration();
assert.doesNotThrow(() => assertExternalModelConfiguration(value));
assert.equal("idColumn" in value.layout, false);
const duplicate = structuredClone(value);
duplicate.columns.ifcGuid = duplicate.columns.worksharingId;
assert.throws(() => assertExternalModelConfiguration(duplicate), /nicht eindeutig/);
const unknownType = structuredClone(value);
unknownType.familyTypeRules[0]!.deviceTypeId = "30000000-0000-4000-8000-000000000001";
assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/);
});
it("persists changes without an independent configuration revision", () => {
const context = createTestDatabase();
try {
const repository = new ExternalModelConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(null, initial),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.equal("configurationVersion" in initial, false);
const target = structuredClone(initial);
target.configuration.deviceTypes[0]!.name = "Steckdose allgemein";
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
});
assert.equal(updated.revision.revisionNumber, 2);
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
}),
/zwischenzeitlich ge.*ndert/
);
} finally {
context.close();
}
});
it("distinguishes missing projects and remaps configuration identities on duplicate", () => {
const context = createTestDatabase();
try {
const reader = new ExternalModelConfigurationRepository(context.db);
assert.deepEqual(reader.getByProject("project-1"), { projectExists: true, configuration: null });
assert.deepEqual(reader.getByProject("missing"), { projectExists: false, configuration: null });
new ExternalModelConfigurationProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(null, snapshot()),
});
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const copied = context.db.select().from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, duplicate.projectId)).get();
assert.ok(copied);
assert.notEqual(copied.id, "config-1");
assert.notEqual(copied.configuration.deviceTypes[0]!.id, deviceTypeId);
assert.equal(
copied.configuration.familyTypeRules[0]!.deviceTypeId,
copied.configuration.deviceTypes[0]!.id
);
} finally {
context.close();
}
});
});

View file

@ -1,431 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.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 { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import {
createExternalRoomKey,
indexExternalObjectsByIfcGuid,
projectInitialExternalObject,
} from "../src/external-model/domain/external-model-matching.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
function configuredCsv() {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Doppelsteckdose",
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: { kind: "mapped-column" },
displayNameSuggestion: { kind: "selection-marker" },
});
return configuration;
}
describe("external model matching", () => {
it("normalizes source rooms and rejects duplicate IFC identities", () => {
assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101");
assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD");
assert.equal(createExternalRoomKey(" ", " "), null);
assert.throws(
() => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]),
/Duplicate IfcGUID/
);
});
it("derives initial planning values from an exact family rule", () => {
const configuration = configuredCsv();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes: utf8Bytes(quotedRevitCsv, true),
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
assert.deepEqual(projected.issues, []);
assert.equal(projected.ifcGuid, firstIfcGuid);
assert.equal(projected.planningValues.displayName, "Arbeitsplatz");
assert.equal(projected.planningValues.category, "single_phase");
assert.equal(projected.planningValues.effectiveQuantity, 2);
assert.equal(projected.planningValues.powerPerUnitW, 120.5);
});
it("keeps unclassified or invalid values visible as issues", () => {
const projected = projectInitialExternalObject(
{
rowNumber: 4,
ifcGuid: firstIfcGuid,
roomNumber: "",
roomName: "",
familyAndType: "Unbekannt",
selectionMarker: "",
circuitIdentifier: "",
power: "12,3,4",
quantity: "0",
additionalSourceValues: {},
},
externalCsvTestConfiguration
);
assert.deepEqual(projected.issues, [
"unknown-family-and-type",
"missing-room",
"invalid-power",
]);
assert.equal(projected.planningValues.powerPerUnitW, null);
});
});
describe("external model state repository", () => {
it("reads a deterministic complete external state without changing project data", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
context.db.insert(externalModelSources).values({
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
}).run();
const configuration = configuredCsv();
const bytes = utf8Bytes(quotedRevitCsv, true);
const document = parseExternalCsv(bytes, configuration);
context.db.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T15:30:00.000Z",
fileName: "revit.csv",
sha256: "hash",
appliedProjectRevision: 1,
configurationVersion: 1,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document,
}).run();
context.db.insert(externalRoomMappings).values({
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:01/101",
sourceFloorName: null,
sourceRoomNumber: "01/101",
sourceRoomName: "Technik",
roomId: null,
defaultDistributionBoardId: null,
}).run();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes,
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
context.db.insert(externalModelObjects).values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: projected.ifcGuid,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: projected.sourceValues,
planningValues: projected.planningValues,
overriddenFields: [],
externalRoomMappingId: "mapping-1",
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: null,
presenceStatus: "present",
}).run();
const result = new ExternalModelStateRepository(context.db).getByProject("project-1");
assert.equal(result.projectExists, true);
assert.equal(result.state.source?.id, "source-1");
assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64"));
assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101");
assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid);
assert.equal(result.state.objects[0].circuitDeviceRowId, null);
assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2);
assert.throws(
() => context.db.insert(externalModelObjects).values({
...context.db.select().from(externalModelObjects).get()!,
id: "object-2",
}).run(),
/UNIQUE constraint failed/
);
} finally {
context.close();
}
});
it("distinguishes an empty project from an unknown project", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
const repository = new ExternalModelStateRepository(context.db);
assert.deepEqual(repository.getByProject("project-1"), {
projectExists: true,
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
});
assert.equal(repository.getByProject("missing").projectExists, false);
} finally {
context.close();
}
});
});
describe("external model snapshot and transfer", () => {
it("captures, restores and undoes the complete external state", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
insertLinkedExternalFixture(context.db);
const populated = readProjectStateSnapshot(context.db, "project-1");
assert.ok(populated);
assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
context.db.delete(externalModelSources).run();
const empty = readProjectStateSnapshot(context.db, "project-1");
assert.ok(empty);
assert.equal(empty.state.externalModel.source, null);
const repository = new ProjectStateRestoreCommandRepository(context.db);
const restored = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectStateRestoreCommand(
empty.payloadSha256,
populated.state
),
});
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.objects[0].linkedProjectDeviceId,
"device-1"
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.source,
null
);
} finally {
context.close();
}
});
it("remaps every internal external-model link for a project duplicate", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
insertLinkedExternalFixture(context.db);
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const copied = new ExternalModelStateRepository(context.db)
.getByProject(duplicate.projectId).state;
assert.notEqual(copied.source?.id, "source-1");
assert.notEqual(copied.importBatches[0].id, "batch-1");
assert.notEqual(copied.roomMappings[0].id, "mapping-1");
assert.notEqual(copied.objects[0].id, "object-1");
assert.equal(copied.objects[0].ifcGuid, firstIfcGuid);
assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id);
assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id);
assert.notEqual(copied.objects[0].distributionBoardId, "board-1");
assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1");
assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1");
assert.equal(
copied.importBatches[0].originalContentBase64,
Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64")
);
} finally {
context.close();
}
});
});
function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseContext>["db"]) {
database.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
database.insert(floors).values({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 10,
}).run();
database.insert(rooms).values({
id: "room-1",
projectId: "project-1",
floorId: "floor-1",
roomNumber: "01/101",
roomName: "Technik",
}).run();
database.insert(projectDevices).values({
id: "device-1",
projectId: "project-1",
name: "socket",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: "socket",
costGroup: null,
category: "single_phase",
quantity: 1,
powerPerUnit: 0.12,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
voltageV: 230,
}).run();
database.insert(distributionBoards).values({
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: "floor-1",
supplyType: "AV",
simultaneityFactor: 1,
}).run();
database.insert(circuitLists).values({
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
}).run();
database.insert(circuitSections).values({
id: "section-1",
circuitListId: "list-1",
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 10,
category: "single_phase",
groupNumber: 1,
}).run();
database.insert(circuits).values({
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-2F1.1",
displayName: "Steckdosen",
sortOrder: 10,
voltage: 230,
isReserve: 0,
}).run();
database.insert(circuitDeviceRows).values({
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "socket",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: "socket",
costGroup: null,
category: "single_phase",
level: null,
roomId: "room-1",
roomNumberSnapshot: "01/101",
roomNameSnapshot: "Technik",
quantity: 2,
powerPerUnit: 0.12,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
}).run();
database.insert(externalModelSources).values({
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
}).run();
const configuration = configuredCsv();
const bytes = utf8Bytes(quotedRevitCsv, true);
database.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T15:30:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document: parseExternalCsv(bytes, configuration),
}).run();
database.insert(externalRoomMappings).values({
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:01/101",
sourceFloorName: "EG",
sourceRoomNumber: "01/101",
sourceRoomName: "Technik",
roomId: "room-1",
defaultDistributionBoardId: "board-1",
}).run();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes,
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
database.insert(externalModelObjects).values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: projected.ifcGuid,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: projected.sourceValues,
planningValues: projected.planningValues,
overriddenFields: [],
externalRoomMappingId: "mapping-1",
distributionBoardId: "board-1",
linkedProjectDeviceId: "device-1",
circuitDeviceRowId: "row-1",
presenceStatus: "present",
}).run();
}

View file

@ -0,0 +1,87 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { parseExternalModelStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
const lowerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff";
const upperId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-FFFFFFFF";
function fullBatch(id: string, importedAtIso: string) {
return {
id,
projectId: "p",
sourceId: "s",
importKind: "follow_up" as const,
importedAtIso,
fileName: `${id}.xlsx`,
sha256: "a".repeat(64),
objectCount: 1,
appliedProjectRevision: 3,
configurationSnapshot: createDefaultExternalModelConfiguration(),
originalContentBase64: "AA==",
document: {
sheetName: "Bauteilliste",
headerRowNumber: 1,
firstDataRowNumber: 3,
firstDataColumnNumber: 1,
columns: [],
rows: [],
issues: [],
},
};
}
describe("Linear XLSX external model foundation", () => {
it("keeps only the newest batch complete", () => {
const newest = fullBatch("new", "2026-02-01T00:00:00.000Z");
const older = {
...fullBatch("old", "2026-01-01T00:00:00.000Z"),
appliedProjectRevision: null,
configurationSnapshot: null,
originalContentBase64: null,
document: null,
};
assert.doesNotThrow(() => parseExternalModelStateSnapshot({
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
importBatches: [older, newest],
roomMappings: [],
objects: [],
}));
assert.throws(() => parseExternalModelStateSnapshot({
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
importBatches: [newest, fullBatch("newer", "2026-03-01T00:00:00.000Z")],
roomMappings: [],
objects: [],
}), /Nur der letzte Importbatch/);
});
it("compares Worksharing-IDs exactly and allows duplicate IfcGUID values", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.sqlite.exec(`
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
INSERT INTO external_model_sources (id, project_id, name) VALUES ('s', 'p', 'Revit');
INSERT INTO external_import_batches
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, object_count)
VALUES ('b', 'p', 's', 'initial', '2026-01-01T00:00:00.000Z', 'model.xlsx', '${"a".repeat(64)}', 2);
`);
const insert = context.sqlite.prepare(`
INSERT INTO external_model_objects
(id, project_id, source_id, worksharing_id, ifc_guid, device_type_id,
last_seen_import_batch_id, last_accepted_import_batch_id,
accepted_source_values, planning_values, overridden_fields)
VALUES (?, 'p', 's', ?, 'same-IfcGUID', 'type', 'b', 'b', '{}', '{}', '[]')
`);
insert.run("o1", lowerId);
insert.run("o2", upperId);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2);
assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/);
} finally {
context.close();
}
});
});

View file

@ -31,7 +31,6 @@ import {
externalObjectAssignToNewCircuitCommandType, externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js"; } from "../src/domain/models/external-object-new-circuit-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
function createFixture(initialObjectRowId: string | null = "row-1") { function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:"); const context = createDatabaseContext(":memory:");
@ -63,20 +62,16 @@ function createFixture(initialObjectRowId: string | null = "row-1") {
simultaneityFactor: 1, simultaneityFactor: 1,
}).run(); }).run();
} }
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run(); database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run();
database.insert(externalImportBatches).values({ database.insert(externalImportBatches).values({
id: "batch-1", id: "batch-1",
projectId: "project-1", projectId: "project-1",
sourceId: "source-1", sourceId: "source-1",
importKind: "initial", importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z", importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv", fileName: "revit.xlsx",
sha256: "a".repeat(64), sha256: "a".repeat(64),
appliedProjectRevision: 0, objectCount: 1,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
}).run(); }).run();
database.insert(externalRoomMappings).values({ database.insert(externalRoomMappings).values({
id: "mapping-1", id: "mapping-1",
@ -98,7 +93,9 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
id: "object-1", id: "object-1",
projectId: "project-1", projectId: "project-1",
sourceId: "source-1", sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
ifcGuid: "ifc-1", ifcGuid: "ifc-1",
deviceTypeId: "device-type-1",
lastSeenImportBatchId: "batch-1", lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: { acceptedSourceValues: {
@ -107,14 +104,18 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
roomName: "Büro", roomName: "Büro",
familyAndType: "Steckdose: Standard", familyAndType: "Steckdose: Standard",
selectionMarker: "Steckdose", selectionMarker: "Steckdose",
socketCount: "2",
sourceRemark: null,
circuitCombined: "-2F1.1",
boardName: "UV 1",
circuitIdentifier: "-2F1.1", circuitIdentifier: "-2F1.1",
power: "120", designation: null,
quantity: "2", electricalPowerW: "120",
additionalSourceValues: {}, voltageV: 230,
currentA: null,
}, },
planningValues: { planningValues: {
displayName: "Steckdose", displayName: "Steckdose",
internalDeviceType: "socket",
category: "single_phase", category: "single_phase",
connectionKind: "socket", connectionKind: "socket",
effectiveQuantity: 2, effectiveQuantity: 2,

View file

@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import {
createExternalXlsxReturn,
ExternalXlsxError,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
import {
createRevitXlsxFixture,
externalXlsxTestConfiguration,
firstWorksharingId,
secondWorksharingId,
} from "./fixtures/revit-xlsx-fixtures.js";
describe("external XLSX transport", () => {
it("reads typed cells and classifies ID-less rows without blocking summaries", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(),
configuration: externalXlsxTestConfiguration,
});
assert.equal(document.sheetName, "Linear Export");
assert.equal(document.headerRowNumber, 1);
assert.equal(document.firstDataRowNumber, 3);
assert.equal(document.firstDataColumnNumber, 1);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
assert.equal(document.rows[0].cells[5].value, 2);
assert.equal(document.rows[0].cells[15].value, true);
assert.equal(document.columns[0].comment, "Kommentar Worksharing-ID");
assert.equal(document.columns[0].width, 12);
assert.deepEqual(
document.issues.map((issue) => issue.code),
["suspect-object-skipped"]
);
});
it("resolves editable layout positions without a separate ID column", async () => {
const configuration = {
...externalXlsxTestConfiguration,
layout: { headerRow: 2, firstDataRow: 5, firstDataColumn: 2 },
};
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(configuration.layout),
configuration,
});
assert.equal(document.headerRowNumber, 2);
assert.equal(document.firstDataRowNumber, 5);
assert.equal(document.firstDataColumnNumber, 2);
assert.equal(document.columns[1].header, "Worksharing-ID");
assert.equal(document.rows[0].cells[1].value, firstWorksharingId);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
});
it("compares Worksharing-ID and IfcGUID exactly and case-sensitively", async () => {
const lowerCaseId = secondWorksharingId.toLowerCase();
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
secondWorksharingId: lowerCaseId,
firstIfcGuid: "IfcGUID",
secondIfcGuid: "IFCGUID",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
2
);
assert.equal(
document.issues.some((issue) => issue.code === "duplicate-ifc-guid"),
false
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ duplicateWorksharingId: true }),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("duplicate-worksharing-id")
);
const duplicateIfcDocument = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
firstIfcGuid: "same",
secondIfcGuid: "same",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
duplicateIfcDocument.issues.filter(
(issue) => issue.code === "duplicate-ifc-guid"
).length,
1
);
});
it("uses cached formula values without evaluating formulas", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ includeFormula: true }),
configuration: externalXlsxTestConfiguration,
});
const powerCell = document.rows[0].cells[11];
assert.equal(powerCell.value, 250);
assert.equal(powerCell.formula, "125*2");
assert.equal(powerCell.usedCachedFormulaValue, true);
assert.equal(
document.issues.some(
(issue) => issue.code === "cached-formula-value-used"
),
true
);
});
it("blocks a required formula without a cached result", async () => {
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
includeRequiredFormulaWithoutResult: true,
}),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("required-formula-result-missing")
);
});
it("writes selected object rows in source order and preserves workbook structure", async () => {
const source = await createRevitXlsxFixture({ includeFormula: true });
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
selectedWorksharingIds: [secondWorksharingId, firstWorksharingId],
updates: [
{
worksharingId: firstWorksharingId,
values: {
circuitCombined: "UV99-3F7",
electricalPowerW: 321.5,
voltageV: 400,
currentA: 0.46,
},
},
],
});
assert.equal(result.exportedObjectCount, 2);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(result.bytes as never);
const worksheet = workbook.worksheets[0];
assert.equal(worksheet.name, "Linear Export");
assert.equal(worksheet.rowCount, 4);
assert.equal(worksheet.getCell("A3").value, firstWorksharingId);
assert.equal(worksheet.getCell("A4").value, secondWorksharingId);
assert.equal(worksheet.getCell("H3").value, "UV99-3F7");
assert.equal(worksheet.getCell("L3").value, 321.5);
assert.equal(worksheet.getCell("M3").value, 400);
assert.equal(worksheet.getCell("N3").value, 0.46);
assert.equal(worksheet.getCell("P4").value, "bleibt");
assert.equal(worksheet.getCell("L3").numFmt, "0.00");
assert.equal(worksheet.getColumn(1).width, 12);
assert.equal(
typeof worksheet.getCell("A1").note === "string"
? worksheet.getCell("A1").note
: worksheet.getCell("A1").note?.texts?.[0]?.text,
"Kommentar Worksharing-ID"
);
const commentedCells: string[] = [];
worksheet.eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.deepEqual(
commentedCells,
Array.from({ length: 16 }, (_, index) =>
worksheet.getCell(1, index + 1).address
)
);
});
it("warns for missing optional return columns and blocks missing circuit columns", async () => {
const source = await createRevitXlsxFixture();
const optionalMissing = {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
designation: "Nicht vorhanden",
},
};
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: optionalMissing,
});
assert.equal(
result.issues.some(
(issue) =>
issue.code === "optional-return-column-missing" &&
issue.field === "designation"
),
true
);
await assert.rejects(
createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
circuitCombined: "Nicht vorhanden",
},
},
}),
isXlsxError("return-circuit-column-missing")
);
});
it("enforces extension, byte size and post-read sheet limits", async () => {
const source = await createRevitXlsxFixture();
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsm",
bytes: source,
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("invalid-file-extension")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: new Uint8Array(10 * 1024 * 1024 + 1),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("file-too-large")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
limits: { maxRows: 3, maxColumns: 100, maxCells: 1_000 },
}),
isXlsxError("worksheet-too-large")
);
});
});
function isXlsxError(code: ExternalXlsxError["code"]) {
return (error: unknown) =>
error instanceof ExternalXlsxError && error.code === code;
}

134
tests/fixtures/revit-xlsx-fixtures.ts vendored Normal file
View file

@ -0,0 +1,134 @@
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxTransportConfiguration,
} from "../../src/external-model/xlsx/external-xlsx-contracts.js";
export const firstWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000001";
export const secondWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000002";
export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration =
createDefaultExternalXlsxTransportConfiguration();
export async function createRevitXlsxFixture(options?: {
duplicateWorksharingId?: boolean;
secondWorksharingId?: string;
firstIfcGuid?: string;
secondIfcGuid?: string;
includeFormula?: boolean;
includeRequiredFormulaWithoutResult?: boolean;
headerRow?: number;
firstDataRow?: number;
firstDataColumn?: number;
}) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Linear Export");
const headerRow = options?.headerRow ?? 1;
const firstDataRow = options?.firstDataRow ?? 3;
const firstDataColumn = options?.firstDataColumn ?? 1;
const headers = [
"Worksharing-ID",
"CAx_Auswahlkenner",
"MEP-Raum: Nummer",
"MEP-Raum: Name",
"Familie und Typ",
"CAX_AnzahlObjekte",
"CAx_Anmerkung",
"kbp_Stromkreisnummer",
"kbp-E-Verteiler",
"kbp-E-Stromkreis",
"kbp-E-Bezeichnung",
"kbp-E-Elektrische Leistung",
"kbp-E-Spannung",
"kbp-E-Stromstärke",
"IfcGUID",
"Unbekannt",
];
headers.forEach((header, index) => {
const columnNumber = firstDataColumn + index;
const cell = worksheet.getCell(headerRow, columnNumber);
cell.value = header;
cell.note = `Kommentar ${header}`;
cell.font = { bold: true };
worksheet.getColumn(columnNumber).width = 12 + index;
});
setRowValues(worksheet, firstDataRow, firstDataColumn, [
firstWorksharingId,
"Steckdose",
"01.101",
"Büro",
"Steckdose: Standard",
2,
"Quelle A",
"UV01-2F1",
"UV01",
"-2F1",
"Arbeitsplatz",
250,
230,
1.09,
options?.firstIfcGuid ?? "IfcCaseSensitive",
true,
]);
worksheet.getCell(firstDataRow, firstDataColumn + 11).numFmt = "0.00";
worksheet.getCell(firstDataRow, firstDataColumn + 12).numFmt = "0";
worksheet.getCell(firstDataRow, firstDataColumn + 13).numFmt = "0.00";
if (options?.includeFormula) {
worksheet.getCell(firstDataRow, firstDataColumn + 11).value = {
formula: "125*2",
result: 250,
};
}
if (options?.includeRequiredFormulaWithoutResult) {
worksheet.getCell(firstDataRow, firstDataColumn).value = { formula: "\"\"" };
}
setRowValues(worksheet, firstDataRow + 1, firstDataColumn, [
options?.duplicateWorksharingId
? firstWorksharingId
: options?.secondWorksharingId ?? secondWorksharingId,
"Leuchte",
null,
null,
"Leuchte: Decke",
1,
"Quelle B",
"UV01-1F1",
"UV01",
"-1F1",
"Beleuchtung",
42.5,
230,
0.18,
options?.secondIfcGuid ?? "IfcCaseSensitive2",
"bleibt",
]);
worksheet.getCell(firstDataRow + 1, firstDataColumn + 11).numFmt = "0.00";
setRowValues(worksheet, firstDataRow + 2, firstDataColumn, [
null,
"verdächtig",
null,
null,
"Gerät: Ohne ID",
]);
setRowValues(worksheet, firstDataRow + 3, firstDataColumn, [
null,
"Summe: 292,5 W",
]);
return new Uint8Array(await workbook.xlsx.writeBuffer());
}
function setRowValues(
worksheet: ExcelJS.Worksheet,
rowNumber: number,
firstColumn: number,
values: ExcelJS.CellValue[]
) {
values.forEach((value, index) => {
worksheet.getCell(rowNumber, firstColumn + index).value = value;
});
}

View file

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { it } from "node:test";
import { spawnSync } from "node:child_process";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
it("replaces legacy CSV state and resets linked row quantities to their manual share", () => {
const source = path.resolve("src", "db", "migrations");
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-"));
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
mkdirSync(path.join(staging, "meta"));
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) {
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
}
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: staging });
context.sqlite.exec(`
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV');
INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste');
INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.');
INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1');
INSERT INTO circuit_device_rows
(id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor)
VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1);
INSERT INTO external_csv_configurations
(id, project_id, configuration_version, configuration)
VALUES ('cfg', 'p', 3, '{}');
INSERT INTO external_model_sources (id, project_id, name, source_type)
VALUES ('src', 'p', 'Revit', 'revit_csv');
INSERT INTO external_import_batches
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256,
applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version)
VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3);
INSERT INTO external_model_objects
(id, project_id, source_id, ifc_guid, last_seen_import_batch_id,
last_accepted_import_batch_id, accepted_source_values, planning_values,
overridden_fields, circuit_device_row_id)
VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r');
`);
migrate(context.db, { migrationsFolder: source });
assert.equal(
(context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity,
2
);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0);
assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/);
const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>;
assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1);
assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
} finally {
context.close();
rmSync(staging, { recursive: true, force: true });
}
});
it("creates and verifies a backup before the Linear-XLSX migration", () => {
const source = path.resolve("src", "db", "migrations");
const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-"));
const staging = path.join(root, "migrations");
const dataDirectory = path.join(root, "data");
mkdirSync(path.join(staging, "meta"), { recursive: true });
mkdirSync(dataDirectory, { recursive: true });
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) {
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
}
const databasePath = path.join(dataDirectory, "leistungsbilanz.db");
const context = createDatabaseContext(databasePath);
try {
migrate(context.db, { migrationsFolder: staging });
} finally {
context.close();
}
try {
const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], {
cwd: path.resolve("."),
env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory },
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Verified pre-migration backup created/);
const backups = readdirSync(path.join(dataDirectory, "backups"));
assert.equal(backups.length, 1);
const migrated = createDatabaseContext(databasePath);
try {
assert.ok(migrated.sqlite.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'"
).get());
} finally {
migrated.close();
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js"; import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
@ -44,7 +44,7 @@ import { projects } from "../src/db/schema/projects.js";
import { buildings } from "../src/db/schema/buildings.js"; import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js"; import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js"; import { rooms } from "../src/db/schema/rooms.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js"; import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
@ -90,9 +90,8 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js"; import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext { function createTestDatabase(): DatabaseContext {
@ -166,7 +165,7 @@ function createService(context: DatabaseContext) {
new CircuitGroupSubtreeProjectCommandRepository(context.db), new CircuitGroupSubtreeProjectCommandRepository(context.db),
new CircuitProtectionProjectCommandRepository(context.db), new CircuitProtectionProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db), new ProjectHistoryRepository(context.db),
new ExternalCsvConfigurationProjectCommandRepository(context.db) new ExternalModelConfigurationProjectCommandRepository(context.db)
); );
} }
@ -1127,31 +1126,62 @@ describe("project command service", () => {
} }
}); });
it("dispatches external CSV configuration updates through persistent history", () => { it("dispatches external XLSX configuration updates through persistent history", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
const service = createService(context); const service = createService(context);
const target = { const target = {
id: "external-config-1", id: "external-config-1",
projectId: "project-1", projectId: "project-1",
configurationVersion: 1, configuration: createDefaultExternalModelConfiguration(),
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
}; };
const updated = service.executeUser({ const updated = service.executeUser({
projectId: "project-1", projectId: "project-1",
expectedRevision: 0, expectedRevision: 0,
command: createExternalCsvConfigurationUpdateProjectCommand(null, target), command: createExternalModelConfigurationUpdateProjectCommand(null, target),
}); });
assert.equal(updated.history.currentRevision, 1); assert.equal(updated.history.currentRevision, 1);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 }); const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
assert.equal(undone.history.currentRevision, 2); assert.equal(undone.history.currentRevision, 2);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0); assert.equal(context.db.select().from(externalModelConfigurations).all().length, 0);
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 }); const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
assert.equal(redone.history.currentRevision, 3); assert.equal(redone.history.currentRevision, 3);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1); assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
} finally {
context.close();
}
});
it("rejects legacy CSV commands stored in project history with a clear error", () => {
const context = createTestDatabase();
try {
const service = createService(context);
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createExternalModelConfigurationUpdateProjectCommand(null, {
id: "external-config-1",
projectId: "project-1",
configuration: createDefaultExternalModelConfiguration(),
}),
});
context.db.update(projectChangeSets).set({
inversePayloadJson: JSON.stringify({
schemaVersion: 1,
type: "external-import.apply-initial",
payload: {},
}),
}).run();
assert.throws(
() => service.undo({ projectId: "project-1", expectedRevision: 1 }),
/alten Revit-CSV-Anbindung/
);
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
} finally { } finally {
context.close(); context.close();
} }

View file

@ -19,7 +19,6 @@ import { externalModelSources } from "../src/db/schema/external-model-sources.js
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import { import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot, type ProjectDeviceSyncRowSnapshot,
@ -178,7 +177,7 @@ function linkExternalObject(
id: "source-1", id: "source-1",
projectId: "project-1", projectId: "project-1",
name: "Revit", name: "Revit",
sourceType: "revit_csv", sourceType: "revit_xlsx",
}) })
.run(); .run();
context.db context.db
@ -189,13 +188,9 @@ function linkExternalObject(
sourceId: "source-1", sourceId: "source-1",
importKind: "initial", importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z", importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv", fileName: "revit.xlsx",
sha256: "a".repeat(64), sha256: "a".repeat(64),
appliedProjectRevision: 0, objectCount: 1,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
}) })
.run(); .run();
context.db context.db
@ -204,7 +199,9 @@ function linkExternalObject(
id: "object-1", id: "object-1",
projectId: "project-1", projectId: "project-1",
sourceId: "source-1", sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
ifcGuid: "ifc-1", ifcGuid: "ifc-1",
deviceTypeId: "device-type-1",
lastSeenImportBatchId: "batch-1", lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: { acceptedSourceValues: {
@ -213,14 +210,18 @@ function linkExternalObject(
roomName: "Büro", roomName: "Büro",
familyAndType: "Leuchte: Standard", familyAndType: "Leuchte: Standard",
selectionMarker: "Leuchte", selectionMarker: "Leuchte",
socketCount: String(effectiveQuantity),
sourceRemark: null,
circuitCombined: "-1F1",
boardName: "UV 1",
circuitIdentifier: "-1F1", circuitIdentifier: "-1F1",
power: "30", designation: null,
quantity: String(effectiveQuantity), electricalPowerW: "30",
additionalSourceValues: {}, voltageV: 230,
currentA: null,
}, },
planningValues: { planningValues: {
displayName: "Leuchte", displayName: "Leuchte",
internalDeviceType: "luminaire",
category: "single_phase", category: "single_phase",
connectionKind: "fixed", connectionKind: "fixed",
effectiveQuantity, effectiveQuantity,

View file

@ -465,6 +465,8 @@ describe("project snapshot repository", () => {
rooms: Array<Record<string, unknown>>; rooms: Array<Record<string, unknown>>;
}; };
payload.schemaVersion = 5; payload.schemaVersion = 5;
(payload as Record<string, unknown>).externalCsvConfiguration = null;
delete (payload as Record<string, unknown>).externalModelConfiguration;
delete payload.buildings; delete payload.buildings;
for (const floor of payload.floors) { for (const floor of payload.floors) {
delete floor.buildingId; delete floor.buildingId;

View file

@ -8,11 +8,16 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js"; } from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.js"; import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
/** Removes the fields introduced by snapshot schema version 9. */ /** Removes the fields introduced by snapshot schema version 9. */
function withoutVersionNineFields< function withoutVersionNineFields<
T extends { distributionBoards: object[] }, T extends { distributionBoards: object[] },
>(snapshot: T): T { >(snapshot: T): T {
const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null;
delete record.externalModelConfiguration;
for (const board of snapshot.distributionBoards) { for (const board of snapshot.distributionBoards) {
delete (board as Record<string, unknown>).feedingDistributionBoardId; delete (board as Record<string, unknown>).feedingDistributionBoardId;
delete (board as Record<string, unknown>).feedingCircuitId; delete (board as Record<string, unknown>).feedingCircuitId;
@ -77,7 +82,7 @@ function minimalSnapshot() {
projectEditor: null as string | null, projectEditor: null as string | null,
}, },
distributionBoards: [], distributionBoards: [],
externalCsvConfiguration: null, externalModelConfiguration: null,
externalModel: { externalModel: {
source: null, source: null,
importBatches: [], importBatches: [],
@ -315,6 +320,7 @@ describe("project state snapshot model", () => {
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot())); const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion; baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding; delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalModelConfiguration;
delete (baseline as Record<string, unknown>).externalCsvConfiguration; delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel; delete (baseline as Record<string, unknown>).externalModel;
@ -322,7 +328,7 @@ describe("project state snapshot model", () => {
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.isPublicBuilding, false); assert.equal(upgraded.project.isPublicBuilding, false);
assert.equal(upgraded.externalCsvConfiguration, null); assert.equal(upgraded.externalModelConfiguration, null);
}); });
it("upgrades snapshot version 2 with an empty external CSV configuration", () => { it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
@ -334,7 +340,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous); const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null); assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, { assert.deepEqual(upgraded.externalModel, {
source: null, source: null,
importBatches: [], importBatches: [],
@ -351,7 +357,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous); const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null); assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, { assert.deepEqual(upgraded.externalModel, {
source: null, source: null,
importBatches: [], importBatches: [],
@ -360,6 +366,28 @@ describe("project state snapshot model", () => {
}); });
}); });
it("rejects legacy CSV configuration and imported state with a German migration error", () => {
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
configured.schemaVersion = 9 as typeof configured.schemaVersion;
configured.externalCsvConfiguration = {
id: "legacy",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
};
assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/);
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
imported.schemaVersion = 9 as typeof imported.schemaVersion;
imported.externalModel.source = {
id: "legacy-source",
projectId: "project-1",
name: "Legacy",
sourceType: "revit_csv",
};
assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/);
});
it("upgrades snapshot version 5 with empty board info and cover fields", () => { it("upgrades snapshot version 5 with empty board info and cover fields", () => {
const previous = withoutVersionSixFields(protectedSnapshot()); const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 5 as typeof previous.schemaVersion; previous.schemaVersion = 5 as typeof previous.schemaVersion;
@ -567,7 +595,7 @@ describe("project state snapshot model", () => {
}); });
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456); assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
const previous = structuredClone(current); const previous = withoutVersionNineFields(structuredClone(current));
previous.schemaVersion = 7 as typeof previous.schemaVersion; previous.schemaVersion = 7 as typeof previous.schemaVersion;
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2; delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
const upgraded = parseProjectStateSnapshot(previous); const upgraded = parseProjectStateSnapshot(previous);

View file

@ -1,251 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
createRevitProjectDevicePrefill,
RevitCsvImportPlan,
RevitCsvModal,
} from "../src/frontend/components/revit-csv-modal.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import { RoomModal } from "../src/frontend/components/room-modal.js";
import {
applyExternalInitialImport,
getExternalCsvConfiguration,
getExternalCircuitListObjects,
planExternalInitialImport,
previewExternalCsv,
updateExternalCsvConfiguration,
} from "../src/frontend/utils/api.js";
import type { ExternalInitialImportPlanDto } from "../src/frontend/types.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("Revit CSV frontend API", () => {
it("uses the project-scoped configuration, preview and import endpoints", 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)) : null,
});
return new Response(JSON.stringify({ configuration: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
try {
await getExternalCsvConfiguration("project-1");
await getExternalCircuitListObjects("project-1", "list-1");
await updateExternalCsvConfiguration("project-1", 7, configuration);
await previewExternalCsv("project-1", "revit.csv", "YWJj");
await planExternalInitialImport("project-1", "revit.csv", "YWJj");
await applyExternalInitialImport("project-1", {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
});
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/external-csv/configuration",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/circuit-lists/list-1/external-objects",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/external-csv/configuration",
method: "PUT",
body: { expectedRevision: 7, configuration },
},
{
url: "/api/projects/project-1/external-csv/preview",
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/plan",
method: "POST",
body: { fileName: "revit.csv", contentBase64: "YWJj" },
},
{
url: "/api/projects/project-1/external-csv/initial-import/apply",
method: "POST",
body: {
expectedRevision: 8,
expectedConfigurationVersion: 2,
expectedSha256: "sha",
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
},
},
]);
});
});
describe("Revit CSV modal presentation", () => {
it("uses the viewport-wide dialog variant", () => {
const markup = renderToStaticMarkup(
createElement(RevitCsvModal, {
currentRevision: 0,
onClose: () => undefined,
onCreateProjectDevice: async () => ({} as never),
onCreateRoom: async () => ({} as never),
onRevisionChange: () => undefined,
projectId: "project-1",
})
);
assert.match(markup, /max-width:calc\(100vw - 2rem\)/);
assert.doesNotMatch(markup, /modal-lg/);
});
it("renders the initial import decisions without individual object rows", () => {
const plan: ExternalInitialImportPlanDto = {
configurationVersion: 2,
fileName: "revit.csv",
sha256: "sha",
byteLength: 100,
rowCount: 2,
objectCount: 1,
passthroughCount: 1,
suspectObjectCount: 0,
suspectRowNumbers: [],
sourceRooms: [{
sourceRoomKey: "101|büro",
roomNumber: "101",
roomName: "Büro",
objectCount: 1,
suggestedRoomId: "room-1",
matchStatus: "exact-number",
candidateRoomIds: ["room-1"],
}],
familyGroups: [{
familyAndType: "Steckdose : Standard",
objectCount: 1,
classified: true,
category: "single_phase",
connectionKind: null,
internalDeviceType: "socket",
}],
issueCounts: { unknownFamilyAndType: 0, invalidPower: 0, invalidQuantity: 0, missingRoom: 0 },
objects: [],
existing: {
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }],
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
},
};
const markup = renderToStaticMarkup(createElement(RevitCsvImportPlan, {
familyLinks: { "Steckdose : Standard": "device-1" },
isApplying: false,
onApply: () => undefined,
onCreateProjectDevice: () => undefined,
onCreateRoom: () => undefined,
onFamilyLinkChange: () => undefined,
onRoomDecisionChange: () => undefined,
plan,
roomDecisions: { "101|büro": { roomId: "room-1", defaultDistributionBoardId: "board-1" } },
setSourceName: () => undefined,
sourceName: "Revit-Gesamtmodell",
}));
assert.match(markup, /Erstimport vorbereiten/);
assert.match(markup, /101 · Büro/);
assert.match(markup, /Steckdose : Standard/);
assert.match(markup, /Erstimport übernehmen/);
assert.match(markup, /Raum 101 neu anlegen/);
assert.match(markup, /ProjectDevice für Steckdose : Standard neu anlegen/);
assert.doesNotMatch(markup, /IFC-GUID/);
});
it("prefills the existing room and ProjectDevice forms from Revit decisions", () => {
const roomMarkup = renderToStaticMarkup(createElement(RoomModal, {
floors: [{ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 0 }],
initialValues: { roomNumber: "101", roomName: "Büro" },
isSaving: false,
onClose: () => undefined,
onSave: async () => undefined,
}));
const deviceMarkup = renderToStaticMarkup(createElement(ProjectDeviceModal, {
globalDevices: [],
initialValues: {
name: "socket",
displayName: "Steckdose",
category: "single_phase",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
}));
assert.match(roomMarkup, /value="101"/);
assert.match(roomMarkup, /value="Büro"/);
assert.match(deviceMarkup, /value="socket"/);
assert.match(deviceMarkup, /value="Steckdose"/);
assert.match(deviceMarkup, /value="2"/);
});
it("converts mapped Revit watts into ProjectDevice kilowatts", () => {
const plan = {
familyGroups: [{
familyAndType: "Steckdose : Standard",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "230 V",
}],
objects: [{
sourceValues: { familyAndType: "Steckdose : Standard" },
suggestedPlanningValues: {
displayName: "Arbeitsplatzsteckdose",
effectiveQuantity: 4,
powerPerUnitW: 250,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
},
}],
} as ExternalInitialImportPlanDto;
assert.deepEqual(
createRevitProjectDevicePrefill(plan, "Steckdose : Standard"),
{
name: "socket",
displayName: "Arbeitsplatzsteckdose",
connectionKind: "230 V",
category: "single_phase",
quantity: 4,
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
cosPhi: 0.95,
costGroup: "440",
remark: "Aus Revit",
}
);
});
});

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { detectExternalModelWorkbook, getExternalModelConfiguration, updateExternalModelConfiguration } from "../src/frontend/utils/api.js";
describe("Revit XLSX settings", () => {
it("uses the project-scoped configuration endpoint with expectedRevision", 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({ configuration: null, history: { currentRevision: 8 } }), { status: 200, headers: { "Content-Type": "application/json" } });
};
try {
const configuration = createDefaultExternalModelConfiguration();
await getExternalModelConfiguration("project-1");
await updateExternalModelConfiguration("project-1", 7, configuration);
await detectExternalModelWorkbook("project-1", new File([new Uint8Array([1, 2])], "sample.xlsx"), configuration);
assert.deepEqual(requests, [
{ url: "/api/projects/project-1/external-model/configuration", method: "GET", body: undefined },
{ url: "/api/projects/project-1/external-model/configuration", method: "PUT", body: { expectedRevision: 7, configuration } },
{ url: "/api/projects/project-1/external-model/configuration/detect", method: "POST", body: { fileName: "sample.xlsx", contentBase64: "AQI=", configuration } },
]);
} finally { globalThis.fetch = originalFetch; }
});
it("exposes the Revit route and removes the old project modal", () => {
const projectPage = readFileSync("src/app/projects/[projectId]/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");
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
assert.match(revitPage, /Importstatus/);
assert.match(settings, /Musterdatei prüfen/);
assert.match(settings, /Kombinierte Stromkreisspalte/);
});
});