Compare commits
20 Commits
c96ba1cbaf
...
74bea1a3c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 74bea1a3c9 | |||
| a69b7b603f | |||
| 347b3717c3 | |||
| 1a7dd7fe03 | |||
| dac19b093c | |||
| bc2bc7ece5 | |||
| ab2449419b | |||
| 8646f346e6 | |||
| c9e5fdd876 | |||
| c634570018 | |||
| e87a3289fa | |||
| 76493b573e | |||
| d0fc1a7caf | |||
| a9d6301753 | |||
| caa6caf99f | |||
| 219da5ec3b | |||
| da689af518 | |||
| bfe8b790b2 | |||
| 43cee890e7 | |||
| 927b868dcb |
@@ -250,13 +250,92 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||
project data atomically and records a new `restore` revision with a complete
|
||||
inverse command. Restore can therefore be undone and redone after a restart.
|
||||
Current snapshot schema version 2 additionally stores whether the project is a
|
||||
public building. Supported baseline version 1 snapshots are upgraded with
|
||||
`isPublicBuilding = false`; pre-baseline snapshots remain unsupported. Both
|
||||
versions contain circuit-group identity, distribution-board components and the
|
||||
Current snapshot schema version 5 additionally stores the manual quantity share
|
||||
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||
external-model source, import batches, room mappings and objects. Version 3
|
||||
snapshots are upgraded with an empty external-model state; version 2 snapshots
|
||||
are additionally upgraded with an empty external CSV configuration. Supported
|
||||
baseline version 1 snapshots are additionally upgraded
|
||||
with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All
|
||||
supported versions contain circuit-group identity, distribution-board components and the
|
||||
separate circuit/component protection records. Portable duplicate imports
|
||||
remap component ids and protection-owner references together with the existing
|
||||
project graph.
|
||||
Project-wide Revit CSV configuration uses
|
||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||
persisted through the shared project-command transaction, so create, update,
|
||||
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
|
||||
project duplication preserve the configuration; duplication remaps its internal
|
||||
id and project link.
|
||||
The project page exposes the Revit CSV modal. It edits the complete
|
||||
transport/column/family-rule configuration through the revision-safe endpoint,
|
||||
requests a stateless preview and then shows the Phase 14.2 initial-import
|
||||
decisions. Preview responses include all mapped objects, while the UI renders
|
||||
only the first 25. No preview or plan creates external objects, rows, revisions
|
||||
or server-side drafts.
|
||||
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
|
||||
immutable import batches with original bytes plus the classified cell matrix,
|
||||
normalized source-room mappings and external objects unique by
|
||||
`(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 version 5
|
||||
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
|
||||
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
||||
Normal manual row inserts and quantity edits update both values together.
|
||||
`external-object.update-row-assignment` atomically assigns external objects to
|
||||
existing rows, moves them between existing rows or detaches them. It preserves
|
||||
the manual quantity, validates the materialized total against every target
|
||||
link, rejects project/board/category/room/selection-marker mixing and requires
|
||||
explicitly listed confirmations for other planning-value conflicts. Its exact
|
||||
inverse supports persistent Undo/Redo, and detach never deletes the row.
|
||||
`external-object.assign-to-new-row` atomically inserts unassigned external
|
||||
objects as one external-only row (`manualQuantity = 0`) into an existing
|
||||
circuit. Its history-only inverse removes the row only while its complete
|
||||
snapshot and exact object-link set remain unchanged. Ordinary detach continues
|
||||
to preserve the row.
|
||||
`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit
|
||||
with stable BMK/UUIDs, derived voltage, one external-only row, required default
|
||||
protection and its external object links. Its history-only inverse requires the
|
||||
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
||||
the BMK. All three external assignment stores share
|
||||
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
||||
The read-only
|
||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||
projection resolves the board through the owned CircuitList and returns only
|
||||
that board's compact external objects, unassigned first. It exposes no original
|
||||
CSV bytes or command payloads and returns an empty list before optional import.
|
||||
Confirmed initial state is written only through
|
||||
`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
|
||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||
project. Named snapshots are never removed by this retention policy.
|
||||
|
||||
@@ -25,6 +25,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||
|
||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
|
||||
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
|
||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||
|
||||
@@ -389,3 +389,38 @@ inverse command. The former direct restore/reconnect endpoints are removed.
|
||||
|
||||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||
project device never triggers synchronization implicitly.
|
||||
|
||||
## Revit CSV Foundation
|
||||
|
||||
- `GET /projects/:projectId/external-csv/configuration`
|
||||
- returns the complete versioned project configuration or `null`
|
||||
- `PUT /projects/:projectId/external-csv/configuration`
|
||||
- request: `expectedRevision` and the complete validated configuration
|
||||
- executes `external-csv-configuration.update`
|
||||
- returns configuration, revision and project history state
|
||||
- `POST /projects/:projectId/external-csv/preview`
|
||||
- request: file name and Base64-encoded CSV content up to 18 MB
|
||||
- requires a stored project configuration
|
||||
- returns transport metadata, SHA-256, row classifications, suspect row
|
||||
numbers and mapped source values for every recognized IFC object
|
||||
- does not persist a draft, mutate project state or create a revision
|
||||
- `POST /projects/:projectId/external-csv/initial-import/plan`
|
||||
- reparses the file and returns grouped room/family decisions plus current
|
||||
room, distribution-board and ProjectDevice catalogs
|
||||
- is stateless and rejects an already imported external source
|
||||
- `POST /projects/:projectId/external-csv/initial-import/apply`
|
||||
- requires the expected revision, configuration version, file hash and one
|
||||
decision for every room and exact family/type group
|
||||
- reparses the file, blocks unknown family/type values and executes
|
||||
`external-import.apply-initial` as one undoable project revision
|
||||
- creates no Circuit or CircuitDeviceRow
|
||||
- `GET /projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||
- resolves the circuit list's distribution board server-side
|
||||
- returns only external objects assigned to that board, with unassigned
|
||||
objects first, their source room/marker values and current row-link status
|
||||
- returns an empty list when the optional Revit import does not exist
|
||||
- is read-only and exposes no stored command payloads or import bytes
|
||||
|
||||
Parser errors expose stable codes such as `header-not-found`,
|
||||
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
||||
import uploads and parses the file again and verifies its hash.
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
- Named and automatic logical project snapshots can be listed and restored
|
||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||
created every 25 revisions and only their newest 12 entries are retained.
|
||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||
- The Revit/CSV foundation and confirmed initial import are implemented.
|
||||
Existing-row assignment, move and detach plus creation of an external-only
|
||||
row in an existing or newly created circuit have atomic persistent commands.
|
||||
Their API planning and editor UI, follow-up import, conflict review and return
|
||||
export are pending.
|
||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||
- The global device library supports basic CRUD and copy operations, but has no
|
||||
versioning, permissions or controlled synchronization model.
|
||||
|
||||
@@ -416,12 +416,18 @@ wieder her.
|
||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||
Snapshot-Schema 2 enthält zusätzlich die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||
enthält bereits den vollständigen externen Modellzustand
|
||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
||||
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||
1:1-Schutzgerätedaten. Capture,
|
||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||
JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte
|
||||
Baseline-Version 1 wird mit `isPublicBuilding = false` hochgestuft; Formate vor
|
||||
Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit
|
||||
leerer externer Konfiguration hochgestuft. Die unterstützte
|
||||
Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor
|
||||
dieser Baseline werden nicht eingelesen.
|
||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
||||
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
||||
@@ -473,7 +479,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
|
||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
|
||||
Snapshot-Schema 2. Persistente Insert/Delete/Update-Commands für
|
||||
Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
|
||||
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
|
||||
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
||||
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
|
||||
@@ -485,10 +491,141 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||
|
||||
## Revit-CSV-Grundlage
|
||||
|
||||
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
||||
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
||||
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
||||
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
|
||||
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
|
||||
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
|
||||
|
||||
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
||||
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
||||
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
|
||||
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
||||
werden Konfigurations-UUID und Projektlink remapped.
|
||||
|
||||
Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
|
||||
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
|
||||
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
|
||||
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
|
||||
Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite
|
||||
Konfigurationsversion am Batch, getrennt von der Schema-Version des
|
||||
Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten
|
||||
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
|
||||
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
|
||||
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
|
||||
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
|
||||
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
|
||||
Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit
|
||||
dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes,
|
||||
Matrix und alle internen Links werden weiterhin vollständig erfasst,
|
||||
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
|
||||
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
|
||||
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
|
||||
einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen
|
||||
Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes
|
||||
erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und
|
||||
validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
|
||||
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
|
||||
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
|
||||
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
|
||||
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
|
||||
erzeugen weiterhin keine dieser Zeilen.
|
||||
|
||||
Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom
|
||||
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
|
||||
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
|
||||
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
|
||||
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
|
||||
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
|
||||
`external-object.update-row-assignment` ist diese atomare Schreibgrenze für
|
||||
bestehende Gerätezeilen. Sie weist mehrere externe Einzelobjekte zu, verschiebt
|
||||
sie zwischen vorhandenen Rows oder löst ihre Links. Vor dem Schreiben werden
|
||||
die vollständigen erwarteten Row- und Objektsnapshots, Projektzugehörigkeit,
|
||||
Verteilung, Gruppenkategorie, Raum und Selektionskennzeichen geprüft.
|
||||
Abweichende lokale Planungswerte benötigen eine explizite Konfliktbestätigung;
|
||||
unterschiedliche Räume, Kategorien oder Selektionskennzeichen werden nicht
|
||||
still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
|
||||
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
|
||||
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
|
||||
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
|
||||
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
|
||||
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
|
||||
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
|
||||
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
|
||||
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
|
||||
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
|
||||
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
|
||||
bestehen.
|
||||
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
|
||||
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
|
||||
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
|
||||
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
|
||||
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
|
||||
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
|
||||
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
|
||||
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
|
||||
Die drei Zuordnungsadapter teilen sich
|
||||
`external-object-assignment.persistence.ts` für Snapshotvergleich,
|
||||
Kompatibilitätsregeln und link-sichere Updates.
|
||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||
liefert die verteilungsbezogene Read-Projektion für den späteren Editor-Drawer.
|
||||
Die CircuitList bestimmt den Verteiler serverseitig; die Antwort enthält nur
|
||||
dessen externe Objekte, sortiert unzugeordnet vor zugeordnet, sowie kompakte
|
||||
Quell-, Raum-, Planungs- und Linkangaben. Originalbytes und vollständige
|
||||
Command-Snapshots werden nicht an diese Oberfläche gegeben. Ohne optionalen
|
||||
Revit-Import ist die Projektion leer.
|
||||
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
||||
ändern die Konfiguration; der PUT plant Identität und nächsten
|
||||
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
|
||||
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
|
||||
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
|
||||
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/plan` verwendet
|
||||
denselben begrenzten Transport, setzt eine gespeicherte Konfiguration und einen
|
||||
noch leeren externen Projektzustand voraus und bleibt ebenfalls zustandsfrei.
|
||||
Die Antwort gruppiert Quellräume, schlägt bei genau einer identischen
|
||||
Raumnummer den vorhandenen Raum vor, gruppiert exakte Familie-und-Typ-Werte,
|
||||
liefert regelbasierte Objektplanungswerte und Konfliktzahlen und enthält die
|
||||
vorhandenen Ebenen, Räume, Verteilungen und ProjectDevices als Auswahlkataloge.
|
||||
Unbekannte Familien, ungültige Leistungen/Mengen und Objekte ohne Raum bleiben
|
||||
sichtbare Warnungen; es wird keine Entscheidung automatisch bestätigt.
|
||||
`POST /api/projects/:projectId/external-csv/initial-import/apply` nimmt Datei,
|
||||
erwarteten Hash, Konfigurationsversion und Projektrevision erneut entgegen.
|
||||
Vollständige Entscheidungen ordnen jeden Quellraum optional einem vorhandenen
|
||||
Raum und einer Standardverteilung sowie jede exakte Familiengruppe optional
|
||||
einem vorhandenen ProjectDevice zu. Unklassifizierte Familien oder fehlende
|
||||
Gruppenentscheidungen blockieren. Der Server parst und plant erneut, erzeugt
|
||||
stabile interne UUIDs und übergibt den vollständigen Zustand an
|
||||
`external-import.apply-initial`; CSV-Stromkreiswerte bleiben reine Quellwerte
|
||||
und alle `circuitDeviceRowId` bleiben `null`.
|
||||
Die Projektseite öffnet über „Revit-CSV“ ein deutsches Modal für Dialekt,
|
||||
Spaltenzuordnung, zusätzliche Quellfelder und exakte Familie-und-Typ-Regeln.
|
||||
Nach dem revisionierten Speichern kann eine lokale CSV gewählt und über den
|
||||
zustandsfreien Endpunkt geprüft werden. Die Oberfläche zeigt Kennzahlen und nur
|
||||
die ersten 25 erkannten Objekte; die vollständigen Objekte bleiben in der
|
||||
Response verfügbar, ohne alle Zeilen in den DOM einzufügen. Der anschließende
|
||||
Erstimport-Wizard gruppiert Quellräume und exakte Familie-und-Typ-Werte. Er
|
||||
zeigt eindeutige, mehrdeutige und neue Raumtreffer, lässt vorhandene Räume,
|
||||
Standardverteilungen und ProjectDevices explizit zuordnen und blockiert die
|
||||
Übernahme bei unklassifizierten Familien. Erst nach einem Bestätigungsdialog
|
||||
wird der vollständige Import als eine persistente, rückgängig machbare
|
||||
Projektrevision angewendet. Dabei entstehen keine Stromkreise oder Gerätezeilen.
|
||||
Fehlende Räume und ProjectDevices können direkt aus der jeweiligen Gruppe über
|
||||
die regulären Projektformulare angelegt werden. Revit-Werte befüllen diese
|
||||
Formulare vor, werden aber vor dem Speichern sichtbar vom Nutzer bestätigt.
|
||||
Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
|
||||
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
|
||||
ihn für die betroffene Gruppe.
|
||||
|
||||
## Noch nicht unterstützt
|
||||
|
||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||
- Revit-/CSV-/IFCGUID-Round-trip
|
||||
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
|
||||
strukturtreuer Revit-Rückexport
|
||||
- vollständige elektrische Dimensionierung
|
||||
- Produktionsdeployment
|
||||
|
||||
|
||||
@@ -532,6 +532,12 @@ Acceptance criteria:
|
||||
|
||||
## Phase 14: External Model Round-Trip
|
||||
|
||||
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
|
||||
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:
|
||||
|
||||
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung
|
||||
|
||||
## Status
|
||||
|
||||
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.
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -40,6 +40,13 @@ const projectDeviceColumns = new Set(
|
||||
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
|
||||
(name) => !projectDeviceColumns.has(name)
|
||||
);
|
||||
const requiredCircuitDeviceRowColumns = ["manual_quantity"];
|
||||
const circuitDeviceRowColumns = new Set(
|
||||
db.prepare("PRAGMA table_info(circuit_device_rows)").all().map((column) => column.name)
|
||||
);
|
||||
const missingCircuitDeviceRowColumns = requiredCircuitDeviceRowColumns.filter(
|
||||
(name) => !circuitDeviceRowColumns.has(name)
|
||||
);
|
||||
const removedProjectDeviceColumns = [
|
||||
"installed_power_per_unit_kw",
|
||||
"demand_factor",
|
||||
@@ -131,6 +138,7 @@ console.log("Database:", dbPath);
|
||||
console.log("Required tables:", requiredTables.join(", "));
|
||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||
console.log("Required circuit-device-row columns:", requiredCircuitDeviceRowColumns.join(", "));
|
||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||
console.log(
|
||||
"Required circuit-section columns:",
|
||||
@@ -171,6 +179,14 @@ if (missingCircuitColumns.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (missingCircuitDeviceRowColumns.length > 0) {
|
||||
console.error(
|
||||
"Missing circuit-device-row columns:",
|
||||
missingCircuitDeviceRowColumns.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (remainingRemovedCircuitColumns.length > 0) {
|
||||
console.error(
|
||||
"Transitional circuit protection columns still present:",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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`
|
||||
);
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../../../frontend/utils/api";
|
||||
import type {
|
||||
CircuitListDto,
|
||||
CreateRoomInput,
|
||||
CreateProjectDeviceInput,
|
||||
DistributionBoardDto,
|
||||
FloorDto,
|
||||
@@ -59,6 +60,8 @@ import {
|
||||
} from "../../../frontend/components/project-settings-modal";
|
||||
import { FormModal } from "../../../frontend/components/form-modal";
|
||||
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
||||
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
|
||||
import { RoomModal } from "../../../frontend/components/room-modal";
|
||||
|
||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||
name: "Technischer Name",
|
||||
@@ -100,11 +103,9 @@ export default function ProjectDetailPage() {
|
||||
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
||||
const [floorName, setFloorName] = useState("");
|
||||
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
||||
const [roomNumber, setRoomNumber] = useState("");
|
||||
const [roomName, setRoomName] = useState("");
|
||||
const [roomFloorId, setRoomFloorId] = useState("");
|
||||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
|
||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
|
||||
const [structureModal, setStructureModal] = useState<
|
||||
"board" | "floor" | "room" | null
|
||||
>(null);
|
||||
@@ -283,24 +284,11 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRoom(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
!projectId ||
|
||||
!project ||
|
||||
!roomNumber.trim() ||
|
||||
!roomName.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
async function handleSaveRoom(input: CreateRoomInput) {
|
||||
if (!projectId || !project) return;
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const input = {
|
||||
floorId: roomFloorId || undefined,
|
||||
roomNumber: roomNumber.trim(),
|
||||
roomName: roomName.trim(),
|
||||
};
|
||||
const result = editingRoom
|
||||
? await updateRoom(
|
||||
projectId,
|
||||
@@ -317,9 +305,6 @@ export default function ProjectDetailPage() {
|
||||
: [...current, result.room]
|
||||
);
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setRoomNumber("");
|
||||
setRoomName("");
|
||||
setRoomFloorId("");
|
||||
setEditingRoom(null);
|
||||
setStructureModal(null);
|
||||
} catch (err) {
|
||||
@@ -498,6 +483,22 @@ 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;
|
||||
}
|
||||
|
||||
function openCreateFloor() {
|
||||
setEditingFloor(null);
|
||||
setFloorName("");
|
||||
@@ -512,17 +513,11 @@ export default function ProjectDetailPage() {
|
||||
|
||||
function openCreateRoom() {
|
||||
setEditingRoom(null);
|
||||
setRoomNumber("");
|
||||
setRoomName("");
|
||||
setRoomFloorId("");
|
||||
setStructureModal("room");
|
||||
}
|
||||
|
||||
function openEditRoom(room: RoomDto) {
|
||||
setEditingRoom(room);
|
||||
setRoomNumber(room.roomNumber);
|
||||
setRoomName(room.roomName);
|
||||
setRoomFloorId(room.floorId ?? "");
|
||||
setStructureModal("room");
|
||||
}
|
||||
|
||||
@@ -555,9 +550,6 @@ export default function ProjectDetailPage() {
|
||||
setRooms((current) => current.filter((room) => room.id !== editingRoom.id));
|
||||
applyProjectRevision(result.history.currentRevision);
|
||||
setEditingRoom(null);
|
||||
setRoomNumber("");
|
||||
setRoomName("");
|
||||
setRoomFloorId("");
|
||||
setStructureModal(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden.");
|
||||
@@ -858,6 +850,14 @@ export default function ProjectDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
onClick={() => setIsRevitCsvOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
Revit-CSV
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
disabled={!project}
|
||||
@@ -1575,73 +1575,17 @@ export default function ProjectDetailPage() {
|
||||
) : null}
|
||||
|
||||
{structureModal === "room" ? (
|
||||
<FormModal
|
||||
<RoomModal
|
||||
floors={floors}
|
||||
initialRoom={editingRoom ?? undefined}
|
||||
isSaving={isSaving}
|
||||
onClose={() => {
|
||||
setStructureModal(null);
|
||||
setEditingRoom(null);
|
||||
}}
|
||||
onSubmit={handleSaveRoom}
|
||||
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
||||
submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"}
|
||||
title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-4">
|
||||
<label className="form-label" htmlFor="room-number">
|
||||
Raumnummer
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="room-number"
|
||||
onChange={(event) => setRoomNumber(event.target.value)}
|
||||
required
|
||||
value={roomNumber}
|
||||
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-8">
|
||||
<label className="form-label" htmlFor="room-name">
|
||||
Raumname
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="room-name"
|
||||
onChange={(event) => setRoomName(event.target.value)}
|
||||
required
|
||||
value={roomName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="room-floor">
|
||||
Etage
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="room-floor"
|
||||
onChange={(event) => setRoomFloorId(event.target.value)}
|
||||
value={roomFloorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{editingRoom ? (
|
||||
<div className="border-top mt-4 pt-3">
|
||||
<p className="text-secondary small mb-2">
|
||||
In Stromkreisen verwendete Räume können nicht gelöscht werden.
|
||||
</p>
|
||||
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteRoom()} type="button">
|
||||
Raum löschen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{isProjectDeviceModalOpen ? (
|
||||
@@ -1678,6 +1622,16 @@ export default function ProjectDetailPage() {
|
||||
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
||||
/>
|
||||
) : null}
|
||||
{project && isRevitCsvOpen ? (
|
||||
<RevitCsvModal
|
||||
currentRevision={project.currentRevision}
|
||||
onClose={() => setIsRevitCsvOpen(false)}
|
||||
onCreateProjectDevice={handleCreateRevitProjectDevice}
|
||||
onCreateRoom={handleCreateRevitRoom}
|
||||
onRevisionChange={applyProjectRevision}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `external_csv_configurations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`configuration_version` integer 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_csv_configurations_project_id_unique` ON `external_csv_configurations` (`project_id`);
|
||||
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
`applied_project_revision` integer NOT NULL,
|
||||
`configuration_snapshot` text NOT NULL,
|
||||
`original_bytes` blob NOT NULL,
|
||||
`document` text 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
|
||||
);
|
||||
--> 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_model_objects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`source_id` text NOT NULL,
|
||||
`ifc_guid` 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_ifc_guid_unique` ON `external_model_objects` (`source_id`,`ifc_guid`);--> 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_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
|
||||
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_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_csv' 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_type_unique` ON `external_model_sources` (`project_id`,`source_type`);--> 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`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `external_import_batches` ADD `configuration_version` integer NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `circuit_device_rows` ADD `manual_quantity` integer DEFAULT 0 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `circuit_device_rows` SET `manual_quantity` = `quantity`;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,34 @@
|
||||
"when": 1785511894897,
|
||||
"tag": "0001_add_public_building_setting",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1785680771603,
|
||||
"tag": "0002_awesome_madripoor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1785683710155,
|
||||
"tag": "0003_outstanding_maddog",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1785684457208,
|
||||
"tag": "0004_illegal_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1785687503453,
|
||||
"tag": "0005_stale_gorilla_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,6 +82,14 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
const targetQuantity = patch.quantity ?? current.quantity;
|
||||
const targetManualQuantity =
|
||||
patch.manualQuantity ?? current.manualQuantity;
|
||||
if (targetManualQuantity > targetQuantity) {
|
||||
throw new Error(
|
||||
"Device-row manual quantity must not exceed total quantity."
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.source === "user" &&
|
||||
patch.phaseType !== undefined &&
|
||||
|
||||
@@ -20,6 +20,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
@@ -92,7 +93,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
throw new Error("Circuit device-row id already exists.");
|
||||
}
|
||||
|
||||
database.insert(circuitDeviceRows).values(row).run();
|
||||
database
|
||||
.insert(circuitDeviceRows)
|
||||
.values(toCircuitDeviceRowInsertValues(row))
|
||||
.run();
|
||||
const circuitUpdate = database
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
|
||||
@@ -61,6 +61,7 @@ export function toCircuitDeviceRowSnapshot(
|
||||
roomNumberSnapshot: row.roomNumberSnapshot,
|
||||
roomNameSnapshot: row.roomNameSnapshot,
|
||||
quantity: row.quantity,
|
||||
manualQuantity: row.manualQuantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
@@ -68,3 +69,10 @@ export function toCircuitDeviceRowSnapshot(
|
||||
overriddenFields: row.overriddenFields,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCircuitDeviceRowInsertValues(row: CircuitDeviceRowSnapshot) {
|
||||
return {
|
||||
...row,
|
||||
manualQuantity: row.manualQuantity ?? row.quantity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CircuitDeviceRowUpdateInput {
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
manualQuantity?: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
@@ -34,6 +35,7 @@ export interface CircuitDeviceRowPatchInput {
|
||||
roomNumberSnapshot?: string | null;
|
||||
roomNameSnapshot?: string | null;
|
||||
quantity?: number;
|
||||
manualQuantity?: number;
|
||||
powerPerUnit?: number;
|
||||
simultaneityFactor?: number;
|
||||
cosPhi?: number | null;
|
||||
@@ -61,6 +63,7 @@ export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInpu
|
||||
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
||||
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
||||
quantity: input.quantity,
|
||||
manualQuantity: input.manualQuantity ?? input.quantity,
|
||||
powerPerUnit: input.powerPerUnit,
|
||||
simultaneityFactor: input.simultaneityFactor,
|
||||
cosPhi: input.cosPhi ?? null,
|
||||
@@ -90,6 +93,7 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
|
||||
}
|
||||
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
|
||||
if (input.quantity !== undefined) values.quantity = input.quantity;
|
||||
if (input.manualQuantity !== undefined) values.manualQuantity = input.manualQuantity;
|
||||
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
|
||||
if (input.simultaneityFactor !== undefined) {
|
||||
values.simultaneityFactor = input.simultaneityFactor;
|
||||
|
||||
@@ -37,6 +37,7 @@ export class CircuitDeviceRowRepository {
|
||||
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||
quantity: circuitDeviceRows.quantity,
|
||||
manualQuantity: circuitDeviceRows.manualQuantity,
|
||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||
cosPhi: circuitDeviceRows.cosPhi,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitGroupSubtreeProjectCommandRepository
|
||||
@@ -212,7 +213,10 @@ export class CircuitGroupSubtreeProjectCommandRepository
|
||||
.run();
|
||||
}
|
||||
if (deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(deviceRows).run();
|
||||
database
|
||||
.insert(circuitDeviceRows)
|
||||
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
@@ -166,7 +167,10 @@ export class CircuitStructureProjectCommandRepository
|
||||
})
|
||||
.run();
|
||||
if (snapshot.deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
|
||||
database
|
||||
.insert(circuitDeviceRows)
|
||||
.values(snapshot.deviceRows.map(toCircuitDeviceRowInsertValues))
|
||||
.run();
|
||||
}
|
||||
if (snapshot.protectionDevice) {
|
||||
database
|
||||
|
||||
@@ -29,6 +29,7 @@ import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class DistributionBoardSubtreeProjectCommandRepository
|
||||
@@ -253,7 +254,10 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||
.run();
|
||||
}
|
||||
if (deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(deviceRows).run();
|
||||
database
|
||||
.insert(circuitDeviceRows)
|
||||
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export class ExternalCsvConfigurationRepository
|
||||
implements ExternalCsvConfigurationReader
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
getByProject(projectId: string) {
|
||||
const project = this.database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
return { projectExists: false, configuration: null };
|
||||
}
|
||||
const configuration = this.database
|
||||
.select()
|
||||
.from(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||
.get() ?? null;
|
||||
return { projectExists: true, configuration };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ExternalModelStateReader } from "../../domain/ports/external-model-state.reader.js";
|
||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { AppDatabase } from "../database-context.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 { projects } from "../schema/projects.js";
|
||||
|
||||
const emptyState = (): ExternalModelStateSnapshot => ({
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [],
|
||||
});
|
||||
|
||||
export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
getByProject(projectId: string) {
|
||||
const project = this.database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) return { projectExists: false, state: emptyState() };
|
||||
|
||||
const source = this.database
|
||||
.select()
|
||||
.from(externalModelSources)
|
||||
.where(eq(externalModelSources.projectId, projectId))
|
||||
.get() ?? null;
|
||||
if (!source) return { projectExists: true, state: emptyState() };
|
||||
|
||||
const batches = this.database
|
||||
.select()
|
||||
.from(externalImportBatches)
|
||||
.where(eq(externalImportBatches.sourceId, source.id))
|
||||
.orderBy(
|
||||
asc(externalImportBatches.appliedProjectRevision),
|
||||
asc(externalImportBatches.id)
|
||||
)
|
||||
.all();
|
||||
const roomMappings = this.database
|
||||
.select()
|
||||
.from(externalRoomMappings)
|
||||
.where(eq(externalRoomMappings.sourceId, source.id))
|
||||
.orderBy(
|
||||
asc(externalRoomMappings.normalizedSourceRoomKey),
|
||||
asc(externalRoomMappings.id)
|
||||
)
|
||||
.all();
|
||||
const objects = this.database
|
||||
.select()
|
||||
.from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.sourceId, source.id))
|
||||
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
|
||||
.all();
|
||||
|
||||
return {
|
||||
projectExists: true,
|
||||
state: {
|
||||
source,
|
||||
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
||||
...batch,
|
||||
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
|
||||
})),
|
||||
roomMappings,
|
||||
objects,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
|
||||
export interface ExternalObjectLinkTransition {
|
||||
expected: ExternalModelObjectSnapshot;
|
||||
target: ExternalModelObjectSnapshot;
|
||||
}
|
||||
|
||||
export function loadExpectedExternalObjectTransitions(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
transitions: ExternalObjectLinkTransition[],
|
||||
changedMessage: string
|
||||
) {
|
||||
const byId = new Map(
|
||||
transitions.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const current = database.select().from(externalModelObjects)
|
||||
.where(inArray(externalModelObjects.id, [...byId.keys()])).all();
|
||||
if (current.length !== byId.size) {
|
||||
throw new Error("One or more external objects no longer exist.");
|
||||
}
|
||||
for (const object of current) {
|
||||
if (
|
||||
object.projectId !== projectId ||
|
||||
!snapshotsEqual(toExternalObjectSnapshot(object), byId.get(object.id)!.expected)
|
||||
) {
|
||||
throw new Error(changedMessage);
|
||||
}
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
export function assertExternalObjectsCompatibleWithRow(input: {
|
||||
database: AppDatabase;
|
||||
projectId: string;
|
||||
row: CircuitDeviceRowSnapshot;
|
||||
distributionBoardId: string;
|
||||
category: CircuitGroupCategory;
|
||||
assignedObjects: ExternalModelObjectSnapshot[];
|
||||
allLinkedObjects: ExternalModelObjectSnapshot[];
|
||||
confirmedConflictObjectIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const mappings = input.database.select().from(externalRoomMappings)
|
||||
.where(eq(externalRoomMappings.projectId, input.projectId)).all();
|
||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||
const markers = new Set(
|
||||
input.allLinkedObjects.map((object) =>
|
||||
object.acceptedSourceValues.selectionMarker.trim()
|
||||
)
|
||||
);
|
||||
if (markers.size > 1) {
|
||||
throw new Error("External objects with different selection markers require separate rows.");
|
||||
}
|
||||
for (const object of input.assignedObjects) {
|
||||
if (object.distributionBoardId !== input.distributionBoardId) {
|
||||
throw new Error("External object distribution does not match target circuit.");
|
||||
}
|
||||
if (object.planningValues.category !== input.category) {
|
||||
throw new Error("External object category does not match target circuit group.");
|
||||
}
|
||||
const roomId = object.externalRoomMappingId === null
|
||||
? null
|
||||
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
|
||||
if (roomId !== input.row.roomId) {
|
||||
throw new Error("External object room does not match target device row.");
|
||||
}
|
||||
if (
|
||||
!matchesRowPlanningValues(input.row, object) &&
|
||||
!input.confirmedConflictObjectIds.has(object.id)
|
||||
) {
|
||||
throw new Error("External object planning values require explicit conflict confirmation.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyExternalObjectLinks(
|
||||
database: AppDatabase,
|
||||
transitions: ExternalObjectLinkTransition[],
|
||||
changedMessage: string
|
||||
) {
|
||||
for (const { expected, target } of transitions) {
|
||||
const updated = database.update(externalModelObjects)
|
||||
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
|
||||
.where(and(
|
||||
eq(externalModelObjects.id, expected.id),
|
||||
expected.circuitDeviceRowId === null
|
||||
? isNull(externalModelObjects.circuitDeviceRowId)
|
||||
: eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId)
|
||||
)).run();
|
||||
if (updated.changes !== 1) throw new Error(changedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function toExternalObjectSnapshot(
|
||||
object: typeof externalModelObjects.$inferSelect
|
||||
): ExternalModelObjectSnapshot {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
export function snapshotsEqual(left: unknown, right: unknown) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function matchesRowPlanningValues(
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
object: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const planning = object.planningValues;
|
||||
return (
|
||||
row.displayName === (planning.displayName ?? row.displayName) &&
|
||||
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
|
||||
row.category === planning.category &&
|
||||
row.connectionKind === planning.connectionKind &&
|
||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
||||
row.cosPhi === planning.cosPhi &&
|
||||
row.costGroup === planning.costGroup &&
|
||||
row.remark === planning.remark
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
import {
|
||||
assertExternalObjectNewCircuitProjectCommand,
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
externalObjectDeleteCreatedCircuitCommandType,
|
||||
invertExternalObjectNewCircuitProjectCommand,
|
||||
type ExternalObjectNewCircuitProjectCommand,
|
||||
} from "../../domain/models/external-object-new-circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
|
||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../../domain/ports/external-object-new-circuit-project-command.store.js";
|
||||
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectNewCircuitProjectCommandRepository
|
||||
implements ExternalObjectNewCircuitProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalObjectNewCircuitProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalObjectNewCircuitProjectCommand(input.command);
|
||||
if (
|
||||
input.source === "user" &&
|
||||
input.command.type === externalObjectDeleteCreatedCircuitCommandType
|
||||
) {
|
||||
throw new Error("Created external circuits may only be removed through project history.");
|
||||
}
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
if (input.command.type === externalObjectAssignToNewCircuitCommandType) {
|
||||
this.insert(tx, input.projectId, input.command);
|
||||
} else {
|
||||
this.remove(tx, input.projectId, input.command);
|
||||
}
|
||||
return invertExternalObjectNewCircuitProjectCommand(input.command);
|
||||
});
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
const circuit = command.payload.circuit;
|
||||
const row = circuit.deviceRows[0]!;
|
||||
const context = this.loadSectionContext(
|
||||
database,
|
||||
projectId,
|
||||
circuit.circuitListId,
|
||||
circuit.sectionId
|
||||
);
|
||||
const parsedIdentifier = parseGroupedEquipmentIdentifier(circuit.equipmentIdentifier);
|
||||
if (
|
||||
!parsedIdentifier ||
|
||||
parsedIdentifier.kind !== "circuit" ||
|
||||
parsedIdentifier.category !== context.category ||
|
||||
parsedIdentifier.groupNumber !== context.groupNumber
|
||||
) {
|
||||
throw new Error("External circuit equipment identifier does not match target group.");
|
||||
}
|
||||
if (row.category !== context.category || !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("External device row does not match target circuit group.");
|
||||
}
|
||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||
const voltage = resolveCircuitVoltage(database, projectId, circuit.sectionId, [row.phaseType]);
|
||||
if (circuit.voltage !== voltage) {
|
||||
throw new Error("External circuit voltage must match the project phase voltage.");
|
||||
}
|
||||
if (database.select({ id: circuits.id }).from(circuits)
|
||||
.where(eq(circuits.id, circuit.id)).get()) {
|
||||
throw new Error("External circuit id already exists.");
|
||||
}
|
||||
if (database.select({ id: circuits.id }).from(circuits).where(and(
|
||||
eq(circuits.circuitListId, circuit.circuitListId),
|
||||
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
|
||||
)).get()) {
|
||||
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
||||
}
|
||||
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, row.id)).get()) {
|
||||
throw new Error("External device-row id already exists.");
|
||||
}
|
||||
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
assignedObjects: objects.map(({ target }) => target),
|
||||
allLinkedObjects: objects.map(({ target }) => target),
|
||||
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: row.quantity,
|
||||
manualQuantity: 0,
|
||||
externalObjects: objects.map(({ target }) => ({
|
||||
effectiveQuantity: target.planningValues.effectiveQuantity,
|
||||
})),
|
||||
});
|
||||
|
||||
database.insert(circuits).values(toCircuitValues(circuit)).run();
|
||||
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
||||
database.insert(circuitProtectionDevices).values(circuit.protectionDevice!).run();
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-circuit assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private remove(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
const expectedCircuit = command.payload.circuit;
|
||||
this.loadSectionContext(
|
||||
database,
|
||||
projectId,
|
||||
expectedCircuit.circuitListId,
|
||||
expectedCircuit.sectionId
|
||||
);
|
||||
const current = this.loadCircuitSnapshot(database, expectedCircuit.id);
|
||||
if (!current || !snapshotsEqual(current, expectedCircuit)) {
|
||||
throw new Error("Created external circuit changed before history removal.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
const rowId = expectedCircuit.deviceRows[0]!.id;
|
||||
const linkedObjects = database.select({ id: externalModelObjects.id })
|
||||
.from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.circuitDeviceRowId, rowId)).all();
|
||||
if (
|
||||
linkedObjects.length !== objects.length ||
|
||||
linkedObjects.some(({ id }) => !objects.some(({ expected }) => expected.id === id))
|
||||
) {
|
||||
throw new Error("Created external circuit has different object links.");
|
||||
}
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-circuit assignment."
|
||||
);
|
||||
const deleted = database.delete(circuits).where(and(
|
||||
eq(circuits.id, expectedCircuit.id),
|
||||
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
||||
)).run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Created external circuit changed during history removal.");
|
||||
}
|
||||
}
|
||||
|
||||
private loadSectionContext(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
sectionId: string
|
||||
) {
|
||||
const context = database.select({
|
||||
projectId: circuitLists.projectId,
|
||||
distributionBoardId: circuitLists.distributionBoardId,
|
||||
category: circuitSections.category,
|
||||
groupNumber: circuitSections.groupNumber,
|
||||
}).from(circuitSections)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuitSections.circuitListId))
|
||||
.where(and(
|
||||
eq(circuitSections.id, sectionId),
|
||||
eq(circuitSections.circuitListId, circuitListId)
|
||||
)).get();
|
||||
if (
|
||||
!context ||
|
||||
context.projectId !== projectId ||
|
||||
context.category === null ||
|
||||
context.groupNumber === null
|
||||
) {
|
||||
throw new Error("Target circuit group does not belong to the project.");
|
||||
}
|
||||
return {
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
groupNumber: context.groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private loadExpectedObjects(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
return loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before new-circuit assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private loadCircuitSnapshot(database: AppDatabase, circuitId: string): CircuitSnapshot | null {
|
||||
const circuit = database.select().from(circuits).where(eq(circuits.id, circuitId)).get();
|
||||
if (!circuit) return null;
|
||||
const rows = database.select().from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||
.orderBy(asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id)).all();
|
||||
const protection = database.select().from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId)).get();
|
||||
return {
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||
protectionDevice: protection ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toCircuitValues(circuit: CircuitSnapshot) {
|
||||
const { deviceRows: _deviceRows, protectionDevice: _protectionDevice, ...values } = circuit;
|
||||
return { ...values, isReserve: circuit.isReserve ? 1 : 0 };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
import {
|
||||
assertExternalObjectNewRowProjectCommand,
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
externalObjectDeleteCreatedRowCommandType,
|
||||
invertExternalObjectNewRowProjectCommand,
|
||||
type ExternalObjectNewRowProjectCommand,
|
||||
} from "../../domain/models/external-object-new-row-project-command.model.js";
|
||||
import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectNewRowProjectCommandRepository
|
||||
implements ExternalObjectNewRowProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalObjectNewRowProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalObjectNewRowProjectCommand(input.command);
|
||||
if (
|
||||
input.source === "user" &&
|
||||
input.command.type === externalObjectDeleteCreatedRowCommandType
|
||||
) {
|
||||
throw new Error("Created external rows may only be removed through project history.");
|
||||
}
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
if (input.command.type === externalObjectAssignToNewRowCommandType) {
|
||||
this.insert(tx, input.projectId, input.command);
|
||||
} else {
|
||||
this.remove(tx, input.projectId, input.command);
|
||||
}
|
||||
return invertExternalObjectNewRowProjectCommand(input.command);
|
||||
});
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewRowProjectCommand
|
||||
) {
|
||||
const row = command.payload.row;
|
||||
const context = this.loadCircuitContext(database, projectId, row.circuitId);
|
||||
if (!isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("External device-row phase type is invalid.");
|
||||
}
|
||||
if (row.category !== context.category) {
|
||||
throw new Error("External device-row category does not match target circuit group.");
|
||||
}
|
||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, row.id)).get()) {
|
||||
throw new Error("External device-row id already exists.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
assignedObjects: objects.map(({ target }) => target),
|
||||
allLinkedObjects: objects.map(({ target }) => target),
|
||||
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: row.quantity,
|
||||
manualQuantity: 0,
|
||||
externalObjects: objects.map(({ target }) => ({
|
||||
effectiveQuantity: target.planningValues.effectiveQuantity,
|
||||
})),
|
||||
});
|
||||
|
||||
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
||||
this.updateCircuitState(database, projectId, row.circuitId);
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-row assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private remove(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewRowProjectCommand
|
||||
) {
|
||||
const expectedRow = command.payload.row;
|
||||
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
|
||||
const currentRow = database.select().from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
|
||||
if (!currentRow || !snapshotsEqual(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
|
||||
throw new Error("Created external device row changed before history removal.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
const linkedObjects = database.select({ id: externalModelObjects.id })
|
||||
.from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.circuitDeviceRowId, expectedRow.id)).all();
|
||||
if (
|
||||
linkedObjects.length !== objects.length ||
|
||||
linkedObjects.some(({ id }) => !objects.some((object) => object.expected.id === id))
|
||||
) {
|
||||
throw new Error("Created external device row has different object links.");
|
||||
}
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-row assignment."
|
||||
);
|
||||
const deleted = database.delete(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Created external device row changed during history removal.");
|
||||
}
|
||||
this.updateCircuitState(database, projectId, expectedRow.circuitId);
|
||||
}
|
||||
|
||||
private loadCircuitContext(database: AppDatabase, projectId: string, circuitId: string) {
|
||||
const context = database.select({
|
||||
projectId: circuitLists.projectId,
|
||||
distributionBoardId: circuitLists.distributionBoardId,
|
||||
category: circuitSections.category,
|
||||
}).from(circuits)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
|
||||
.where(eq(circuits.id, circuitId)).get();
|
||||
if (!context || context.projectId !== projectId || context.category === null) {
|
||||
throw new Error("Target circuit does not belong to the project.");
|
||||
}
|
||||
return {
|
||||
projectId: context.projectId,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
};
|
||||
}
|
||||
|
||||
private loadExpectedObjects(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewRowProjectCommand
|
||||
) {
|
||||
return loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before new-row assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) {
|
||||
const remaining = database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuitId)).limit(1).get();
|
||||
const result = database.update(circuits).set({ isReserve: remaining ? 0 : 1 })
|
||||
.where(eq(circuits.id, circuitId)).run();
|
||||
if (result.changes !== 1) throw new Error("Target circuit changed during row assignment.");
|
||||
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitDeviceRowQuantity,
|
||||
} from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
import {
|
||||
assertExternalObjectRowAssignmentProjectCommand,
|
||||
invertExternalObjectRowAssignmentProjectCommand,
|
||||
} from "../../domain/models/external-object-row-assignment-project-command.model.js";
|
||||
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../../domain/ports/external-object-row-assignment-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
toExternalObjectSnapshot,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
implements ExternalObjectRowAssignmentProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
this.apply(tx, input.projectId, input.command);
|
||||
return invertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
});
|
||||
}
|
||||
|
||||
private apply(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
|
||||
) {
|
||||
const rowTransitions = new Map(
|
||||
command.payload.rows.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const objectTransitions = new Map(
|
||||
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const rowIds = [...rowTransitions.keys()];
|
||||
const currentRows = database.select().from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds)).all();
|
||||
if (currentRows.length !== rowIds.length) {
|
||||
throw new Error("External object assignment target no longer exists.");
|
||||
}
|
||||
for (const row of currentRows) {
|
||||
const expected = rowTransitions.get(row.id)!.expected;
|
||||
if (!snapshotsEqual(toCircuitDeviceRowSnapshot(row), expected)) {
|
||||
throw new Error("Circuit device row changed before external object assignment.");
|
||||
}
|
||||
}
|
||||
loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before row assignment."
|
||||
);
|
||||
|
||||
const rowContexts = database
|
||||
.select({
|
||||
rowId: circuitDeviceRows.id,
|
||||
projectId: circuitLists.projectId,
|
||||
distributionBoardId: circuitLists.distributionBoardId,
|
||||
category: circuitSections.category,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
if (
|
||||
rowContexts.length !== rowIds.length ||
|
||||
rowContexts.some((context) =>
|
||||
context.projectId !== projectId || context.category === null
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit device row belongs to another project.");
|
||||
}
|
||||
const contextByRowId = new Map(rowContexts.map((context) => [context.rowId, context]));
|
||||
|
||||
const allProjectObjects = database.select().from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.projectId, projectId)).all()
|
||||
.map(toExternalObjectSnapshot);
|
||||
const targetObjects = allProjectObjects.map((object) =>
|
||||
objectTransitions.get(object.id)?.target ?? object
|
||||
);
|
||||
const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds);
|
||||
|
||||
for (const transition of command.payload.rows) {
|
||||
const linkedObjects = targetObjects.filter(
|
||||
(object) => object.circuitDeviceRowId === transition.target.id
|
||||
);
|
||||
const assignedObjects = command.payload.objects
|
||||
.map(({ target }) => target)
|
||||
.filter((object) => object.circuitDeviceRowId === transition.target.id);
|
||||
const context = contextByRowId.get(transition.target.id)!;
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row: transition.target,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category!,
|
||||
assignedObjects,
|
||||
allLinkedObjects: linkedObjects,
|
||||
confirmedConflictObjectIds: confirmedConflicts,
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: transition.target.quantity,
|
||||
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
|
||||
externalObjects: linkedObjects.map((object) => ({
|
||||
effectiveQuantity: object.planningValues.effectiveQuantity,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const transition of command.payload.rows) {
|
||||
const result = database.update(circuitDeviceRows)
|
||||
.set({ quantity: transition.target.quantity })
|
||||
.where(and(
|
||||
eq(circuitDeviceRows.id, transition.expected.id),
|
||||
eq(circuitDeviceRows.quantity, transition.expected.quantity),
|
||||
eq(circuitDeviceRows.manualQuantity, transition.expected.manualQuantity ?? transition.expected.quantity)
|
||||
)).run();
|
||||
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
|
||||
}
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
command.payload.objects,
|
||||
"External object changed during assignment."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
readProjectStateSnapshot,
|
||||
@@ -161,6 +166,13 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
projectDevices: byId(state.projectDevices),
|
||||
floors: byId(state.floors),
|
||||
rooms: byId(state.rooms),
|
||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
||||
externalModel: {
|
||||
source: state.externalModel.source,
|
||||
importBatches: byId(state.externalModel.importBatches),
|
||||
roomMappings: byId(state.externalModel.roomMappings),
|
||||
objects: byId(state.externalModel.objects),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,6 +195,14 @@ function replaceProjectState(
|
||||
database: AppDatabase,
|
||||
state: ProjectStateSnapshot
|
||||
) {
|
||||
database
|
||||
.delete(externalModelSources)
|
||||
.where(eq(externalModelSources.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(distributionBoards)
|
||||
.where(eq(distributionBoards.projectId, state.project.id))
|
||||
@@ -218,6 +238,12 @@ function replaceProjectState(
|
||||
.run();
|
||||
|
||||
insertMany(database, floors, state.floors);
|
||||
if (state.externalCsvConfiguration !== null) {
|
||||
database
|
||||
.insert(externalCsvConfigurations)
|
||||
.values(state.externalCsvConfiguration)
|
||||
.run();
|
||||
}
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
insertMany(database, distributionBoards, state.distributionBoards);
|
||||
@@ -251,6 +277,21 @@ function replaceProjectState(
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
if (state.externalModel.source !== null) {
|
||||
database.insert(externalModelSources).values(state.externalModel.source).run();
|
||||
}
|
||||
insertMany(
|
||||
database,
|
||||
externalImportBatches,
|
||||
state.externalModel.importBatches.map(
|
||||
({ originalContentBase64, ...batch }) => ({
|
||||
...batch,
|
||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||
})
|
||||
)
|
||||
);
|
||||
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
||||
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
|
||||
@@ -19,6 +19,8 @@ import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||
|
||||
export interface PersistedProjectStateSnapshot {
|
||||
currentRevision: number;
|
||||
@@ -59,6 +61,13 @@ export function readProjectStateSnapshot(
|
||||
.where(eq(distributionBoards.projectId, projectId))
|
||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||
.all();
|
||||
const externalCsvConfiguration = database
|
||||
.select()
|
||||
.from(externalCsvConfigurations)
|
||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||
.get() ?? null;
|
||||
const externalModel = new ExternalModelStateRepository(database)
|
||||
.getByProject(projectId).state;
|
||||
const listRows = database
|
||||
.select()
|
||||
.from(circuitLists)
|
||||
@@ -183,6 +192,7 @@ export function readProjectStateSnapshot(
|
||||
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||
quantity: circuitDeviceRows.quantity,
|
||||
manualQuantity: circuitDeviceRows.manualQuantity,
|
||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||
cosPhi: circuitDeviceRows.cosPhi,
|
||||
@@ -241,6 +251,8 @@ export function readProjectStateSnapshot(
|
||||
project.enabledDistributionBoardSupplyTypes,
|
||||
},
|
||||
distributionBoards: boardRows,
|
||||
externalCsvConfiguration,
|
||||
externalModel,
|
||||
circuitLists: listRows,
|
||||
circuitSections: sectionRows,
|
||||
distributionBoardComponents: componentRows,
|
||||
|
||||
@@ -20,6 +20,11 @@ import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.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 {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
@@ -116,6 +121,12 @@ function insertProjectState(
|
||||
.insert(projects)
|
||||
.values({ ...state.project, currentRevision: 0 })
|
||||
.run();
|
||||
if (state.externalCsvConfiguration !== null) {
|
||||
database
|
||||
.insert(externalCsvConfigurations)
|
||||
.values(state.externalCsvConfiguration)
|
||||
.run();
|
||||
}
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
@@ -150,6 +161,21 @@ function insertProjectState(
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
if (state.externalModel.source !== null) {
|
||||
database.insert(externalModelSources).values(state.externalModel.source).run();
|
||||
}
|
||||
insertMany(
|
||||
database,
|
||||
externalImportBatches,
|
||||
state.externalModel.importBatches.map(
|
||||
({ originalContentBase64, ...batch }) => ({
|
||||
...batch,
|
||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||
})
|
||||
)
|
||||
);
|
||||
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
||||
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
|
||||
@@ -25,6 +25,7 @@ export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
|
||||
roomNumberSnapshot: text("room_number_snapshot"),
|
||||
roomNameSnapshot: text("room_name_snapshot"),
|
||||
quantity: integer("quantity").notNull(),
|
||||
manualQuantity: integer("manual_quantity").notNull().default(0),
|
||||
powerPerUnit: real("power_per_unit").notNull(),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull(),
|
||||
cosPhi: real("cos_phi"),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
ExternalCsvConfiguration,
|
||||
ExternalCsvDocument,
|
||||
} from "../../external-model/csv/external-csv-contracts.js";
|
||||
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
|
||||
import { externalModelSources } from "./external-model-sources.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const externalImportBatches = sqliteTable(
|
||||
"external_import_batches",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
sourceId: text("source_id")
|
||||
.notNull()
|
||||
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
||||
importKind: text("import_kind", { enum: externalImportKinds }).notNull(),
|
||||
importedAtIso: text("imported_at_iso").notNull(),
|
||||
fileName: text("file_name").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
appliedProjectRevision: integer("applied_project_revision").notNull(),
|
||||
configurationVersion: integer("configuration_version").notNull(),
|
||||
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
||||
.$type<ExternalCsvConfiguration>()
|
||||
.notNull(),
|
||||
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
|
||||
document: text("document", { mode: "json" })
|
||||
.$type<ExternalCsvDocument>()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("external_import_batches_project_revision_idx").on(
|
||||
table.projectId,
|
||||
table.appliedProjectRevision
|
||||
),
|
||||
index("external_import_batches_source_imported_idx").on(
|
||||
table.sourceId,
|
||||
table.importedAtIso
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type {
|
||||
ExternalModelObjectOverrideField,
|
||||
ExternalModelObjectPlanningValues,
|
||||
ExternalModelObjectSourceValues,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
import { externalObjectPresenceStatuses } from "../../external-model/domain/external-model-contracts.js";
|
||||
import { circuitDeviceRows } from "./circuit-device-rows.js";
|
||||
import { distributionBoards } from "./distribution-boards.js";
|
||||
import { externalImportBatches } from "./external-import-batches.js";
|
||||
import { externalModelSources } from "./external-model-sources.js";
|
||||
import { externalRoomMappings } from "./external-room-mappings.js";
|
||||
import { projectDevices } from "./project-devices.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const externalModelObjects = sqliteTable(
|
||||
"external_model_objects",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
sourceId: text("source_id")
|
||||
.notNull()
|
||||
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
||||
ifcGuid: text("ifc_guid").notNull(),
|
||||
lastSeenImportBatchId: text("last_seen_import_batch_id")
|
||||
.notNull()
|
||||
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
||||
lastAcceptedImportBatchId: text("last_accepted_import_batch_id")
|
||||
.notNull()
|
||||
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
||||
acceptedSourceValues: text("accepted_source_values", { mode: "json" })
|
||||
.$type<ExternalModelObjectSourceValues>()
|
||||
.notNull(),
|
||||
planningValues: text("planning_values", { mode: "json" })
|
||||
.$type<ExternalModelObjectPlanningValues>()
|
||||
.notNull(),
|
||||
overriddenFields: text("overridden_fields", { mode: "json" })
|
||||
.$type<ExternalModelObjectOverrideField[]>()
|
||||
.notNull(),
|
||||
externalRoomMappingId: text("external_room_mapping_id").references(
|
||||
() => externalRoomMappings.id,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
distributionBoardId: text("distribution_board_id").references(
|
||||
() => distributionBoards.id,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
linkedProjectDeviceId: text("linked_project_device_id").references(
|
||||
() => projectDevices.id,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
circuitDeviceRowId: text("circuit_device_row_id").references(
|
||||
() => circuitDeviceRows.id,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
presenceStatus: text("presence_status", {
|
||||
enum: externalObjectPresenceStatuses,
|
||||
})
|
||||
.notNull()
|
||||
.default("present"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
|
||||
table.sourceId,
|
||||
table.ifcGuid
|
||||
),
|
||||
index("external_model_objects_project_presence_idx").on(
|
||||
table.projectId,
|
||||
table.presenceStatus
|
||||
),
|
||||
index("external_model_objects_distribution_board_idx").on(
|
||||
table.distributionBoardId
|
||||
),
|
||||
index("external_model_objects_circuit_device_row_idx").on(
|
||||
table.circuitDeviceRowId
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { externalModelSourceTypes } from "../../external-model/domain/external-model-contracts.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const externalModelSources = sqliteTable(
|
||||
"external_model_sources",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
sourceType: text("source_type", { enum: externalModelSourceTypes })
|
||||
.notNull()
|
||||
.default("revit_csv"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("external_model_sources_project_type_unique").on(
|
||||
table.projectId,
|
||||
table.sourceType
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { distributionBoards } from "./distribution-boards.js";
|
||||
import { externalModelSources } from "./external-model-sources.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { rooms } from "./rooms.js";
|
||||
|
||||
export const externalRoomMappings = sqliteTable(
|
||||
"external_room_mappings",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
sourceId: text("source_id")
|
||||
.notNull()
|
||||
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
||||
normalizedSourceRoomKey: text("normalized_source_room_key").notNull(),
|
||||
sourceFloorName: text("source_floor_name"),
|
||||
sourceRoomNumber: text("source_room_number").notNull(),
|
||||
sourceRoomName: text("source_room_name").notNull(),
|
||||
roomId: text("room_id").references(() => rooms.id, { onDelete: "set null" }),
|
||||
defaultDistributionBoardId: text("default_distribution_board_id").references(
|
||||
() => distributionBoards.id,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("external_room_mappings_source_key_unique").on(
|
||||
table.sourceId,
|
||||
table.normalizedSourceRoomKey
|
||||
),
|
||||
index("external_room_mappings_project_room_idx").on(
|
||||
table.projectId,
|
||||
table.roomId
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ExternalQuantityContribution {
|
||||
effectiveQuantity: number;
|
||||
}
|
||||
|
||||
export function calculateCircuitDeviceRowQuantity(
|
||||
manualQuantity: number,
|
||||
externalObjects: ExternalQuantityContribution[]
|
||||
) {
|
||||
assertNonNegativeFinite(manualQuantity, "Manual quantity");
|
||||
return externalObjects.reduce((quantity, object) => {
|
||||
assertPositiveFinite(object.effectiveQuantity, "External effective quantity");
|
||||
return quantity + object.effectiveQuantity;
|
||||
}, manualQuantity);
|
||||
}
|
||||
|
||||
export function assertCircuitDeviceRowQuantity(input: {
|
||||
quantity: number;
|
||||
manualQuantity: number;
|
||||
externalObjects: ExternalQuantityContribution[];
|
||||
}) {
|
||||
assertNonNegativeFinite(input.quantity, "Materialized quantity");
|
||||
const expected = calculateCircuitDeviceRowQuantity(
|
||||
input.manualQuantity,
|
||||
input.externalObjects
|
||||
);
|
||||
if (input.quantity !== expected) {
|
||||
throw new Error(
|
||||
`Materialized quantity ${input.quantity} does not match manual and external quantity ${expected}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeFinite(value: number, label: string) {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} must be a non-negative finite number.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(value: number, label: string) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive finite number.`);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface CircuitDeviceRowUpdateValues {
|
||||
roomNumberSnapshot: string | null;
|
||||
roomNameSnapshot: string | null;
|
||||
quantity: number;
|
||||
manualQuantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
@@ -51,7 +52,18 @@ export function createCircuitDeviceRowUpdateProjectCommand(
|
||||
rowId: string,
|
||||
patch: CircuitDeviceRowUpdatePatch
|
||||
): CircuitDeviceRowUpdateProjectCommand {
|
||||
const changes = Object.entries(patch).map(([field, value]) => ({
|
||||
const normalizedPatch =
|
||||
patch.quantity !== undefined && patch.manualQuantity === undefined
|
||||
? { ...patch, manualQuantity: patch.quantity }
|
||||
: patch;
|
||||
if (
|
||||
normalizedPatch.quantity !== undefined &&
|
||||
normalizedPatch.manualQuantity !== undefined &&
|
||||
normalizedPatch.manualQuantity > normalizedPatch.quantity
|
||||
) {
|
||||
throw new Error("manualQuantity must not exceed quantity.");
|
||||
}
|
||||
const changes = Object.entries(normalizedPatch).map(([field, value]) => ({
|
||||
field: field as CircuitDeviceRowUpdateField,
|
||||
value,
|
||||
})) as CircuitDeviceRowUpdateFieldChange[];
|
||||
@@ -125,6 +137,7 @@ function assertCircuitDeviceRowUpdateFieldValue(
|
||||
}
|
||||
if (
|
||||
field === "quantity" ||
|
||||
field === "manualQuantity" ||
|
||||
field === "powerPerUnit" ||
|
||||
field === "simultaneityFactor"
|
||||
) {
|
||||
@@ -164,6 +177,7 @@ function isCircuitDeviceRowUpdateField(
|
||||
"roomNumberSnapshot",
|
||||
"roomNameSnapshot",
|
||||
"quantity",
|
||||
"manualQuantity",
|
||||
"powerPerUnit",
|
||||
"simultaneityFactor",
|
||||
"cosPhi",
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface CircuitDeviceRowSnapshot {
|
||||
roomNumberSnapshot: string | null;
|
||||
roomNameSnapshot: string | null;
|
||||
quantity: number;
|
||||
manualQuantity?: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
@@ -57,10 +58,14 @@ export type CircuitDeviceRowStructureProjectCommand =
|
||||
export function createCircuitDeviceRowInsertProjectCommand(
|
||||
row: CircuitDeviceRowSnapshot
|
||||
): CircuitDeviceRowInsertProjectCommand {
|
||||
const normalizedRow = {
|
||||
...row,
|
||||
manualQuantity: row.manualQuantity ?? row.quantity,
|
||||
};
|
||||
const command: CircuitDeviceRowInsertProjectCommand = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowInsertCommandType,
|
||||
payload: { row },
|
||||
payload: { row: normalizedRow },
|
||||
};
|
||||
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||
return command;
|
||||
@@ -117,7 +122,13 @@ export function assertCircuitDeviceRowInsertProjectCommand(
|
||||
] as const) {
|
||||
assertNullableString(row[field], `row.${field}`);
|
||||
}
|
||||
assertNonNegativeNumber(row.quantity, "row.quantity");
|
||||
const quantity = row.quantity;
|
||||
assertNonNegativeNumber(quantity, "row.quantity");
|
||||
const manualQuantity = row.manualQuantity ?? quantity;
|
||||
assertNonNegativeNumber(manualQuantity, "row.manualQuantity");
|
||||
if (manualQuantity > quantity) {
|
||||
throw new Error("row.manualQuantity must not exceed row.quantity.");
|
||||
}
|
||||
assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit");
|
||||
assertNonNegativeNumber(
|
||||
row.simultaneityFactor,
|
||||
@@ -172,7 +183,10 @@ function assertFiniteNumber(
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeNumber(value: unknown, field: string) {
|
||||
function assertNonNegativeNumber(
|
||||
value: unknown,
|
||||
field: string
|
||||
): asserts value is number {
|
||||
assertFiniteNumber(value, field);
|
||||
if (value < 0) {
|
||||
throw new Error(`${field} must not be negative.`);
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CircuitDeviceRow {
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
manualQuantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CircuitTreeDeviceRow {
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
manualQuantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
assertCircuitInsertProjectCommand,
|
||||
circuitInsertCommandType,
|
||||
circuitStructureCommandSchemaVersion,
|
||||
type CircuitSnapshot,
|
||||
} from "./circuit-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
|
||||
export const externalObjectAssignToNewCircuitCommandType =
|
||||
"external-object.assign-to-new-circuit" as const;
|
||||
export const externalObjectDeleteCreatedCircuitCommandType =
|
||||
"external-object.unassign-and-delete-created-circuit" as const;
|
||||
export const externalObjectNewCircuitCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ExternalObjectNewCircuitPayload {
|
||||
circuit: CircuitSnapshot;
|
||||
objects: Array<{
|
||||
expected: ExternalModelObjectSnapshot;
|
||||
target: ExternalModelObjectSnapshot;
|
||||
}>;
|
||||
confirmedConflictObjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectNewCircuitProjectCommand
|
||||
extends SerializedProjectCommand<ExternalObjectNewCircuitPayload> {
|
||||
schemaVersion: typeof externalObjectNewCircuitCommandSchemaVersion;
|
||||
type:
|
||||
| typeof externalObjectAssignToNewCircuitCommandType
|
||||
| typeof externalObjectDeleteCreatedCircuitCommandType;
|
||||
}
|
||||
|
||||
export function createExternalObjectNewCircuitProjectCommand(
|
||||
type: ExternalObjectNewCircuitProjectCommand["type"],
|
||||
payload: ExternalObjectNewCircuitPayload
|
||||
): ExternalObjectNewCircuitProjectCommand {
|
||||
const row = payload.circuit.deviceRows[0];
|
||||
const command: ExternalObjectNewCircuitProjectCommand = {
|
||||
schemaVersion: externalObjectNewCircuitCommandSchemaVersion,
|
||||
type,
|
||||
payload: {
|
||||
circuit: row
|
||||
? {
|
||||
...payload.circuit,
|
||||
deviceRows: [{
|
||||
...row,
|
||||
manualQuantity: row.manualQuantity ?? row.quantity,
|
||||
}],
|
||||
}
|
||||
: payload.circuit,
|
||||
objects: payload.objects,
|
||||
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
||||
},
|
||||
};
|
||||
assertExternalObjectNewCircuitProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalObjectNewCircuitProjectCommand(
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
return createExternalObjectNewCircuitProjectCommand(
|
||||
command.type === externalObjectAssignToNewCircuitCommandType
|
||||
? externalObjectDeleteCreatedCircuitCommandType
|
||||
: externalObjectAssignToNewCircuitCommandType,
|
||||
{
|
||||
circuit: command.payload.circuit,
|
||||
objects: command.payload.objects.map(({ expected, target }) => ({
|
||||
expected: target,
|
||||
target: expected,
|
||||
})),
|
||||
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function assertExternalObjectNewCircuitProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ExternalObjectNewCircuitProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== externalObjectNewCircuitCommandSchemaVersion ||
|
||||
(command.type !== externalObjectAssignToNewCircuitCommandType &&
|
||||
command.type !== externalObjectDeleteCreatedCircuitCommandType) ||
|
||||
!isRecord(command.payload) ||
|
||||
!Array.isArray(command.payload.objects) ||
|
||||
command.payload.objects.length === 0 ||
|
||||
!Array.isArray(command.payload.confirmedConflictObjectIds)
|
||||
) {
|
||||
throw new Error("Unsupported external object new-circuit command.");
|
||||
}
|
||||
assertCircuitInsertProjectCommand({
|
||||
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||
type: circuitInsertCommandType,
|
||||
payload: { circuit: command.payload.circuit },
|
||||
});
|
||||
const circuit = command.payload.circuit as CircuitSnapshot;
|
||||
if (
|
||||
circuit.deviceRows.length !== 1 ||
|
||||
circuit.deviceRows[0]!.manualQuantity !== 0 ||
|
||||
circuit.protectionDevice === undefined ||
|
||||
circuit.protectionDevice === null
|
||||
) {
|
||||
throw new Error(
|
||||
"An external circuit must contain one external-only row and protection device."
|
||||
);
|
||||
}
|
||||
const rowId = circuit.deviceRows[0]!.id;
|
||||
const objectIds = new Set<string>();
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
|
||||
const expected = parseObject(transition.expected);
|
||||
const target = parseObject(transition.target);
|
||||
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||
throw new Error("New-circuit assignment contains mismatched or duplicate objects.");
|
||||
}
|
||||
objectIds.add(expected.id);
|
||||
const assigning = command.type === externalObjectAssignToNewCircuitCommandType;
|
||||
if (
|
||||
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== rowId)) ||
|
||||
(!assigning && (expected.circuitDeviceRowId !== rowId || target.circuitDeviceRowId !== null))
|
||||
) {
|
||||
throw new Error("External object links do not match the new-circuit action.");
|
||||
}
|
||||
assertOnlyAssignmentChanged(expected, target);
|
||||
}
|
||||
const confirmedIds = new Set<string>();
|
||||
for (const objectId of command.payload.confirmedConflictObjectIds) {
|
||||
if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) {
|
||||
throw new Error("New-circuit assignment contains an invalid conflict confirmation.");
|
||||
}
|
||||
confirmedIds.add(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
||||
return parseExternalModelStateSnapshot({
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [value],
|
||||
}).objects[0]!;
|
||||
}
|
||||
|
||||
function assertOnlyAssignmentChanged(
|
||||
expected: ExternalModelObjectSnapshot,
|
||||
target: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const expectedRest = { ...expected, circuitDeviceRowId: null };
|
||||
const targetRest = { ...target, circuitDeviceRowId: null };
|
||||
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
||||
throw new Error("New-circuit assignment may only change the external object row link.");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
assertCircuitDeviceRowInsertProjectCommand,
|
||||
circuitDeviceRowInsertCommandType,
|
||||
circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type CircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
|
||||
export const externalObjectAssignToNewRowCommandType =
|
||||
"external-object.assign-to-new-row" as const;
|
||||
export const externalObjectDeleteCreatedRowCommandType =
|
||||
"external-object.unassign-and-delete-created-row" as const;
|
||||
export const externalObjectNewRowCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ExternalObjectNewRowPayload {
|
||||
row: CircuitDeviceRowSnapshot;
|
||||
objects: Array<{
|
||||
expected: ExternalModelObjectSnapshot;
|
||||
target: ExternalModelObjectSnapshot;
|
||||
}>;
|
||||
confirmedConflictObjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectNewRowProjectCommand
|
||||
extends SerializedProjectCommand<ExternalObjectNewRowPayload> {
|
||||
schemaVersion: typeof externalObjectNewRowCommandSchemaVersion;
|
||||
type:
|
||||
| typeof externalObjectAssignToNewRowCommandType
|
||||
| typeof externalObjectDeleteCreatedRowCommandType;
|
||||
}
|
||||
|
||||
export function createExternalObjectNewRowProjectCommand(
|
||||
type: ExternalObjectNewRowProjectCommand["type"],
|
||||
payload: ExternalObjectNewRowPayload
|
||||
): ExternalObjectNewRowProjectCommand {
|
||||
const command: ExternalObjectNewRowProjectCommand = {
|
||||
schemaVersion: externalObjectNewRowCommandSchemaVersion,
|
||||
type,
|
||||
payload: {
|
||||
row: {
|
||||
...payload.row,
|
||||
manualQuantity: payload.row.manualQuantity ?? payload.row.quantity,
|
||||
},
|
||||
objects: payload.objects,
|
||||
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
||||
},
|
||||
};
|
||||
assertExternalObjectNewRowProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalObjectNewRowProjectCommand(
|
||||
command: ExternalObjectNewRowProjectCommand
|
||||
) {
|
||||
return createExternalObjectNewRowProjectCommand(
|
||||
command.type === externalObjectAssignToNewRowCommandType
|
||||
? externalObjectDeleteCreatedRowCommandType
|
||||
: externalObjectAssignToNewRowCommandType,
|
||||
{
|
||||
row: command.payload.row,
|
||||
objects: command.payload.objects.map(({ expected, target }) => ({
|
||||
expected: target,
|
||||
target: expected,
|
||||
})),
|
||||
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function assertExternalObjectNewRowProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ExternalObjectNewRowProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== externalObjectNewRowCommandSchemaVersion ||
|
||||
(command.type !== externalObjectAssignToNewRowCommandType &&
|
||||
command.type !== externalObjectDeleteCreatedRowCommandType) ||
|
||||
!isRecord(command.payload) ||
|
||||
!Array.isArray(command.payload.objects) ||
|
||||
command.payload.objects.length === 0 ||
|
||||
!Array.isArray(command.payload.confirmedConflictObjectIds)
|
||||
) {
|
||||
throw new Error("Unsupported external object new-row command.");
|
||||
}
|
||||
const row = parseRow(command.payload.row);
|
||||
if (row.manualQuantity !== 0) {
|
||||
throw new Error("An external-only device row must start with zero manual quantity.");
|
||||
}
|
||||
const objectIds = new Set<string>();
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
|
||||
const expected = parseObject(transition.expected);
|
||||
const target = parseObject(transition.target);
|
||||
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||
throw new Error("New-row assignment contains mismatched or duplicate objects.");
|
||||
}
|
||||
objectIds.add(expected.id);
|
||||
const assigning = command.type === externalObjectAssignToNewRowCommandType;
|
||||
if (
|
||||
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== row.id)) ||
|
||||
(!assigning && (expected.circuitDeviceRowId !== row.id || target.circuitDeviceRowId !== null))
|
||||
) {
|
||||
throw new Error("External object links do not match the new-row action.");
|
||||
}
|
||||
assertOnlyAssignmentChanged(expected, target);
|
||||
}
|
||||
const confirmedIds = new Set<string>();
|
||||
for (const objectId of command.payload.confirmedConflictObjectIds) {
|
||||
if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) {
|
||||
throw new Error("New-row assignment contains an invalid conflict confirmation.");
|
||||
}
|
||||
confirmedIds.add(objectId);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
|
||||
const envelope = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowInsertCommandType,
|
||||
payload: { row: value },
|
||||
};
|
||||
assertCircuitDeviceRowInsertProjectCommand(envelope);
|
||||
const row = envelope.payload.row;
|
||||
if (row.manualQuantity === undefined) {
|
||||
throw new Error("External object new-row command requires manual quantity.");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
||||
return parseExternalModelStateSnapshot({
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [value],
|
||||
}).objects[0]!;
|
||||
}
|
||||
|
||||
function assertOnlyAssignmentChanged(
|
||||
expected: ExternalModelObjectSnapshot,
|
||||
target: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const expectedRest = { ...expected, circuitDeviceRowId: null };
|
||||
const targetRest = { ...target, circuitDeviceRowId: null };
|
||||
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
||||
throw new Error("New-row assignment may only change the external object row link.");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
assertCircuitDeviceRowInsertProjectCommand,
|
||||
circuitDeviceRowInsertCommandType,
|
||||
circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type CircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
|
||||
export const externalObjectRowAssignmentCommandType =
|
||||
"external-object.update-row-assignment" as const;
|
||||
export const externalObjectRowAssignmentCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ExternalObjectRowAssignmentPayload {
|
||||
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
|
||||
objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>;
|
||||
confirmedConflictObjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectRowAssignmentProjectCommand
|
||||
extends SerializedProjectCommand<ExternalObjectRowAssignmentPayload> {
|
||||
schemaVersion: typeof externalObjectRowAssignmentCommandSchemaVersion;
|
||||
type: typeof externalObjectRowAssignmentCommandType;
|
||||
}
|
||||
|
||||
export function createExternalObjectRowAssignmentProjectCommand(
|
||||
payload: ExternalObjectRowAssignmentPayload
|
||||
): ExternalObjectRowAssignmentProjectCommand {
|
||||
const normalizedPayload: ExternalObjectRowAssignmentPayload = {
|
||||
...payload,
|
||||
rows: payload.rows.map(({ expected, target }) => ({
|
||||
expected: normalizeRow(expected),
|
||||
target: normalizeRow(target),
|
||||
})),
|
||||
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
||||
};
|
||||
const command: ExternalObjectRowAssignmentProjectCommand = {
|
||||
schemaVersion: externalObjectRowAssignmentCommandSchemaVersion,
|
||||
type: externalObjectRowAssignmentCommandType,
|
||||
payload: normalizedPayload,
|
||||
};
|
||||
assertExternalObjectRowAssignmentProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalObjectRowAssignmentProjectCommand(
|
||||
command: ExternalObjectRowAssignmentProjectCommand
|
||||
) {
|
||||
return createExternalObjectRowAssignmentProjectCommand({
|
||||
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
confirmedConflictObjectIds: [...command.payload.confirmedConflictObjectIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function assertExternalObjectRowAssignmentProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ExternalObjectRowAssignmentProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== externalObjectRowAssignmentCommandSchemaVersion ||
|
||||
command.type !== externalObjectRowAssignmentCommandType ||
|
||||
!isRecord(command.payload) ||
|
||||
!Array.isArray(command.payload.rows) ||
|
||||
!Array.isArray(command.payload.objects) ||
|
||||
!Array.isArray(command.payload.confirmedConflictObjectIds) ||
|
||||
command.payload.rows.length === 0 ||
|
||||
command.payload.objects.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported external object row-assignment command.");
|
||||
}
|
||||
const rowIds = new Set<string>();
|
||||
for (const transition of command.payload.rows) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid row assignment transition.");
|
||||
const expected = parseRow(transition.expected);
|
||||
const target = parseRow(transition.target);
|
||||
if (expected.manualQuantity === undefined || target.manualQuantity === undefined) {
|
||||
throw new Error("Row assignment requires an explicit manual quantity.");
|
||||
}
|
||||
if (expected.id !== target.id || rowIds.has(expected.id)) {
|
||||
throw new Error("Row assignment contains mismatched or duplicate rows.");
|
||||
}
|
||||
rowIds.add(expected.id);
|
||||
if (expected.manualQuantity !== target.manualQuantity) {
|
||||
throw new Error("Row assignment must preserve the manual quantity.");
|
||||
}
|
||||
assertOnlyFieldChanged(
|
||||
expected as unknown as Record<string, unknown>,
|
||||
target as unknown as Record<string, unknown>,
|
||||
"quantity",
|
||||
"row"
|
||||
);
|
||||
}
|
||||
const objectIds = new Set<string>();
|
||||
const affectedRowIds = new Set<string>();
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid object assignment transition.");
|
||||
const expected = parseObject(transition.expected);
|
||||
const target = parseObject(transition.target);
|
||||
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||
throw new Error("Row assignment contains mismatched or duplicate objects.");
|
||||
}
|
||||
objectIds.add(expected.id);
|
||||
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
|
||||
throw new Error("External object row assignment must change its row link.");
|
||||
}
|
||||
assertOnlyFieldChanged(
|
||||
expected as unknown as Record<string, unknown>,
|
||||
target as unknown as Record<string, unknown>,
|
||||
"circuitDeviceRowId",
|
||||
"external object"
|
||||
);
|
||||
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
|
||||
if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId);
|
||||
}
|
||||
const confirmedIds = new Set<string>();
|
||||
for (const objectId of command.payload.confirmedConflictObjectIds) {
|
||||
if (
|
||||
typeof objectId !== "string" ||
|
||||
!objectIds.has(objectId) ||
|
||||
confirmedIds.has(objectId)
|
||||
) {
|
||||
throw new Error("Row assignment contains an invalid conflict confirmation.");
|
||||
}
|
||||
confirmedIds.add(objectId);
|
||||
}
|
||||
if (
|
||||
affectedRowIds.size !== rowIds.size ||
|
||||
[...affectedRowIds].some((rowId) => !rowIds.has(rowId))
|
||||
) {
|
||||
throw new Error("Row assignment must include every and only affected device row.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot {
|
||||
return { ...row, manualQuantity: row.manualQuantity ?? row.quantity };
|
||||
}
|
||||
|
||||
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
|
||||
const envelope = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowInsertCommandType,
|
||||
payload: { row: value },
|
||||
};
|
||||
assertCircuitDeviceRowInsertProjectCommand(envelope);
|
||||
return envelope.payload.row;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
||||
const state = parseExternalModelStateSnapshot({
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [value],
|
||||
});
|
||||
return state.objects[0]!;
|
||||
}
|
||||
|
||||
function assertOnlyFieldChanged(
|
||||
expected: Record<string, unknown>,
|
||||
target: Record<string, unknown>,
|
||||
allowedField: string,
|
||||
label: string
|
||||
) {
|
||||
const expectedRest = { ...expected };
|
||||
const targetRest = { ...target };
|
||||
delete expectedRest[allowedField];
|
||||
delete targetRest[allowedField];
|
||||
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
||||
throw new Error(`Row assignment may only change the ${label} assignment state.`);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -18,11 +18,26 @@ import {
|
||||
resolveCircuitPhaseType,
|
||||
resolveProjectVoltage,
|
||||
} from "../services/project-voltage.service.js";
|
||||
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
||||
import {
|
||||
externalImportKinds,
|
||||
externalModelSourceTypes,
|
||||
externalObjectPresenceStatuses,
|
||||
type ExternalModelStateSnapshot,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 2 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 1 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 5 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
|
||||
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1);
|
||||
const externalIfcGuidSchema = z.string().min(1).refine(
|
||||
(value) => value === value.trim(),
|
||||
"Snapshot IFCGUID must not contain outer whitespace."
|
||||
);
|
||||
const nullableStringSchema = z.string().nullable();
|
||||
const finiteNumberSchema = z.number().finite();
|
||||
|
||||
@@ -89,8 +104,15 @@ const circuitSectionSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const circuitDeviceRowSchema = z
|
||||
.object({
|
||||
const circuitDeviceRowSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const row = value as Record<string, unknown>;
|
||||
return Object.prototype.hasOwnProperty.call(row, "manualQuantity")
|
||||
? row
|
||||
: { ...row, manualQuantity: row.quantity };
|
||||
},
|
||||
z.object({
|
||||
id: idSchema,
|
||||
circuitId: idSchema,
|
||||
linkedProjectDeviceId: idSchema.nullable(),
|
||||
@@ -106,13 +128,15 @@ const circuitDeviceRowSchema = z
|
||||
roomNumberSnapshot: nullableStringSchema,
|
||||
roomNameSnapshot: nullableStringSchema,
|
||||
quantity: finiteNumberSchema.nonnegative(),
|
||||
manualQuantity: finiteNumberSchema.nonnegative(),
|
||||
powerPerUnit: finiteNumberSchema.nonnegative(),
|
||||
simultaneityFactor: finiteNumberSchema.nonnegative(),
|
||||
cosPhi: finiteNumberSchema.positive().nullable(),
|
||||
remark: nullableStringSchema,
|
||||
overriddenFields: nullableStringSchema,
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
);
|
||||
|
||||
const circuitSchema = z
|
||||
.object({
|
||||
@@ -249,6 +273,165 @@ const componentProtectionDeviceSchema = z
|
||||
.strict()
|
||||
.superRefine(validatePersistedProtectionDevice);
|
||||
|
||||
const externalCsvConfigurationSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
configurationVersion: z.number().int().positive(),
|
||||
configuration: z.custom<ExternalCsvConfiguration>(
|
||||
(value) => validateExternalCsvConfiguration(value).success
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const externalCsvDialectSchema = z.object({
|
||||
encoding: z.literal("utf-8"),
|
||||
hasBom: z.boolean(),
|
||||
delimiter: z.enum([";", ",", "\t"]),
|
||||
lineEnding: z.enum(["\r\n", "\n", "\r"]),
|
||||
quoteCharacter: z.literal('"'),
|
||||
quoteAllFields: z.boolean(),
|
||||
hasTrailingLineEnding: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
const externalCsvDocumentSchema = z.object({
|
||||
dialect: externalCsvDialectSchema,
|
||||
headerRowIndex: z.number().int().nonnegative(),
|
||||
rows: z.array(z.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
cells: z.array(z.object({
|
||||
value: z.string(),
|
||||
wasQuoted: z.boolean(),
|
||||
}).strict()),
|
||||
classification: z.enum([
|
||||
"metadata",
|
||||
"header",
|
||||
"passthrough",
|
||||
"object",
|
||||
"suspect-object",
|
||||
]),
|
||||
}).strict()).min(1),
|
||||
}).strict().superRefine((document, context) => {
|
||||
if (document.rows[document.headerRowIndex]?.classification !== "header") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Snapshot external CSV document header is invalid.",
|
||||
});
|
||||
}
|
||||
document.rows.forEach((row, index) => {
|
||||
if (row.index !== index) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Snapshot external CSV row indexes must be contiguous.",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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}$/),
|
||||
appliedProjectRevision: z.number().int().nonnegative(),
|
||||
configurationVersion: z.number().int().positive(),
|
||||
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
|
||||
(value) => validateExternalCsvConfiguration(value).success
|
||||
),
|
||||
originalContentBase64: z.string().min(1).regex(
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
),
|
||||
document: externalCsvDocumentSchema,
|
||||
}).strict();
|
||||
|
||||
const externalRoomMappingSchema = z.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
sourceId: idSchema,
|
||||
normalizedSourceRoomKey: z.string().trim().min(1),
|
||||
sourceFloorName: nullableStringSchema,
|
||||
sourceRoomNumber: z.string(),
|
||||
sourceRoomName: z.string(),
|
||||
roomId: idSchema.nullable(),
|
||||
defaultDistributionBoardId: idSchema.nullable(),
|
||||
}).strict();
|
||||
|
||||
const externalPlanningFieldNames = [
|
||||
"displayName",
|
||||
"internalDeviceType",
|
||||
"category",
|
||||
"connectionKind",
|
||||
"effectiveQuantity",
|
||||
"powerPerUnitW",
|
||||
"simultaneityFactor",
|
||||
"cosPhi",
|
||||
"costGroup",
|
||||
"remark",
|
||||
] as const;
|
||||
|
||||
const externalModelObjectSchema = z.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
sourceId: idSchema,
|
||||
ifcGuid: externalIfcGuidSchema,
|
||||
lastSeenImportBatchId: idSchema,
|
||||
lastAcceptedImportBatchId: idSchema,
|
||||
acceptedSourceValues: z.object({
|
||||
rowNumber: z.number().int().positive(),
|
||||
roomNumber: z.string(),
|
||||
roomName: z.string(),
|
||||
familyAndType: z.string(),
|
||||
selectionMarker: z.string(),
|
||||
circuitIdentifier: z.string(),
|
||||
power: z.string(),
|
||||
quantity: z.string().nullable(),
|
||||
additionalSourceValues: z.record(z.string(), z.string()),
|
||||
}).strict(),
|
||||
planningValues: z.object({
|
||||
displayName: nullableStringSchema,
|
||||
internalDeviceType: nullableStringSchema,
|
||||
category: z.enum(circuitGroupCategories).nullable(),
|
||||
connectionKind: nullableStringSchema,
|
||||
effectiveQuantity: z.number().int().positive(),
|
||||
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
|
||||
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
|
||||
costGroup: nullableStringSchema,
|
||||
remark: nullableStringSchema,
|
||||
}).strict(),
|
||||
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).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 externalModelStateSchema = z.object({
|
||||
source: externalModelSourceSchema.nullable(),
|
||||
importBatches: z.array(externalImportBatchSchema),
|
||||
roomMappings: z.array(externalRoomMappingSchema),
|
||||
objects: z.array(externalModelObjectSchema),
|
||||
}).strict();
|
||||
|
||||
export function parseExternalModelStateSnapshot(
|
||||
value: unknown
|
||||
): ExternalModelStateSnapshot {
|
||||
return externalModelStateSchema.parse(value);
|
||||
}
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
@@ -268,6 +451,8 @@ export const projectStateSnapshotSchema = z
|
||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
@@ -275,6 +460,36 @@ export const projectStateSnapshotSchema = z
|
||||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const externalModelProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const baselineProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||
project: previousProjectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
@@ -301,19 +516,54 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
(value as { schemaVersion?: unknown }).schemaVersion !==
|
||||
previousProjectStateSnapshotSchemaVersion
|
||||
typeof (value as { schemaVersion?: unknown }).schemaVersion !== "number"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
|
||||
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
|
||||
const previous = previousProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...previous.project,
|
||||
isPublicBuilding: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
||||
const previous = externalModelProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
}
|
||||
if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) {
|
||||
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
}
|
||||
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
|
||||
const baseline = baselineProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...baseline.project, isPublicBuilding: false },
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: emptyExternalModelState(),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function emptyExternalModelState() {
|
||||
return {
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,6 +627,12 @@ function assertProjectStateSnapshotRelations(
|
||||
snapshot: ProjectStateSnapshot
|
||||
) {
|
||||
const projectId = snapshot.project.id;
|
||||
if (
|
||||
snapshot.externalCsvConfiguration !== null &&
|
||||
snapshot.externalCsvConfiguration.projectId !== projectId
|
||||
) {
|
||||
throw new Error("Snapshot external CSV configuration belongs to a different project.");
|
||||
}
|
||||
const boardIds = uniqueIds(
|
||||
snapshot.distributionBoards,
|
||||
"distribution board"
|
||||
@@ -537,6 +793,11 @@ function assertProjectStateSnapshotRelations(
|
||||
if (row.roomId !== null) {
|
||||
assertReference(roomIds, row.roomId, "device row room");
|
||||
}
|
||||
if (row.manualQuantity > row.quantity) {
|
||||
throw new Error(
|
||||
"Snapshot device row manual quantity must not exceed total quantity."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +888,94 @@ function assertProjectStateSnapshotRelations(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertExternalModelRelations(snapshot, {
|
||||
boardIds,
|
||||
deviceRowIds,
|
||||
projectDeviceIds,
|
||||
projectId,
|
||||
roomIds,
|
||||
});
|
||||
}
|
||||
|
||||
function assertExternalModelRelations(
|
||||
snapshot: ProjectStateSnapshot,
|
||||
references: {
|
||||
boardIds: ReadonlySet<string>;
|
||||
deviceRowIds: ReadonlySet<string>;
|
||||
projectDeviceIds: ReadonlySet<string>;
|
||||
projectId: string;
|
||||
roomIds: ReadonlySet<string>;
|
||||
}
|
||||
) {
|
||||
const { source, importBatches, roomMappings, objects } = snapshot.externalModel;
|
||||
if (source === null) {
|
||||
if (importBatches.length || roomMappings.length || objects.length) {
|
||||
throw new Error("Snapshot external model entries require a source.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
||||
const batchIds = uniqueIds(importBatches, "external import batch");
|
||||
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
||||
uniqueIds(objects, "external model object");
|
||||
|
||||
for (const batch of importBatches) {
|
||||
assertProjectOwnership(batch.projectId, references.projectId, "external import batch");
|
||||
if (batch.sourceId !== source.id) {
|
||||
throw new Error("Snapshot external import batch belongs to a different source.");
|
||||
}
|
||||
}
|
||||
|
||||
const roomKeys = new Set<string>();
|
||||
for (const mapping of roomMappings) {
|
||||
assertProjectOwnership(mapping.projectId, references.projectId, "external room mapping");
|
||||
if (mapping.sourceId !== source.id) {
|
||||
throw new Error("Snapshot external room mapping belongs to a different source.");
|
||||
}
|
||||
assertUniqueKey(
|
||||
roomKeys,
|
||||
mapping.normalizedSourceRoomKey,
|
||||
"Snapshot contains duplicate external room keys."
|
||||
);
|
||||
if (mapping.roomId !== null) {
|
||||
assertReference(references.roomIds, mapping.roomId, "external room mapping room");
|
||||
}
|
||||
if (mapping.defaultDistributionBoardId !== null) {
|
||||
assertReference(
|
||||
references.boardIds,
|
||||
mapping.defaultDistributionBoardId,
|
||||
"external room mapping distribution board"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ifcGuids = new Set<string>();
|
||||
for (const object of objects) {
|
||||
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
||||
if (object.sourceId !== source.id) {
|
||||
throw new Error("Snapshot external model object belongs to a different source.");
|
||||
}
|
||||
assertUniqueKey(
|
||||
ifcGuids,
|
||||
object.ifcGuid,
|
||||
"Snapshot contains duplicate external IFCGUIDs."
|
||||
);
|
||||
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
||||
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
||||
if (object.externalRoomMappingId !== null) {
|
||||
assertReference(mappingIds, object.externalRoomMappingId, "external object room mapping");
|
||||
}
|
||||
if (object.distributionBoardId !== null) {
|
||||
assertReference(references.boardIds, object.distributionBoardId, "external object distribution board");
|
||||
}
|
||||
if (object.linkedProjectDeviceId !== null) {
|
||||
assertReference(references.projectDeviceIds, object.linkedProjectDeviceId, "external object project device");
|
||||
}
|
||||
if (object.circuitDeviceRowId !== null) {
|
||||
assertReference(references.deviceRowIds, object.circuitDeviceRowId, "external object device row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertComponentPlacement(
|
||||
|
||||
@@ -91,9 +91,86 @@ export function remapProjectState(
|
||||
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||
)
|
||||
);
|
||||
const externalSourceId = source.externalModel.source ? createId() : null;
|
||||
const externalBatchIds = idMap(source.externalModel.importBatches, createId);
|
||||
const externalRoomMappingIds = idMap(
|
||||
source.externalModel.roomMappings,
|
||||
createId
|
||||
);
|
||||
const externalObjectIds = idMap(source.externalModel.objects, createId);
|
||||
return parseProjectStateSnapshot({
|
||||
...source,
|
||||
project: { ...source.project, id: targetProjectId, name },
|
||||
externalCsvConfiguration:
|
||||
source.externalCsvConfiguration === null
|
||||
? null
|
||||
: {
|
||||
...source.externalCsvConfiguration,
|
||||
id: createId(),
|
||||
projectId: targetProjectId,
|
||||
},
|
||||
externalModel: {
|
||||
source: source.externalModel.source === null
|
||||
? null
|
||||
: {
|
||||
...source.externalModel.source,
|
||||
id: externalSourceId!,
|
||||
projectId: targetProjectId,
|
||||
},
|
||||
importBatches: source.externalModel.importBatches.map((batch) => ({
|
||||
...batch,
|
||||
id: requiredId(externalBatchIds, batch.id),
|
||||
projectId: targetProjectId,
|
||||
sourceId: externalSourceId!,
|
||||
})),
|
||||
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
|
||||
...mapping,
|
||||
id: requiredId(externalRoomMappingIds, mapping.id),
|
||||
projectId: targetProjectId,
|
||||
sourceId: externalSourceId!,
|
||||
roomId:
|
||||
mapping.roomId === null
|
||||
? null
|
||||
: requiredId(roomIds, mapping.roomId),
|
||||
defaultDistributionBoardId:
|
||||
mapping.defaultDistributionBoardId === null
|
||||
? null
|
||||
: requiredId(boardIds, mapping.defaultDistributionBoardId),
|
||||
})),
|
||||
objects: source.externalModel.objects.map((object) => ({
|
||||
...object,
|
||||
id: requiredId(externalObjectIds, object.id),
|
||||
projectId: targetProjectId,
|
||||
sourceId: externalSourceId!,
|
||||
lastSeenImportBatchId: requiredId(
|
||||
externalBatchIds,
|
||||
object.lastSeenImportBatchId
|
||||
),
|
||||
lastAcceptedImportBatchId: requiredId(
|
||||
externalBatchIds,
|
||||
object.lastAcceptedImportBatchId
|
||||
),
|
||||
externalRoomMappingId:
|
||||
object.externalRoomMappingId === null
|
||||
? null
|
||||
: requiredId(
|
||||
externalRoomMappingIds,
|
||||
object.externalRoomMappingId
|
||||
),
|
||||
distributionBoardId:
|
||||
object.distributionBoardId === null
|
||||
? null
|
||||
: requiredId(boardIds, object.distributionBoardId),
|
||||
linkedProjectDeviceId:
|
||||
object.linkedProjectDeviceId === null
|
||||
? null
|
||||
: requiredId(deviceIds, object.linkedProjectDeviceId),
|
||||
circuitDeviceRowId:
|
||||
object.circuitDeviceRowId === null
|
||||
? null
|
||||
: requiredId(rowIds, object.circuitDeviceRowId),
|
||||
})),
|
||||
},
|
||||
distributionBoards: source.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
id: requiredId(boardIds, board.id),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteExternalInitialImportCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: ExternalInitialImportProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExternalInitialImportProjectCommandStore {
|
||||
execute(input: ExecuteExternalInitialImportCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: ExternalInitialImportProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export interface ExternalModelStateReadResult {
|
||||
projectExists: boolean;
|
||||
state: ExternalModelStateSnapshot;
|
||||
}
|
||||
|
||||
export interface ExternalModelStateReader {
|
||||
getByProject(projectId: string): ExternalModelStateReadResult;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ExternalObjectNewCircuitProjectCommand } from "../models/external-object-new-circuit-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteExternalObjectNewCircuitCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: ExternalObjectNewCircuitProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExternalObjectNewCircuitProjectCommandStore {
|
||||
execute(input: ExecuteExternalObjectNewCircuitCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: ExternalObjectNewCircuitProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ExternalObjectNewRowProjectCommand } from "../models/external-object-new-row-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteExternalObjectNewRowCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: ExternalObjectNewRowProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExternalObjectNewRowProjectCommandStore {
|
||||
execute(input: ExecuteExternalObjectNewRowCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: ExternalObjectNewRowProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ExternalObjectRowAssignmentProjectCommand } from "../models/external-object-row-assignment-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteExternalObjectRowAssignmentCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: ExternalObjectRowAssignmentProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExternalObjectRowAssignmentProjectCommandStore {
|
||||
execute(input: ExecuteExternalObjectRowAssignmentCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: ExternalObjectRowAssignmentProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,33 @@ import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project
|
||||
import type { ProjectRevisionSource } from "../ports/project-revision.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 {
|
||||
assertExternalCsvConfigurationUpdateProjectCommand,
|
||||
externalCsvConfigurationUpdateCommandType,
|
||||
} from "../models/external-csv-configuration-project-command.model.js";
|
||||
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-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 {
|
||||
assertExternalObjectRowAssignmentProjectCommand,
|
||||
externalObjectRowAssignmentCommandType,
|
||||
} from "../models/external-object-row-assignment-project-command.model.js";
|
||||
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js";
|
||||
import {
|
||||
assertExternalObjectNewRowProjectCommand,
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
externalObjectDeleteCreatedRowCommandType,
|
||||
} from "../models/external-object-new-row-project-command.model.js";
|
||||
import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js";
|
||||
import {
|
||||
assertExternalObjectNewCircuitProjectCommand,
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
externalObjectDeleteCreatedCircuitCommandType,
|
||||
} from "../models/external-object-new-circuit-project-command.model.js";
|
||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
|
||||
|
||||
interface DispatchProjectCommandInput {
|
||||
projectId: string;
|
||||
@@ -195,7 +222,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
|
||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
private readonly historyStore: ProjectHistoryStore,
|
||||
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
|
||||
) {}
|
||||
|
||||
executeUser(
|
||||
@@ -552,6 +584,58 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case externalCsvConfigurationUpdateCommandType: {
|
||||
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
|
||||
if (!this.externalCsvConfigurationStore) {
|
||||
throw new Error("External CSV configuration store is not available.");
|
||||
}
|
||||
return this.externalCsvConfigurationStore.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,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case externalObjectRowAssignmentCommandType: {
|
||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
if (!this.externalObjectRowAssignmentStore) {
|
||||
throw new Error("External object row-assignment store is not available.");
|
||||
}
|
||||
return this.externalObjectRowAssignmentStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case externalObjectAssignToNewRowCommandType:
|
||||
case externalObjectDeleteCreatedRowCommandType: {
|
||||
assertExternalObjectNewRowProjectCommand(input.command);
|
||||
if (!this.externalObjectNewRowStore) {
|
||||
throw new Error("External object new-row store is not available.");
|
||||
}
|
||||
return this.externalObjectNewRowStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case externalObjectAssignToNewCircuitCommandType:
|
||||
case externalObjectDeleteCreatedCircuitCommandType: {
|
||||
assertExternalObjectNewCircuitProjectCommand(input.command);
|
||||
if (!this.externalObjectNewCircuitStore) {
|
||||
throw new Error("External object new-circuit store is not available.");
|
||||
}
|
||||
return this.externalObjectNewCircuitStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectStateRestoreCommandType: {
|
||||
assertProjectStateRestoreCommand(input.command);
|
||||
return this.projectStateRestoreStore.execute({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
externalCsvConfigurationSchemaVersion,
|
||||
type ExternalCsvConfiguration,
|
||||
type ExternalCsvDisplayNameSuggestion,
|
||||
type ExternalCsvQuantityRule,
|
||||
} from "./external-csv-contracts.js";
|
||||
|
||||
const supportedCategories = new Set([
|
||||
"lighting",
|
||||
"single_phase",
|
||||
"three_phase",
|
||||
]);
|
||||
|
||||
const requiredColumnKeys = [
|
||||
"ifcGuid",
|
||||
"roomNumber",
|
||||
"roomName",
|
||||
"familyAndType",
|
||||
"selectionMarker",
|
||||
"circuitIdentifier",
|
||||
"power",
|
||||
] as const;
|
||||
|
||||
export function assertExternalCsvConfiguration(
|
||||
value: unknown
|
||||
): asserts value is ExternalCsvConfiguration {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("External CSV configuration must be an object.");
|
||||
}
|
||||
assertExactKeyCount(value, 9, "External CSV configuration");
|
||||
if (value.schemaVersion !== externalCsvConfigurationSchemaVersion) {
|
||||
throw new Error("Unsupported external CSV configuration schema version.");
|
||||
}
|
||||
if (value.encoding !== "utf-8") {
|
||||
throw new Error("External CSV encoding must be utf-8.");
|
||||
}
|
||||
if (value.delimiter !== ";" && value.delimiter !== "," && value.delimiter !== "\t") {
|
||||
throw new Error("External CSV delimiter is unsupported.");
|
||||
}
|
||||
if (value.decimalSeparator !== "," && value.decimalSeparator !== ".") {
|
||||
throw new Error("External CSV decimal separator is unsupported.");
|
||||
}
|
||||
if (value.powerUnit !== "W" && value.powerUnit !== "kW") {
|
||||
throw new Error("External CSV power unit is unsupported.");
|
||||
}
|
||||
assertPositiveFiniteNumber(value.wattsPerSourceUnit, "wattsPerSourceUnit");
|
||||
|
||||
if (!isRecord(value.columns)) {
|
||||
throw new Error("External CSV columns must be an object.");
|
||||
}
|
||||
assertExactKeyCount(value.columns, 8, "External CSV columns");
|
||||
const mappedColumns = new Set<string>();
|
||||
for (const key of requiredColumnKeys) {
|
||||
const columnName = assertTrimmedString(value.columns[key], `columns.${key}`);
|
||||
if (mappedColumns.has(columnName)) {
|
||||
throw new Error(`External CSV column is mapped more than once: ${columnName}`);
|
||||
}
|
||||
mappedColumns.add(columnName);
|
||||
}
|
||||
if (value.columns.quantity !== null) {
|
||||
const quantityColumn = assertTrimmedString(
|
||||
value.columns.quantity,
|
||||
"columns.quantity"
|
||||
);
|
||||
if (mappedColumns.has(quantityColumn)) {
|
||||
throw new Error(`External CSV column is mapped more than once: ${quantityColumn}`);
|
||||
}
|
||||
mappedColumns.add(quantityColumn);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.additionalSourceMappings)) {
|
||||
throw new Error("External CSV additional source mappings must be an array.");
|
||||
}
|
||||
const targetFields = new Set<string>();
|
||||
for (const [index, mapping] of value.additionalSourceMappings.entries()) {
|
||||
if (!isRecord(mapping)) {
|
||||
throw new Error(`additionalSourceMappings.${index} must be an object.`);
|
||||
}
|
||||
assertExactKeyCount(mapping, 2, `additionalSourceMappings.${index}`);
|
||||
const sourceColumn = assertTrimmedString(
|
||||
mapping.sourceColumn,
|
||||
`additionalSourceMappings.${index}.sourceColumn`
|
||||
);
|
||||
const targetField = assertTrimmedString(
|
||||
mapping.targetField,
|
||||
`additionalSourceMappings.${index}.targetField`
|
||||
);
|
||||
if (mappedColumns.has(sourceColumn)) {
|
||||
throw new Error(`External CSV column is mapped more than once: ${sourceColumn}`);
|
||||
}
|
||||
if (targetFields.has(targetField)) {
|
||||
throw new Error(`External target field is mapped more than once: ${targetField}`);
|
||||
}
|
||||
mappedColumns.add(sourceColumn);
|
||||
targetFields.add(targetField);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.familyTypeRules)) {
|
||||
throw new Error("External CSV family/type rules must be an array.");
|
||||
}
|
||||
const exactFamilyValues = new Set<string>();
|
||||
for (const [index, rule] of value.familyTypeRules.entries()) {
|
||||
if (!isRecord(rule)) {
|
||||
throw new Error(`familyTypeRules.${index} must be an object.`);
|
||||
}
|
||||
assertExactKeyCount(rule, 6, `familyTypeRules.${index}`);
|
||||
const exactFamilyAndType = assertTrimmedString(
|
||||
rule.exactFamilyAndType,
|
||||
`familyTypeRules.${index}.exactFamilyAndType`
|
||||
);
|
||||
if (exactFamilyValues.has(exactFamilyAndType)) {
|
||||
throw new Error(
|
||||
`External CSV family/type rule is duplicated: ${exactFamilyAndType}`
|
||||
);
|
||||
}
|
||||
exactFamilyValues.add(exactFamilyAndType);
|
||||
assertTrimmedString(
|
||||
rule.internalDeviceType,
|
||||
`familyTypeRules.${index}.internalDeviceType`
|
||||
);
|
||||
if (rule.connectionKind !== null) {
|
||||
assertTrimmedString(
|
||||
rule.connectionKind,
|
||||
`familyTypeRules.${index}.connectionKind`
|
||||
);
|
||||
}
|
||||
if (typeof rule.category !== "string" || !supportedCategories.has(rule.category)) {
|
||||
throw new Error(`familyTypeRules.${index}.category is unsupported.`);
|
||||
}
|
||||
assertQuantityRule(rule.quantityRule, index);
|
||||
if (rule.quantityRule.kind === "mapped-column" && value.columns.quantity === null) {
|
||||
throw new Error(
|
||||
`familyTypeRules.${index}.quantityRule requires a configured quantity column.`
|
||||
);
|
||||
}
|
||||
assertDisplayNameSuggestion(rule.displayNameSuggestion, index);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateExternalCsvConfiguration(
|
||||
value: unknown
|
||||
): { success: true; data: ExternalCsvConfiguration } | { success: false; error: string } {
|
||||
try {
|
||||
assertExternalCsvConfiguration(value);
|
||||
return { success: true, data: value };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Invalid external CSV configuration.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function assertQuantityRule(value: unknown, index: number): asserts value is ExternalCsvQuantityRule {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`familyTypeRules.${index}.quantityRule must be an object.`);
|
||||
}
|
||||
if (value.kind === "mapped-column") {
|
||||
assertExactKeyCount(value, 1, `familyTypeRules.${index}.quantityRule`);
|
||||
return;
|
||||
}
|
||||
if (value.kind === "fixed") {
|
||||
assertExactKeyCount(value, 2, `familyTypeRules.${index}.quantityRule`);
|
||||
assertPositiveFiniteNumber(value.quantity, `familyTypeRules.${index}.quantityRule.quantity`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`familyTypeRules.${index}.quantityRule is unsupported.`);
|
||||
}
|
||||
|
||||
function assertDisplayNameSuggestion(
|
||||
value: unknown,
|
||||
index: number
|
||||
): asserts value is ExternalCsvDisplayNameSuggestion | null {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`familyTypeRules.${index}.displayNameSuggestion must be an object or null.`);
|
||||
}
|
||||
if (value.kind === "selection-marker" || value.kind === "family-and-type") {
|
||||
assertExactKeyCount(value, 1, `familyTypeRules.${index}.displayNameSuggestion`);
|
||||
return;
|
||||
}
|
||||
if (value.kind === "fixed") {
|
||||
assertExactKeyCount(value, 2, `familyTypeRules.${index}.displayNameSuggestion`);
|
||||
assertTrimmedString(value.value, `familyTypeRules.${index}.displayNameSuggestion.value`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`familyTypeRules.${index}.displayNameSuggestion is unsupported.`);
|
||||
}
|
||||
|
||||
function assertPositiveFiniteNumber(value: unknown, field: string) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${field} must be a positive finite number.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrimmedString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty trimmed string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeyCount(value: Record<string, unknown>, count: number, field: string) {
|
||||
if (Object.keys(value).length !== count) {
|
||||
throw new Error(`${field} contains unknown or missing fields.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export const externalCsvConfigurationSchemaVersion = 1 as const;
|
||||
|
||||
export type ExternalCsvEncoding = "utf-8";
|
||||
export type ExternalCsvDelimiter = ";" | "," | "\t";
|
||||
export type ExternalCsvDecimalSeparator = "," | ".";
|
||||
export type ExternalCsvPowerUnit = "W" | "kW";
|
||||
|
||||
export interface ExternalCsvColumnMapping {
|
||||
ifcGuid: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
circuitIdentifier: string;
|
||||
power: string;
|
||||
quantity: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalCsvAdditionalSourceMapping {
|
||||
sourceColumn: string;
|
||||
targetField: string;
|
||||
}
|
||||
|
||||
export type ExternalCsvQuantityRule =
|
||||
| { kind: "fixed"; quantity: number }
|
||||
| { kind: "mapped-column" };
|
||||
|
||||
export type ExternalCsvDisplayNameSuggestion =
|
||||
| { kind: "fixed"; value: string }
|
||||
| { kind: "selection-marker" }
|
||||
| { kind: "family-and-type" };
|
||||
|
||||
export interface ExternalCsvFamilyTypeRule {
|
||||
exactFamilyAndType: string;
|
||||
internalDeviceType: string;
|
||||
connectionKind: string | null;
|
||||
category: CircuitGroupCategory;
|
||||
quantityRule: ExternalCsvQuantityRule;
|
||||
displayNameSuggestion: ExternalCsvDisplayNameSuggestion | null;
|
||||
}
|
||||
|
||||
export interface ExternalCsvConfiguration {
|
||||
schemaVersion: typeof externalCsvConfigurationSchemaVersion;
|
||||
encoding: ExternalCsvEncoding;
|
||||
delimiter: ExternalCsvDelimiter;
|
||||
decimalSeparator: ExternalCsvDecimalSeparator;
|
||||
powerUnit: ExternalCsvPowerUnit;
|
||||
wattsPerSourceUnit: number;
|
||||
columns: ExternalCsvColumnMapping;
|
||||
additionalSourceMappings: ExternalCsvAdditionalSourceMapping[];
|
||||
familyTypeRules: ExternalCsvFamilyTypeRule[];
|
||||
}
|
||||
|
||||
export type ExternalCsvLineEnding = "\r\n" | "\n" | "\r";
|
||||
|
||||
export interface ExternalCsvDialect {
|
||||
encoding: ExternalCsvEncoding;
|
||||
hasBom: boolean;
|
||||
delimiter: ExternalCsvDelimiter;
|
||||
lineEnding: ExternalCsvLineEnding;
|
||||
quoteCharacter: '"';
|
||||
quoteAllFields: boolean;
|
||||
hasTrailingLineEnding: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalCsvCell {
|
||||
value: string;
|
||||
wasQuoted: boolean;
|
||||
}
|
||||
|
||||
export type ExternalCsvRowClassification =
|
||||
| "metadata"
|
||||
| "header"
|
||||
| "passthrough"
|
||||
| "object"
|
||||
| "suspect-object";
|
||||
|
||||
export interface ExternalCsvRow {
|
||||
index: number;
|
||||
cells: ExternalCsvCell[];
|
||||
classification: ExternalCsvRowClassification;
|
||||
}
|
||||
|
||||
export interface ExternalCsvDocument {
|
||||
dialect: ExternalCsvDialect;
|
||||
headerRowIndex: number;
|
||||
rows: ExternalCsvRow[];
|
||||
}
|
||||
|
||||
export function createDefaultExternalCsvConfiguration(
|
||||
columns: ExternalCsvColumnMapping
|
||||
): ExternalCsvConfiguration {
|
||||
return {
|
||||
schemaVersion: externalCsvConfigurationSchemaVersion,
|
||||
encoding: "utf-8",
|
||||
delimiter: ";",
|
||||
decimalSeparator: ",",
|
||||
powerUnit: "W",
|
||||
wattsPerSourceUnit: 1,
|
||||
columns,
|
||||
additionalSourceMappings: [],
|
||||
familyTypeRules: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type {
|
||||
ExternalCsvConfiguration,
|
||||
ExternalCsvDocument,
|
||||
} from "../csv/external-csv-contracts.js";
|
||||
|
||||
export const externalModelSourceTypes = ["revit_csv"] as const;
|
||||
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
|
||||
|
||||
export const externalImportKinds = ["initial", "follow_up"] as const;
|
||||
export type ExternalImportKind = (typeof externalImportKinds)[number];
|
||||
|
||||
export const externalObjectPresenceStatuses = ["present", "missing"] as const;
|
||||
export type ExternalObjectPresenceStatus =
|
||||
(typeof externalObjectPresenceStatuses)[number];
|
||||
|
||||
export interface ExternalModelSourceSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sourceType: ExternalModelSourceType;
|
||||
}
|
||||
|
||||
export interface ExternalImportBatchSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
importKind: ExternalImportKind;
|
||||
importedAtIso: string;
|
||||
fileName: string;
|
||||
sha256: string;
|
||||
appliedProjectRevision: number;
|
||||
configurationVersion: number;
|
||||
configurationSnapshot: ExternalCsvConfiguration;
|
||||
originalContentBase64: string;
|
||||
document: ExternalCsvDocument;
|
||||
}
|
||||
|
||||
export interface ExternalRoomMappingSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
normalizedSourceRoomKey: string;
|
||||
sourceFloorName: string | null;
|
||||
sourceRoomNumber: string;
|
||||
sourceRoomName: string;
|
||||
roomId: string | null;
|
||||
defaultDistributionBoardId: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalModelObjectSourceValues {
|
||||
rowNumber: number;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
circuitIdentifier: string;
|
||||
power: string;
|
||||
quantity: string | null;
|
||||
additionalSourceValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExternalModelObjectPlanningValues {
|
||||
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;
|
||||
}
|
||||
|
||||
export type ExternalModelObjectOverrideField =
|
||||
keyof ExternalModelObjectPlanningValues;
|
||||
|
||||
export interface ExternalModelObjectSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sourceId: string;
|
||||
ifcGuid: string;
|
||||
lastSeenImportBatchId: string;
|
||||
lastAcceptedImportBatchId: string;
|
||||
acceptedSourceValues: ExternalModelObjectSourceValues;
|
||||
planningValues: ExternalModelObjectPlanningValues;
|
||||
overriddenFields: ExternalModelObjectOverrideField[];
|
||||
externalRoomMappingId: string | null;
|
||||
distributionBoardId: string | null;
|
||||
linkedProjectDeviceId: string | null;
|
||||
circuitDeviceRowId: string | null;
|
||||
presenceStatus: ExternalObjectPresenceStatus;
|
||||
}
|
||||
|
||||
export interface ExternalModelStateSnapshot {
|
||||
source: ExternalModelSourceSnapshot | null;
|
||||
importBatches: ExternalImportBatchSnapshot[];
|
||||
roomMappings: ExternalRoomMappingSnapshot[];
|
||||
objects: ExternalModelObjectSnapshot[];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import React, { type FormEvent, type ReactNode } from "react";
|
||||
interface FormModalProps {
|
||||
children: ReactNode;
|
||||
description?: string;
|
||||
dialogSize?: "large" | "viewport";
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
@@ -16,6 +17,7 @@ interface FormModalProps {
|
||||
export function FormModal({
|
||||
children,
|
||||
description,
|
||||
dialogSize = "large",
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
@@ -31,7 +33,10 @@ export function FormModal({
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div
|
||||
className={`modal-dialog modal-dialog-centered ${dialogSize === "large" ? "modal-lg" : ""}`}
|
||||
style={dialogSize === "viewport" ? { maxWidth: "calc(100vw - 2rem)" } : undefined}
|
||||
>
|
||||
<form className="modal-content" onSubmit={onSubmit}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
interface ProjectDeviceModalProps {
|
||||
globalDevices: GlobalDeviceDto[];
|
||||
initialDevice?: ProjectDeviceDto;
|
||||
initialValues?: Partial<CreateProjectDeviceInput>;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
||||
@@ -25,12 +26,13 @@ interface ProjectDeviceModalProps {
|
||||
export function ProjectDeviceModal({
|
||||
globalDevices,
|
||||
initialDevice,
|
||||
initialValues,
|
||||
isSaving,
|
||||
onClose,
|
||||
onImportGlobal,
|
||||
onSave,
|
||||
}: ProjectDeviceModalProps) {
|
||||
const [values, setValues] = useState(() => toFormValues(initialDevice));
|
||||
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
|
||||
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||
|
||||
function update(key: keyof typeof values, value: string) {
|
||||
@@ -254,18 +256,21 @@ function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
function toFormValues(device?: ProjectDeviceDto) {
|
||||
function toFormValues(
|
||||
device?: ProjectDeviceDto,
|
||||
initialValues?: Partial<CreateProjectDeviceInput>
|
||||
) {
|
||||
return {
|
||||
name: device?.name ?? "",
|
||||
displayName: device?.displayName ?? "",
|
||||
connectionKind: device?.connectionKind ?? "",
|
||||
costGroup: device?.costGroup ?? "",
|
||||
category: device?.category ?? "single_phase",
|
||||
quantity: String(device?.quantity ?? 1),
|
||||
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
|
||||
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
|
||||
cosPhi: String(device?.cosPhi ?? 1),
|
||||
remark: device?.remark ?? "",
|
||||
name: device?.name ?? initialValues?.name ?? "",
|
||||
displayName: device?.displayName ?? initialValues?.displayName ?? "",
|
||||
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
|
||||
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
|
||||
category: device?.category ?? initialValues?.category ?? "single_phase",
|
||||
quantity: String(device?.quantity ?? initialValues?.quantity ?? 1),
|
||||
powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1),
|
||||
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
|
||||
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
|
||||
remark: device?.remark ?? initialValues?.remark ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { type FormEvent, useState } from "react";
|
||||
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface RoomModalProps {
|
||||
floors: FloorDto[];
|
||||
initialRoom?: RoomDto;
|
||||
initialValues?: Partial<CreateRoomInput>;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onDelete?: () => void;
|
||||
onSave: (input: CreateRoomInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RoomModal({
|
||||
floors,
|
||||
initialRoom,
|
||||
initialValues,
|
||||
isSaving,
|
||||
onClose,
|
||||
onDelete,
|
||||
onSave,
|
||||
}: RoomModalProps) {
|
||||
const [roomNumber, setRoomNumber] = useState(
|
||||
initialRoom?.roomNumber ?? initialValues?.roomNumber ?? ""
|
||||
);
|
||||
const [roomName, setRoomName] = useState(
|
||||
initialRoom?.roomName ?? initialValues?.roomName ?? ""
|
||||
);
|
||||
const [floorId, setFloorId] = useState(
|
||||
initialRoom?.floorId ?? initialValues?.floorId ?? ""
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!roomNumber.trim() || !roomName.trim()) return;
|
||||
await onSave({
|
||||
floorId: floorId || undefined,
|
||||
roomNumber: roomNumber.trim(),
|
||||
roomName: roomName.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
||||
submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"}
|
||||
title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-4">
|
||||
<label className="form-label" htmlFor="room-number">Raumnummer</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="room-number"
|
||||
onChange={(event) => setRoomNumber(event.target.value)}
|
||||
required
|
||||
value={roomNumber}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-8">
|
||||
<label className="form-label" htmlFor="room-name">Raumname</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="room-name"
|
||||
onChange={(event) => setRoomName(event.target.value)}
|
||||
required
|
||||
value={roomName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="room-floor">Etage</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="room-floor"
|
||||
onChange={(event) => setFloorId(event.target.value)}
|
||||
value={floorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{initialRoom && onDelete ? (
|
||||
<div className="border-top mt-4 pt-3">
|
||||
<p className="text-secondary small mb-2">
|
||||
In Stromkreisen verwendete Räume können nicht gelöscht werden.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
disabled={isSaving}
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
Raum löschen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,77 @@ import type {
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../shared/constants/protection-device";
|
||||
import type {
|
||||
ExternalCsvConfiguration,
|
||||
ExternalCsvDialect,
|
||||
} from "../external-model/csv/external-csv-contracts";
|
||||
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
|
||||
|
||||
export interface ExternalCsvConfigurationSnapshotDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
configurationVersion: number;
|
||||
configuration: ExternalCsvConfiguration;
|
||||
}
|
||||
|
||||
export interface ExternalCsvPreviewObjectDto {
|
||||
rowNumber: number;
|
||||
ifcGuid: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
circuitIdentifier: string;
|
||||
power: string;
|
||||
quantity: string | null;
|
||||
additionalSourceValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExternalCsvPreviewDto {
|
||||
fileName: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
dialect: ExternalCsvDialect;
|
||||
headerRowNumber: number;
|
||||
headerColumns: string[];
|
||||
rowCount: number;
|
||||
objectCount: number;
|
||||
passthroughCount: number;
|
||||
nonEmptyPassthroughCount: number;
|
||||
suspectObjectCount: number;
|
||||
objects: ExternalCsvPreviewObjectDto[];
|
||||
suspectRowNumbers: number[];
|
||||
}
|
||||
|
||||
export type ExternalInitialImportPlanDto = ReturnType<
|
||||
typeof createExternalInitialImportPlan
|
||||
>;
|
||||
|
||||
export interface ApplyExternalInitialImportInput {
|
||||
expectedRevision: number;
|
||||
expectedConfigurationVersion: number;
|
||||
expectedSha256: string;
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
sourceName: string;
|
||||
roomDecisions: Array<{
|
||||
sourceRoomKey: string;
|
||||
roomId: string | null;
|
||||
defaultDistributionBoardId: string | null;
|
||||
}>;
|
||||
familyProjectDeviceDecisions: Array<{
|
||||
familyAndType: string;
|
||||
projectDeviceId: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ExternalInitialImportResultDto extends ProjectCommandResultDto {
|
||||
import: {
|
||||
sourceId: string;
|
||||
importBatchId: string;
|
||||
objectCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
@@ -279,6 +350,7 @@ export interface CircuitTreeDeviceRowDto {
|
||||
roomNumberSnapshot?: string;
|
||||
roomNameSnapshot?: string;
|
||||
quantity: number;
|
||||
manualQuantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
@@ -356,6 +428,32 @@ export interface CircuitTreeResponseDto {
|
||||
footerComponents: CircuitTreeComponentDto[];
|
||||
}
|
||||
|
||||
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 interface CreateCircuitInputDto {
|
||||
sectionId: string;
|
||||
equipmentIdentifier: string;
|
||||
|
||||
@@ -23,10 +23,17 @@ import type {
|
||||
ProjectRoomCommandResultDto,
|
||||
ProjectDeviceSyncCommandResultDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ExternalCircuitListObjectsDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
CircuitTreeResponseDto,
|
||||
ExternalCsvConfigurationSnapshotDto,
|
||||
ExternalCsvPreviewDto,
|
||||
ExternalInitialImportPlanDto,
|
||||
ApplyExternalInitialImportInput,
|
||||
ExternalInitialImportResultDto,
|
||||
} from "../types";
|
||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||
import {
|
||||
createCircuitUpdateProjectCommand,
|
||||
@@ -334,6 +341,72 @@ export function updateDistributionBoard(
|
||||
);
|
||||
}
|
||||
|
||||
export function getExternalCsvConfiguration(projectId: string) {
|
||||
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
|
||||
`/api/projects/${projectId}/external-csv/configuration`
|
||||
);
|
||||
}
|
||||
|
||||
export function getExternalCircuitListObjects(
|
||||
projectId: string,
|
||||
circuitListId: string
|
||||
) {
|
||||
return request<ExternalCircuitListObjectsDto>(
|
||||
`/api/projects/${projectId}/circuit-lists/${circuitListId}/external-objects`
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
projectId: string,
|
||||
distributionBoardId: string,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
@@ -28,3 +30,6 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||
export const projectRepository = new ProjectRepository(db);
|
||||
export const roomRepository = new RoomRepository(db);
|
||||
export const externalCsvConfigurationRepository =
|
||||
new ExternalCsvConfigurationRepository(db);
|
||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||
|
||||
@@ -22,6 +22,11 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
|
||||
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
||||
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-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 { 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";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
export const circuitDeviceRowProjectCommandStore =
|
||||
@@ -65,6 +70,16 @@ export const projectSettingsProjectCommandStore =
|
||||
export const projectStateRestoreCommandStore =
|
||||
new ProjectStateRestoreCommandRepository(db);
|
||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||
export const externalCsvConfigurationProjectCommandStore =
|
||||
new ExternalCsvConfigurationProjectCommandRepository(db);
|
||||
export const externalInitialImportProjectCommandStore =
|
||||
new ExternalInitialImportProjectCommandRepository(db);
|
||||
export const externalObjectRowAssignmentProjectCommandStore =
|
||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||
export const externalObjectNewRowProjectCommandStore =
|
||||
new ExternalObjectNewRowProjectCommandRepository(db);
|
||||
export const externalObjectNewCircuitProjectCommandStore =
|
||||
new ExternalObjectNewCircuitProjectCommandRepository(db);
|
||||
export const projectCommandService = new ProjectCommandService(
|
||||
circuitProjectCommandStore,
|
||||
circuitDeviceRowProjectCommandStore,
|
||||
@@ -87,5 +102,10 @@ export const projectCommandService = new ProjectCommandService(
|
||||
circuitGroupMoveProjectCommandStore,
|
||||
circuitGroupSubtreeProjectCommandStore,
|
||||
circuitProtectionProjectCommandStore,
|
||||
projectHistoryStore
|
||||
projectHistoryStore,
|
||||
externalCsvConfigurationProjectCommandStore,
|
||||
externalInitialImportProjectCommandStore,
|
||||
externalObjectRowAssignmentProjectCommandStore,
|
||||
externalObjectNewRowProjectCommandStore,
|
||||
externalObjectNewCircuitProjectCommandStore
|
||||
);
|
||||
|
||||
@@ -160,6 +160,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
||||
quantity: row.quantity,
|
||||
manualQuantity: row.manualQuantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? undefined,
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
}
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
importProjectTransfer,
|
||||
importProjectTransferAsNewProject,
|
||||
} from "../controllers/project-transfer.controller.js";
|
||||
import {
|
||||
getExternalCsvConfiguration,
|
||||
applyExternalInitialImport,
|
||||
previewExternalCsv,
|
||||
planExternalInitialImport,
|
||||
updateExternalCsvConfiguration,
|
||||
getExternalCircuitListObjects,
|
||||
} from "../controllers/external-csv.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
@@ -44,6 +52,21 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||
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/revisions", listProjectRevisions);
|
||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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(24_000_000),
|
||||
})
|
||||
.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(24_000_000),
|
||||
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();
|
||||
@@ -128,6 +128,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte lokal",
|
||||
quantity: 1,
|
||||
manualQuantity: 1,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
@@ -138,6 +139,7 @@ function createTestDatabase(): DatabaseContext {
|
||||
name: "Fremdzeile",
|
||||
displayName: "Fremdzeile",
|
||||
quantity: 1,
|
||||
manualQuantity: 1,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
@@ -175,6 +177,7 @@ describe("circuit device-row project-command repository", () => {
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 2);
|
||||
assert.equal(row.manualQuantity, 2);
|
||||
assert.equal(row.roomId, "room-1");
|
||||
assert.equal(row.remark, null);
|
||||
assert.equal(row.overriddenFields, "[\"quantity\"]");
|
||||
@@ -185,6 +188,7 @@ describe("circuit device-row project-command repository", () => {
|
||||
{ field: "quantity", value: 1 },
|
||||
{ field: "roomId", value: null },
|
||||
{ field: "remark", value: null },
|
||||
{ field: "manualQuantity", value: 1 },
|
||||
{ field: "overriddenFields", value: null },
|
||||
],
|
||||
});
|
||||
@@ -203,6 +207,7 @@ describe("circuit device-row project-command repository", () => {
|
||||
{ field: "quantity", value: 2 },
|
||||
{ field: "roomId", value: "room-1" },
|
||||
{ field: "remark", value: null },
|
||||
{ field: "manualQuantity", value: 2 },
|
||||
{ field: "overriddenFields", value: "[\"quantity\"]" },
|
||||
],
|
||||
},
|
||||
@@ -234,6 +239,7 @@ describe("circuit device-row project-command repository", () => {
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 1);
|
||||
assert.equal(row.manualQuantity, 1);
|
||||
assert.equal(row.overriddenFields, null);
|
||||
assert.equal(undone.revision.revisionNumber, 2);
|
||||
assert.deepEqual(
|
||||
@@ -377,4 +383,22 @@ describe("circuit device-row project-command repository", () => {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a manual share above the materialized total", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
assert.throws(() => store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
manualQuantity: 2,
|
||||
}),
|
||||
}), /manual quantity/);
|
||||
assert.equal(getRow(context).manualQuantity, 1);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
assertCircuitDeviceRowQuantity,
|
||||
calculateCircuitDeviceRowQuantity,
|
||||
} from "../src/domain/calculations/circuit-device-row-quantity.js";
|
||||
|
||||
describe("circuit device-row quantity", () => {
|
||||
it("adds the manual share and every indivisible external quantity", () => {
|
||||
assert.equal(
|
||||
calculateCircuitDeviceRowQuantity(2, [
|
||||
{ effectiveQuantity: 2 },
|
||||
{ effectiveQuantity: 1 },
|
||||
]),
|
||||
5
|
||||
);
|
||||
assert.doesNotThrow(() => assertCircuitDeviceRowQuantity({
|
||||
quantity: 5,
|
||||
manualQuantity: 2,
|
||||
externalObjects: [{ effectiveQuantity: 2 }, { effectiveQuantity: 1 }],
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects invalid shares and a stale materialized total", () => {
|
||||
assert.throws(
|
||||
() => calculateCircuitDeviceRowQuantity(-1, []),
|
||||
/Manual quantity/
|
||||
);
|
||||
assert.throws(
|
||||
() => calculateCircuitDeviceRowQuantity(0, [{ effectiveQuantity: 0 }]),
|
||||
/External effective quantity/
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCircuitDeviceRowQuantity({
|
||||
quantity: 2,
|
||||
manualQuantity: 1,
|
||||
externalObjects: [{ effectiveQuantity: 2 }],
|
||||
}),
|
||||
/does not match/
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing row quantities in migration 0005", () => {
|
||||
const database = new Database(":memory:");
|
||||
try {
|
||||
database.exec("CREATE TABLE circuit_device_rows (id text PRIMARY KEY, quantity integer NOT NULL)");
|
||||
database.exec("INSERT INTO circuit_device_rows (id, quantity) VALUES ('row-1', 7)");
|
||||
const migration = fs
|
||||
.readFileSync("src/db/migrations/0005_stale_gorilla_man.sql", "utf8")
|
||||
.replaceAll("--> statement-breakpoint", "");
|
||||
database.exec(migration);
|
||||
assert.deepEqual(
|
||||
database.prepare("SELECT quantity, manual_quantity FROM circuit_device_rows").get(),
|
||||
{ quantity: 7, manual_quantity: 7 }
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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: [] }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
applyExternalInitialImportSchema,
|
||||
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(24_000_001),
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
createDefaultExternalCsvConfiguration,
|
||||
type ExternalCsvColumnMapping,
|
||||
} from "../src/external-model/csv/external-csv-contracts.js";
|
||||
import {
|
||||
assertExternalCsvConfiguration,
|
||||
validateExternalCsvConfiguration,
|
||||
} from "../src/external-model/csv/external-csv-configuration.js";
|
||||
|
||||
const columns: ExternalCsvColumnMapping = {
|
||||
ifcGuid: "IfcGUID",
|
||||
roomNumber: "Raumnummer",
|
||||
roomName: "Raumname",
|
||||
familyAndType: "Familie und Typ",
|
||||
selectionMarker: "CAx_Auswahlkenner",
|
||||
circuitIdentifier: "Stromkreis",
|
||||
power: "Elektrische Leistung",
|
||||
quantity: "Anzahl",
|
||||
};
|
||||
|
||||
describe("external CSV configuration", () => {
|
||||
it("creates the agreed UTF-8, semicolon and watt defaults", () => {
|
||||
const configuration = createDefaultExternalCsvConfiguration(columns);
|
||||
|
||||
assert.doesNotThrow(() => assertExternalCsvConfiguration(configuration));
|
||||
assert.equal(configuration.encoding, "utf-8");
|
||||
assert.equal(configuration.delimiter, ";");
|
||||
assert.equal(configuration.powerUnit, "W");
|
||||
assert.equal(configuration.wattsPerSourceUnit, 1);
|
||||
});
|
||||
|
||||
it("accepts exact family/type rules and additional source mappings", () => {
|
||||
const configuration = createDefaultExternalCsvConfiguration(columns);
|
||||
configuration.additionalSourceMappings.push({
|
||||
sourceColumn: "Montageart",
|
||||
targetField: "mountingType",
|
||||
});
|
||||
configuration.familyTypeRules.push({
|
||||
exactFamilyAndType: "_E_CAx Mehrfachsteckdose: Doppelsteckdose",
|
||||
internalDeviceType: "Steckdose",
|
||||
connectionKind: "socket",
|
||||
category: "single_phase",
|
||||
quantityRule: { kind: "mapped-column" },
|
||||
displayNameSuggestion: { kind: "selection-marker" },
|
||||
});
|
||||
|
||||
assert.deepEqual(validateExternalCsvConfiguration(configuration), {
|
||||
success: true,
|
||||
data: configuration,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects ambiguous column and family/type mappings", () => {
|
||||
const duplicateColumn = createDefaultExternalCsvConfiguration({
|
||||
...columns,
|
||||
quantity: columns.power,
|
||||
});
|
||||
assert.match(
|
||||
validateFailure(duplicateColumn),
|
||||
/column is mapped more than once/
|
||||
);
|
||||
|
||||
const duplicateRule = createDefaultExternalCsvConfiguration(columns);
|
||||
duplicateRule.familyTypeRules = [
|
||||
{
|
||||
exactFamilyAndType: "Leuchte: Standard",
|
||||
internalDeviceType: "Leuchte",
|
||||
connectionKind: null,
|
||||
category: "lighting",
|
||||
quantityRule: { kind: "fixed", quantity: 1 },
|
||||
displayNameSuggestion: null,
|
||||
},
|
||||
{
|
||||
exactFamilyAndType: "Leuchte: Standard",
|
||||
internalDeviceType: "Leuchte",
|
||||
connectionKind: null,
|
||||
category: "lighting",
|
||||
quantityRule: { kind: "fixed", quantity: 1 },
|
||||
displayNameSuggestion: null,
|
||||
},
|
||||
];
|
||||
assert.match(
|
||||
validateFailure(duplicateRule),
|
||||
/family\/type rule is duplicated/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported transport values and invalid fixed quantities", () => {
|
||||
const configuration = createDefaultExternalCsvConfiguration(columns) as unknown as Record<string, unknown>;
|
||||
configuration.encoding = "ansi";
|
||||
assert.match(validateFailure(configuration), /encoding must be utf-8/);
|
||||
|
||||
const invalidQuantity = createDefaultExternalCsvConfiguration(columns);
|
||||
invalidQuantity.familyTypeRules.push({
|
||||
exactFamilyAndType: "Steckdose: Standard",
|
||||
internalDeviceType: "Steckdose",
|
||||
connectionKind: null,
|
||||
category: "single_phase",
|
||||
quantityRule: { kind: "fixed", quantity: 0 },
|
||||
displayNameSuggestion: { kind: "fixed", value: "Steckdose" },
|
||||
});
|
||||
assert.match(validateFailure(invalidQuantity), /positive finite number/);
|
||||
});
|
||||
|
||||
it("allows a missing quantity column only for fixed quantity rules", () => {
|
||||
const configuration = createDefaultExternalCsvConfiguration({
|
||||
...columns,
|
||||
quantity: null,
|
||||
});
|
||||
configuration.familyTypeRules.push({
|
||||
exactFamilyAndType: "Steckdose: Standard",
|
||||
internalDeviceType: "Steckdose",
|
||||
connectionKind: null,
|
||||
category: "single_phase",
|
||||
quantityRule: { kind: "fixed", quantity: 1 },
|
||||
displayNameSuggestion: null,
|
||||
});
|
||||
assert.equal(validateExternalCsvConfiguration(configuration).success, true);
|
||||
|
||||
configuration.familyTypeRules[0].quantityRule = { kind: "mapped-column" };
|
||||
assert.match(validateFailure(configuration), /requires a configured quantity column/);
|
||||
});
|
||||
|
||||
it("rejects unknown configuration fields", () => {
|
||||
const configuration = {
|
||||
...createDefaultExternalCsvConfiguration(columns),
|
||||
legacyMapping: true,
|
||||
};
|
||||
assert.match(validateFailure(configuration), /unknown or missing fields/);
|
||||
});
|
||||
});
|
||||
|
||||
function validateFailure(value: unknown) {
|
||||
const result = validateExternalCsvConfiguration(value);
|
||||
assert.equal(result.success, false);
|
||||
return result.error;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
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 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
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 AppDatabase } from "../src/db/database-context.js";
|
||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
|
||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.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 { projects } from "../src/db/schema/projects.js";
|
||||
import { rooms } from "../src/db/schema/rooms.js";
|
||||
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
|
||||
import {
|
||||
createExternalObjectNewRowProjectCommand,
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
} from "../src/domain/models/external-object-new-row-project-command.model.js";
|
||||
import {
|
||||
createExternalObjectNewCircuitProjectCommand,
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
} 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 { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||
|
||||
function createFixture(initialObjectRowId: string | null = "row-1") {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
const database = context.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: "101", roomName: "Büro" }).run();
|
||||
database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run();
|
||||
database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run();
|
||||
database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
|
||||
for (const index of [1, 2]) {
|
||||
database.insert(circuits).values({ id: `circuit-${index}`, circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: `-2F1.${index}`, sortOrder: index * 10, voltage: 230 }).run();
|
||||
database.insert(circuitDeviceRows).values({
|
||||
id: `row-${index}`,
|
||||
circuitId: `circuit-${index}`,
|
||||
sortOrder: 10,
|
||||
name: "socket",
|
||||
displayName: "Steckdose",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: "socket",
|
||||
category: "single_phase",
|
||||
roomId: "room-1",
|
||||
roomNumberSnapshot: "101",
|
||||
roomNameSnapshot: "Büro",
|
||||
quantity: index === 1 ? (initialObjectRowId === "row-1" ? 3 : 1) : 0,
|
||||
manualQuantity: index === 1 ? 1 : 0,
|
||||
powerPerUnit: 0.12,
|
||||
simultaneityFactor: 1,
|
||||
}).run();
|
||||
}
|
||||
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run();
|
||||
database.insert(externalImportBatches).values({
|
||||
id: "batch-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
importKind: "initial",
|
||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||
fileName: "revit.csv",
|
||||
sha256: "a".repeat(64),
|
||||
appliedProjectRevision: 0,
|
||||
configurationVersion: 1,
|
||||
configurationSnapshot: externalCsvTestConfiguration,
|
||||
originalBytes: Buffer.from("test"),
|
||||
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
||||
}).run();
|
||||
database.insert(externalRoomMappings).values({
|
||||
id: "mapping-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
normalizedSourceRoomKey: "number:101",
|
||||
sourceFloorName: "EG",
|
||||
sourceRoomNumber: "101",
|
||||
sourceRoomName: "Büro",
|
||||
roomId: "room-1",
|
||||
defaultDistributionBoardId: "board-1",
|
||||
}).run();
|
||||
database.insert(externalModelObjects).values(objectSnapshot(initialObjectRowId)).run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot {
|
||||
return {
|
||||
id: "object-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
ifcGuid: "ifc-1",
|
||||
lastSeenImportBatchId: "batch-1",
|
||||
lastAcceptedImportBatchId: "batch-1",
|
||||
acceptedSourceValues: {
|
||||
rowNumber: 2,
|
||||
roomNumber: "101",
|
||||
roomName: "Büro",
|
||||
familyAndType: "Steckdose: Standard",
|
||||
selectionMarker: "Steckdose",
|
||||
circuitIdentifier: "-2F1.1",
|
||||
power: "120",
|
||||
quantity: "2",
|
||||
additionalSourceValues: {},
|
||||
},
|
||||
planningValues: {
|
||||
displayName: "Steckdose",
|
||||
internalDeviceType: "socket",
|
||||
category: "single_phase",
|
||||
connectionKind: "socket",
|
||||
effectiveQuantity: 2,
|
||||
powerPerUnitW: 120,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: null,
|
||||
costGroup: null,
|
||||
remark: null,
|
||||
},
|
||||
overriddenFields: [],
|
||||
externalRoomMappingId: "mapping-1",
|
||||
distributionBoardId: "board-1",
|
||||
linkedProjectDeviceId: null,
|
||||
circuitDeviceRowId,
|
||||
presenceStatus: "present",
|
||||
};
|
||||
}
|
||||
|
||||
function row(database: AppDatabase, id: string) {
|
||||
return toCircuitDeviceRowSnapshot(
|
||||
database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, id)).get()!
|
||||
);
|
||||
}
|
||||
|
||||
function moveCommand(database: AppDatabase, confirmedConflictObjectIds: string[] = []) {
|
||||
const row1 = row(database, "row-1");
|
||||
const row2 = row(database, "row-2");
|
||||
const object = objectSnapshot("row-1");
|
||||
return createExternalObjectRowAssignmentProjectCommand({
|
||||
rows: [
|
||||
{ expected: row1, target: { ...row1, quantity: 1 } },
|
||||
{ expected: row2, target: { ...row2, quantity: 2 } },
|
||||
],
|
||||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-2" } }],
|
||||
confirmedConflictObjectIds,
|
||||
});
|
||||
}
|
||||
|
||||
describe("external object row assignment project command", () => {
|
||||
it("moves an object atomically and preserves exact quantities through undo and redo", () => {
|
||||
const context = createFixture();
|
||||
try {
|
||||
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
|
||||
const moved = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) });
|
||||
assert.equal(row(context.db, "row-1").quantity, 1);
|
||||
assert.equal(row(context.db, "row-2").quantity, 2);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
|
||||
|
||||
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: moved.revision.changeSetId, command: moved.inverse });
|
||||
assert.equal(row(context.db, "row-1").quantity, 3);
|
||||
assert.equal(row(context.db, "row-2").quantity, 0);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
|
||||
|
||||
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: moved.revision.changeSetId, command: undone.inverse });
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("detaches the last object but keeps the now-manual row", () => {
|
||||
const context = createFixture();
|
||||
try {
|
||||
const currentRow = row(context.db, "row-1");
|
||||
const object = objectSnapshot("row-1");
|
||||
const command = createExternalObjectRowAssignmentProjectCommand({
|
||||
rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }],
|
||||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }],
|
||||
confirmedConflictObjectIds: [],
|
||||
});
|
||||
new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(row(context.db, "row-1").quantity, 1);
|
||||
assert.equal(row(context.db, "row-1").manualQuantity, 1);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires explicit confirmation for differing planning values", () => {
|
||||
const context = createFixture();
|
||||
try {
|
||||
context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-2")).run();
|
||||
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }),
|
||||
/explicit conflict confirmation/
|
||||
);
|
||||
repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db, ["object-1"]) });
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2");
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back row quantities and links when history persistence fails", () => {
|
||||
const context = createFixture();
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_assignment_history
|
||||
BEFORE INSERT ON project_change_sets
|
||||
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||||
`);
|
||||
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }),
|
||||
/late history failure/
|
||||
);
|
||||
assert.equal(row(context.db, "row-1").quantity, 3);
|
||||
assert.equal(row(context.db, "row-2").quantity, 0);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("external object new-row project command", () => {
|
||||
it("creates an external-only row and supports exact persisted undo and redo", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
const base = row(context.db, "row-2");
|
||||
const newRow = {
|
||||
...base,
|
||||
id: "row-new",
|
||||
quantity: 2,
|
||||
manualQuantity: 0,
|
||||
sortOrder: 20,
|
||||
};
|
||||
const object = objectSnapshot(null);
|
||||
const command = createExternalObjectNewRowProjectCommand(
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
{
|
||||
row: newRow,
|
||||
objects: [{
|
||||
expected: object,
|
||||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||
}],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
);
|
||||
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(row(context.db, "row-new").manualQuantity, 0);
|
||||
assert.equal(row(context.db, "row-new").quantity, 2);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
|
||||
assert.throws(
|
||||
() => repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/only be removed through project history/
|
||||
);
|
||||
|
||||
const undone = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: undone.inverse,
|
||||
});
|
||||
assert.equal(row(context.db, "row-new").quantity, 2);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the row and object link when history persistence fails", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_new_row_history
|
||||
BEFORE INSERT ON project_change_sets
|
||||
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||||
`);
|
||||
const base = row(context.db, "row-2");
|
||||
const object = objectSnapshot(null);
|
||||
const command = createExternalObjectNewRowProjectCommand(
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
{
|
||||
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
|
||||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
);
|
||||
assert.throws(
|
||||
() => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
}),
|
||||
/late history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the created row when it changed after assignment", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
const base = row(context.db, "row-2");
|
||||
const object = objectSnapshot(null);
|
||||
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createExternalObjectNewRowProjectCommand(
|
||||
externalObjectAssignToNewRowCommandType,
|
||||
{
|
||||
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
|
||||
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
),
|
||||
});
|
||||
context.db.update(circuitDeviceRows).set({ displayName: "Geändert" })
|
||||
.where(eq(circuitDeviceRows.id, "row-new")).run();
|
||||
assert.throws(
|
||||
() => repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/changed before history removal/
|
||||
);
|
||||
assert.equal(row(context.db, "row-new").displayName, "Geändert");
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function newExternalCircuit(database: AppDatabase) {
|
||||
const sourceRow = row(database, "row-2");
|
||||
return {
|
||||
id: "circuit-new",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-2F1.3",
|
||||
displayName: "Steckdose",
|
||||
sortOrder: 30,
|
||||
cableType: null,
|
||||
cableCrossSection: null,
|
||||
cableLength: null,
|
||||
rcdAssignment: null,
|
||||
terminalDesignation: null,
|
||||
voltage: 230,
|
||||
controlRequirement: null,
|
||||
status: null,
|
||||
isReserve: false,
|
||||
remark: null,
|
||||
deviceRows: [{
|
||||
...sourceRow,
|
||||
id: "row-new",
|
||||
circuitId: "circuit-new",
|
||||
quantity: 2,
|
||||
manualQuantity: 0,
|
||||
}],
|
||||
protectionDevice: {
|
||||
circuitId: "circuit-new",
|
||||
type: "FI_LS" as const,
|
||||
ratedCurrentA: 16,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B" as const,
|
||||
rcdType: "A" as const,
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("external object new-circuit project command", () => {
|
||||
it("creates circuit, protection, row and links as one undoable revision", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
const object = objectSnapshot(null);
|
||||
const circuit = newExternalCircuit(context.db);
|
||||
const command = createExternalObjectNewCircuitProjectCommand(
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
{
|
||||
circuit,
|
||||
objects: [{
|
||||
expected: object,
|
||||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||
}],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
);
|
||||
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||||
.equipmentIdentifier,
|
||||
"-2F1.3"
|
||||
);
|
||||
assert.equal(row(context.db, "row-new").manualQuantity, 0);
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type,
|
||||
"FI_LS"
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
|
||||
"row-new"
|
||||
);
|
||||
assert.throws(
|
||||
() => repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: inserted.inverse,
|
||||
}),
|
||||
/only be removed through project history/
|
||||
);
|
||||
|
||||
const undone = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: undone.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||||
.equipmentIdentifier,
|
||||
"-2F1.3"
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a preplanned BMK that does not belong to the target group", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
const object = objectSnapshot(null);
|
||||
const circuit = {
|
||||
...newExternalCircuit(context.db),
|
||||
equipmentIdentifier: "-1F1.3",
|
||||
};
|
||||
assert.throws(
|
||||
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createExternalObjectNewCircuitProjectCommand(
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
{
|
||||
circuit,
|
||||
objects: [{
|
||||
expected: object,
|
||||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||
}],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
),
|
||||
}),
|
||||
/equipment identifier does not match target group/
|
||||
);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the complete circuit subtree when history persistence fails", () => {
|
||||
const context = createFixture(null);
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_new_circuit_history
|
||||
BEFORE INSERT ON project_change_sets
|
||||
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||||
`);
|
||||
const object = objectSnapshot(null);
|
||||
assert.throws(
|
||||
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createExternalObjectNewCircuitProjectCommand(
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
{
|
||||
circuit: newExternalCircuit(context.db),
|
||||
objects: [{
|
||||
expected: object,
|
||||
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||
}],
|
||||
confirmedConflictObjectIds: [],
|
||||
}
|
||||
),
|
||||
}),
|
||||
/late history failure/
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
createDefaultExternalCsvConfiguration,
|
||||
type ExternalCsvColumnMapping,
|
||||
} from "../../src/external-model/csv/external-csv-contracts.js";
|
||||
|
||||
export const externalCsvTestColumns: ExternalCsvColumnMapping = {
|
||||
ifcGuid: "IfcGUID",
|
||||
roomNumber: "Raumnummer",
|
||||
roomName: "Raumname",
|
||||
familyAndType: "Familie und Typ",
|
||||
selectionMarker: "CAx_Auswahlkenner",
|
||||
circuitIdentifier: "Stromkreis",
|
||||
power: "Elektrische Leistung",
|
||||
quantity: "Anzahl",
|
||||
};
|
||||
|
||||
export const externalCsvTestConfiguration =
|
||||
createDefaultExternalCsvConfiguration(externalCsvTestColumns);
|
||||
|
||||
export const firstIfcGuid = "0AbCdEfGhIjKlMnOpQrStu";
|
||||
export const secondIfcGuid = "1AbCdEfGhIjKlMnOpQrStu";
|
||||
|
||||
export const quotedRevitCsv = [
|
||||
'"Projekt-Export";"";"";"";"";"";"";""',
|
||||
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"Anzahl";"IfcGUID"',
|
||||
'"";"";"";"";"";"";"";""',
|
||||
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Doppelsteckdose";"Arbeitsplatz";"120,5";"2";"${firstIfcGuid}"`,
|
||||
'"UV_AV_01-2F1: 2";"";"";"";"";"";"";""',
|
||||
'"";"01/102";"Büro";"Steckdose: Standard";"Arbeitsplatz";"100";"1";""',
|
||||
`"";"01/103";"Büro \"\"Nord\"\"";"Steckdose: Standard";"Arbeitsplatz";"100";"1";"${secondIfcGuid}"`,
|
||||
].join("\r\n") + "\r\n";
|
||||
|
||||
export function utf8Bytes(value: string, withBom = false) {
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
if (!withBom) {
|
||||
return encoded;
|
||||
}
|
||||
const result = new Uint8Array(encoded.length + 3);
|
||||
result.set([0xef, 0xbb, 0xbf]);
|
||||
result.set(encoded, 3);
|
||||
return result;
|
||||
}
|
||||
@@ -179,6 +179,7 @@ describe("circuit device-row update project commands", () => {
|
||||
{ field: "roomId", value: null },
|
||||
{ field: "quantity", value: 2 },
|
||||
{ field: "cosPhi", value: 0.9 },
|
||||
{ field: "manualQuantity", value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -187,6 +188,13 @@ describe("circuit device-row update project commands", () => {
|
||||
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
|
||||
/at least one change/
|
||||
);
|
||||
assert.throws(
|
||||
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 1,
|
||||
manualQuantity: 2,
|
||||
}),
|
||||
/must not exceed/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
@@ -470,7 +478,7 @@ describe("circuit device-row structure project commands", () => {
|
||||
row.circuitId
|
||||
);
|
||||
|
||||
assert.deepEqual(insert.payload.row, row);
|
||||
assert.deepEqual(insert.payload.row, { ...row, manualQuantity: 1 });
|
||||
assert.deepEqual(remove.payload, {
|
||||
rowId: "row-1",
|
||||
expectedCircuitId: "circuit-1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user