Compare commits
No commits in common. "c633e78cd406688a23f89c0dc15569ad87f6f394" and "cf022b250f882d97bc897b0be4ce5329d29d8e91" have entirely different histories.
c633e78cd4
...
cf022b250f
93 changed files with 7031 additions and 8497 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -9,5 +9,3 @@ dist/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
/docs/spec/kbp-Stromkreis.xlsx
|
|
||||||
/data/revit-reference/*.xlsx
|
|
||||||
|
|
|
||||||
192
AGENTS.md
192
AGENTS.md
|
|
@ -295,10 +295,7 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
Current snapshot schema version 9 additionally stores
|
||||||
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
|
||||||
versions containing legacy CSV configuration or external CSV state are rejected
|
|
||||||
with a German migration error. Version 9 additionally stores
|
|
||||||
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
||||||
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
||||||
stores the optional net room area `rooms[].areaM2` (see
|
stores the optional net room area `rooms[].areaM2` (see
|
||||||
|
|
@ -310,8 +307,8 @@ distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
||||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||||
with `null` for these fields.
|
with `null` for these fields.
|
||||||
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
|
Stored named and automatic snapshots of every supported version (1 to 9)
|
||||||
they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores
|
remain restorable; they are upgraded while parsing. Version 5 stores
|
||||||
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
external-model source, import batches, room mappings and objects. Version 3
|
external-model source, import batches, room mappings and objects. Version 3
|
||||||
|
|
@ -323,21 +320,29 @@ supported versions contain circuit-group identity, distribution-board components
|
||||||
separate circuit/component protection records. Portable duplicate imports
|
separate circuit/component protection records. Portable duplicate imports
|
||||||
remap component ids and protection-owner references together with the existing
|
remap component ids and protection-owner references together with the existing
|
||||||
project graph.
|
project graph.
|
||||||
R3 replaced the runtime configuration and persistence foundation with Linear
|
Project-wide Revit CSV configuration uses
|
||||||
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
|
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||||
the old development-only external CSV data after resetting linked row totals to
|
persisted through the shared project-command transaction, so create, update,
|
||||||
their manual share. Snapshot schema 10, restore and project transfer use the
|
delete, Undo and Redo are revision-safe. Snapshot schema 5-9, restore and portable
|
||||||
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
|
project duplication preserve the configuration; duplication remaps its internal
|
||||||
rejected with a German error. The backend CSV parser and routes are removed.
|
id and project link.
|
||||||
R4 adds `/projects/:projectId/revit`, its revisioned XLSX settings and
|
The project page exposes the Revit CSV modal. It edits the complete
|
||||||
server-side sample detection, and removes the old project-page CSV modal. See
|
transport/column/family-rule configuration through the revision-safe endpoint,
|
||||||
`docs/spec/revit-excel-integration-requirements.md` and
|
requests a stateless preview and then shows the Phase 14.2 initial-import
|
||||||
`docs/spec/revit-excel-phase-14-plan.md`.
|
decisions. Preview responses include all mapped objects, while the UI renders
|
||||||
|
only the first 25. No preview or plan creates external objects, rows, revisions
|
||||||
The target configuration has no independent version counter; `expectedRevision`
|
or server-side drafts.
|
||||||
invalidates stale plans, and each import batch stores a configuration copy.
|
Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
|
||||||
Only the newest import batch retains workbook bytes and the full matrix; older
|
immutable import batches with original bytes plus the classified cell matrix,
|
||||||
batches retain date, filename, SHA-256 and object count.
|
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 versions 5-9
|
||||||
|
capture the complete external state and remap every internal UUID and link while
|
||||||
|
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
||||||
|
Import batches additionally store the monotonic project CSV configuration
|
||||||
|
version introduced by additive migration `0004`; it is distinct from the CSV
|
||||||
|
configuration JSON schema version.
|
||||||
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
|
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
|
||||||
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
existing rows from `quantity` and keeps `quantity` as the materialized total.
|
||||||
Normal manual row inserts and quantity edits update both values together.
|
Normal manual row inserts and quantity edits update both values together.
|
||||||
|
|
@ -358,12 +363,37 @@ protection and its external object links. Its history-only inverse requires the
|
||||||
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
||||||
the BMK. All three external assignment stores share
|
the BMK. All three external assignment stores share
|
||||||
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
||||||
The current read-only
|
The read-only
|
||||||
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
`GET /api/projects/:projectId/circuit-lists/:circuitListId/external-objects`
|
||||||
projection resolves the board through the owned CircuitList and returns compact
|
projection resolves the board through the owned CircuitList and returns only
|
||||||
legacy external objects. The target Revit page will load one compact project
|
that board's compact external objects, unassigned first. It exposes no original
|
||||||
list and filter/sort it client-side in a virtualized table, without server
|
CSV bytes or command payloads and returns an empty list before optional import.
|
||||||
pagination or batch blobs in the DTO.
|
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
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
project. Named snapshots are never removed by this retention policy.
|
project. Named snapshots are never removed by this retention policy.
|
||||||
|
|
@ -545,57 +575,70 @@ Required operations:
|
||||||
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
|
||||||
is served with `nosniff` and a restrictive CSP.
|
is served with `nosniff` and a restrictive CSP.
|
||||||
|
|
||||||
## Revit / Linear Excel External Model Work
|
## Revit / CSV External Model Work
|
||||||
|
|
||||||
Phase 14 is active and follows
|
Status: further Revit work (editor assignment UI, follow-up import, return
|
||||||
`docs/spec/revit-excel-integration-requirements.md` plus
|
export) is deferred and will be tackled later. When it resumes, the exchange
|
||||||
`docs/spec/revit-excel-phase-14-plan.md`. The old CSV requirements and phases
|
stays CSV-based and uses the CSV export/import of the Linear plugin in Revit.
|
||||||
14.0–14.6 are superseded. Dynamo, Revit API and JSON transport remain excluded.
|
The internal Revit/CAD steering committee prefers this over manipulating the
|
||||||
|
model directly through Dynamo. Do not start Dynamo, Revit API or JSON
|
||||||
|
transport work. The Linear CSV layout is not yet known: check a sample export
|
||||||
|
against the existing project CSV configuration before changing the parser.
|
||||||
|
Earlier Dynamo considerations are archived in
|
||||||
|
`docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md`; the
|
||||||
|
removed diagnostic scripts remain reachable through the git tag
|
||||||
|
`archive/dynamo-vorueberlegungen`.
|
||||||
|
|
||||||
Critical target rules:
|
When explicitly working on Phase 14, use
|
||||||
|
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
|
||||||
|
truth together with the current architecture documents.
|
||||||
|
|
||||||
- Exchange uses only `.xlsx` files produced and consumed by the Linear plugin.
|
Critical rules:
|
||||||
- Revit integration remains optional. Import never creates a CircuitDeviceRow,
|
|
||||||
Circuit or equipment identifier.
|
|
||||||
- Worksharing-ID is the external key and is compared exactly and
|
|
||||||
case-sensitively. It stays separate from internal UUIDs and circuit BMKs.
|
|
||||||
IfcGUID is optional redundancy, never a key, and duplicate values are only a
|
|
||||||
warning.
|
|
||||||
- A row without Worksharing-ID is suspicious only when `Familie und Typ` is
|
|
||||||
populated. Show a warning and skip it; do not block. Other ID-less rows,
|
|
||||||
including the sample summary in column B, are skipped silently.
|
|
||||||
- Source, planning and override values remain separate. Existing ProjectDevices
|
|
||||||
remain canonical and are never silently changed.
|
|
||||||
- Multi-socket objects remain indivisible. Row quantity stays
|
|
||||||
`manualQuantity + sum(effectiveQuantity)`.
|
|
||||||
- The project configuration has no independent version counter. Preview and
|
|
||||||
apply use `expectedRevision`; import batches store a configuration copy.
|
|
||||||
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
|
|
||||||
apply only stored before/after states.
|
|
||||||
- Only the newest import batch retains original bytes and the full matrix;
|
|
||||||
older batches retain date, filename, SHA-256 and object count.
|
|
||||||
- The Revit object table loads one compact list, filters/sorts in the client and
|
|
||||||
renders it virtually; do not add server pagination.
|
|
||||||
- Linked objects always follow their row's distribution board. Changing a
|
|
||||||
room's default board affects only unassigned objects and is not blocked.
|
|
||||||
- Deleting a row, circuit or group warns that X Revit objects become unassigned,
|
|
||||||
detaches them in the same command and restores links on Undo. Only deletion
|
|
||||||
of referenced rooms and distribution boards is blocked.
|
|
||||||
- Missing optional return columns for designation, power, voltage or current
|
|
||||||
produce warnings and are skipped. Only missing circuit columns for the
|
|
||||||
selected combined/separate mode block export.
|
|
||||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
|
||||||
- The corrected R2 return workbook was successfully imported into Linear on
|
|
||||||
2026-09-30; R3 and R4 are complete and R5 is the next package.
|
|
||||||
- All confirmed writes use typed project commands with `expectedRevision`,
|
|
||||||
complete inverse data and the shared project-command transaction boundary.
|
|
||||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
|
||||||
management, partial imports, PostgreSQL, background jobs or new permissions.
|
|
||||||
|
|
||||||
The real sample `docs/spec/kbp-Stromkreis.xlsx` contains project data. It is
|
- Revit integration is optional. Do not make imported external data mandatory
|
||||||
gitignored and must never be embedded in tests or committed. Use synthetic
|
for projects, rooms, project devices, circuits or device rows.
|
||||||
workbooks for automated tests and accept the real file only as an explicit
|
- Parse imports into staging first. Never write CSV rows directly into circuits
|
||||||
local path in the reference checker.
|
or device rows.
|
||||||
|
- Keep external object identity separate from internal UUIDs and circuit BMKs.
|
||||||
|
`IfcGUID` identifies the external Revit object and must never replace an
|
||||||
|
internal UUID or `equipmentIdentifier`.
|
||||||
|
- One imported Revit object may exist without a `CircuitDeviceRow`.
|
||||||
|
Import, room assignment, distribution-board assignment, classification and
|
||||||
|
ProjectDevice linking do not create a row.
|
||||||
|
- Create or change a `CircuitDeviceRow` only after an explicit user action in
|
||||||
|
the circuit-list editor.
|
||||||
|
- Several external objects may link to one `CircuitDeviceRow`, while every
|
||||||
|
external object remains individually traceable for follow-up imports and
|
||||||
|
object-specific export.
|
||||||
|
- Multi-socket Revit objects are indivisible. Their effective quantity may be
|
||||||
|
greater than one, but one external object must never be split across circuits.
|
||||||
|
- Existing ProjectDevices are canonical project templates and must not be
|
||||||
|
overwritten from Revit values. When creating a new ProjectDevice, mapped
|
||||||
|
Revit values may prefill the creation form.
|
||||||
|
- Store imported source values separately from local planning values and local
|
||||||
|
overrides. Imports never silently overwrite local values.
|
||||||
|
- Repeated imports match deterministically by project/source and `IfcGUID`.
|
||||||
|
New, changed, missing and conflicting objects require preview and explicit
|
||||||
|
user decisions.
|
||||||
|
- The first implementation assumes one complete Revit model export per project.
|
||||||
|
Do not add visible multi-source, linked-model or partial-import management.
|
||||||
|
- CSV configuration is project-scoped. Preserve title, header, blank, subtotal
|
||||||
|
and unknown rows and columns for the return export.
|
||||||
|
- Treat rows without `IfcGUID` as passthrough when they contain no object data.
|
||||||
|
Do not report Revit subtotal rows as missing-identity objects.
|
||||||
|
- Apply each confirmed import diff through a typed project command with
|
||||||
|
`expectedRevision`, complete inverse data and the shared project command
|
||||||
|
transaction boundary.
|
||||||
|
- External-model state must be added to logical project snapshots and portable
|
||||||
|
project transfer when it becomes part of the supported runtime.
|
||||||
|
- Keep CSV parsing/serialization in a transport adapter. Domain services must
|
||||||
|
not depend on CSV syntax, React, `better-sqlite3` or concrete repositories.
|
||||||
|
- Do not implement automatic circuit planning, Dynamo/API exchange,
|
||||||
|
PostgreSQL, background jobs or permissions as part of the initial CSV phase.
|
||||||
|
|
||||||
|
Before implementation, audit the current ProjectDevice, CircuitDeviceRow,
|
||||||
|
command, snapshot, transfer and editor drag-and-drop paths. Propose concrete
|
||||||
|
domain objects and commands before adding migrations.
|
||||||
|
|
||||||
## Cable Sizing Module
|
## Cable Sizing Module
|
||||||
|
|
||||||
|
|
@ -690,6 +733,9 @@ for the early-phase power balance by DIN 276 cost group (see
|
||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
|
- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never
|
||||||
|
Dynamo), except when Phase 14 is explicitly requested and
|
||||||
|
`docs/spec/revit-csv-integration-requirements.md` is being followed
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- supported production deployment
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -9,11 +9,11 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
||||||
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
|
||||||
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
|
||||||
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
vorhanden. Revit-CSV-Konfiguration, Vorschau
|
||||||
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
|
||||||
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R4 sind abgeschlossen;
|
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere
|
||||||
die eigene Revit-Seite mit XLSX-Einstellungen ist vorhanden. Erstimport,
|
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import
|
||||||
Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10.
|
des Linear-Plugins in Revit.
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -40,9 +40,9 @@ Editorzuordnung, Rückgabe und Folgeimport folgen in R5 bis R10.
|
||||||
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
- grobe Leistungsbilanzen für LPH 2/3 nach Kostengruppen führen, mit der
|
||||||
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
|
||||||
übernehmen
|
übernehmen
|
||||||
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
|
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
|
||||||
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
|
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
|
||||||
Phase-14-Plan in den nächsten Paketen angebunden
|
noch keine Stromkreise oder Gerätezeilen
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
@ -177,8 +177,6 @@ vor der Baseline werden nicht unterstützt.
|
||||||
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
|
- [Anmeldung und Benutzerverwaltung](docs/authentication.md)
|
||||||
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
||||||
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
||||||
- [Revit-Integration über Linear-Excel](docs/spec/revit-excel-integration-requirements.md)
|
|
||||||
- [Phase-14-Plan R0–R10](docs/spec/revit-excel-phase-14-plan.md)
|
|
||||||
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
||||||
|
|
||||||
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
Für LLM-gestützte Änderungen enthält [AGENTS.md](AGENTS.md) die verbindlichen
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,13 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
- [Entwicklungs- und Contributor-Workflow](development-workflow.md)
|
||||||
- [Deployment und Betrieb](deployment.md)
|
- [Deployment und Betrieb](deployment.md)
|
||||||
|
|
||||||
## Aktive Revit-Umstellung
|
|
||||||
|
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
|
||||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md) –
|
|
||||||
verbindliche Anforderungen für XLSX und Worksharing-ID
|
|
||||||
- [Phase-14-Plan R0–R10](spec/revit-excel-phase-14-plan.md) – Status,
|
|
||||||
Paketgrenzen und Linear-Freigabepunkt nach R2
|
|
||||||
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
|
||||||
- [LLM-Kontext für Phase 14](revit-requirements-llm-context.md)
|
|
||||||
|
|
||||||
## Zukunftsarchitektur
|
## Zukunftsarchitektur
|
||||||
|
|
||||||
|
- [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) –
|
||||||
|
zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins
|
||||||
|
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
|
||||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||||
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,8 @@ bleiben.
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
||||||
aktive Phase 14 verwendet Linear-XLSX; das Modell steht der expliziten
|
Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die
|
||||||
Raumzuordnung im Importwizard nicht im Weg.
|
Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg.
|
||||||
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
||||||
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
||||||
und ausdrücklicher Bestätigung einhalten.
|
und ausdrücklicher Bestätigung einhalten.
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- The current Revit/CSV foundation and confirmed initial import are a legacy
|
- Further Revit work is deferred; it will later use the CSV export/import of
|
||||||
starting point. The active Phase-14 plan replaces them with the Linear XLSX
|
the Linear plugin in Revit, not Dynamo.
|
||||||
exchange and exact Worksharing-ID identity.
|
- The Revit/CSV foundation and confirmed initial import are implemented.
|
||||||
R2 must pass a real Linear return-import check before database cutover in R3.
|
|
||||||
Existing-row assignment, move and detach plus creation of an external-only
|
Existing-row assignment, move and detach plus creation of an external-only
|
||||||
row in an existing or newly created circuit have atomic persistent commands.
|
row in an existing or newly created circuit have atomic persistent commands.
|
||||||
Their API planning and editor UI, follow-up import, conflict review and return
|
Their API planning and editor UI, follow-up import, conflict review and return
|
||||||
|
|
|
||||||
|
|
@ -510,9 +510,6 @@ Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
|
||||||
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
||||||
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
||||||
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
||||||
Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den
|
|
||||||
externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden
|
|
||||||
hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt.
|
|
||||||
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
||||||
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
||||||
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
||||||
|
|
@ -523,9 +520,9 @@ Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
|
||||||
Projekts; ältere Versionen werden mit `null`
|
Projekts; ältere Versionen werden mit `null`
|
||||||
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||||
enthielt bereits den damaligen externen CSV-Modellzustand
|
enthält bereits den vollständigen externen Modellzustand
|
||||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
|
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
||||||
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||||
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||||
1:1-Schutzgerätedaten. Capture,
|
1:1-Schutzgerätedaten. Capture,
|
||||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||||
|
|
@ -643,42 +640,11 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||||
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||||
|
|
||||||
## Revit-Anbindung: Linear-XLSX-Grundlage nach R3
|
## Revit-CSV-Grundlage
|
||||||
|
|
||||||
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
|
||||||
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
|
||||||
Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
|
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md).
|
||||||
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
|
|
||||||
Änderungen laufen als Projekt-Commands über `expectedRevision`.
|
|
||||||
|
|
||||||
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
|
|
||||||
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
|
|
||||||
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
|
|
||||||
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
|
|
||||||
Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und
|
|
||||||
Projekttransfer verwenden diesen Zustand und remappen alle internen
|
|
||||||
Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9
|
|
||||||
werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem
|
|
||||||
CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
|
|
||||||
abgelehnt.
|
|
||||||
|
|
||||||
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
|
||||||
seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte
|
|
||||||
Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch
|
|
||||||
die Route `/projects/:projectId/revit` ersetzt.
|
|
||||||
|
|
||||||
### Historische Ausgangslage vor R3
|
|
||||||
|
|
||||||
Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle
|
|
||||||
Laufzeitdokumentation mehr.
|
|
||||||
|
|
||||||
Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen
|
|
||||||
Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte
|
|
||||||
Ausgangslage für den schrittweisen Cutover; er darf nicht erweitert werden.
|
|
||||||
Das verbindliche Ziel ist der XLSX-Austausch des Linear-Plugins mit exakter,
|
|
||||||
case-sensitiver Worksharing-ID. Details:
|
|
||||||
[Excel-Anforderungen](spec/revit-excel-integration-requirements.md) und
|
|
||||||
[R0–R10-Plan](spec/revit-excel-phase-14-plan.md).
|
|
||||||
|
|
||||||
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
||||||
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
||||||
|
|
@ -808,33 +774,11 @@ Die Kataloganlage ist jeweils ein eigener Projekt-Command; der Wizard übernimmt
|
||||||
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
|
den neuen Datensatz anschließend in seinen lokalen Auswahlkatalog und markiert
|
||||||
ihn für die betroffene Gruppe.
|
ihn für die betroffene Gruppe.
|
||||||
|
|
||||||
### Freigegebener Zielpfad ab R2
|
|
||||||
|
|
||||||
R2 ersetzt zunächst nur den Transportadapter und endet mit einem realen
|
|
||||||
Linear-Rückimporttest. R3 bleibt bis zu dessen Freigabe gesperrt. Scheitert der
|
|
||||||
ExcelJS-Roundtrip, wird vor R3 ein Adapter vorgeschlagen, der die Original-XLSX
|
|
||||||
direkt auf OOXML-Ebene ändert.
|
|
||||||
|
|
||||||
Der Zielzustand verwendet Worksharing-ID als exakten, case-sensitiven externen
|
|
||||||
Schlüssel; IfcGUID bleibt optional. Die Konfiguration hat keinen eigenen
|
|
||||||
Versionszähler. `expectedRevision` bindet Vorschau und Apply, Importbatches
|
|
||||||
speichern eine Konfigurationskopie. Nur der letzte Batch hält Workbook und
|
|
||||||
Matrix vollständig, ältere Batches nur Metadaten. Apply parst die erneut
|
|
||||||
übertragene Datei, Undo/Redo arbeitet nur auf persistierten Zuständen.
|
|
||||||
|
|
||||||
Die eigene Route `/projects/:projectId/revit` lädt eine kompakte Objektliste
|
|
||||||
einmal und stellt sie clientseitig gefiltert, sortiert und virtualisiert dar.
|
|
||||||
Verknüpfte Objekte folgen dem Verteiler ihrer Row. Row-, Circuit- und
|
|
||||||
Gruppenlöschungen lösen externe Links nach Warnung im selben Command; Undo
|
|
||||||
stellt sie wieder her. Raum- und Verteilerlöschungen bleiben bei externen
|
|
||||||
Referenzen gesperrt.
|
|
||||||
|
|
||||||
## Noch nicht unterstützt
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||||
- Linear-XLSX-Transport, eigene Revit-Seite, Editor-Oberfläche für die
|
- Editor-Oberfläche für die vorhandene Zuordnungslogik, Folgeimport und
|
||||||
Zuordnungslogik, Folgeimport und strukturtreuer Revit-Rückexport; Umsetzung
|
strukturtreuer Revit-Rückexport
|
||||||
folgt dem R0–R10-Plan
|
|
||||||
- vollständige elektrische Dimensionierung
|
- vollständige elektrische Dimensionierung
|
||||||
- Produktionsdeployment
|
- Produktionsdeployment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ This document records architectural direction for future work. It is not a final
|
||||||
- persist undo/redo across page reloads and application restarts
|
- persist undo/redo across page reloads and application restarts
|
||||||
- provide a project-wide version history with named restore points
|
- provide a project-wide version history with named restore points
|
||||||
- preserve an auditable sequence of user, import, migration and restore operations
|
- preserve an auditable sequence of user, import, migration and restore operations
|
||||||
- support the Revit/Linear-XLSX round-trip using stable, exact
|
- support future Revit/CSV round-trips using stable IFCGUID-based links
|
||||||
Worksharing-ID-based external identity
|
|
||||||
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
- keep the persistence boundary replaceable so PostgreSQL can be adopted later
|
||||||
|
|
||||||
## Separate Concerns
|
## Separate Concerns
|
||||||
|
|
@ -112,12 +111,11 @@ Implemented automatic-snapshot policy:
|
||||||
- automatic capture and retention run inside the same transaction as the
|
- automatic capture and retention run inside the same transaction as the
|
||||||
triggering project revision
|
triggering project revision
|
||||||
|
|
||||||
## Revit / Linear Excel Round-Trip Direction
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
Phase 14 is active and follows the
|
**Deferred:** further Revit work will be tackled later, then through the
|
||||||
[Linear-Excel requirements](spec/revit-excel-integration-requirements.md) and
|
CSV export/import of the Linear plugin in Revit instead of Dynamo. Details:
|
||||||
[R0–R10 plan](spec/revit-excel-phase-14-plan.md). The existing CSV/IFCGUID
|
[Revit-CSV requirements, section 1](spec/revit-csv-integration-requirements.md).
|
||||||
runtime is a legacy starting point for the cutover, not the target model.
|
|
||||||
|
|
||||||
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
|
|
||||||
|
|
@ -126,12 +124,9 @@ Conceptual entities:
|
||||||
- `ExternalModelSource`
|
- `ExternalModelSource`
|
||||||
- identifies a Revit/IFC model or another external source within a project
|
- identifies a Revit/IFC model or another external source within a project
|
||||||
- `ExternalImportBatch`
|
- `ExternalImportBatch`
|
||||||
- file name, checksum, import time, object count and processing status
|
- file name, checksum, import time, format/version and processing status
|
||||||
- only the latest batch keeps original workbook bytes, matrix and the
|
|
||||||
configuration copy; older batches keep date, filename, checksum and count
|
|
||||||
- `ExternalModelObject`
|
- `ExternalModelObject`
|
||||||
- exact case-sensitive Worksharing-ID as external identity
|
- stable IFCGUID and optional Revit element identifiers
|
||||||
- optional IfcGUID as non-unique source redundancy
|
|
||||||
- source attributes and last-seen import batch
|
- source attributes and last-seen import batch
|
||||||
- `ExternalObjectLink`
|
- `ExternalObjectLink`
|
||||||
- explicit relationship to a project device, circuit device row or another future domain entity
|
- explicit relationship to a project device, circuit device row or another future domain entity
|
||||||
|
|
@ -142,27 +137,13 @@ Conceptual entities:
|
||||||
|
|
||||||
Required behavior:
|
Required behavior:
|
||||||
|
|
||||||
- Worksharing-ID uniqueness is scoped to the external source and uses exact,
|
- IFCGUID uniqueness is scoped to project and external model source.
|
||||||
case-sensitive comparison. IfcGUID is not a matching key.
|
|
||||||
- Parsing writes to staging first.
|
- Parsing writes to staging first.
|
||||||
- Users preview additions, changes, missing objects and conflicts before applying them.
|
- Users preview additions, changes, missing objects and conflicts before applying them.
|
||||||
- Applying an import creates one project revision and one auditable change set.
|
- Applying an import creates one project revision and one auditable change set.
|
||||||
- Imported source values and locally assigned planning values remain distinguishable.
|
- Imported source values and locally assigned planning values remain distinguishable.
|
||||||
- Export retains Worksharing-ID so Linear can match rows deterministically.
|
- Export retains IFCGUID and source identifiers so Revit parameters can be matched deterministically.
|
||||||
- Missing objects and duplicate/conflicting Worksharing-IDs are reported,
|
- Missing objects and duplicate/conflicting identifiers are reported, never silently reassigned.
|
||||||
never silently reassigned.
|
|
||||||
|
|
||||||
The project configuration has no separate revision counter. Stateless plans
|
|
||||||
and apply requests bind to `expectedRevision`; every applied import batch holds
|
|
||||||
its configuration copy. Apply reparses the retransmitted workbook. Undo and
|
|
||||||
Redo operate only on persisted complete before/after states and never parse an
|
|
||||||
Excel file.
|
|
||||||
|
|
||||||
Deleting a linked row, circuit or group detaches affected Revit objects in the
|
|
||||||
same typed command after an explicit warning; its inverse restores all links.
|
|
||||||
Only deleting referenced rooms and distribution boards is blocked. Linked
|
|
||||||
objects always derive their board from their row, while a room default-board
|
|
||||||
change affects only unassigned objects.
|
|
||||||
|
|
||||||
## SQLite and PostgreSQL Direction
|
## SQLite and PostgreSQL Direction
|
||||||
|
|
||||||
|
|
@ -311,8 +292,7 @@ Remaining constraints before completing project version history:
|
||||||
boundary; extend it whenever further project mutations are introduced
|
boundary; extend it whenever further project mutations are introduced
|
||||||
- project creation remains initialization at revision zero, while global device
|
- project creation remains initialization at revision zero, while global device
|
||||||
templates remain outside any single project history
|
templates remain outside any single project history
|
||||||
- do not model Worksharing-ID or IfcGUID as an overloaded circuit equipment
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
identifier or an internal relation id
|
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
## Deferred Decisions
|
## Deferred Decisions
|
||||||
|
|
@ -320,6 +300,5 @@ Remaining constraints before completing project version history:
|
||||||
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
- whether large snapshot payloads remain in PostgreSQL or move to object storage
|
||||||
- branch visualization after undo followed by new edits
|
- branch visualization after undo followed by new edits
|
||||||
- user/role model and actor attribution
|
- user/role model and actor attribution
|
||||||
- Linear-XLSX compatibility beyond the observed sample and any future
|
- supported Revit CSV dialects and parameter mapping UI
|
||||||
project-independent mapping profiles
|
|
||||||
- PostgreSQL deployment topology and migration date
|
- PostgreSQL deployment topology and migration date
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,273 @@
|
||||||
# LLM-Kontext für die Revit-Integration
|
# Kontext für die Revit-Anforderungsplanung
|
||||||
|
|
||||||
Die frühere offene Revit-/CSV-Anforderungsplanung ist abgeschlossen und
|
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
|
||||||
abgelöst. Für Arbeiten an Phase 14 sind vollständig zu lesen:
|
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
|
||||||
|
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md).
|
||||||
|
|
||||||
- [Revit-Integration über Linear-Excel](spec/revit-excel-integration-requirements.md)
|
## Zweck dieser Datei
|
||||||
- [R0–R10-Plan](spec/revit-excel-phase-14-plan.md)
|
|
||||||
- [R0-Bestandsaudit](spec/revit-excel-r0-audit.md)
|
|
||||||
- [Aktuelle Systemarchitektur](current-architecture.md)
|
|
||||||
- [Projekt-History und externer Modellzustand](project-history-and-external-model-architecture.md)
|
|
||||||
|
|
||||||
Bei Widersprüchen gilt das Anforderungsdokument. CSV, IfcGUID als Schlüssel,
|
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||||
Dynamo, Revit-API und JSON-Transport sind keine Zielpfade. Der derzeit noch im
|
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||||
Code vorhandene CSV-Stand ist ausschließlich die in R1 dokumentierte
|
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||||
Legacy-Ausgangslage für den schrittweisen Cutover.
|
|
||||||
|
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
||||||
|
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||||
|
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||||
|
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
||||||
|
|
||||||
|
## Produkt und fachliches Modell
|
||||||
|
|
||||||
|
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||||
|
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
|
||||||
|
|
||||||
|
Das unterstützte Laufzeitmodell ist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project
|
||||||
|
├── Floor
|
||||||
|
│ └── Room
|
||||||
|
├── ProjectDevice
|
||||||
|
└── DistributionBoard
|
||||||
|
└── CircuitList
|
||||||
|
└── CircuitSection
|
||||||
|
└── Circuit
|
||||||
|
└── CircuitDeviceRow
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige fachliche Grenzen:
|
||||||
|
|
||||||
|
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
|
||||||
|
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
|
||||||
|
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
|
||||||
|
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
|
||||||
|
und Kategorie gehören zur Gerätezeile.
|
||||||
|
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
|
||||||
|
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
|
||||||
|
lokale Werte und ausdrücklich erfasste Abweichungen.
|
||||||
|
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
|
||||||
|
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
|
||||||
|
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
|
||||||
|
erlaubt.
|
||||||
|
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
|
||||||
|
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
|
||||||
|
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Für einen späteren Revit-Austausch besonders relevante Daten sind:
|
||||||
|
|
||||||
|
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
|
||||||
|
Kennzeichnung als öffentliches Gebäude und Projektspannungen.
|
||||||
|
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
|
||||||
|
Raumname.
|
||||||
|
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
|
||||||
|
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
|
||||||
|
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
|
||||||
|
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
|
||||||
|
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
|
||||||
|
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
|
||||||
|
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
|
||||||
|
|
||||||
|
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
|
||||||
|
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
|
||||||
|
aktuellen Produkt nicht vorgesehen.
|
||||||
|
|
||||||
|
## Aktuelle technische Architektur
|
||||||
|
|
||||||
|
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
|
||||||
|
`src/frontend`.
|
||||||
|
- API: Express und Zod unter `src/server`.
|
||||||
|
- Fachregeln und typisierte Commands: `src/domain`.
|
||||||
|
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
|
||||||
|
- Laufzeitkomposition konkreter Repositories:
|
||||||
|
`src/server/composition`.
|
||||||
|
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
|
||||||
|
SQLite-Datei unter `data/leistungsbilanz.db`.
|
||||||
|
- Docker Compose wird für die lokale Entwicklung unterstützt.
|
||||||
|
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
|
||||||
|
|
||||||
|
Der zentrale Editor liegt unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
|
||||||
|
src/frontend/components/circuit-tree-editor.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
|
||||||
|
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
|
||||||
|
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
|
||||||
|
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
|
||||||
|
|
||||||
|
## Revisionen, Undo/Redo und Sicherungspunkte
|
||||||
|
|
||||||
|
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
|
||||||
|
Command- und Transaktionsgrenze:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
|
||||||
|
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
|
||||||
|
geben die erwartete Revision mit, damit veraltete Clients keine neueren
|
||||||
|
Änderungen überschreiben.
|
||||||
|
|
||||||
|
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
|
||||||
|
Anwendungsneustart. Zusätzlich gibt es:
|
||||||
|
|
||||||
|
- eine unveränderliche Revisions-Timeline;
|
||||||
|
- benannte und automatische logische Projektsnapshots;
|
||||||
|
- wiederherstellbare Snapshots mit Prüfsumme;
|
||||||
|
- einen portablen, versionierten JSON-Projekttransfer.
|
||||||
|
|
||||||
|
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
|
||||||
|
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
|
||||||
|
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
|
||||||
|
revisioniert und rückgängig machbar sein.
|
||||||
|
|
||||||
|
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
|
||||||
|
|
||||||
|
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
|
||||||
|
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
|
||||||
|
Projekts innerhalb von Leistungsbilanz.
|
||||||
|
|
||||||
|
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
|
||||||
|
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
|
||||||
|
für CSV-Dateien oder Revit-Parameter verwendet werden.
|
||||||
|
|
||||||
|
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
|
||||||
|
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
|
||||||
|
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
|
||||||
|
zuweisen kann.
|
||||||
|
|
||||||
|
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
|
||||||
|
|
||||||
|
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
|
||||||
|
Implementierungsdesign:
|
||||||
|
|
||||||
|
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
|
||||||
|
Gerätezeilen.
|
||||||
|
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
|
||||||
|
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
|
||||||
|
widersprüchliche Objekte angezeigt.
|
||||||
|
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
|
||||||
|
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
|
||||||
|
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
|
||||||
|
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
|
||||||
|
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
|
||||||
|
unterscheidbar bleiben.
|
||||||
|
- Lokale Planungswerte dürfen niemals still überschrieben werden.
|
||||||
|
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
|
||||||
|
ausdrücklich modelliert werden.
|
||||||
|
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
|
||||||
|
automatisch einem anderen Objekt zugeordnet.
|
||||||
|
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
|
||||||
|
die Werte deterministisch zurückschreiben kann.
|
||||||
|
- Ein Export bezieht sich auf eine bekannte Projektrevision.
|
||||||
|
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
|
||||||
|
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
|
||||||
|
Serverbetrieb oder unabhängigen Hintergrundjobs.
|
||||||
|
|
||||||
|
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
|
||||||
|
|
||||||
|
- `ExternalModelSource`
|
||||||
|
- `ExternalImportBatch`
|
||||||
|
- `ExternalModelObject`
|
||||||
|
- `ExternalObjectLink`
|
||||||
|
- `ExternalParameterMapping`
|
||||||
|
- `ExternalExportBatch`
|
||||||
|
|
||||||
|
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
|
||||||
|
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
|
||||||
|
Tabellen behandeln.
|
||||||
|
|
||||||
|
## Auftrag an das andere LLM
|
||||||
|
|
||||||
|
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
|
||||||
|
realen Ablauf in Revit und kläre danach schrittweise mindestens:
|
||||||
|
|
||||||
|
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
|
||||||
|
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
|
||||||
|
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
|
||||||
|
Revit-Versionen)?
|
||||||
|
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
|
||||||
|
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
|
||||||
|
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
|
||||||
|
aktualisierte Revit-Dateien unterschieden?
|
||||||
|
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
|
||||||
|
Leistungsbilanz geplant?
|
||||||
|
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
|
||||||
|
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
|
||||||
|
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
|
||||||
|
Projektgerät, Raum oder einer Verteilung zugeordnet?
|
||||||
|
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
|
||||||
|
Einheiten und unter welchen Revit-Parameternamen?
|
||||||
|
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
|
||||||
|
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
|
||||||
|
Rückgabedatei erzeugt werden?
|
||||||
|
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
|
||||||
|
wiederverwendet und zwischen Projekten geteilt werden?
|
||||||
|
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
|
||||||
|
doppelte oder nicht mehr sichtbare Revit-Objekte?
|
||||||
|
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
|
||||||
|
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
|
||||||
|
eine Auswahl?
|
||||||
|
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
|
||||||
|
Anwender vor der Übernahme?
|
||||||
|
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
|
||||||
|
verhindern oder nur eine Warnung erzeugen?
|
||||||
|
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
|
||||||
|
16. Müssen Import und Export synchron im Browser laufen oder werden später
|
||||||
|
Hintergrundjobs benötigt?
|
||||||
|
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
|
||||||
|
Versionshistorie und Support gespeichert werden?
|
||||||
|
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
|
||||||
|
Abnahmekriterien?
|
||||||
|
|
||||||
|
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
|
||||||
|
Notizen ausdrücklich:
|
||||||
|
|
||||||
|
- vom Anwender bestätigte Anforderungen;
|
||||||
|
- sinnvolle Vorschläge;
|
||||||
|
- offene Entscheidungen;
|
||||||
|
- technische Folgerungen;
|
||||||
|
- bewusst nicht betrachtete spätere Ausbaustufen.
|
||||||
|
|
||||||
|
## Erwartetes Ergebnis der Anforderungsrunde
|
||||||
|
|
||||||
|
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
|
||||||
|
soll mindestens enthalten:
|
||||||
|
|
||||||
|
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
|
||||||
|
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
|
||||||
|
Rückexport;
|
||||||
|
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
|
||||||
|
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
|
||||||
|
- Identitäts-, Matching- und Quellmodell;
|
||||||
|
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
|
||||||
|
- Konflikt-, Validierungs- und Freigaberegeln;
|
||||||
|
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
|
||||||
|
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
|
||||||
|
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
|
||||||
|
Architekturregeln zu umgehen;
|
||||||
|
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
|
||||||
|
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
|
||||||
|
müssen.
|
||||||
|
|
||||||
|
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
|
||||||
|
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
|
||||||
|
nur Architekturvorschläge des LLM sind.
|
||||||
|
|
||||||
|
## Weiterführende Projektdokumente
|
||||||
|
|
||||||
|
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
|
||||||
|
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
|
||||||
|
Command-Pfad
|
||||||
|
- `docs/project-history-and-external-model-architecture.md` – bisherige
|
||||||
|
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
|
||||||
|
- `docs/spec/01-domain-context.md` – fachlicher Kontext
|
||||||
|
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||||
|
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||||
|
kontrollierte Synchronisierung
|
||||||
|
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 14
|
||||||
|
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||||
|
Funktionen
|
||||||
|
|
|
||||||
|
|
@ -532,34 +532,36 @@ Acceptance criteria:
|
||||||
|
|
||||||
## Phase 14: External Model Round-Trip
|
## Phase 14: External Model Round-Trip
|
||||||
|
|
||||||
Status: R0 ist mit Korrekturen freigegeben; R1 stellt die Dokumentation auf
|
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
|
||||||
Linear-XLSX und Worksharing-ID um. Die frühere CSV-Planung 14.0–14.6 ist
|
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
|
||||||
abgelöst und wird nicht fortgeschrieben.
|
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md).
|
||||||
|
|
||||||
Verbindliche Quellen:
|
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).
|
||||||
|
|
||||||
- [Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md)
|
Goal:
|
||||||
- [R0–R10-Plan](revit-excel-phase-14-plan.md)
|
|
||||||
- [R0-Bestandsaudit](revit-excel-r0-audit.md)
|
|
||||||
|
|
||||||
Die Umsetzung erfolgt ausschließlich in den Paketen R0–R10. R2 endet mit
|
Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import).
|
||||||
einer aus der realen Musterdatei erzeugten Rückgabedatei. Der Nutzer muss sie
|
|
||||||
erfolgreich in Linear importieren, bevor R3 beginnt. Bei einem Fehlschlag wird
|
|
||||||
zuvor eine direkte OOXML-Änderung des Originalworkbooks als Alternative
|
|
||||||
vorgelegt.
|
|
||||||
|
|
||||||
Kernabnahme der Phase:
|
Tasks:
|
||||||
|
|
||||||
- Worksharing-ID ist der exakte, case-sensitive externe Schlüssel;
|
- model external sources, import batches and stable IFCGUID objects
|
||||||
- XLSX-Kopfkommentare, Struktur, Formate und unbekannte Spalten bleiben beim
|
- stage and validate imports before applying them
|
||||||
Rückexport erhalten;
|
- show additions, changes, missing objects and conflicts
|
||||||
- Import erzeugt keine Rows und überschreibt keine lokale Planung still;
|
- link external objects explicitly to project-domain entities
|
||||||
- Zuordnung, Löschung, Folgeimport und Undo/Redo sind atomar und nach Neustart
|
- apply an import as one auditable project revision
|
||||||
reproduzierbar;
|
- configure inbound and outbound parameter mappings
|
||||||
- Undo/Redo liest keine Excel-Datei;
|
- export against a known project revision while preserving IFCGUID
|
||||||
- nur der letzte Importbatch enthält Workbook und Matrix vollständig;
|
|
||||||
- mehrere Tausend Objekte bleiben über eine clientseitig gefilterte,
|
Acceptance criteria:
|
||||||
virtualisierte Liste bedienbar.
|
|
||||||
|
- repeated imports match objects deterministically by source and IFCGUID
|
||||||
|
- imports never silently overwrite local planning values
|
||||||
|
- users approve the diff before it changes the project
|
||||||
|
- exported data can be assigned back to the correct Revit objects
|
||||||
|
|
||||||
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
|
PostgreSQL should be introduced when shared multi-user operation, background jobs or operational scale justify it. External-object count alone does not require an immediate migration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ ProjectDevices and device rows (#20).
|
||||||
3. [x] Snapshot-to-revision descriptions and history presentation.
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
4. [x] Distribution-board power summary and project column layout.
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
The Revit/Linear-XLSX round-trip is now the separately planned active Phase 14.
|
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||||
It uses exact Worksharing-ID identity and must not be inferred from these
|
foundation as a separate jointly planned phase and must not be inferred from
|
||||||
unrelated backlog tasks. See
|
these tasks. It is currently deferred and will later use the CSV
|
||||||
[the R0–R10 plan](revit-excel-phase-14-plan.md).
|
export/import of the Linear plugin in Revit, not Dynamo.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,406 @@
|
||||||
# Abgelöst: alter Phase-14-CSV-Plan
|
# Phase 14: Audit- und Implementierungsplan für die Revit-CSV-Anbindung
|
||||||
|
|
||||||
Die Phasen 14.0–14.6 und ihre Erledigt-Markierungen sind abgelöst. Für die
|
## Status
|
||||||
Umstellung auf Linear-Excel gelten der [R0–R10-Plan](revit-excel-phase-14-plan.md)
|
|
||||||
und die [neuen Anforderungen](revit-excel-integration-requirements.md).
|
|
||||||
|
|
||||||
Der historische Inhalt bleibt im Git-Verlauf bis einschließlich Commit
|
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
|
||||||
`ae318a5` erhalten und darf nicht fortgeschrieben werden.
|
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
|
||||||
|
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md).
|
||||||
|
|
||||||
|
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
|
||||||
|
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
|
||||||
|
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
|
||||||
|
additiven Persistenzschema begonnen; eine bestätigte Importübernahme existiert
|
||||||
|
noch nicht.
|
||||||
|
|
||||||
|
Fachliche Quelle bleibt
|
||||||
|
[Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
|
||||||
|
Dieses Dokument konkretisiert dafür Namen, Modulgrenzen, Persistenz und die
|
||||||
|
Reihenfolge kleiner Arbeitspakete.
|
||||||
|
|
||||||
|
## Ergebnis des Bestandsaudits
|
||||||
|
|
||||||
|
### Beibehalten
|
||||||
|
|
||||||
|
- Das Circuit-First-Modell bleibt unverändert: Ein `Circuit` besitzt null bis
|
||||||
|
viele `CircuitDeviceRow`-Datensätze.
|
||||||
|
- `ProjectDevice` bleibt die kanonische projektweite Vorlage. Ein Import darf
|
||||||
|
diese Datensätze weder erzeugen noch ändern, solange der Nutzer dies nicht
|
||||||
|
ausdrücklich bestätigt.
|
||||||
|
- `CircuitDeviceRow.linkedProjectDeviceId` bleibt die optionale Verbindung
|
||||||
|
zwischen einer Gerätezeile und genau einer Projektgerätevorlage.
|
||||||
|
- Die bestehenden getrennten Drag-and-drop-Pfade für Projektgeräte,
|
||||||
|
Gerätezeilen und Stromkreise bleiben bestehen. Externe Objekte erhalten einen
|
||||||
|
vierten, fachlich eigenen Pfad.
|
||||||
|
- Alle bestätigten Änderungen verwenden typisierte Commands mit
|
||||||
|
`expectedRevision`, vollständiger Gegenrichtung und
|
||||||
|
`project-command-transaction.persistence.ts` als gemeinsamer
|
||||||
|
Transaktionsgrenze.
|
||||||
|
- Snapshot-Erfassung, Restore und Projektduplikat bleiben explizite Adapter mit
|
||||||
|
fester Fremdschlüsselreihenfolge und UUID-Remapping.
|
||||||
|
- SQLite und die heutige Server-Komposition bleiben der einzige unterstützte
|
||||||
|
Laufzeitadapter.
|
||||||
|
|
||||||
|
### Erweitern oder gezielt refaktorieren
|
||||||
|
|
||||||
|
- `ProjectStateSnapshot` und der portable Projekttransfer werden additiv um den
|
||||||
|
bestätigten externen Projektzustand erweitert. Bereits veröffentlichte
|
||||||
|
Snapshot-Versionen werden nicht verändert.
|
||||||
|
- Der Command-Dispatcher erhält schmale Stores für externe Konfiguration,
|
||||||
|
Importübernahme, Objektplanung und Row-Zuordnung. Controller erhalten keine
|
||||||
|
konkreten SQLite-Abhängigkeiten.
|
||||||
|
- Der Stromkreislisteneditor erhält später einen separaten Drawer für
|
||||||
|
unzugeordnete externe Objekte. Die bestehende Projektgeräteauswahl wird nicht
|
||||||
|
dafür zweckentfremdet.
|
||||||
|
- Vor der ersten externen Row-Zuordnung wird die Mengenhoheit einer
|
||||||
|
`CircuitDeviceRow` explizit gemacht; Details stehen unter „Mengenmodell“.
|
||||||
|
|
||||||
|
### Hinzufügen
|
||||||
|
|
||||||
|
- verlustarmer CSV-Transportadapter außerhalb von React und Circuit-Domain;
|
||||||
|
- projektweite, versionierte Revit-CSV-Konfiguration;
|
||||||
|
- eine zunächst implizite externe Modellquelle je Projekt;
|
||||||
|
- Importbatches einschließlich Originaltransport und akzeptiertem
|
||||||
|
Konfigurationsstand;
|
||||||
|
- persistente externe Objekte mit `IfcGUID`, getrennten Quell- und
|
||||||
|
Planungswerten und einzelnem Lebenszyklus;
|
||||||
|
- Quellraum-Mappings auf interne Räume und Standardverteilungen;
|
||||||
|
- explizite Verknüpfungen zu `ProjectDevice`, `CircuitDeviceRow` und optionaler
|
||||||
|
abweichender Verteilung;
|
||||||
|
- Vorschau-, Konflikt- und Exportprojektionen als reine Anwendungsservices;
|
||||||
|
- fokussierte Parser-, Command-, Transaktions-, Snapshot-, Transfer- und
|
||||||
|
End-to-End-Tests.
|
||||||
|
|
||||||
|
### Entfernen
|
||||||
|
|
||||||
|
Es existiert kein unterstützter Revit-Laufzeitpfad. Deshalb wird in Phase 14
|
||||||
|
kein bestehender Fachpfad entfernt und keine Legacy-Kompatibilität eingeführt.
|
||||||
|
|
||||||
|
## Vorgeschlagenes Domänenmodell
|
||||||
|
|
||||||
|
Die Namen sind ab Phase 14.1 verbindliche Arbeitsnamen. Tabellen verwenden die
|
||||||
|
jeweilige englische Pluralform in `snake_case`.
|
||||||
|
|
||||||
|
### `ExternalModelSource`
|
||||||
|
|
||||||
|
- stabile interne UUID;
|
||||||
|
- `projectId`;
|
||||||
|
- technischer Name und Quelltyp `revit_csv`;
|
||||||
|
- der zuletzt bestätigte Importbatch wird aus der geordneten Batchhistorie
|
||||||
|
abgeleitet und nicht als redundanter Zeiger gespeichert.
|
||||||
|
|
||||||
|
In der ersten Ausbaustufe wird genau eine Quelle je Projekt automatisch
|
||||||
|
angelegt. Es gibt bewusst keine sichtbare Mehrquellenverwaltung. Die
|
||||||
|
Eindeutigkeit eines externen Objekts ist trotzdem von Anfang an
|
||||||
|
`(sourceId, ifcGuid)`.
|
||||||
|
|
||||||
|
### `ExternalCsvConfiguration`
|
||||||
|
|
||||||
|
- stabile UUID und `projectId` mit genau einer aktiven Konfiguration;
|
||||||
|
- Konfigurationsschema-Version;
|
||||||
|
- CSV-Dialekt: Encoding, BOM, Trennzeichen, Zeilenende, Quote-Zeichen,
|
||||||
|
Dezimalformat und Kopfzeilenerkennung;
|
||||||
|
- exakte Spaltenzuordnung;
|
||||||
|
- geordnete, exakte Familie-und-Typ-Regeln;
|
||||||
|
- monotoner Konfigurationsstand.
|
||||||
|
|
||||||
|
Eine Änderung ersetzt den vollständigen erwarteten Konfigurationssnapshot in
|
||||||
|
einem Command. Jeder bestätigte Importbatch speichert zusätzlich den dabei
|
||||||
|
verwendeten Konfigurationssnapshot. Damit bleiben ältere Importe erklärbar,
|
||||||
|
ohne eine zweite veränderbare Historientabelle einzuführen.
|
||||||
|
|
||||||
|
### `ExternalImportBatch`
|
||||||
|
|
||||||
|
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
|
||||||
|
- Dateiname, SHA-256 und zugehörige Projektrevision;
|
||||||
|
- verwendete monotone Konfigurationsversion und vollständiger
|
||||||
|
Konfigurationssnapshot;
|
||||||
|
- Originaldatei als Bytes;
|
||||||
|
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
|
||||||
|
|
||||||
|
Die Kombination aus Originalbytes und Matrix ist bewusst gewählt: Die Bytes
|
||||||
|
belegen den unveränderten Eingang, die Matrix erlaubt einen gezielten
|
||||||
|
Rückexport einzelner gemappter Objektzellen, ohne unbekannte Spalten,
|
||||||
|
Titelzeilen, Leerzeilen oder Summenzeilen neu zu interpretieren.
|
||||||
|
|
||||||
|
### `ExternalRoomMapping`
|
||||||
|
|
||||||
|
- stabile UUID, `sourceId` und normalisierter Quellraumschlüssel;
|
||||||
|
- Quellgeschoss-, Raumnummer- und Raumname-Snapshot;
|
||||||
|
- optionales `roomId`;
|
||||||
|
- optionale `defaultDistributionBoardId`.
|
||||||
|
|
||||||
|
Das Mapping bleibt Teil der optionalen externen Integration. Dadurch erhält
|
||||||
|
das allgemeine Raummodell keine Revit-spezifische Pflichtbeziehung.
|
||||||
|
|
||||||
|
### `ExternalModelObject`
|
||||||
|
|
||||||
|
- stabile interne UUID, `projectId`, `sourceId` und unveränderte `ifcGuid`;
|
||||||
|
- letzter gesehener und letzter akzeptierter Importbatch;
|
||||||
|
- akzeptierte Quellwerte getrennt als strukturierter Snapshot;
|
||||||
|
- lokale Planungswerte für Anzeigename, Kategorie, Anschlussart, wirksame
|
||||||
|
Menge, Leistung je Einheit, Gleichzeitigkeitsfaktor, `cosPhi`, Kostengruppe
|
||||||
|
und Bemerkung;
|
||||||
|
- explizite Override-Metadaten;
|
||||||
|
- optionales `externalRoomMappingId`;
|
||||||
|
- optionale abweichende `distributionBoardId`;
|
||||||
|
- optionales `linkedProjectDeviceId`;
|
||||||
|
- optionales `circuitDeviceRowId`;
|
||||||
|
- Anwesenheitsstatus für bestätigte fehlende Objekte.
|
||||||
|
|
||||||
|
Die n:1-Beziehung entsteht durch `circuitDeviceRowId` am Einzelobjekt: Viele
|
||||||
|
einzeln nachverfolgbare IFC-Objekte dürfen genau einer Gerätezeile zugeordnet
|
||||||
|
sein. Eine Mehrfachsteckdose bleibt ein einzelnes Objekt und trägt ihre
|
||||||
|
wirksame Menge größer eins.
|
||||||
|
|
||||||
|
Konflikte werden soweit möglich aus akzeptiertem Quellstand, neuem
|
||||||
|
Vorschaustand und lokalen Overrides berechnet. Nur ein vom Nutzer bestätigter
|
||||||
|
Anwesenheits- oder Planungszustand wird persistiert.
|
||||||
|
|
||||||
|
## Mengenmodell der Gerätezeile
|
||||||
|
|
||||||
|
Die heutige `CircuitDeviceRow.quantity` unterscheidet nicht zwischen manueller
|
||||||
|
Menge und verknüpften IFC-Objekten. Ohne Erweiterung könnte das Lösen eines
|
||||||
|
Objekts manuelle Mengen löschen oder doppelt zählen.
|
||||||
|
|
||||||
|
Vor Phase 14.3 wird deshalb `manualQuantity` ergänzt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quantity = manualQuantity
|
||||||
|
+ Summe(effectiveQuantity aller verknüpften ExternalModelObjects)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Bei der Migration erhält jede bestehende Row
|
||||||
|
`manualQuantity = quantity`; ihr Ergebnis bleibt unverändert.
|
||||||
|
- Eine ausschließlich aus externen Objekten erstellte Row startet mit
|
||||||
|
`manualQuantity = 0`.
|
||||||
|
- `quantity` bleibt zunächst der materialisierte Gesamtwert für bestehende
|
||||||
|
Berechnungen, Filter und Exporte und wird im selben Command aktualisiert.
|
||||||
|
- Ein externes Objekt wird nie geteilt und kann höchstens einer Row zugeordnet
|
||||||
|
sein.
|
||||||
|
- Objekte dürfen nur aggregiert werden, wenn die rowbestimmenden lokalen Werte
|
||||||
|
übereinstimmen. Widersprüchliche Werte werden in der ersten Ausbaustufe nicht
|
||||||
|
automatisch vereinheitlicht, sondern benötigen getrennte Rows oder eine
|
||||||
|
ausdrückliche Nutzerentscheidung.
|
||||||
|
- Eine bestehende Row mit manueller Menge darf externe Objekte aufnehmen; beim
|
||||||
|
Lösen bleibt der manuelle Anteil erhalten.
|
||||||
|
|
||||||
|
Für die UI vor Phase 14.3 bleibt noch festzulegen, ob eine nach dem Lösen leere
|
||||||
|
Row stehen bleibt oder nach gesonderter Bestätigung gelöscht wird. Ein stiller
|
||||||
|
Row-Lebenszyklus ist ausgeschlossen.
|
||||||
|
|
||||||
|
## Command-Schnittstellen
|
||||||
|
|
||||||
|
Alle Payloads tragen `schemaVersion: 1`, vollständige erwartete und gewünschte
|
||||||
|
Snapshots und werden mit der aktuellen Projektrevision geplant. Große
|
||||||
|
Importcommands dürfen umfangreich sein; sie müssen für Redo deterministisch
|
||||||
|
bleiben und dürfen spätere Quellstände nicht erneut auswerten.
|
||||||
|
|
||||||
|
- `external-csv-configuration.update`: vollständige projektweite
|
||||||
|
Konfiguration ändern.
|
||||||
|
- `external-import.apply-initial`: Quelle, Batch, Raum-Mappings, externe
|
||||||
|
Objekte und bestätigte Links atomar einfügen.
|
||||||
|
- `external-import.apply-follow-up`: neuen Batch und die vollständigen
|
||||||
|
erwarteten/gewünschten Änderungen bestehender, neuer und fehlender Objekte
|
||||||
|
atomar übernehmen.
|
||||||
|
- `external-model-object.update-planning`: lokale Werte und Overrides eines
|
||||||
|
oder mehrerer Objekte ändern.
|
||||||
|
- `external-model-object.update-project-device-link`: ProjectDevice-Links
|
||||||
|
explizit setzen oder lösen, ohne das ProjectDevice zu verändern.
|
||||||
|
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
|
||||||
|
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
|
||||||
|
und alle betroffenen materialisierten Mengen atomar aktualisieren.
|
||||||
|
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
|
||||||
|
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
|
||||||
|
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
|
||||||
|
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
|
||||||
|
Command gespeichert und bei Redo nicht neu berechnet.
|
||||||
|
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
|
||||||
|
weiterhin unveränderte fehlende Objekte löschen.
|
||||||
|
|
||||||
|
Die Erzeugung eines neuen `ProjectDevice` verwendet weiterhin den vorhandenen
|
||||||
|
`project-device.insert`-Command. Revit-Werte füllen lediglich das sichtbare
|
||||||
|
Formular vor; erst dessen Bestätigung erzeugt die Vorlage. CSV-Vorschau und
|
||||||
|
Rückexport sind reine Leseoperationen. Append-only Exportnachweise verändern
|
||||||
|
keine Projektrevision und laufen über einen eigenen Audit-Store.
|
||||||
|
|
||||||
|
## Snapshot-, Transfer- und Migrationsfolgen
|
||||||
|
|
||||||
|
- Phase 14.1 führt Migration `0002` für die Konfiguration ein und erhöht das
|
||||||
|
Project-State-Snapshot-Schema additiv von 2 auf 3. Der Upgrader von Version 2
|
||||||
|
ergänzt `externalCsvConfiguration = null`.
|
||||||
|
- Phase 14.2 führt Migration `0003` für Quelle, Batches, Raum-Mappings und
|
||||||
|
Objekte ein und erhöht das Snapshot-Schema von 3 auf 4. Der Upgrader ergänzt
|
||||||
|
leere externe Collections.
|
||||||
|
- Phase 14.3 führt die additive Row-Spalte `manual_quantity` ein. Bestehende
|
||||||
|
Werte werden einmalig aus `quantity` übernommen.
|
||||||
|
- Snapshot-Capture und Restore erhalten Konfiguration, Quelle, bestätigte
|
||||||
|
Batches, Raum-Mappings und Objekte in expliziter FK-Reihenfolge.
|
||||||
|
- Beim Projektduplikat werden alle neuen internen UUIDs und internen Links
|
||||||
|
remappt. `IfcGUID`, Quellwerte, Transportmatrix und unbekannte CSV-Inhalte
|
||||||
|
bleiben unverändert.
|
||||||
|
- Der Transfer-Envelope kann Version 1 bleiben; die enthaltene
|
||||||
|
Project-State-Schema-Version wird additiv erhöht.
|
||||||
|
- Vorhandene Migrationen und Snapshot-Upgrader werden nicht editiert.
|
||||||
|
|
||||||
|
## Implementierungsfolge
|
||||||
|
|
||||||
|
### 14.1 – CSV-Konfiguration und reine Vorschau
|
||||||
|
|
||||||
|
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine
|
||||||
|
synthetische Fixtures ergänzen. **Erledigt.**
|
||||||
|
2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
|
||||||
|
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
|
||||||
|
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
|
||||||
|
synthetische Testdaten; die Referenzdatei steht noch aus.**
|
||||||
|
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
|
||||||
|
`external-csv-configuration.update` persistieren. **Erledigt.**
|
||||||
|
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
|
||||||
|
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt sowie das
|
||||||
|
deutsche Konfigurations-/Vorschaumodal. Der Server speichert keinen Entwurf: Bei
|
||||||
|
Bestätigung wird die Datei erneut übertragen, erneut geparst und ihr Hash
|
||||||
|
gegen die Vorschau geprüft.
|
||||||
|
|
||||||
|
**Phase 14.1 ist damit technisch abgeschlossen.** Die fachliche GUI-Abnahme
|
||||||
|
erfolgt vor Beginn der persistenten externen Objekte aus Phase 14.2.
|
||||||
|
|
||||||
|
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
|
||||||
|
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
|
||||||
|
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
|
||||||
|
Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
|
||||||
|
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
|
||||||
|
einen Commit aufgenommen.
|
||||||
|
|
||||||
|
### 14.2 – Persistente Objekte und Erstimport
|
||||||
|
|
||||||
|
1. Schema, Repositories und reine Identitäts-/Matchingregeln ergänzen.
|
||||||
|
**Erledigt:** Migration `0003`, vollständiger externer Read-State sowie
|
||||||
|
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
|
||||||
|
weiterhin keinen Import-Write außerhalb eines Commands.
|
||||||
|
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
|
||||||
|
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
|
||||||
|
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
|
||||||
|
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
|
||||||
|
lassen IFCGUID und Quelltransport jedoch unverändert.
|
||||||
|
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
|
||||||
|
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
|
||||||
|
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
|
||||||
|
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
|
||||||
|
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
|
||||||
|
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
|
||||||
|
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
|
||||||
|
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
|
||||||
|
bleibt unverändert.
|
||||||
|
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
|
||||||
|
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. **Erledigt:**
|
||||||
|
Der zustandsfreie Planungs-Endpunkt liefert gruppierte Quellräume mit
|
||||||
|
exaktem Raumnummernvorschlag, Familie-und-Typ-Gruppen, Objektvorschläge,
|
||||||
|
Warnungszahlen sowie vorhandene Ebenen, Räume, Verteilungen und
|
||||||
|
ProjectDevices. Er blockiert nach einem bereits bestätigten Erstimport und
|
||||||
|
schreibt weder Entwurf noch Projektzustand. Der dedizierte
|
||||||
|
Endpunkt verlangt denselben Datei-Hash und Konfigurationsstand wie die
|
||||||
|
Planung, vollständige Entscheidungen für alle Quellräume und exakten
|
||||||
|
Familie-und-Typ-Gruppen und baut daraus serverseitig stabile UUIDs. Nicht
|
||||||
|
klassifizierte Familien blockieren. Raum-/Standardverteiler- und optionale
|
||||||
|
ProjectDevice-Links werden bestätigt, Stromkreis- und Row-Links bleiben
|
||||||
|
ausgeschlossen. Das breite Projektmodal zeigt Raumtreffer und Warnungen,
|
||||||
|
bietet die vorhandenen Raum-, Verteiler- und ProjectDevice-Kataloge an und
|
||||||
|
verlangt vor der atomaren Übernahme eine ausdrückliche Bestätigung. Fehlende
|
||||||
|
Räume und ProjectDevices lassen sich über die regulären, vorausgefüllten
|
||||||
|
Projektformulare anlegen und werden danach unmittelbar ausgewählt. Diese
|
||||||
|
Kataloganlagen bleiben eigenständige, rückgängig machbare Projektrevisionen.
|
||||||
|
|
||||||
|
Vor Beginn werden die oben vorgeschlagenen Namen, die erneute Dateiübertragung
|
||||||
|
statt serverseitiger Entwürfe, die Konfigurationsversionierung und die
|
||||||
|
Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
|
||||||
|
|
||||||
|
### 14.3 – Editor und explizite Zuordnung
|
||||||
|
|
||||||
|
1. `manualQuantity` und Mengeninvarianten migrieren und testen.
|
||||||
|
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
|
||||||
|
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
|
||||||
|
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
|
||||||
|
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
|
||||||
|
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
|
||||||
|
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
|
||||||
|
**Teilweise erledigt:** `external-object.update-row-assignment` deckt
|
||||||
|
Zuweisung zu vorhandenen Rows, Verschieben zwischen vorhandenen Rows und
|
||||||
|
Lösen ab. Der Command prüft Projekt, Verteilung, Gruppenkategorie, Raum,
|
||||||
|
Selektionskennzeichen und Planungswerte, verlangt für bestätigbare
|
||||||
|
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
|
||||||
|
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
|
||||||
|
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
|
||||||
|
Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
|
||||||
|
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
|
||||||
|
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
|
||||||
|
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
|
||||||
|
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
|
||||||
|
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
|
||||||
|
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
|
||||||
|
Standardschutz und genau einer externen Row wird gemeinsam mit den
|
||||||
|
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
|
||||||
|
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
|
||||||
|
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
|
||||||
|
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
|
||||||
|
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
|
||||||
|
**Begonnen:** Die reine Read-Projektion und der projekt-/listenbezogene
|
||||||
|
GET-Endpunkt liefern ausschließlich Objekte des zugehörigen Verteilers,
|
||||||
|
unzugeordnete Objekte zuerst, einschließlich Quellraum, Selektionsmarker,
|
||||||
|
Planungswerten und aktuellem Row-Link. API-Client und DTOs sind vorhanden;
|
||||||
|
Drawer, Filter und Mengenwirkung folgen.
|
||||||
|
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
|
||||||
|
testen.
|
||||||
|
|
||||||
|
Vor Beginn werden Drawerposition, Verhalten widersprüchlicher Mehrfachauswahl,
|
||||||
|
Warnungsdialoge und der Lebenszyklus leerer Rows festgelegt.
|
||||||
|
|
||||||
|
### 14.4 – Strukturtreuer Rückexport
|
||||||
|
|
||||||
|
1. Exportprojektion gegen eine bekannte Projektrevision ergänzen.
|
||||||
|
2. Nur gemappte Objektzellen in einer Kopie der akzeptierten Matrix ersetzen.
|
||||||
|
3. Dialekt, Reihenfolge, unbekannte Zellen und Passthrough-Zeilen erhalten.
|
||||||
|
4. Download und append-only Exportnachweis ergänzen.
|
||||||
|
|
||||||
|
Zahlenformat, leere Stromkreiswerte und Dateiname werden vorher fachlich
|
||||||
|
festgelegt.
|
||||||
|
|
||||||
|
### 14.5 – Folgeimport und Konflikte
|
||||||
|
|
||||||
|
1. Deterministischen Diff nach `(sourceId, ifcGuid)` ergänzen.
|
||||||
|
2. Neu, geändert, unverändert, fehlend und konfliktbehaftet projizieren.
|
||||||
|
3. Einzel- und Sammelentscheidungen im Wizard ergänzen.
|
||||||
|
4. Akzeptierten Diff als einen atomaren Command mit vollständigem Undo/Redo
|
||||||
|
übernehmen.
|
||||||
|
|
||||||
|
### 14.6 – Härtung und End-to-End-Abnahme
|
||||||
|
|
||||||
|
1. Alle Szenarien A bis G der verbindlichen Spezifikation automatisieren.
|
||||||
|
2. Referenzdatei über Vorschau, Erstimport, Zuordnung, Export, Folgeimport,
|
||||||
|
Snapshot/Restore und Projektduplikat prüfen.
|
||||||
|
3. Abfrageindizes und große Payloads mit mehreren tausend Objekten messen.
|
||||||
|
4. Architektur-, API-, Betriebs- und Nutzerdokumentation auf den tatsächlich
|
||||||
|
ausgelieferten Stand nachziehen.
|
||||||
|
|
||||||
|
## Risiken und Schutzmaßnahmen
|
||||||
|
|
||||||
|
- **Mengenverlust:** `manualQuantity` trennt manuelle und externe Anteile;
|
||||||
|
Commands ändern Links und materialisierte Menge gemeinsam.
|
||||||
|
- **Stilles Überschreiben:** Quellwerte, lokale Werte und Overrides sind
|
||||||
|
getrennt; jede Übernahme benötigt eine Nutzerentscheidung.
|
||||||
|
- **ProjectDevice-Verfälschung:** Links ändern niemals die kanonische Vorlage;
|
||||||
|
deren bestehende Commands bleiben alleinige Schreibgrenze.
|
||||||
|
- **Nicht reproduzierbarer Export:** Jeder Batch hält Originalbytes, Matrix,
|
||||||
|
Dialekt, Konfiguration, Hash und Projektrevision.
|
||||||
|
- **Sehr große Undo-Payloads:** Importcommands speichern vollständige
|
||||||
|
erwartete/Zielzustände. Größe und Transaktionsdauer werden in 14.2 gemessen,
|
||||||
|
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
|
||||||
|
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
|
||||||
|
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
|
||||||
|
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
|
||||||
|
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
|
||||||
|
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
|
||||||
|
|
||||||
|
## Freigabepunkt
|
||||||
|
|
||||||
|
Mit der Freigabe dieses Audits beginnt Arbeitspaket 14.1.1: reine
|
||||||
|
Transportverträge, Validatoren und synthetische CSV-Fixtures. Dieses Paket
|
||||||
|
ändert weder Datenbank noch GUI und führt keine Projektschreibvorgänge aus.
|
||||||
|
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
# Revit-Integration über Linear-Excel – verbindliche Anforderungen
|
|
||||||
|
|
||||||
Stand: 29.09.2026. Dieses Dokument ist die fachliche und technische
|
|
||||||
Arbeitsgrundlage für die neue Phase 14. Es ersetzt die frühere CSV-/IfcGUID-
|
|
||||||
Spezifikation. Der Umsetzungsstand steht im
|
|
||||||
[R0–R10-Plan](revit-excel-phase-14-plan.md); der geprüfte Ausgangszustand im
|
|
||||||
[R0-Audit](revit-excel-r0-audit.md).
|
|
||||||
|
|
||||||
## 1. Ziel und Grenzen
|
|
||||||
|
|
||||||
Der Austausch mit Revit erfolgt ausschließlich über den XLSX-Export/-Import
|
|
||||||
des Linear-Plugins. Eine vollständige Revit-Bauteilliste wird importiert, in
|
|
||||||
Leistungsbilanz geplant und mit gleichem Tabellenaufbau an Linear
|
|
||||||
zurückgegeben. Linear ordnet enthaltene Zeilen anhand der Worksharing-ID zu.
|
|
||||||
|
|
||||||
Nicht Teil dieser Phase sind Dynamo, Revit-API, JSON-Transport, automatische
|
|
||||||
Stromkreisbildung, Übernahme vorhandener Revit-Stromkreiswerte,
|
|
||||||
Teilimporte, mehrere Modellquellen, PostgreSQL und neue Rollen/Rechte.
|
|
||||||
|
|
||||||
Die Integration bleibt optional. Manuelle Projekte, Räume, ProjectDevices,
|
|
||||||
Stromkreise und Gerätezeilen funktionieren ohne externes Modell. Der
|
|
||||||
Circuit-First-Aufbau, stabile interne UUIDs und BMKs, ausdrückliche
|
|
||||||
Neunummerierung sowie die gemeinsame Project-Command-Transaktionsgrenze
|
|
||||||
bleiben unverändert.
|
|
||||||
|
|
||||||
## 2. Verbindliche fachliche Regeln
|
|
||||||
|
|
||||||
- Import erzeugt keine `CircuitDeviceRow`, keinen `Circuit` und kein BMK.
|
|
||||||
- Quellwerte, lokale Planungswerte und Overrides bleiben getrennt.
|
|
||||||
- Ein Folgeimport überschreibt keine lokale Planung still.
|
|
||||||
- Fehlende Objekte werden markiert und erst nach ausdrücklicher Bestätigung
|
|
||||||
entfernt.
|
|
||||||
- Mehrfachsteckdosen sind unteilbare externe Objekte. Ihre wirksame Menge kann
|
|
||||||
größer als eins sein.
|
|
||||||
- Die Row-Menge ist
|
|
||||||
`manualQuantity + Summe(effectiveQuantity der verknüpften Objekte)`.
|
|
||||||
- Nach einer Row-Zuordnung ist die `CircuitDeviceRow` die Planungsebene für
|
|
||||||
alle verknüpften Objekte.
|
|
||||||
- Bestehende ProjectDevices bleiben kanonische Vorlagen und werden durch
|
|
||||||
Importe nie verändert.
|
|
||||||
- Sortieren, Filtern, Importieren, Zuordnen und Verschieben nummeriert keine
|
|
||||||
Stromkreise automatisch neu.
|
|
||||||
|
|
||||||
## 3. Linear-XLSX-Profil
|
|
||||||
|
|
||||||
Die lokale Referenzdatei `docs/spec/kbp-Stromkreis.xlsx` enthält vertrauliche
|
|
||||||
Projektdaten. Sie wird nicht committed und nicht in Tests eingebettet. Tests
|
|
||||||
erzeugen synthetische Workbooks. Der lokale Referenzprüfer erhält den Pfad als
|
|
||||||
Argument.
|
|
||||||
|
|
||||||
Beobachtetes Profil:
|
|
||||||
|
|
||||||
- genau ein Arbeitsblatt; Blattname nach Excel-Regel maximal 31 Zeichen;
|
|
||||||
- Kopfzeile in Zeile 1, Zeile 2 leer, Daten ab Zeile 3, erste Datenspalte A;
|
|
||||||
- Spalte A `Worksharing-ID` im Format `<GUID>-<8 Hex>`;
|
|
||||||
- Parameterbindungen stehen als klassische Excel-Kommentare in den übrigen
|
|
||||||
Kopfzellen;
|
|
||||||
- Zahlen sind typisierte Excel-Zahlen; importierte Werte bleiben unverändert,
|
|
||||||
fachlich verwendete Leistung wird auf 0,01 W projiziert;
|
|
||||||
- Objektzeilen dürfen keinen MEP-Raum besitzen;
|
|
||||||
- eine Schluss-/Summenzeile ohne ID kann vorkommen und wird weder importiert
|
|
||||||
noch zurückgegeben.
|
|
||||||
|
|
||||||
Die Referenzdatei enthält 487 Objektzeilen, eindeutige Worksharing-IDs und
|
|
||||||
keine doppelten nichtleeren IfcGUIDs bei exaktem, case-sensitivem Vergleich.
|
|
||||||
Ihre Summenzeile steht in Spalte B (`CAx_Auswahlkenner`), während
|
|
||||||
`Familie und Typ` leer ist.
|
|
||||||
|
|
||||||
### Zeilen ohne Worksharing-ID
|
|
||||||
|
|
||||||
Nur eine Zeile ohne Worksharing-ID, aber mit befülltem Feld
|
|
||||||
`Familie und Typ`, gilt als verdächtige Objektzeile. Sie erzeugt einen Hinweis
|
|
||||||
und wird übersprungen; sie blockiert den Import nicht. Andere Zeilen ohne ID,
|
|
||||||
einschließlich einer nur in Spalte B befüllten Summenzeile, werden ohne Hinweis
|
|
||||||
übersprungen. Eine Objektzeile ist für den Import eine Zeile mit nichtleerer
|
|
||||||
Worksharing-ID.
|
|
||||||
|
|
||||||
Worksharing-ID und IfcGUID werden stets exakt, ordinal und mit
|
|
||||||
Groß-/Kleinschreibung verglichen. Es findet kein Trimmen, Normalisieren oder
|
|
||||||
Case-Folding für die Identität statt. Eine nichtleere Worksharing-ID mit
|
|
||||||
unerlaubten äußeren Leerzeichen oder falschem Format blockiert. Doppelte
|
|
||||||
Worksharing-IDs blockieren. IfcGUID ist optional, kein Schlüssel und darf
|
|
||||||
doppelt vorkommen; Duplikate erzeugen lediglich einen Hinweis.
|
|
||||||
|
|
||||||
## 4. Upload und Transportadapter
|
|
||||||
|
|
||||||
Der Server akzeptiert ausschließlich Dateien mit Endung `.xlsx` bis 10 MiB.
|
|
||||||
`.xls` und `.xlsm` werden abgelehnt. Parsing findet serverseitig statt. Nach
|
|
||||||
dem Einlesen gelten feste Zeilen-, Spalten- und Zellgrenzen, die in R2 anhand
|
|
||||||
synthetischer Grenztests festgelegt und zentral definiert werden. Es gibt in
|
|
||||||
dieser Phase keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
|
|
||||||
|
|
||||||
Der Adapter unter `src/external-model/xlsx` liest eine transportneutrale
|
|
||||||
Zellmatrix. Eine Zelle ist `string`, `number`, `boolean` oder `empty`.
|
|
||||||
Zusätzlich werden Blattname, Originalzeilennummer, Kopfzeilenkommentare,
|
|
||||||
Spaltenbreiten und die für Hinweise nötigen Formelmetadaten erfasst.
|
|
||||||
Domain-Services kennen weder ExcelJS noch XLSX, React oder SQLite.
|
|
||||||
|
|
||||||
Formeln werden niemals ausgeführt. Ist ein gecachter skalarer Wert vorhanden,
|
|
||||||
wird er verwendet und als Hinweis ausgewiesen. Fehlt er oder ist er ein
|
|
||||||
Fehlerwert, bleibt das Feld leer; bei der Worksharing-ID oder einem anderen
|
|
||||||
Pflichtfeld blockiert dies die betreffende Objektzeile beziehungsweise den
|
|
||||||
Import.
|
|
||||||
|
|
||||||
Genau eine gepflegte Bibliothek wird ergänzt. Vorgesehen ist `exceljs`, sofern
|
|
||||||
R2 die Node-24-Verträglichkeit und den Roundtrip von Kopfkommentaren,
|
|
||||||
Blattname, Spaltenbreiten, Formaten und Zelltypen bestätigt.
|
|
||||||
|
|
||||||
## 5. Projektkonfiguration
|
|
||||||
|
|
||||||
Die Konfiguration ist projektweit, revisionssicher und besitzt keine eigene
|
|
||||||
Versionszählung. Jeder Plan und jedes Apply tragen `expectedRevision`.
|
|
||||||
Da die Projektrevision auch bei Undo/Redo strikt steigt, wird ein alter Plan
|
|
||||||
automatisch ungültig. Jeder Importbatch speichert eine vollständige Kopie der
|
|
||||||
beim Import verwendeten Konfiguration.
|
|
||||||
|
|
||||||
Das Einstellungsmodal auf der Revit-Seite erkennt aus einer zustandslos
|
|
||||||
hochgeladenen Musterdatei Kopfzeile, erste Datenzeile und erste Datenspalte.
|
|
||||||
Diese Werte bleiben editierbar; Standard ist 1 / 3 / A. Es gibt nur ein Feld
|
|
||||||
für die Identitätsspalte: `columns.worksharingId`. Ein separates
|
|
||||||
`layout.idColumn` existiert nicht.
|
|
||||||
|
|
||||||
Zielvertrag:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ExternalModelConfiguration {
|
|
||||||
schemaVersion: 1;
|
|
||||||
layout: {
|
|
||||||
headerRow: number;
|
|
||||||
firstDataRow: number;
|
|
||||||
firstDataColumn: number;
|
|
||||||
};
|
|
||||||
circuitMode: "combined" | "separate";
|
|
||||||
columns: {
|
|
||||||
worksharingId: string;
|
|
||||||
ifcGuid: string | null;
|
|
||||||
roomNumber: string | null;
|
|
||||||
roomName: string | null;
|
|
||||||
familyAndType: string;
|
|
||||||
selectionMarker: string | null;
|
|
||||||
socketCount: string | null;
|
|
||||||
sourceRemark: string | null;
|
|
||||||
circuitCombined: string | null;
|
|
||||||
boardName: string | null;
|
|
||||||
circuitIdentifier: string | null;
|
|
||||||
designation: string | null;
|
|
||||||
electricalPowerW: string | null;
|
|
||||||
voltageV: string | null;
|
|
||||||
currentA: string | null;
|
|
||||||
};
|
|
||||||
deviceTypes: DeviceTypeConfiguration[];
|
|
||||||
familyTypeRules: FamilyTypeRule[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Standardzuordnung:
|
|
||||||
|
|
||||||
| Schlüssel | Bedeutung | Standardspalte |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `worksharingId` | externe Identität, Pflicht | `Worksharing-ID` |
|
|
||||||
| `ifcGuid` | optionale Redundanz | `IfcGUID` |
|
|
||||||
| `roomNumber` | Raumnummer | `MEP-Raum: Nummer` |
|
|
||||||
| `roomName` | Raumname | `MEP-Raum: Name` |
|
|
||||||
| `familyAndType` | Familie und Typ, Pflicht | `Familie und Typ` |
|
|
||||||
| `selectionMarker` | CAx-Auswahlkenner | `CAx_Auswahlkenner` |
|
|
||||||
| `socketCount` | Anzahl Einzelsteckdosen | `CAX_AnzahlObjekte` |
|
|
||||||
| `sourceRemark` | Anmerkung | `CAx_Anmerkung` |
|
|
||||||
| `circuitCombined` | Stromkreis gesamt | `kbp_Stromkreisnummer` |
|
|
||||||
| `boardName` | Verteiler | `kbp-E-Verteiler` |
|
|
||||||
| `circuitIdentifier` | Stromkreis-BMK | `kbp-E-Stromkreis` |
|
|
||||||
| `designation` | Bezeichnung | `kbp-E-Bezeichnung` |
|
|
||||||
| `electricalPowerW` | elektrische Leistung [W] | `kbp-E-Elektrische Leistung` |
|
|
||||||
| `voltageV` | Spannung [V] | `kbp-E-Spannung` |
|
|
||||||
| `currentA` | Stromstärke [A] | `kbp-E-Stromstärke` |
|
|
||||||
|
|
||||||
`worksharingId` und `familyAndType` sind Pflichtmappings. Im Modus
|
|
||||||
`combined` ist `circuitCombined` für den Rückexport Pflicht; im Modus
|
|
||||||
`separate` sind `boardName` und `circuitIdentifier` Pflicht. Fehlende optionale
|
|
||||||
Rückgabespalten `designation`, `electricalPowerW`, `voltageV` und `currentA`
|
|
||||||
werden mit Hinweis übersprungen und blockieren den Export nicht.
|
|
||||||
|
|
||||||
Jedes interne Feld besitzt im Modal ein Dropdown aus den Kopfzellen und eine
|
|
||||||
freie Eingabe. Exakte kbp-Standardnamen werden vorbelegt. Typ und Einheit aus
|
|
||||||
dem Linear-Kommentar werden angezeigt. Nicht gemappte Spalten bleiben beim
|
|
||||||
Rückexport unverändert.
|
|
||||||
|
|
||||||
## 6. Gerätearten und Familie-und-Typ-Regeln
|
|
||||||
|
|
||||||
Eine Geräteart besitzt stabile interne UUID, Name, Kategorie
|
|
||||||
`lighting | single_phase | three_phase` sowie optionale Standardwerte für
|
|
||||||
Leistung je Einheit in W, GZF, cosPhi und Kostengruppe. Jede exakte
|
|
||||||
`Familie und Typ`-Regel verweist auf genau eine Geräteart und verwendet die
|
|
||||||
Mengenregel `1` oder `socketCount`.
|
|
||||||
|
|
||||||
Mehrere Revit-Familien dürfen auf dieselbe Geräteart zeigen. Jedes Revit-
|
|
||||||
Objekt bleibt trotzdem einzeln über seine Worksharing-ID nachverfolgbar.
|
|
||||||
Unbekannte Familien müssen im Importwizard zugeordnet oder über eine neue
|
|
||||||
Geräteart klassifiziert werden; andernfalls blockiert Apply.
|
|
||||||
|
|
||||||
Planungsstartwerte folgen dieser Reihenfolge:
|
|
||||||
|
|
||||||
1. ausdrücklich verknüpftes ProjectDevice;
|
|
||||||
2. Geräteart-Standard;
|
|
||||||
3. leer.
|
|
||||||
|
|
||||||
Der Anzeigename wird aus CAx-Auswahlkenner, dann Geräteartname, dann
|
|
||||||
`Familie und Typ` vorgeschlagen. Ein fehlender GZF wird mit `1` vorbelegt,
|
|
||||||
ohne zusätzliche Bestätigung. Standardwerte sind Startwerte, keine dauerhafte
|
|
||||||
Synchronisierung.
|
|
||||||
|
|
||||||
## 7. Persistenter externer Zustand
|
|
||||||
|
|
||||||
Pro Projekt existiert genau eine implizite Revit-XLSX-Quelle. Ein externes
|
|
||||||
Objekt besitzt eine interne UUID und ist je `(sourceId, worksharingId)`
|
|
||||||
eindeutig. `worksharingId` ist NOT NULL; `ifcGuid` ist nullable und nicht
|
|
||||||
eindeutig. Interne Relationen verwenden ausschließlich interne UUIDs.
|
|
||||||
|
|
||||||
Gespeichert werden:
|
|
||||||
|
|
||||||
- akzeptierte Quellwerte einschließlich aller Rückgabefelder;
|
|
||||||
- lokale Planungswerte und Override-Felder;
|
|
||||||
- Geräteart;
|
|
||||||
- optionales Raum-Mapping, abweichender Verteiler, ProjectDevice und
|
|
||||||
`CircuitDeviceRow`;
|
|
||||||
- Anwesenheitsstatus und Verweis auf den zuletzt relevanten Import;
|
|
||||||
- effektive Menge als fachlicher Wert des unteilbaren Objekts.
|
|
||||||
|
|
||||||
Der effektive Verteiler eines verknüpften Objekts ist immer der Verteiler
|
|
||||||
seiner Row. Für ein unzugeordnetes Objekt gilt zuerst die explizite
|
|
||||||
Objektabweichung, danach der Standardverteiler seines Raum-Mappings. Ändert
|
|
||||||
sich der Standardverteiler eines Raums, betrifft das nur unzugeordnete
|
|
||||||
Objekte; verknüpfte Objekte bleiben über ihre Row unverändert und die Änderung
|
|
||||||
wird deshalb nicht gesperrt.
|
|
||||||
|
|
||||||
## 8. Importbatch-Aufbewahrung, Snapshot und Transfer
|
|
||||||
|
|
||||||
Der aktive Projektzustand enthält den letzten Importbatch vollständig:
|
|
||||||
Originalbytes, klassifizierte Matrix, Dateiname, SHA-256, Zeitpunkt,
|
|
||||||
Objektzahl, Importart, Projektrevision und Konfigurationskopie. Ältere Batches
|
|
||||||
werden zu Metadaten reduziert: Datum, Dateiname, SHA-256 und Objektzahl.
|
|
||||||
Sie enthalten keine Originalbytes, Matrix oder Konfigurationskopie mehr.
|
|
||||||
|
|
||||||
Snapshot-Schema 10 und portabler Projekttransfer übernehmen genau diese
|
|
||||||
Aufteilung. Beim Duplizieren werden alle internen UUIDs und Links remapped;
|
|
||||||
Worksharing-IDs, optionale IfcGUIDs, Quellwerte und das letzte Workbook bleiben
|
|
||||||
unverändert.
|
|
||||||
|
|
||||||
Versionen 1–9 ohne externe CSV-Daten werden verlustfrei auf Version 10
|
|
||||||
hochgestuft. Enthält ein alter Stand eine CSV-Konfiguration oder einen nicht
|
|
||||||
leeren CSV-Zustand, wird er mit klarer deutscher Fehlermeldung abgelehnt.
|
|
||||||
Alte CSV-History-Commands werden ebenfalls vor jedem Write abgelehnt und nie
|
|
||||||
stillschweigend übersprungen.
|
|
||||||
|
|
||||||
## 9. Erstimport
|
|
||||||
|
|
||||||
Der Ablauf ist Upload → zustandslose Vorschau/Planung → erneute
|
|
||||||
Dateiübertragung bei Apply. Apply prüft SHA-256, `expectedRevision`,
|
|
||||||
Konfigurationskopie, Matrix und sämtliche Entscheidungen erneut. Nur Apply
|
|
||||||
liest die Excel-Datei erneut ein.
|
|
||||||
|
|
||||||
Undo und Redo lesen keine Excel-Datei und parsen keine gespeicherten Bytes.
|
|
||||||
Sie arbeiten ausschließlich auf den im Command gespeicherten vollständigen
|
|
||||||
Vorher-/Nachher-Zuständen. Dadurch bleiben sie nach Neustart deterministisch
|
|
||||||
und unabhängig vom Parser.
|
|
||||||
|
|
||||||
Der Wizard umfasst:
|
|
||||||
|
|
||||||
1. Vorschau mit Objektzahl, übersprungenen und verdächtigen Zeilen,
|
|
||||||
doppelten Worksharing-IDs, doppelten IfcGUID-Hinweisen, unbekannten
|
|
||||||
Familien, nicht lesbaren Zahlen und Formeln;
|
|
||||||
2. Räume anlegen oder vorhandenen Räumen/Ebenen zuordnen;
|
|
||||||
3. Standardverteiler je Raum, Sammelzuordnung, Objektabweichungen und Objekte
|
|
||||||
ohne Raum;
|
|
||||||
4. Familien exakt Gerätearten und einer Mengenregel zuordnen;
|
|
||||||
5. optional vorhandene oder neu angelegte ProjectDevices verknüpfen;
|
|
||||||
6. Zusammenfassung und ausdrückliche Bestätigung.
|
|
||||||
|
|
||||||
Die Übernahme ist ein atomarer Command und ein Undo-Schritt. Im Import
|
|
||||||
entstehen keine Rows. Neu angelegte Räume und ProjectDevices verwenden ihre
|
|
||||||
vorhandenen Commands und bleiben eigene Undo-Schritte.
|
|
||||||
|
|
||||||
## 10. Objektplanung und Revit-Seite
|
|
||||||
|
|
||||||
Route: `/projects/:projectId/revit`, erreichbar aus der Projektnavigation.
|
|
||||||
Der Revit-Button und das CSV-Modal auf der Projektübersicht entfallen.
|
|
||||||
|
|
||||||
Die Seite besitzt:
|
|
||||||
|
|
||||||
- Statuskarte mit letztem Import, Objektzahl, ohne Verteiler, ohne
|
|
||||||
Stromkreis, fehlend und Abweichung zu Revit;
|
|
||||||
- Aktionen `Import`, `Rückgabe-Excel`, `Einstellungen`;
|
|
||||||
- einklappbare Geräteart-/Familienregel-Übersicht;
|
|
||||||
- kompakte Objekttabelle mit Suche, Spaltenfiltern, Sortierung,
|
|
||||||
Mehrfachauswahl und Sammelzuordnung für Verteiler, ProjectDevice und
|
|
||||||
Planungswerte.
|
|
||||||
|
|
||||||
Die kompakte Liste wird einmal geladen, vollständig im Client gefiltert und
|
|
||||||
sortiert und virtualisiert dargestellt. Es gibt keine Server-Pagination.
|
|
||||||
Originalbytes, Matrix und Command-Payloads gehören nicht in dieses DTO.
|
|
||||||
|
|
||||||
Vor Row-Zuordnung lassen sich Leistung je Einheit, GZF, cosPhi,
|
|
||||||
Kostengruppe, Anzeigename und Bemerkung einzeln oder gesammelt ändern.
|
|
||||||
Geänderte Felder werden Overrides. Nach Row-Zuordnung werden diese Werte im
|
|
||||||
Grid an der Row bearbeitet. Beim ausdrücklichen Lösen erhält das Objekt die
|
|
||||||
aktuellen Row-Werte als Planungswerte und entsprechende Overrides.
|
|
||||||
|
|
||||||
## 11. Editor-Zuordnung und Löschen
|
|
||||||
|
|
||||||
Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie
|
|
||||||
Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum,
|
|
||||||
Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler
|
|
||||||
ProjectDevice-Link. Die Anzeige nennt Summe der wirksamen Mengen und Zahl der
|
|
||||||
externen Objekte. Gruppen sind aufklappbar; Einzel- und Mehrfachauswahl sowie
|
|
||||||
Filter bleiben möglich.
|
|
||||||
|
|
||||||
Drag-and-drop:
|
|
||||||
|
|
||||||
- auf vorhandene Row: genau diese Row;
|
|
||||||
- auf Circuit: neue Row mit `manualQuantity = 0`;
|
|
||||||
- auf freien Bereich: neuer Circuit, Schutz und Row in einem Command.
|
|
||||||
|
|
||||||
Neue IDs und BMK werden einmal geplant; Redo rechnet sie nicht neu. Neue Rows
|
|
||||||
übernehmen den bestätigten Gruppenzustand und die Summe der effektiven Mengen.
|
|
||||||
An Rows zeigt ein Hinweis die Zahl verknüpfter Revit-Objekte; einzelne Objekte
|
|
||||||
können ausdrücklich gelöst werden.
|
|
||||||
|
|
||||||
Wird eine Row, ein Circuit oder eine Gruppe mit verknüpften Revit-Objekten
|
|
||||||
gelöscht, zeigt die UI vor Bestätigung:
|
|
||||||
`X Revit-Objekte werden wieder unzugeordnet`. Derselbe Löschcommand löst die
|
|
||||||
Objekte, entfernt die Struktur und speichert alle Links in seiner Inversen.
|
|
||||||
Undo stellt Struktur und Links exakt wieder her. Diese Löschungen werden nicht
|
|
||||||
gesperrt.
|
|
||||||
|
|
||||||
Das Löschen eines Verteilers oder Raums mit referenzierten externen Objekten
|
|
||||||
bleibt gesperrt. Der Nutzer muss die Verknüpfungen beziehungsweise Zuordnungen
|
|
||||||
zuvor ausdrücklich ändern. Die Regel gilt auch für Unterbaum- und History-
|
|
||||||
Pfade.
|
|
||||||
|
|
||||||
## 12. Rückgabe-Excel
|
|
||||||
|
|
||||||
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
|
|
||||||
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
|
|
||||||
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash
|
|
||||||
und Objektzahl.
|
|
||||||
|
|
||||||
Umfang:
|
|
||||||
|
|
||||||
- alle im letzten Import vorhandenen Objekte (Standard);
|
|
||||||
- nur Objekte mit Abweichung zum zuletzt importierten Revit-Wert;
|
|
||||||
- Auswahl aus der Objekttabelle.
|
|
||||||
|
|
||||||
Die Rückgabe basiert auf einer Kopie des zuletzt importierten Workbooks.
|
|
||||||
Zeilen oberhalb der ersten Datenzeile, Kopfkommentare, Formate,
|
|
||||||
Spaltenbreiten, Blattname und Spaltenreihenfolge bleiben erhalten. Danach
|
|
||||||
folgen nur die gewählten Objektzeilen in ihrer Originalreihenfolge. Alle
|
|
||||||
Spalten bleiben enthalten; Summenzeilen entfallen.
|
|
||||||
|
|
||||||
Stromkreiswerte:
|
|
||||||
|
|
||||||
- `combined`: `DistributionBoard.name + Circuit.equipmentIdentifier`, ohne
|
|
||||||
Trennzeichen;
|
|
||||||
- `separate`: Verteilername und unverändertes BMK in getrennten Spalten;
|
|
||||||
- ohne Circuit: Stromkreisfeld beziehungsweise beide Felder leer.
|
|
||||||
|
|
||||||
Die Wahl des Verteilerwerts liegt in genau einer Funktion und verwendet
|
|
||||||
zunächst `DistributionBoard.name`.
|
|
||||||
|
|
||||||
Weitere Rückgabewerte:
|
|
||||||
|
|
||||||
- Bezeichnung = Row-`displayName`, ohne Row Planungs-Anzeigename;
|
|
||||||
- Leistung [W] = effektive Menge × Einzelleistung, ohne GZF;
|
|
||||||
- Spannung = Circuit-Spannung, sonst Projektstandard der Kategorie;
|
|
||||||
- Strom = `P / (U × cosPhi)` beziehungsweise
|
|
||||||
`P / (sqrt(3) × U × cosPhi)`, auf 0,01 A;
|
|
||||||
- nicht berechenbar = leere Zelle plus Hinweis.
|
|
||||||
|
|
||||||
Zahlen werden als Excel-Zahlen geschrieben. Fehlende optionale Zielspalten
|
|
||||||
werden mit Hinweis ausgelassen. Nur fehlende Stromkreisspalten des gewählten
|
|
||||||
Modus blockieren. Dateiname:
|
|
||||||
`<Projektname>_Revit-Rueckgabe_<YYYY-MM-DD>.xlsx`.
|
|
||||||
|
|
||||||
## 13. Verbindliche R2-Abnahme mit Linear
|
|
||||||
|
|
||||||
R2 endet mit einer Rückgabedatei, die aus der lokalen realen Musterdatei
|
|
||||||
erzeugt wurde und einige klar identifizierbare Rückgabewerte verändert. Diese
|
|
||||||
Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach
|
|
||||||
erfolgreichem Linear-Rückimport beginnen.
|
|
||||||
|
|
||||||
Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear
|
|
||||||
eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben.
|
|
||||||
|
|
||||||
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
|
|
||||||
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
|
|
||||||
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
|
|
||||||
Alternative bleibt hinter demselben transportneutralen Adaptervertrag.
|
|
||||||
|
|
||||||
## 14. Folgeimport
|
|
||||||
|
|
||||||
Jeder Import ist eine vollständige Projektbauteilliste. Matching erfolgt
|
|
||||||
ausschließlich über exakt und case-sensitiv verglichene Worksharing-ID.
|
|
||||||
|
|
||||||
- neu: Wizard-Schritte nur für neue Objekte und unbekannte Familien;
|
|
||||||
- geänderte Quellwerte: Einzel- oder Sammelentscheidung;
|
|
||||||
- akzeptierte Mengenänderung: verknüpfte Row-Menge im selben Command;
|
|
||||||
- fehlend: markieren, Link und Menge zunächst beibehalten;
|
|
||||||
- Löschen: erst nach gesonderter Bestätigung;
|
|
||||||
- abweichende Rückgabefelder: Hinweis `in Revit noch nicht aktualisiert`,
|
|
||||||
keine Übernahmeentscheidung.
|
|
||||||
|
|
||||||
`external-import.apply-follow-up` ist atomar und ein Undo-Schritt.
|
|
||||||
Unveränderte Objekte erzeugen keine unnötigen Objekt- oder Row-Änderungen.
|
|
||||||
Der neue vollständige Batch wird letzter Batch; der zuvor letzte Batch wird
|
|
||||||
auf Metadaten reduziert.
|
|
||||||
|
|
||||||
## 15. Commands, Persistenz und Sicherheit
|
|
||||||
|
|
||||||
Projektbezogene Writes verwenden typisierte Commands mit vollständiger
|
|
||||||
Gegenrichtung, `expectedRevision`, Anmeldung, CSRF-Header und
|
|
||||||
`project-command-transaction.persistence.ts`. Dazu gehören mindestens:
|
|
||||||
|
|
||||||
- `external-model-configuration.update`;
|
|
||||||
- `external-import.apply-initial`;
|
|
||||||
- `external-import.apply-follow-up`;
|
|
||||||
- `external-model-object.update-planning`;
|
|
||||||
- `external-model-object.update-distribution-board`;
|
|
||||||
- `external-model-room-mapping.update`;
|
|
||||||
- `external-model-object.update-project-device-link`;
|
|
||||||
- `external-object.update-row-assignment`;
|
|
||||||
- `external-object.assign-to-new-row`;
|
|
||||||
- `external-object.assign-to-new-circuit`;
|
|
||||||
- `external-model-object.delete-missing`;
|
|
||||||
- die erweiterten Row-/Circuit-/Gruppen-Löschcommands mit Objekt-Link-
|
|
||||||
Transitionen.
|
|
||||||
|
|
||||||
Die Konfiguration selbst ist Teil der erwarteten/gewünschten Snapshots, aber
|
|
||||||
hat keinen eigenen monotonen Zähler. Importbatches halten eine unveränderliche
|
|
||||||
Konfigurationskopie. Preview/Plan sind zustandslos. Apply überträgt und parst
|
|
||||||
die Datei erneut; Undo/Redo führt nur gespeicherte Zustandsübergänge aus.
|
|
||||||
|
|
||||||
Neue Migrationen sind additiv. Vor Anwendung wird die Datenbank gesichert und
|
|
||||||
das generierte SQL geprüft. Da nur Entwicklungsdaten existieren, darf die
|
|
||||||
Migration alte externe CSV-Daten verwerfen. Vor dem Löschen externer Objekte
|
|
||||||
werden betroffene Row-Mengen auf `manualQuantity` zurückgesetzt. Der Verlust
|
|
||||||
des alten externen Entwicklungszustands wird dokumentiert.
|
|
||||||
|
|
||||||
## 16. Abnahme und Leistung
|
|
||||||
|
|
||||||
Die Oberfläche muss mehrere Tausend Objekte flüssig bedienen. R10 misst
|
|
||||||
Import, Command-/Snapshot-Größe, Speicherbedarf und Client-Interaktion mit
|
|
||||||
synthetischen großen Workbooks. Die virtualisierte Tabelle rendert nur den
|
|
||||||
sichtbaren Ausschnitt, während Filterung, Sortierung und Auswahl auf der einmal
|
|
||||||
geladenen kompakten Liste arbeiten.
|
|
||||||
|
|
||||||
Jedes Paket R1–R10 führt fokussierte Tests sowie aus:
|
|
||||||
|
|
||||||
```text
|
|
||||||
npm test
|
|
||||||
npm run build:api
|
|
||||||
npm run build:web
|
|
||||||
npm run typecheck:scripts
|
|
||||||
npx tsc --noEmit -p tsconfig.next.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Jedes Paket erhält einen eigenen knappen imperativen Commit. Die konkrete
|
|
||||||
Paketfolge und ihre Freigabepunkte stehen im
|
|
||||||
[R0–R10-Plan](revit-excel-phase-14-plan.md).
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
# Phase 14 neu – Plan für die Linear-Excel-Anbindung
|
|
||||||
|
|
||||||
Verbindliche Fachgrundlage:
|
|
||||||
[Revit-Integration über Linear-Excel](revit-excel-integration-requirements.md).
|
|
||||||
Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Paket | Status | Ergebnis / Freigabepunkt |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
|
|
||||||
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
|
|
||||||
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
|
|
||||||
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
|
|
||||||
| R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. |
|
|
||||||
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
|
|
||||||
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
|
|
||||||
| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. |
|
|
||||||
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
|
|
||||||
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
|
|
||||||
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
|
|
||||||
|
|
||||||
## R0 – Audit
|
|
||||||
|
|
||||||
Ergebnis: [R0-Audit](revit-excel-r0-audit.md). Die Freigabe vom 29.09.2026
|
|
||||||
enthält 13 Korrekturen. Bei Abweichungen zwischen dem historischen Audit und
|
|
||||||
der aktuellen Anforderung gilt das neue Anforderungsdokument.
|
|
||||||
|
|
||||||
## R1 – Dokumentation
|
|
||||||
|
|
||||||
Lieferumfang:
|
|
||||||
|
|
||||||
- neues verbindliches Excel-Anforderungsdokument;
|
|
||||||
- dieser R0–R10-Plan;
|
|
||||||
- alte CSV-Anforderungen und alter Plan als kurze Ablösungshinweise;
|
|
||||||
- LLM-Kontext auf die neue Quelle der Wahrheit kürzen;
|
|
||||||
- `AGENTS.md`, aktuelle Architektur, History-/External-Model-Architektur,
|
|
||||||
Roadmap, README, Dokumentationsindex und bekannte Einschränkungen anpassen;
|
|
||||||
- reale Musterdatei per `.gitignore` vom Commit ausschließen;
|
|
||||||
- alle internen Links auf die neuen Dokumente umstellen.
|
|
||||||
|
|
||||||
Keine Laufzeit-, Schema- oder UI-Änderung. Abnahme: keine aktive Dokumentation
|
|
||||||
nennt CSV/IfcGUID als künftigen Transport beziehungsweise Schlüssel; der
|
|
||||||
aktuell noch vorhandene Legacy-Code bleibt als solcher klar benannt.
|
|
||||||
|
|
||||||
## R2 – XLSX-Transportadapter
|
|
||||||
|
|
||||||
Dateien/Module: neues `src/external-model/xlsx`, synthetische Testfixtures und
|
|
||||||
ein auf XLSX umgestellter Referenzprüfer mit Dateipfadargument. Eine neue
|
|
||||||
direkte Bibliothek (`exceljs`, vorbehaltlich der Tests), keine DB-Änderung.
|
|
||||||
|
|
||||||
Abnahme:
|
|
||||||
|
|
||||||
- Kopfzeilenkommentare, Blattname, Spaltenbreiten, Formate und Zelltypen
|
|
||||||
überleben einen strukturellen Roundtrip;
|
|
||||||
- erste Datenzeile/-spalte und Mappings funktionieren ohne separates
|
|
||||||
`layout.idColumn`;
|
|
||||||
- Worksharing-ID/IfcGUID werden exakt und case-sensitiv behandelt;
|
|
||||||
- Zeile ohne ID und ohne `Familie und Typ` wird übersprungen, die Muster-
|
|
||||||
Summenzeile B490 blockiert nicht;
|
|
||||||
- Zeile ohne ID mit `Familie und Typ` erzeugt Hinweis und wird übersprungen;
|
|
||||||
- maximal 10 MiB, nur `.xlsx`, Zeilen-/Spaltengrenzen nach dem Einlesen;
|
|
||||||
- keine Formelausführung;
|
|
||||||
- lokale Referenzprüfung bestätigt 487 Objekte und keine ID-Duplikate;
|
|
||||||
- aus der realen Musterdatei entsteht lokal eine nicht versionierte
|
|
||||||
Rückgabedatei mit einigen geänderten Werten.
|
|
||||||
|
|
||||||
Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein
|
|
||||||
erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter
|
|
||||||
OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert.
|
|
||||||
|
|
||||||
Implementierungsstand vom 30.09.2026: Der Adapter liegt unter
|
|
||||||
`src/external-model/xlsx`, der Referenzprüfer unter
|
|
||||||
`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei
|
|
||||||
die ignorierte Datei
|
|
||||||
`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei
|
|
||||||
enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte
|
|
||||||
Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen,
|
|
||||||
Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte
|
|
||||||
ID-Vergleiche und Grenzen ab.
|
|
||||||
|
|
||||||
Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der
|
|
||||||
Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete
|
|
||||||
Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer;
|
|
||||||
ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten
|
|
||||||
Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete
|
|
||||||
Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im
|
|
||||||
fachlich begrenzten R2-Paket aktualisiert.
|
|
||||||
|
|
||||||
## R3 – Datenmodell und Backend-Cutover
|
|
||||||
|
|
||||||
- Konfiguration ohne eigene Versionszählung;
|
|
||||||
- `worksharing_id NOT NULL`, Unique je Quelle; `ifc_guid` nullable ohne Unique;
|
|
||||||
- additive Migration nach vorherigem DB-Backup; alte externe Entwicklungsdaten
|
|
||||||
verwerfen und Row-Mengen vorher auf `manualQuantity` zurücksetzen;
|
|
||||||
- Batch-Aufbewahrung: nur letzter Batch vollständig, ältere nur Metadaten;
|
|
||||||
- Snapshot 10, Restore und Transfer; alte CSV-Zustände/History klar ablehnen;
|
|
||||||
- Backend-CSV-Parser, Endpunkte und Verträge entfernen;
|
|
||||||
- Apply parst erneut; Undo/Redo verwenden ausschließlich gespeicherte
|
|
||||||
Vorher-/Nachher-Zustände.
|
|
||||||
|
|
||||||
Risiken: Migration mit verknüpften Row-Mengen, alte History-Spitzen und
|
|
||||||
vollständiges UUID-Remapping. Pflicht: echte SQLite-Commit-/Rollback-Tests.
|
|
||||||
|
|
||||||
## R4 – Revit-Seite und Einstellungen
|
|
||||||
|
|
||||||
- Route `/projects/:projectId/revit` und Navigationseintrag;
|
|
||||||
- Status-/Aktionsgrundgerüst;
|
|
||||||
- Einstellungsmodal mit Musterdateierkennung, Layout und Feldzuordnung;
|
|
||||||
- Stromkreismodus `combined | separate`;
|
|
||||||
- Projektseiten-Button und `revit-csv-modal.tsx` entfernen;
|
|
||||||
- nur deutsche UI-Texte und bestehende Icon-/Kartenregeln.
|
|
||||||
|
|
||||||
## R5 – Erstimport
|
|
||||||
|
|
||||||
- Vorschau und Plan mit erneuter Dateiübertragung beim Apply;
|
|
||||||
- Räume, Verteiler, Gerätearten, exakte Familienregeln und optionale
|
|
||||||
ProjectDevices;
|
|
||||||
- fehlender GZF als sichtbarer Startwert `1`, ohne Zusatzbestätigung;
|
|
||||||
- Warn-/Überspringregel für ID-lose Zeilen;
|
|
||||||
- ein atomarer Importcommand, keine Row-/Circuit-Erzeugung;
|
|
||||||
- Undo/Redo aus Commandzuständen ohne Workbook-Parsing.
|
|
||||||
|
|
||||||
## R6 – Objekttabelle und Sammelaktionen
|
|
||||||
|
|
||||||
- kompakte Objektliste einmal laden;
|
|
||||||
- clientseitig suchen, filtern und sortieren;
|
|
||||||
- virtualisierte Darstellung ohne Server-Pagination;
|
|
||||||
- stabile Mehrfachauswahl und Sammelaktionen für Verteiler, ProjectDevice und
|
|
||||||
Objektplanungswerte;
|
|
||||||
- Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte
|
|
||||||
Objekte folgen unverändert ihrer Row.
|
|
||||||
|
|
||||||
## R7 – Stromkreislisteneditor
|
|
||||||
|
|
||||||
- gruppierter rechter Revit-Bereich mit Anzahl-Badge;
|
|
||||||
- Drop auf Row, Circuit oder freien Bereich über vorhandene, angepasste
|
|
||||||
Commands;
|
|
||||||
- Row-Indikator, Objektanzeige und Lösen mit Übernahme aktueller Row-Werte;
|
|
||||||
- Row-/Circuit-/Gruppen-Löschcommands lösen verknüpfte Objekte atomar und
|
|
||||||
zeigen `X Revit-Objekte werden wieder unzugeordnet`;
|
|
||||||
- Undo stellt Objektlinks exakt wieder her;
|
|
||||||
- nur Raum-/Verteilerlöschung mit externen Referenzen bleibt gesperrt.
|
|
||||||
|
|
||||||
## R8 – Rückgabe-Excel
|
|
||||||
|
|
||||||
- alle / abweichende / ausgewählte Objekte;
|
|
||||||
- zuletzt importiertes Workbook als strukturelle Grundlage;
|
|
||||||
- Stromkreisfelder des gewählten Modus sind Pflicht;
|
|
||||||
- fehlende optionale Bezeichnung-/Leistung-/Spannung-/Stromspalten nur als
|
|
||||||
Hinweis überspringen;
|
|
||||||
- typisierte Zahlen, gemeinsame Export-/Abweichungsprojektion;
|
|
||||||
- append-only Exportnachweis ohne Projektrevision.
|
|
||||||
|
|
||||||
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
|
|
||||||
|
|
||||||
## R9 – Folgeimport
|
|
||||||
|
|
||||||
- exaktes, case-sensitives Matching nach Worksharing-ID;
|
|
||||||
- neu, geändert, unverändert, fehlend und Rückgabefeldabweichungen;
|
|
||||||
- Entscheidungen nur für fachliche Quelländerungen;
|
|
||||||
- akzeptierte Mengenänderungen und Row-Summen atomar;
|
|
||||||
- fehlende Objekte nicht automatisch lösen oder löschen;
|
|
||||||
- neuer letzter Vollbatch, vorheriger Batch auf Metadaten reduzieren;
|
|
||||||
- ein persistenter Undo-Schritt ohne Excel-Parsing bei Undo/Redo.
|
|
||||||
|
|
||||||
## R10 – Härtung
|
|
||||||
|
|
||||||
Ende-zu-Ende-Test:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Import → Objektplanung → Row-Zuordnung → Rückgabe → Folgeimport
|
|
||||||
→ Snapshot/Restore → Projektduplikat → Undo/Redo nach Neustart
|
|
||||||
```
|
|
||||||
|
|
||||||
Zusätzlich: mehrere Tausend synthetische Objekte, virtualisierte UI,
|
|
||||||
Speicher-/Payload-/Transaktionsmessungen, Löschwarnungen samt Link-Inversen,
|
|
||||||
alte CSV-Ablehnungen und Dokumentation auf den ausgelieferten Stand.
|
|
||||||
|
|
||||||
## Pflichtprüfungen je Paket R1–R10
|
|
||||||
|
|
||||||
```text
|
|
||||||
fokussierte Tests
|
|
||||||
npm test
|
|
||||||
npm run build:api
|
|
||||||
npm run build:web
|
|
||||||
npm run typecheck:scripts
|
|
||||||
npx tsc --noEmit -p tsconfig.next.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Jedes Paket wird mit einer knappen imperativen Commit-Message abgeschlossen.
|
|
||||||
|
|
@ -1,528 +0,0 @@
|
||||||
# R0 – Audit der Linear-Excel-Anbindung
|
|
||||||
|
|
||||||
Stand: 29.09.2026. Geprüfter Code: `cf022b2`.
|
|
||||||
|
|
||||||
**Status: R0 abgeschlossen und mit Korrekturen für R1 freigegeben.** Dieser Bericht
|
|
||||||
ist ein Umsetzungsvorschlag, kein Nachweis einer bereits implementierten
|
|
||||||
Excel-Anbindung. Der neue Nutzerauftrag ersetzt die CSV-/IfcGUID-Vorgaben und
|
|
||||||
die alte Phasenliste 14.0–14.6. R1 bereinigt die bestehenden Dokumente; R0
|
|
||||||
ändert weder Laufzeitcode noch Datenbank noch Oberfläche.
|
|
||||||
|
|
||||||
Die 13 Freigabekorrekturen vom 29.09.2026 sind in den
|
|
||||||
[verbindlichen Anforderungen](revit-excel-integration-requirements.md) und im
|
|
||||||
[R0–R10-Plan](revit-excel-phase-14-plan.md) eingearbeitet. Bei Abweichungen
|
|
||||||
gelten diese beiden R1-Dokumente.
|
|
||||||
|
|
||||||
## 1. Geprüfter Bestand und Keep/Change/Remove
|
|
||||||
|
|
||||||
Gelesene Grundlagen: `AGENTS.md`, `docs/current-architecture.md`,
|
|
||||||
`docs/project-history-and-external-model-architecture.md`,
|
|
||||||
`docs/spec/revit-csv-integration-requirements.md` und
|
|
||||||
`docs/spec/revit-csv-phase-14-audit-and-plan.md`. Der alte Plan widerspricht
|
|
||||||
sich bereits intern: Sein Status nennt die Erstübernahme noch nicht vorhanden,
|
|
||||||
während Implementierung und Tests sie nachweisen. Alte Erledigt-Markierungen
|
|
||||||
werden deshalb nicht übernommen.
|
|
||||||
|
|
||||||
| Bereich / Codebeleg | Entscheidung | Begründung und Änderung |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `src/db/repositories/project-command-transaction.persistence.ts` | Keep | Eine Grenze für Fachänderung, Inverse, Revision und History; auch automatische Snapshots bleiben darin. |
|
|
||||||
| `src/db/repositories/external-model-state.repository.ts`, `src/db/schema/external-*.ts` | Change | Quelle, Batches, Raum-Mappings und Objekte wiederverwenden; neue Identität, Konfiguration und typisierte Matrix. Schlanke Objekt-Reads von großen Batch-Blobs trennen. |
|
|
||||||
| `src/domain/models/external-initial-import-project-command.model.ts`, zugehöriger Store | Change | Vollständiger erwarteter/Zielzustand und Row-freier Import bleiben; Worksharing-ID, Gerätearten und atomare Regelübernahme ergänzen. |
|
|
||||||
| `src/external-model/application/external-initial-import-plan.ts`, `external-initial-import-target.ts` | Change | Raumvorschläge, Kataloge, vollständige Entscheidungen und stabile UUIDs behalten. Objektentscheidungen und Gerätearten ergänzen. |
|
|
||||||
| `src/db/repositories/external-object-assignment.persistence.ts` und drei Zuordnungs-Stores | Change | Mengeninvariante und exakte Inversen behalten; Geräteart, effektiven Verteiler, Anwesenheit und Übernahme der Row-Werte beim Lösen ergänzen. |
|
|
||||||
| `CircuitDeviceRow.manualQuantity`, bestehende Mengenprüfungen | Keep | `quantity = manualQuantity + Summe(externer effektiver Mengen)`; Mehrfachsteckdosen bleiben unteilbar. |
|
|
||||||
| `src/external-model/application/external-circuit-list-objects.ts` | Change | Listen-/Projektprüfung behalten; vollständige Planungswerte, Geräteart, effektiver Verteiler, Status und Row-Anzahlen liefern. |
|
|
||||||
| `src/domain/models/project-state-snapshot.model.ts`, Snapshot-Persistenz und Transfer | Change | Schema 10; historische CSV-Zustände ausdrücklich ablehnen; alle neuen internen Referenzen remappen. |
|
|
||||||
| `src/external-model/csv/*`, CSV-Preview, CSV-Validierungen und Controller | Remove/replace | XLSX ersetzt CSV vollständig; kein zweiter aktiver Transportpfad. |
|
|
||||||
| `src/frontend/components/revit-csv-modal.tsx`, Einbindung auf Projektseite | Remove/replace | Eigene Revit-Seite; Einstellungen nur im Modal, Import in eigenem Wizard. |
|
|
||||||
| `src/frontend/components/Sidebar.tsx`, `src/frontend/utils/api.ts`, `src/frontend/types.ts` | Change | Route `revit`, neue DTOs und Endpunkte, CSV-Imports entfernen. |
|
|
||||||
| `src/frontend/components/circuit-tree-editor.tsx`, Grid-Drop-/Projektionsmodule | Change | Separater externer Drag-Payload; Row-, Circuit- und Freiplatz-Ziele bewusst unterscheiden. Bestehende Grid-Bedienung behalten. |
|
|
||||||
| Raum-/ProjectDevice-Formulare und Commands | Keep | Sichtbar vorbelegte, ausdrücklich bestätigte Anlage als eigener Undo-Schritt. |
|
|
||||||
| `scripts/verify-revit-reference-csv.ts`, Transporttests | Replace | Pfadargument für lokalen XLSX-Prüfer; ausschließlich synthetische Workbooks in Tests. |
|
|
||||||
|
|
||||||
### Konkrete Lücken gegenüber dem Auftrag
|
|
||||||
|
|
||||||
- `external-model-contracts.ts` importiert heute CSV-Verträge. Die neutralen
|
|
||||||
Konfigurations-/Matrixverträge gehören künftig in `external-model/domain`;
|
|
||||||
nur `external-model/xlsx` darf ExcelJS kennen.
|
|
||||||
- `ifc_guid` ist derzeit NOT NULL und je Quelle eindeutig; Matching,
|
|
||||||
Snapshots, DTOs und Commands setzen diese Identität voraus.
|
|
||||||
- `internalDeviceType` ist derzeit ein freier String in Planungswerten.
|
|
||||||
Es gibt keinen Geräteart-Katalog mit stabilen IDs und Standardwerten.
|
|
||||||
- Der Erstimport setzt den ProjectDevice-Link, verwendet im Target aber
|
|
||||||
weiterhin `suggestedPlanningValues`. Die neue Priorität ProjectDevice vor
|
|
||||||
Geräteart muss ausdrücklich implementiert werden; ein Link allein genügt
|
|
||||||
nicht. Revit-Leistung wird künftig nicht automatisch zum Planungswert.
|
|
||||||
- Der Import-Target setzt `distributionBoardId = null`; die Raumvorgabe liegt
|
|
||||||
separat im Mapping. Projektion und Zuordnungsprüfung vergleichen heute
|
|
||||||
direkt das Objektfeld. Eine zentrale Auflösung des effektiven Verteilers
|
|
||||||
fehlt und ist vor der Editor-Nutzung erforderlich.
|
|
||||||
- Die bisherige Row-Zuordnung darf laut Validator am Objekt ausschließlich
|
|
||||||
`circuitDeviceRowId` ändern; der Store schreibt auch nur dieses Feld.
|
|
||||||
Lösen mit aktuellen Row-Planungswerten benötigt eine neue Command-Version.
|
|
||||||
- Die Kompatibilitätsprüfung vergleicht Raum, Kategorie, Auswahlkenner und
|
|
||||||
Planungswerte, aber keine stabile Geräteart. Unterschiedliche Gerätearten
|
|
||||||
dürfen nicht versehentlich in dieselbe Row aggregiert werden.
|
|
||||||
- Rows speichern `powerPerUnit` in **kW**, externe Planung
|
|
||||||
`powerPerUnitW` in **W**. Eine zentrale bidirektionale Konvertierung ist
|
|
||||||
nötig; Exportleistung darf nicht um Faktor 1000 falsch sein.
|
|
||||||
- Externe FKs auf Row, ProjectDevice, Raum-Mapping und Verteiler verwenden
|
|
||||||
teilweise `ON DELETE SET NULL`. Bestehende allgemeine Löschpfade sichern
|
|
||||||
externe Objektübergänge nicht durchgängig in ihren Inversen. Auch
|
|
||||||
Unterbaum-/History-Löschungen müssen abgesichert werden.
|
|
||||||
- Ein Editor-Drawer, projektweite Objekttabelle, Exportnachweis und
|
|
||||||
ausführbarer Folgeimport fehlen. `follow_up` als vorhandener Enumwert ist
|
|
||||||
kein implementierter Folgeimport.
|
|
||||||
- Konfigurationsversionen steigen heute nur bei normalen Updates gegenüber
|
|
||||||
dem aktuellen Wert; Undo setzt alte Versionen zurück. Für eine tatsächlich
|
|
||||||
monotone Generation muss diese Semantik korrigiert werden.
|
|
||||||
- XLSX-Parsing ist asynchron. Das heutige erneute synchrone CSV-Parsing
|
|
||||||
innerhalb der SQLite-Transaktion darf nicht einfach durch `await` ersetzt
|
|
||||||
werden.
|
|
||||||
|
|
||||||
## 2. Prüfung der lokalen Musterdatei
|
|
||||||
|
|
||||||
`docs/spec/kbp-Stromkreis.xlsx` wurde ausschließlich lesend auf ZIP-/OOXML-
|
|
||||||
Ebene untersucht; keine Inhalte wurden hochgeladen oder in Fixtures kopiert.
|
|
||||||
Die Datei ist unversioniert und darf nicht committed werden.
|
|
||||||
|
|
||||||
| Befund | Ergebnis |
|
|
||||||
| --- | --- |
|
|
||||||
| Dateigröße / entpackte ZIP-Einträge | 56.765 / 337.740 Bytes |
|
|
||||||
| Arbeitsblätter / Blattnamenlänge | 1 / 31 Zeichen |
|
|
||||||
| Zellbereich | A1:O490 |
|
|
||||||
| Kopfzeile / Leerzeile / Objekte / Abschluss | 1 / 2 / 3–489 / 490 |
|
|
||||||
| Objektidentitäten | 487 syntaktisch passende Worksharing-IDs, keine Duplikate |
|
|
||||||
| Kopfzeilenkommentare | 14, zu den 14 Spalten nach der ID-Spalte |
|
|
||||||
| Fehlende Raumangaben | 67 Objekte ohne Raumnummer und Raumname |
|
|
||||||
| IfcGUID-Duplikate | Keine bei exaktem, case-sensitivem Vergleich |
|
|
||||||
| Formeln | Keine; synthetische Tests dafür erforderlich |
|
|
||||||
| Spaltendefinitionen | 16, obwohl nur 15 Datenspalten vorhanden sind |
|
|
||||||
|
|
||||||
Das beobachtete Profil aus dem Auftrag wird damit bestätigt. Zusätzliche
|
|
||||||
Spaltenformatdefinitionen dürfen beim Export nicht auf die gemappten Spalten
|
|
||||||
gekürzt werden. Das Muster enthält die neuen getrennten Stromkreisfelder und
|
|
||||||
die Bezeichnung noch nicht. Fehlende Rückgabespalten werden angezeigt; sie
|
|
||||||
dürfen nicht ohne Linear-Parameterbindung erfunden werden.
|
|
||||||
|
|
||||||
Die frühere Angabe von zwei IfcGUID-Duplikatgruppen war falsch: PowerShells
|
|
||||||
standardmäßiges Gruppieren verglich ohne Beachtung der Groß-/Kleinschreibung.
|
|
||||||
Ein erneuter ordinaler, case-sensitiver Vergleich ergab keine Duplikate. Die
|
|
||||||
Summenzeile hat nur in B490 einen Wert; `Familie und Typ` ist leer.
|
|
||||||
|
|
||||||
Nicht nachgewiesen: erfolgreicher Rückimport in Linear, dessen genaue
|
|
||||||
Auswertung der Kommentar-/VML-Metadaten und der ExcelJS-Roundtrip. R2 endet
|
|
||||||
deshalb mit einer aus der Musterdatei erzeugten Rückgabedatei und einem
|
|
||||||
verbindlichen Linear-Test durch den Nutzer vor R3.
|
|
||||||
|
|
||||||
## 3. Bibliothek, Adapter und Uploadgrenzen
|
|
||||||
|
|
||||||
**Vorschlag: genau eine neue direkte Abhängigkeit `exceljs`.** Seine
|
|
||||||
öffentliche API unterstützt XLSX, Zellnotizen, Formate, Spaltenbreiten und
|
|
||||||
typisierte Werte. Das passt zum beobachteten klassischen Kommentarformat.
|
|
||||||
Version und transitive Abhängigkeiten werden in R2 festgeschrieben und
|
|
||||||
geprüft. Die veröffentlichte Version ist laut Paketregister 4.4.0; eine
|
|
||||||
aktuelle Releasepflege ist damit nicht bewiesen. Dies ist ein ausdrückliches
|
|
||||||
Wartungsrisiko, kein als erfüllt abgehakter Bibliotheksnachweis.
|
|
||||||
|
|
||||||
Quellen, am 29.09.2026 geprüft:
|
|
||||||
[ExcelJS-Projekt](https://github.com/exceljs/exceljs),
|
|
||||||
[offizielle Paketdokumentation](https://www.npmjs.com/package/exceljs).
|
|
||||||
Keine Bibliothek wird in R0 installiert. R2 muss vor Integration Kommentar-
|
|
||||||
Roundtrip, Node-24-Verträglichkeit und Abhängigkeitsrisiken prüfen; bei einem
|
|
||||||
Blocker wird die Bibliotheksentscheidung neu vorgelegt.
|
|
||||||
|
|
||||||
Vorgeschlagene Grenzen, zentral konfiguriert und synthetisch getestet:
|
|
||||||
|
|
||||||
- ausschließlich `.xlsx`, kein `.xls` oder `.xlsm`;
|
|
||||||
- maximal 10 MiB Upload;
|
|
||||||
- genau ein Arbeitsblatt und keine Formelausführung;
|
|
||||||
- feste Zeilen-, Spalten- und Zellgrenzen nach dem Einlesen.
|
|
||||||
|
|
||||||
Es gibt keinen eigenen ZIP-Prüfer und keinen Parser-Worker.
|
|
||||||
|
|
||||||
Neutrale Matrix: Blattname, Kopfzeilen-/Startpositionen, Spaltenmetadaten
|
|
||||||
(Index, Name, Kommentartext, erkannter Parametertyp/Einheit), geordnete
|
|
||||||
Zeilen mit Originalzeilennummer und Klasse sowie Zellen als diskriminierte
|
|
||||||
Union `string | number | boolean | empty`. Formeltext und Cache-Status sind
|
|
||||||
optionale Transportmetadaten; fachlich zählt nur der gecachte skalare Wert.
|
|
||||||
Fehlender Cache oder Fehlerwert erzeugt einen Hinweis bzw. bei Pflichtfeldern
|
|
||||||
einen Blocker. Originalbytes bleiben unverändert gespeichert.
|
|
||||||
|
|
||||||
Eine Zeile ohne Worksharing-ID ist nur verdächtig, wenn `Familie und Typ`
|
|
||||||
gefüllt ist. Sie erzeugt einen Hinweis und wird übersprungen, ohne zu
|
|
||||||
blockieren. Andere ID-lose Zeilen, auch die Summenzeile mit Inhalt in B,
|
|
||||||
werden still übersprungen. Nicht passende nichtleere IDs blockieren.
|
|
||||||
Worksharing-ID und IfcGUID bleiben als Quelltext erhalten und werden exakt,
|
|
||||||
ordinal und case-sensitiv verglichen; ungültige äußere Leerzeichen werden
|
|
||||||
gemeldet statt still korrigiert. IfcGUID wird optional gespeichert, doppelte
|
|
||||||
Werte sind nur Hinweise.
|
|
||||||
Numerische Projektion rundet Leistung auf 0,01 W, nicht die gespeicherte
|
|
||||||
Quellzelle. Mengen aus `socketCount` müssen positive ganze Zahlen sein.
|
|
||||||
|
|
||||||
Rückgabe lädt eine Kopie der Originalbytes, bewahrt den Bereich oberhalb der
|
|
||||||
ersten Datenzeile samt Kommentaren, Formatierung und Spaltenbreiten und
|
|
||||||
ersetzt den Datenbereich durch gewählte Objektzeilen in Originalreihenfolge.
|
|
||||||
Alle Spalten bleiben enthalten; Summenzeilen entfallen. Unveränderte Zellen
|
|
||||||
behalten ihre importierten Werte und Typen. Formeln in Objektzeilen werden
|
|
||||||
als gecachte Werte ausgegeben, damit Zeilenauswahl keine falschen relativen
|
|
||||||
Bezüge erzeugt; dies wird vor Export als Hinweis ausgewiesen. Keine Formeln
|
|
||||||
werden berechnet. Roundtrip bedeutet strukturelle/semantische Gleichheit,
|
|
||||||
nicht identische ZIP-Bytes.
|
|
||||||
|
|
||||||
## 4. Konfigurationsschema
|
|
||||||
|
|
||||||
Neuer neutraler Vertrag `ExternalModelConfiguration`, Schema-Version 1:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
layout: {
|
|
||||||
headerRow: 1, firstDataRow: 3, firstDataColumn: 1
|
|
||||||
},
|
|
||||||
circuitMode: "combined" | "separate",
|
|
||||||
columns: {
|
|
||||||
worksharingId: string, familyAndType: string,
|
|
||||||
ifcGuid: string | null, roomNumber: string | null,
|
|
||||||
roomName: string | null, selectionMarker: string | null,
|
|
||||||
socketCount: string | null, sourceRemark: string | null,
|
|
||||||
circuitCombined: string | null, boardName: string | null,
|
|
||||||
circuitIdentifier: string | null, designation: string | null,
|
|
||||||
electricalPowerW: string | null, voltageV: string | null,
|
|
||||||
currentA: string | null
|
|
||||||
},
|
|
||||||
deviceTypes: [{
|
|
||||||
id: UUID, name: string,
|
|
||||||
category: "lighting" | "single_phase" | "three_phase",
|
|
||||||
defaults: {
|
|
||||||
powerPerUnitW: number | null, simultaneityFactor: number | null,
|
|
||||||
cosPhi: number | null, costGroup: string | null
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
familyTypeRules: [{
|
|
||||||
id: UUID, exactFamilyAndType: string, deviceTypeId: UUID,
|
|
||||||
quantityRule: "one" | "socketCount"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Zeilen/Spalten sind intern 1-basiert; UI stellt Spalten als A, B, … dar.
|
|
||||||
Es gibt nur `columns.worksharingId` und kein separates `layout.idColumn`.
|
|
||||||
Jede gemappte Kopfzeile muss eindeutig auflösbar sein;
|
|
||||||
doppelte Ausgabebelegungen werden abgelehnt. Freie Eingabe bleibt zulässig,
|
|
||||||
aber ein nicht existierender Name erzeugt vor Import/Export einen konkreten
|
|
||||||
Fehler. Erkennung und Vorbelegung nutzen die exakten Standardnamen aus dem
|
|
||||||
Auftrag. Unbekannte Spalten bleiben ohne Mapping erhalten.
|
|
||||||
|
|
||||||
Geräteart-IDs sind interne UUIDs, keine Namen als Relationsschlüssel. Regeln
|
|
||||||
sind exakt und je Familien-/Typtext eindeutig; jede importierte Familie muss
|
|
||||||
eine Geräteart haben. Gerätearten und Regeln werden als ein versioniertes
|
|
||||||
Konfigurationsaggregat gespeichert. Keine zusätzliche Gerätearttabelle ist
|
|
||||||
für diesen Umfang nötig; Validatoren erzwingen referenzielle Integrität.
|
|
||||||
Referenzierte Gerätearten können nicht kommentarlos entfernt werden.
|
|
||||||
|
|
||||||
Startwerte: je Feld ProjectDevice vor Geräteart vor leer. Anzeigename:
|
|
||||||
Auswahlkenner, sonst Geräteartname, sonst Familie/Typ. Quellleistung bleibt
|
|
||||||
Quelle; eine Übernahme geschieht nur als sichtbare Planungsentscheidung.
|
|
||||||
Fehlender GZF wird sichtbar mit `1` vorbelegt, ohne zusätzliche Bestätigung.
|
|
||||||
Geräteart-
|
|
||||||
Änderungen schreiben keine vorhandenen Objekt-Overrides oder Rows um.
|
|
||||||
|
|
||||||
## 5. Konkretes Persistenzschema
|
|
||||||
|
|
||||||
Bestehende UUID-/Projekt-FKs und Eigentumsprüfungen werden beibehalten.
|
|
||||||
`JSON` bezeichnet validierten JSON-Text in SQLite, nicht ExcelJS-Objekte.
|
|
||||||
|
|
||||||
| Tabelle | Zielspalten / Constraints |
|
|
||||||
| --- | --- |
|
|
||||||
| `external_model_configurations` (ersetzt CSV-Tabelle) | `id TEXT PK`, `project_id TEXT NOT NULL UNIQUE FK projects`, `configuration JSON NOT NULL`. Enthält obiges vollständiges Aggregat; keine eigene Versionszählung. |
|
|
||||||
| `external_model_sources` | `id`, `project_id UNIQUE`, `name`, `source_type = revit_xlsx`. Genau eine implizite Quelle je Projekt. |
|
|
||||||
| `external_import_batches` | Letzter Batch vollständig mit IDs, Importart, Zeit, Dateiname, SHA-256, Objektzahl, `applied_project_revision`, `configuration_snapshot JSON`, `original_bytes BLOB`, `document JSON`; ältere Batches nur als Metadaten Datum, Dateiname, SHA-256 und Objektzahl. |
|
|
||||||
| `external_room_mappings` | Vorhandene Felder einschließlich normalisiertem Quellraumschlüssel, `room_id`, `default_distribution_board_id`; UNIQUE `(source_id, normalized_source_room_key)` bleibt. |
|
|
||||||
| `external_model_objects` | Bestehende interne IDs/Links; neu `worksharing_id TEXT NOT NULL`, UNIQUE `(source_id, worksharing_id)`; `ifc_guid TEXT NULL`, ohne Unique; `device_type_id TEXT NOT NULL` als validierter Verweis in das Konfigurationsaggregat. |
|
|
||||||
| Objektzustand | `accepted_source_values JSON` mit rohen typisierten Werten für Raum, Familie/Typ, Marker, Menge, Bemerkung und alle Rückgabefelder; `planning_values JSON` mit Anzeigename, Kategorie, Anschlussart, effektiver Menge, Einzelleistung W, nullable GZF/cosPhi/Kostengruppe/Bemerkung; `overridden_fields JSON`; bisherige Batch-/Raum-/Board-/ProjectDevice-/Row-Verweise und `presence_status` bleiben. |
|
|
||||||
| `external_export_batches` (neu) | `id TEXT PK`, `project_id FK`, `project_revision INTEGER`, `import_batch_id TEXT`, `exported_at_iso TEXT`, `file_name TEXT`, `sha256 TEXT`, `configuration_snapshot/hash`, `scope TEXT`, `object_count INTEGER`. Append-only Audit-Store. |
|
|
||||||
|
|
||||||
`import_batch_id` des Exportnachweises ist absichtlich eine historische UUID
|
|
||||||
ohne löschenden FK: Undo eines Imports oder Snapshot-Restore darf den Nachweis
|
|
||||||
nicht entfernen. Exportnachweise liegen außerhalb rücksetzbarer Fachsnapshots
|
|
||||||
und werden beim Projektduplikat nicht als dort erfolgte Exporte ausgegeben.
|
|
||||||
Sie bleiben beim Originalprojekt erhalten; diese Abgrenzung wird in R1
|
|
||||||
dokumentiert. Nur der letzte Importbatch gehört mit Bytes und Matrix zu
|
|
||||||
Snapshot/Transfer; ältere Batches werden dort nur als Metadaten erhalten.
|
|
||||||
|
|
||||||
Indices: bisherige Projekt-/Presence-, Board- und Row-Indices behalten;
|
|
||||||
zusätzlich `(project_id, device_type_id)`, `external_room_mapping_id`,
|
|
||||||
`linked_project_device_id`, Export `(project_id, exported_at_iso)`.
|
|
||||||
Cross-Project-Links und Geräteart-Verweise prüft jeder Command in der
|
|
||||||
Transaktion; bloße Existenz eines UUID-FK genügt nicht.
|
|
||||||
|
|
||||||
Effektiver Verteiler: ein gemeinsamer Resolver verwendet bei verknüpften
|
|
||||||
Objekten die Row/Circuit-Verteilung, sonst die Objektabweichung, sonst die
|
|
||||||
Raumvorgabe. `distributionBoardId = null` bedeutet Raumvorgabe verwenden;
|
|
||||||
bei fehlender Vorgabe ist das Objekt ohne Verteiler. Eine Änderung des
|
|
||||||
Raum-Standardverteilers betrifft nur unzugeordnete Objekte; verknüpfte Objekte
|
|
||||||
folgen immer der Row-Verteilung und blockieren diese Änderung nicht.
|
|
||||||
|
|
||||||
Planungswerte bleiben von Quellwerten getrennt. Nach Row-Zuordnung sind
|
|
||||||
Objekt-Planungswerte eingefrorene Startwerte; Anzeige, Abweichung und Export
|
|
||||||
verwenden die Row. Beim Lösen werden aktuelle Row-Werte einschließlich
|
|
||||||
Einheitenkonvertierung in die Objektplanung übernommen und als explizite
|
|
||||||
Overrides markiert. Effektive Objektmenge wird nicht durch die Row-Gesamtmenge
|
|
||||||
ersetzt. Die Row und ihr manueller Anteil bleiben erhalten.
|
|
||||||
|
|
||||||
## 6. Commands und Transaktionsgrenze
|
|
||||||
|
|
||||||
Alle öffentlichen Fach-Writes verlangen `expectedRevision`, Anmeldung und
|
|
||||||
`X-Leistungsbilanz-Request: 1`. `expectedRevision` gehört wie bisher zum
|
|
||||||
Ausführungsumschlag. Jede Transition enthält vollständige erwartete/Ziel-
|
|
||||||
Snapshots der betroffenen Datensätze, keine ungesicherten Teilpatches.
|
|
||||||
|
|
||||||
| Command | Payload-Skizze und Gegenrichtung |
|
|
||||||
| --- | --- |
|
|
||||||
| `external-model-configuration.update` v1 | `{ expected, target }` einschließlich Layout, Mapping, Gerätearten und Regeln. Inverse tauscht den vollständigen Fachinhalt; keine eigene Konfigurationsversion. |
|
|
||||||
| `external-import.apply-initial` v2 | `{ expectedConfiguration, targetConfiguration, expected, target }`. Quelle, Batch, Mapping, Objekte und neue Regeln zusammen; Inverse stellt leeren externen und vorherigen Konfigurationsinhalt wieder her. Keine Rows. |
|
|
||||||
| `external-import.apply-follow-up` v1 | `{ configuration: transition, batch: transition, roomMappings: transitions, objects: transitions, rows: quantityTransitions, decisions }`; Server erzeugt deterministischen Diff und alle UUIDs. Inverse tauscht alle betroffenen Zustände. |
|
|
||||||
| `external-model-object.update-planning` v1 | `{ objects: transitions }`; nur ungebundene Objekte; geänderte Felder in Overrides. Einzel-/Sammelbearbeitung identischer Regeln. |
|
|
||||||
| `external-model-object.update-distribution-board` v1 | `{ objects: transitions }`; explizite Objektabweichung, Projekt-/Row-Konsistenz. |
|
|
||||||
| `external-model-room-mapping.update` v1 | `{ mappings: transitions }`; interner Raum/Standardverteiler; betroffene Objekt-/Row-Konsistenz mitprüfen. |
|
|
||||||
| `external-model-object.update-project-device-link` v1 | `{ objects: transitions, confirmedPlanningFields }`; keine Vorlagenänderung; neue Startwerte nur für ausdrücklich bestätigte Felder. Gebundene Objekte werden über ihre Row bearbeitet. |
|
|
||||||
| `external-object.update-row-assignment` v2 | `{ rows: transitions, objects: transitions, confirmedConflictObjectIds }`; Linkwechsel, materialisierte Mengen, beim Lösen aktuelle Row-Planungswerte; vollständige Inverse. |
|
|
||||||
| `external-object.assign-to-new-row` v2 | Bestehender vollständiger Row-/Objekt-/Reserveplan mit neuen Objektverträgen; history-only Inverse entfernt nur exakt unveränderte Erzeugnisse. |
|
|
||||||
| `external-object.assign-to-new-circuit` v2 | Bestehender Circuit-/Protection-/Row-/Objektplan; BMK einmal planen und festschreiben, gleiche UUIDs bei Redo. |
|
|
||||||
| `external-model-object.delete-missing` v1 | `{ objects: transitionsToNull, rows: quantityTransitions }`; ausschließlich ausdrücklich bestätigte weiterhin fehlende Objekte, kein Row-/Circuit-Löschen; Inverse stellt IDs, Links und Mengen wieder her. |
|
|
||||||
|
|
||||||
Bestehende `project-device.insert`, Room-/Floor-Commands bleiben eigenständige
|
|
||||||
Undo-Schritte. Familien-/Geräteartentscheidungen im Wizard sind lokale
|
|
||||||
Entwürfe und werden erst mit dem Import atomar persistiert. Nach separater
|
|
||||||
Kataloganlage aktualisiert der Wizard die Projektrevision und plant neu,
|
|
||||||
ohne lokale Entscheidungen zu verlieren.
|
|
||||||
|
|
||||||
Die Konfiguration hat keine eigene Versionszählung. Vorschau und Apply binden
|
|
||||||
sich an `expectedRevision`; weil auch Undo/Redo neue, strikt steigende
|
|
||||||
Projektrevisionen erzeugen, wird ein veralteter Plan eindeutig ungültig.
|
|
||||||
Importbatches speichern eine vollständige Konfigurationskopie. Prüfen:
|
|
||||||
Änderung → Undo → anderer Zweig darf niemals denselben Plan wieder gültig
|
|
||||||
machen.
|
|
||||||
|
|
||||||
XLSX wird vor der synchronen SQLite-Transaktion in einer serverinternen
|
|
||||||
Vorbereitung geparst. Apply überträgt Bytes erneut; Hash, Matrix und
|
|
||||||
Entscheidungen werden erneut validiert. Im Store werden Revision,
|
|
||||||
Konfigurationsinhalt, erwartete Objekte, Links und vollständiger
|
|
||||||
vorbereiteter Plan atomar geprüft. Vorbereitete Daten sind keine vom Client
|
|
||||||
behauptete Validierung: Generic-Command-Requests dürfen Importcommands nicht
|
|
||||||
an dieser serverseitigen Vorbereitung vorbeischleusen. Undo/Redo lesen und
|
|
||||||
parsen keine Excel-Datei; sie wenden ausschließlich die gespeicherten
|
|
||||||
Vorher-/Nachher-Zustände an. Keine asynchronen Callbacks in
|
|
||||||
`better-sqlite3`-Transaktionen.
|
|
||||||
|
|
||||||
Row-/Circuit-/Gruppen-Löschcommands erfassen verknüpfte Objekte vollständig.
|
|
||||||
Nach der Warnung `X Revit-Objekte werden wieder unzugeordnet` lösen sie die
|
|
||||||
Links im selben Command; ihre Inverse stellt sie exakt wieder her. Sperren
|
|
||||||
bleiben nur für das Löschen referenzierter Verteiler und Räume und gelten auch
|
|
||||||
in Unterbaum- und History-only-Pfaden.
|
|
||||||
|
|
||||||
## 7. Migration, Snapshot 10, History und Transfer
|
|
||||||
|
|
||||||
Neue additive Migration nach `0013_users` (voraussichtlich `0014`, Nummer
|
|
||||||
beim Beginn von R3 erneut prüfen). Bestehende SQL-/Metadateien unverändert.
|
|
||||||
|
|
||||||
1. Vor R3-Anwendung API-Schreibzugriffe stoppen und `npm run db:backup`
|
|
||||||
ausführen; verifiziertes Backup samt Wiederherstellungsmöglichkeit sichern.
|
|
||||||
R0 verändert keine DB und benötigt daher kein Migrationsbackup.
|
|
||||||
2. Vorhandene externe CSV-Entwicklungsdaten dürfen gemäß Auftrag verworfen
|
|
||||||
werden. Zuerst je verknüpfter Row `quantity = manual_quantity` setzen;
|
|
||||||
sonst bliebe nach Entfernen der externen Objekte eine Phantomlast.
|
|
||||||
Rows, Circuits, manuelle Anteile, UUIDs und BMKs bleiben bestehen.
|
|
||||||
3. Externe Objekte → Mappings → Batches → Quellen → CSV-Konfiguration in
|
|
||||||
FK-sicherer Reihenfolge entfernen. Tabellen gezielt neu aufbauen, wo
|
|
||||||
SQLite NOT-NULL-/Unique-Änderungen dies erfordern. Neue Zieltabellen und
|
|
||||||
Indices erstellen. Keine leeren Worksharing-IDs als Backfill erfinden.
|
|
||||||
4. Generiertes SQL und Drizzle-Metadaten prüfen; saubere und bestehende
|
|
||||||
Test-DBs migrieren; FK-/Integritätsprüfung und Mengenabgleich ausführen.
|
|
||||||
Der Reset ist eine dokumentierte einmalige Entwicklungsmigration,
|
|
||||||
kein stiller Laufzeit-Import.
|
|
||||||
5. Alte Revisionsdaten nicht umschreiben, keine CSV-History heimlich
|
|
||||||
überspringen. An der Ausführungsgrenze alte CSV-Konfigurationscommands,
|
|
||||||
Initial-/Zuordnungscommands v1 einschließlich ihrer History-Inversen und
|
|
||||||
Restores mit CSV-Inhalt vor jedem Write ablehnen.
|
|
||||||
|
|
||||||
Fehlermeldung für History:
|
|
||||||
„Dieser Verlaufsschritt verwendet die frühere CSV-/IfcGUID-Anbindung und
|
|
||||||
kann mit der Excel-Anbindung nicht ausgeführt werden.“
|
|
||||||
Der History-Status sollte den blockierten obersten Schritt samt Grund zeigen;
|
|
||||||
keine automatisch geleerten Stacks und keine Änderung bei Ablehnung. Frühere
|
|
||||||
allgemeine Commands können nach dem Reset zusätzlich an ihren exakten
|
|
||||||
Vorbedingungen scheitern; das bleibt eine sichere, sichtbare Ablehnung.
|
|
||||||
|
|
||||||
Snapshot v10 enthält aktive Excel-Konfiguration, Gerätearten/Regeln, Quelle,
|
|
||||||
den letzten Batch mit Originalbytes/Matrix, ältere Batches nur als Metadaten
|
|
||||||
sowie Raum-Mappings, Objekte und Links.
|
|
||||||
Upgrade v1–9: bestehende nicht-externe Upgrader erhalten. Nur wenn
|
|
||||||
`externalCsvConfiguration` fehlt/null ist **und** der externe Zustand leer
|
|
||||||
ist, verlustfrei auf v10 heben. Auch eine gespeicherte CSV-Konfiguration
|
|
||||||
allein zählt als nicht leerer CSV-Zustand und wird nicht still verworfen.
|
|
||||||
Snapshot-Checksumme am gespeicherten Original vor Upgrade prüfen.
|
|
||||||
|
|
||||||
Fehlermeldung:
|
|
||||||
„Dieser Projektstand enthält Daten der früheren CSV-/IfcGUID-Anbindung.
|
|
||||||
Eine automatische Übernahme in die Excel-Anbindung ist nicht möglich.
|
|
||||||
Bitte verwenden Sie einen Projektstand ohne CSV-Daten.“
|
|
||||||
|
|
||||||
Der aktuelle portable Transfer ist bereits **Format 2** (Grobbilanzen),
|
|
||||||
nicht Format 1 wie im alten Audit. Envelope 2 und dessen Format-1-Lesepfad
|
|
||||||
bleiben; enthaltenes Projektzustandsschema wird 10. Duplizieren remappt
|
|
||||||
Projekt-, Konfigurations-, Geräteart-, Regel-, Quellen-, Batch-, Mapping-,
|
|
||||||
Objekt-UUIDs und alle internen Links, auch in Konfigurationssnapshots der
|
|
||||||
Batches. Worksharing-IDs, optionale IfcGUIDs und Originalbytes bleiben
|
|
||||||
unverändert. Grobbilanzen und globale Office-/Auth-Daten behalten ihre
|
|
||||||
bisherige Abgrenzung. Exportnachweise werden nicht als neue Projektexporte
|
|
||||||
dupliziert.
|
|
||||||
|
|
||||||
## 8. Read-Modelle, Oberfläche und Rückgabe
|
|
||||||
|
|
||||||
Vorgeschlagene API-Basis `/api/projects/:projectId/revit`:
|
|
||||||
`configuration` (GET/PUT), `profile/preview` (POST, zustandslos),
|
|
||||||
`initial-import/plan|apply`, `follow-up-import/plan|apply` (POST),
|
|
||||||
`status` und `objects` (GET), `return-workbook` (POST mit Export-Audit).
|
|
||||||
Der bestehende listenbezogene External-Objects-GET bleibt erhalten.
|
|
||||||
Objektmutationen verwenden den vorhandenen Project-Command-Endpunkt mit
|
|
||||||
neuen typisierten Verträgen. CSV-Endpunkte werden entfernt, keine Aliasse.
|
|
||||||
|
|
||||||
Export verwendet `expectedRevision` für einen konsistent gelesenen Stand;
|
|
||||||
die Datei-Erzeugung erfolgt außerhalb des kurzen Read-Snapshots. Danach
|
|
||||||
separater append-only Audit-Write, ohne Projektrevision oder Undo-Schritt.
|
|
||||||
Bei Auditfehler kein Erfolg/download; ein Eintrag belegt Dateierzeugung,
|
|
||||||
nicht einen tatsächlich abgeschlossenen Browserdownload.
|
|
||||||
|
|
||||||
Die Revit-Seite lädt einmal eine kompakte Objektliste. Filterung und Sortierung
|
|
||||||
laufen im Client; die Tabelle wird virtualisiert und verwendet keine Server-
|
|
||||||
Pagination. Filter-/Sortierzustand, UUID-Auswahl und Sammelaktionen sind
|
|
||||||
unabhängig vom DOM. „Alle gefilterten“ wird vor einer Mutation zu einer
|
|
||||||
expliziten ID-Auswahl an der aktuellen Revision aufgelöst. Große Auswahlmengen
|
|
||||||
werden nicht in getrennte Undo-Schritte aufgeteilt. Kein vollständiges
|
|
||||||
Batch-Blob im DTO.
|
|
||||||
|
|
||||||
Einstellungen verwenden Musterdatei-Erkennung mit editierbaren Positionen,
|
|
||||||
Feld-Dropdowns/freier Eingabe und Kommentar-Typ-/Einheitenanzeige. Regeln
|
|
||||||
entstehen im Wizard und sind auf der Seite einklappbar nachbearbeitbar.
|
|
||||||
Fehlende Raumangaben bleiben zulässig, Board-Zuordnung ist auch ohne Raum
|
|
||||||
möglich. ProjectDevice-Zuordnung bleibt optional.
|
|
||||||
|
|
||||||
Rechter Editor-Drawer: nur vorhandene, ungebundene konfliktfreie Objekte des
|
|
||||||
effektiven Verteilers. Gruppierung nach internem Raum, Auswahlkenner,
|
|
||||||
Geräteart-ID, Kategorie und identischen Planungswerten; unterschiedliche
|
|
||||||
ProjectDevice-Links bleiben ebenfalls getrennt, da eine Row nur einen Link
|
|
||||||
haben kann. Effektive Einzelmengen sind kein Gleichheitsfeld: Einfach- und
|
|
||||||
Mehrfachsteckdosen sollen gemeinsam gruppierbar sein. Anzeige enthält
|
|
||||||
Mengensumme und Objektanzahl. Fachlich heterogene Auswahl darf nicht
|
|
||||||
willkürlich auf eine einzelne Row zusammenfallen; Konfliktanzeige verlangt
|
|
||||||
eine kompatible Auswahl. Row-Indikator öffnet Objekte und explizites Lösen.
|
|
||||||
|
|
||||||
Eine Funktion `getRevitBoardName(board)` liefert ausschließlich `board.name`.
|
|
||||||
Gemeinsam: Name + unverändertes `equipmentIdentifier`, ohne Separator.
|
|
||||||
Getrennt: Name und BMK in eigene Parameter. Ohne Circuit bleiben beide
|
|
||||||
Stromkreiswerte leer, auch wenn ein Planungsverteiler bekannt ist.
|
|
||||||
|
|
||||||
Exportprojektion und Abweichungsanzeige teilen dieselbe reine Funktion:
|
|
||||||
Row-Anzeigename/-Leistung bei Link, sonst Objektplanung; Objektleistung in W
|
|
||||||
= effektive Menge × Einzelleistung (Row-kW vorher × 1000), ohne GZF.
|
|
||||||
Spannung vom Circuit, sonst Projektstandard passend zur Kategorie.
|
|
||||||
Strom aus Objektleistung, U, cosPhi und Phasenart; Drehstrom mit Faktor √3,
|
|
||||||
auf 0,01 A. Fehlende/ungültige Werte ergeben leer plus Hinweis. Nullleistung
|
|
||||||
bleibt bei gültigem Nenner numerisch 0. Kein still angenommenes cosPhi.
|
|
||||||
Abweichungsvergleich normalisiert Zahlpräzision, Leere und skalare Typen
|
|
||||||
gegen den **zuletzt importierten**, nicht den zuletzt exportierten Stand.
|
|
||||||
|
|
||||||
Umfang: alle im letzten Batch vorhandenen Objekte / abweichende / explizite
|
|
||||||
Auswahl. Fehlende Objekte haben keine aktuelle Originalzeile und werden
|
|
||||||
nicht künstlich wieder eingefügt. Alle Spalten bleiben, nicht ausgegebene
|
|
||||||
Parameter behalten importierte Werte. Fehlende optionale Spalten für
|
|
||||||
Bezeichnung, Leistung, Spannung und Strom werden mit Hinweis übersprungen.
|
|
||||||
Nur fehlende Stromkreisspalten des gewählten Modus blockieren. Dateiname gemäß
|
|
||||||
Auftrag mit bereinigtem Projektnamen und Tagesdatum.
|
|
||||||
|
|
||||||
Folgeimport matcht ausschließlich Worksharing-ID. Ein neuer Batch speichert
|
|
||||||
immer den vollständigen letzten Quellstand. Unveränderte Objekte werden
|
|
||||||
nicht allein zum Fortschreiben von `lastSeenImportBatchId` neu geschrieben;
|
|
||||||
aktuelle Sichtbarkeit/Quellwerte kommen aus Batch/Matrix. Akzeptierte
|
|
||||||
Planungsgrundlage und letzte importierte Beobachtung sind getrennt.
|
|
||||||
Raum-/Familien-/Marker-/Mengenänderungen brauchen Entscheidungen. Bei
|
|
||||||
unvereinbarer Row-Zuordnung blockiert die Übernahme bis zum ausdrücklichen
|
|
||||||
Lösen/Umordnen, statt Rows automatisch zu erzeugen. Übernommene Mengen
|
|
||||||
ändern alle betroffenen Row-Summen im selben Importcommand. Fehlend setzt
|
|
||||||
nur den Status, erhält vorerst Link und Menge. Rückgabefeld-Abweichungen
|
|
||||||
sind Hinweise, keine Übernahmeentscheidungen. Ein völlig unveränderter
|
|
||||||
Folgeimport erzeugt einen nachvollziehbaren Batch, aber keine unnötigen
|
|
||||||
Objekt-/Row-Updates.
|
|
||||||
|
|
||||||
## 9. Pakete, Verifikation und Risiken
|
|
||||||
|
|
||||||
R0 liefert nur diesen Bericht und einen eigenen Commit. R1–R10 starten erst
|
|
||||||
nach Freigabe; keine Fortschreibung der alten 14.0–14.6-Liste.
|
|
||||||
|
|
||||||
| Paket | Arbeit und entscheidende Abnahme |
|
|
||||||
| --- | --- |
|
|
||||||
| R1 | Neue Excel-Anforderungen und R0–R10-Plan; alte Dokumente archivieren/auf Verweise kürzen; AGENTS, Architektur, History-Doku, Phase-14-Abschnitt, LLM-Kontext und alle Links nachziehen. Referenzdatei gezielt ignorieren. |
|
|
||||||
| R2 | XLSX-Adapter und lokaler Referenzprüfer mit Pfadargument. Synthetischer Roundtrip für Kommentare, Blattname, Spaltenbreiten, Typen, Header-/Startpositionen, unbekannte Spalten, Auswahl und Summenausschluss. Aus der lokalen Musterdatei entsteht eine Rückgabedatei mit geänderten Werten. Danach Nutzerabnahme in Linear; R3 bleibt bis zum Erfolg gesperrt. Bei Fehlschlag vor R3 OOXML-Direktänderung als Alternative vorlegen. Keine DB-Änderung. |
|
|
||||||
| R3 | Neue Konfiguration/Identität, additive Migration, Snapshot 10, Transfer und History-Ablehnungen; Backend-CSV entfernen. Parser-Vorbereitung von synchroner Transaktion trennen. |
|
|
||||||
| R4 | Revit-Seite, Navigation, Einstellungen; Projektseitenmodal und dessen CSV-Frontend-Verträge entfernen. |
|
|
||||||
| R5 | Erstimport mit atomaren Regeln, Geräten, Raum-/Board- und Objektentscheidungen; ProjectDevice-Vorbelegung nach Priorität; ein Import-Undo-Schritt. |
|
|
||||||
| R6 | Kompakte Objekt-Reads, clientseitige Filter/Sortierung, Virtualisierung, Sammel-Commands und Overrides. |
|
|
||||||
| R7 | Gruppierung, Drag-and-drop, Row-Indikatoren und Lösen inklusive aktueller Row-Werte; drei Drop-Ziele, atomare Objektlösung beim Row-/Circuit-/Gruppen-Löschen und Reload/Undo/Redo. |
|
|
||||||
| R8 | Drei Exportumfänge, gemeinsame Abweichungsprojektion, typisierte Rückgabe, append-only Nachweis und Linear-Strukturabnahme. |
|
|
||||||
| R9 | Kontrollierter Folgeimport, fehlende Objekte, explizites Löschen, mengenwirksame Entscheidungen und atomare Inverse. |
|
|
||||||
| R10 | Vollständige synthetische Ende-zu-Ende-Kette einschließlich Neustart, Snapshot/Restore und Duplikat; mehrere Tausend Objekte; Doku auf ausgelieferten Stand. |
|
|
||||||
|
|
||||||
Die temporäre Grenze zwischen R3 und R4 wird ausdrücklich behandelt:
|
|
||||||
Buildfähige, nicht aktive Kompatibilitätstypen für das alte Modal dürfen bis
|
|
||||||
R4 bestehen, aber kein aktiver CSV-Backendpfad. Veraltete Projektaktion bis
|
|
||||||
zur neuen Seite deaktivieren/entfernen, keinen funktionslosen Upload anbieten.
|
|
||||||
Die Dokumentation darf Zwischenstände nicht als fertigen Workflow ausweisen.
|
|
||||||
|
|
||||||
Pflicht je R1–R10: fokussierte Tests, `npm test`, `npm run build:api`,
|
|
||||||
`npm run build:web`, `npm run typecheck:scripts`,
|
|
||||||
`npx tsc --noEmit -p tsconfig.next.json`, je ein knapper imperativer Commit.
|
|
||||||
R0: 31 vorhandene Tests aus sechs Dateien erfolgreich ausgeführt:
|
|
||||||
`external-model-foundation`, `external-initial-import-plan`,
|
|
||||||
`external-initial-import-project-command.repository`,
|
|
||||||
`external-object-row-assignment-project-command.repository`,
|
|
||||||
`external-circuit-list-objects`,
|
|
||||||
`external-csv-configuration-project-command.repository` (je `.test.ts`).
|
|
||||||
Diese decken vorhandene Commit-/Rollback-, Undo-/Redo-, Snapshot-/Transfer-
|
|
||||||
Grundlagen ab, nicht die neue Excel-Funktion. Der erste Start scheiterte an
|
|
||||||
Sandbox-`spawn EPERM`; derselbe Lauf außerhalb der Sandbox bestand.
|
|
||||||
Vollständige Builds/Tests sind für diesen reinen R0-Bericht nicht ausgeführt.
|
|
||||||
|
|
||||||
Zusätzlich erforderliche neue Prüfungen:
|
|
||||||
|
|
||||||
- Migration mit externen Row-Anteilen; keine Phantomlast und unveränderte BMKs.
|
|
||||||
- v1–9 ohne CSV verlustfrei, mit CSV klare Ablehnung; alte Command-Inversen
|
|
||||||
und alte Restore-Payloads ebenfalls ohne Teilwrites ablehnen.
|
|
||||||
- Config-Änderung/Undo/Neuzweig invalidiert alte Pläne über `expectedRevision`; Apply mit anderem
|
|
||||||
Dateiinhalt, fehlenden Entscheidungen oder fremden UUIDs rollt vollständig
|
|
||||||
zurück. Später Historyfehler rollt auch Regeln, Links und Mengen zurück.
|
|
||||||
- Gebundene Row ändern → Export → Lösen → Undo/Redo behält fachliche Werte.
|
|
||||||
- Mehrfachsteckdosen: unterschiedliche effektive Mengen, identische
|
|
||||||
Einzelwerte, richtige Gruppierung und objektweise Rückgabeleistung.
|
|
||||||
- Row-/Circuit-/Gruppen-Löschen löst externe Links im Command und stellt sie
|
|
||||||
per Undo wieder her. Raum-/Verteilerlöschen mit Referenzen blockiert.
|
|
||||||
- Export-Audit scheitert: keine fachliche Revision; SHA-256 entspricht exakt
|
|
||||||
den gelieferten Bytes. Snapshot/Restore entfernt keine Exportnachweise.
|
|
||||||
- 5.000/10.000 synthetische Objekte: Parserzeit, Peak-Speicher, Payloadgröße,
|
|
||||||
Command-/Snapshot-Dauer und UI-Reaktionszeit messen; nur sichtbare Zeilen im
|
|
||||||
DOM, stabile Auswahl über virtuelle Bereiche.
|
|
||||||
|
|
||||||
Wesentliche Restrisiken: Linear-Kommentarbindung ist nur anhand des
|
|
||||||
Exports beobachtet; ExcelJS-Releasepflege und Roundtrip sind in R2 zu
|
|
||||||
belegen. Große Originaldateien vervielfachen sich in History/Snapshots;
|
|
||||||
Limits und Messungen müssen reale Speichergrößen berücksichtigen. Die
|
|
||||||
CSV-Entwicklungsbereinigung ist absichtlich verlustbehaftet, aber auf den
|
|
||||||
externen Zustand begrenzt und nur nach verifiziertem Backup zulässig.
|
|
||||||
Fehlende Zielparameter im Muster verlangen einen neuen vollständigen
|
|
||||||
Linear-Export, keine geratenen GUID-Kommentare.
|
|
||||||
|
|
||||||
**Freigabepunkt:** R0 wurde am 29.09.2026 mit den in R1 dokumentierten
|
|
||||||
Korrekturen freigegeben. R2 besitzt einen neuen verbindlichen Freigabepunkt:
|
|
||||||
Vor Beginn von R3 muss der Nutzer die in R2 erzeugte Rückgabedatei erfolgreich
|
|
||||||
in Linear getestet haben.
|
|
||||||
811
package-lock.json
generated
811
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -24,10 +24,10 @@
|
||||||
"test": "node scripts/run-tests.mjs",
|
"test": "node scripts/run-tests.mjs",
|
||||||
"test:watch": "node scripts/run-tests.mjs --watch",
|
"test:watch": "node scripts/run-tests.mjs --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "node scripts/run-migrations.js",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
|
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
@ -36,7 +36,6 @@
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"exceljs": "^4.4.0",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.4",
|
||||||
|
|
|
||||||
|
|
@ -4,65 +4,13 @@ const Database = require('better-sqlite3');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const dataDir = path.resolve(
|
const dataDir = path.resolve('data');
|
||||||
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
|
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
|
||||||
|
const db = drizzle(sqlite);
|
||||||
|
|
||||||
async function run() {
|
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
|
||||||
const sqlite = new Database(databasePath);
|
sqlite.close();
|
||||||
try {
|
|
||||||
if (requiresLinearXlsxBackup(sqlite)) {
|
|
||||||
const backupDir = path.join(dataDir, 'backups');
|
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const backupPath = path.join(
|
|
||||||
backupDir,
|
|
||||||
`leistungsbilanz-before-linear-xlsx-${timestamp}.db`
|
|
||||||
);
|
|
||||||
await sqlite.backup(backupPath);
|
|
||||||
verifyBackup(backupPath);
|
|
||||||
console.log(`Verified pre-migration backup created: ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate(drizzle(sqlite), {
|
console.log('Migrations applied');
|
||||||
migrationsFolder: path.resolve('src/db/migrations'),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
sqlite.close();
|
|
||||||
}
|
|
||||||
console.log('Migrations applied');
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiresLinearXlsxBackup(sqlite) {
|
|
||||||
const tables = new Set(
|
|
||||||
sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
||||||
.all()
|
|
||||||
.map((row) => row.name)
|
|
||||||
);
|
|
||||||
return tables.has('external_csv_configurations') &&
|
|
||||||
!tables.has('external_model_configurations');
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyBackup(backupPath) {
|
|
||||||
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
|
||||||
try {
|
|
||||||
const integrity = backup.pragma('integrity_check', { simple: true });
|
|
||||||
const foreignKeyViolations = backup.pragma('foreign_key_check');
|
|
||||||
if (integrity !== 'ok' || foreignKeyViolations.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Pre-migration backup verification failed: integrity=${integrity}, ` +
|
|
||||||
`foreign-key violations=${foreignKeyViolations.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
backup.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error('Migration failed:', error instanceof Error ? error.message : error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
81
scripts/verify-revit-reference-csv.ts
Normal file
81
scripts/verify-revit-reference-csv.ts
Normal file
|
|
@ -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`
|
||||||
|
);
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import {
|
|
||||||
createDefaultExternalXlsxTransportConfiguration,
|
|
||||||
type ExternalXlsxScalar,
|
|
||||||
} from "../src/external-model/xlsx/external-xlsx-contracts.js";
|
|
||||||
import {
|
|
||||||
createExternalXlsxReturn,
|
|
||||||
parseExternalXlsx,
|
|
||||||
} from "../src/external-model/xlsx/external-xlsx-transport.js";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const inputPath = resolve(
|
|
||||||
process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx"
|
|
||||||
);
|
|
||||||
const outputPath = resolve(
|
|
||||||
process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx"
|
|
||||||
);
|
|
||||||
assert.notEqual(
|
|
||||||
inputPath,
|
|
||||||
outputPath,
|
|
||||||
"Eingabe- und Ausgabedatei müssen verschieden sein."
|
|
||||||
);
|
|
||||||
|
|
||||||
const source = readFileSync(inputPath);
|
|
||||||
const configuration = createDefaultExternalXlsxTransportConfiguration();
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: inputPath,
|
|
||||||
bytes: source,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
const objectRows = document.rows.filter((row) => row.classification === "object");
|
|
||||||
const suspectRows = document.rows.filter(
|
|
||||||
(row) => row.classification === "suspect-object"
|
|
||||||
);
|
|
||||||
const worksharingIdColumn = document.columns.find(
|
|
||||||
(column) => column.header === configuration.columns.worksharingId
|
|
||||||
)?.columnNumber;
|
|
||||||
assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst.");
|
|
||||||
|
|
||||||
assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet.");
|
|
||||||
assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet.");
|
|
||||||
assert.equal(
|
|
||||||
document.columns.filter((column) => column.comment !== null).length,
|
|
||||||
14,
|
|
||||||
"Es werden 14 Kopfzeilenkommentare erwartet."
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length,
|
|
||||||
0,
|
|
||||||
"Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten."
|
|
||||||
);
|
|
||||||
|
|
||||||
const testValues = [
|
|
||||||
{
|
|
||||||
circuitCombined: "UV_R2_TEST-2F1",
|
|
||||||
electricalPowerW: 111,
|
|
||||||
voltageV: 230,
|
|
||||||
currentA: 0.48,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
circuitCombined: "UV_R2_TEST-2F2",
|
|
||||||
electricalPowerW: 222,
|
|
||||||
voltageV: 230,
|
|
||||||
currentA: 0.97,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
circuitCombined: "UV_R2_TEST-3F1",
|
|
||||||
electricalPowerW: 333,
|
|
||||||
voltageV: 400,
|
|
||||||
currentA: 0.48,
|
|
||||||
},
|
|
||||||
] satisfies Record<string, ExternalXlsxScalar>[];
|
|
||||||
|
|
||||||
const updates = testValues.map((values, index) => {
|
|
||||||
const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value;
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new Error("Worksharing-ID muss Text sein.");
|
|
||||||
}
|
|
||||||
return { worksharingId: value, values };
|
|
||||||
});
|
|
||||||
const result = await createExternalXlsxReturn({
|
|
||||||
fileName: inputPath,
|
|
||||||
bytes: source,
|
|
||||||
configuration,
|
|
||||||
updates,
|
|
||||||
});
|
|
||||||
assert.equal(result.exportedObjectCount, 487);
|
|
||||||
mkdirSync(dirname(outputPath), { recursive: true });
|
|
||||||
writeFileSync(outputPath, result.bytes);
|
|
||||||
|
|
||||||
const outputDocument = await parseExternalXlsx({
|
|
||||||
fileName: outputPath,
|
|
||||||
bytes: result.bytes,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
outputDocument.rows.filter((row) => row.classification === "object").length,
|
|
||||||
487
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
outputDocument.rows.some((row) => row.classification !== "object"),
|
|
||||||
false,
|
|
||||||
"Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten."
|
|
||||||
);
|
|
||||||
assert.equal(outputDocument.sheetName, document.sheetName);
|
|
||||||
assert.deepEqual(
|
|
||||||
outputDocument.columns,
|
|
||||||
document.columns,
|
|
||||||
"Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben."
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
outputDocument.rows.slice(updates.length).map((row) => row.cells),
|
|
||||||
objectRows.slice(updates.length).map((row) => row.cells),
|
|
||||||
"Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten."
|
|
||||||
);
|
|
||||||
const outputWorkbook = new ExcelJS.Workbook();
|
|
||||||
await outputWorkbook.xlsx.load(result.bytes as never);
|
|
||||||
const commentedCells: string[] = [];
|
|
||||||
outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => {
|
|
||||||
row.eachCell({ includeEmpty: true }, (cell) => {
|
|
||||||
if (cell.note !== undefined) commentedCells.push(cell.address);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
commentedCells.length,
|
|
||||||
14,
|
|
||||||
"Nur die 14 Kopfzellen dürfen Kommentare enthalten."
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
commentedCells.every((address) => address.endsWith("1")),
|
|
||||||
true,
|
|
||||||
"Datenzellen dürfen keine Kommentare enthalten."
|
|
||||||
);
|
|
||||||
|
|
||||||
process.stdout.write(
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
input: inputPath,
|
|
||||||
output: outputPath,
|
|
||||||
inputBytes: source.length,
|
|
||||||
outputBytes: result.bytes.length,
|
|
||||||
inputSha256: createHash("sha256").update(source).digest("hex"),
|
|
||||||
outputSha256: createHash("sha256").update(result.bytes).digest("hex"),
|
|
||||||
sheetName: document.sheetName,
|
|
||||||
objectCount: objectRows.length,
|
|
||||||
headerCommentCount: document.columns.filter(
|
|
||||||
(column) => column.comment !== null
|
|
||||||
).length,
|
|
||||||
exactDuplicateIfcGuidCount: 0,
|
|
||||||
changedObjectCount: updates.length,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main();
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileSpreadsheet,
|
FileUp,
|
||||||
Library,
|
Library,
|
||||||
Link2,
|
Link2,
|
||||||
ListTree,
|
ListTree,
|
||||||
|
|
@ -51,6 +51,7 @@ import {
|
||||||
copyProjectDeviceToGlobal,
|
copyProjectDeviceToGlobal,
|
||||||
createDistributionBoard,
|
createDistributionBoard,
|
||||||
createProjectDevice,
|
createProjectDevice,
|
||||||
|
createRoom,
|
||||||
deleteProjectDevice,
|
deleteProjectDevice,
|
||||||
deleteDistributionBoard,
|
deleteDistributionBoard,
|
||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
|
|
@ -82,6 +83,7 @@ import type {
|
||||||
BuildingDto,
|
BuildingDto,
|
||||||
CircuitListDto,
|
CircuitListDto,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
|
CreateRoomInput,
|
||||||
CreateProjectDeviceInput,
|
CreateProjectDeviceInput,
|
||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
FloorDto,
|
FloorDto,
|
||||||
|
|
@ -113,6 +115,7 @@ import {
|
||||||
} from "../../../frontend/components/project-settings-modal";
|
} from "../../../frontend/components/project-settings-modal";
|
||||||
import { FormModal } from "../../../frontend/components/form-modal";
|
import { FormModal } from "../../../frontend/components/form-modal";
|
||||||
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
||||||
|
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
|
||||||
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
|
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
|
||||||
|
|
||||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
|
|
@ -197,6 +200,7 @@ export default function ProjectDetailPage() {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||||||
|
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
|
||||||
const [structureModal, setStructureModal] = useState<"board" | null>(null);
|
const [structureModal, setStructureModal] = useState<"board" | null>(null);
|
||||||
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
@ -675,6 +679,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;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopyBoard() {
|
async function handleCopyBoard() {
|
||||||
if (
|
if (
|
||||||
!projectId ||
|
!projectId ||
|
||||||
|
|
@ -977,13 +997,15 @@ export default function ProjectDetailPage() {
|
||||||
<Printer aria-hidden="true" size={16} />
|
<Printer aria-hidden="true" size={16} />
|
||||||
Seitenansicht
|
Seitenansicht
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<button
|
||||||
className="btn btn-outline-primary btn-with-icon"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
href={`/projects/${projectId}/revit`}
|
disabled={!project}
|
||||||
|
onClick={() => setIsRevitCsvOpen(true)}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet aria-hidden="true" size={16} strokeWidth={2} />
|
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
|
||||||
Revit
|
Revit-CSV
|
||||||
</Link>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-with-icon"
|
className="btn btn-outline-primary btn-with-icon"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
|
|
@ -1875,6 +1897,16 @@ export default function ProjectDetailPage() {
|
||||||
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{project && isRevitCsvOpen ? (
|
||||||
|
<RevitCsvModal
|
||||||
|
currentRevision={project.currentRevision}
|
||||||
|
onClose={() => setIsRevitCsvOpen(false)}
|
||||||
|
onCreateProjectDevice={handleCreateRevitProjectDevice}
|
||||||
|
onCreateRoom={handleCreateRevitRoom}
|
||||||
|
onRevisionChange={applyProjectRevision}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, FileSpreadsheet, Settings } from "lucide-react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { IconLink } from "../../../../frontend/components/icon-link";
|
|
||||||
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
|
|
||||||
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
|
|
||||||
import { detectExternalModelWorkbook, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api";
|
|
||||||
import { createDefaultRevitXlsxConfiguration } from "../../../../frontend/utils/revit-xlsx-settings";
|
|
||||||
import type { ExternalModelConfigurationSnapshotDto, ProjectDto } from "../../../../frontend/types";
|
|
||||||
|
|
||||||
export default function RevitPage() {
|
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
|
||||||
const [snapshot, setSnapshot] = useState<ExternalModelConfigurationSnapshotDto | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
useEffect(() => { Promise.all([getProject(projectId), getExternalModelConfiguration(projectId)]).then(([loadedProject, result]) => { setProject(loadedProject); setSnapshot(result.configuration); }).catch((value: unknown) => setError(value instanceof Error ? value.message : "Revit-Daten konnten nicht geladen werden.")); }, [projectId]);
|
|
||||||
async function save(configuration: ExternalModelConfiguration) {
|
|
||||||
if (!project) return;
|
|
||||||
setSaving(true); setError(null);
|
|
||||||
try { const result = await updateExternalModelConfiguration(projectId, project.currentRevision, configuration); setSnapshot(result.configuration); setProject({ ...project, currentRevision: result.history.currentRevision }); setOpen(false); }
|
|
||||||
catch (value) { setError(value instanceof Error ? value.message : "Einstellungen konnten nicht gespeichert werden."); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
return <main className="container py-4">
|
|
||||||
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
|
|
||||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
|
||||||
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2><p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden. Der Erstimport folgt in R5.</p></div></section></div></div>
|
|
||||||
{open ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isSaving={saving} onClose={() => setOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
|
||||||
UPDATE `circuit_device_rows`
|
|
||||||
SET `quantity` = `manual_quantity`
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM `external_model_objects`
|
|
||||||
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
|
|
||||||
);--> statement-breakpoint
|
|
||||||
DROP TABLE `external_model_objects`;--> statement-breakpoint
|
|
||||||
DROP TABLE `external_room_mappings`;--> statement-breakpoint
|
|
||||||
DROP TABLE `external_import_batches`;--> statement-breakpoint
|
|
||||||
DROP TABLE `external_model_sources`;--> statement-breakpoint
|
|
||||||
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_model_configurations` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`configuration` text NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_model_sources` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`source_type` text DEFAULT 'revit_xlsx' NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_import_batches` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`import_kind` text NOT NULL,
|
|
||||||
`imported_at_iso` text NOT NULL,
|
|
||||||
`file_name` text NOT NULL,
|
|
||||||
`sha256` text NOT NULL,
|
|
||||||
`object_count` integer NOT NULL,
|
|
||||||
`applied_project_revision` integer,
|
|
||||||
`configuration_snapshot` text,
|
|
||||||
`original_bytes` blob,
|
|
||||||
`document` text,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_room_mappings` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`normalized_source_room_key` text NOT NULL,
|
|
||||||
`source_floor_name` text,
|
|
||||||
`source_room_number` text NOT NULL,
|
|
||||||
`source_room_name` text NOT NULL,
|
|
||||||
`room_id` text,
|
|
||||||
`default_distribution_board_id` text,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `external_model_objects` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`source_id` text NOT NULL,
|
|
||||||
`worksharing_id` text NOT NULL,
|
|
||||||
`ifc_guid` text,
|
|
||||||
`device_type_id` text NOT NULL,
|
|
||||||
`last_seen_import_batch_id` text NOT NULL,
|
|
||||||
`last_accepted_import_batch_id` text NOT NULL,
|
|
||||||
`accepted_source_values` text NOT NULL,
|
|
||||||
`planning_values` text NOT NULL,
|
|
||||||
`overridden_fields` text NOT NULL,
|
|
||||||
`external_room_mapping_id` text,
|
|
||||||
`distribution_board_id` text,
|
|
||||||
`linked_project_device_id` text,
|
|
||||||
`circuit_device_row_id` text,
|
|
||||||
`presence_status` text DEFAULT 'present' NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
|
||||||
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
|
||||||
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
|
|
||||||
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
|
|
||||||
);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -99,13 +99,6 @@
|
||||||
"when": 1790624623732,
|
"when": 1790624623732,
|
||||||
"tag": "0013_users",
|
"tag": "0013_users",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 14,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790748107566,
|
|
||||||
"tag": "0014_linear_xlsx_external_model",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
|
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
export class ExternalModelConfigurationRepository
|
export class ExternalCsvConfigurationRepository
|
||||||
implements ExternalModelConfigurationReader {
|
implements ExternalCsvConfigurationReader
|
||||||
|
{
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
getByProject(projectId: string) {
|
getByProject(projectId: string) {
|
||||||
|
|
@ -14,11 +15,13 @@ export class ExternalModelConfigurationRepository
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.id, projectId))
|
.where(eq(projects.id, projectId))
|
||||||
.get();
|
.get();
|
||||||
if (!project) return { projectExists: false, configuration: null };
|
if (!project) {
|
||||||
|
return { projectExists: false, configuration: null };
|
||||||
|
}
|
||||||
const configuration = this.database
|
const configuration = this.database
|
||||||
.select()
|
.select()
|
||||||
.from(externalModelConfigurations)
|
.from(externalCsvConfigurations)
|
||||||
.where(eq(externalModelConfigurations.projectId, projectId))
|
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
return { projectExists: true, configuration };
|
return { projectExists: true, configuration };
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertExternalInitialImportProjectCommand,
|
||||||
|
createExternalInitialImportProjectCommand,
|
||||||
|
isEmptyExternalModelState,
|
||||||
|
} from "../../domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
|
||||||
|
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
||||||
|
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import {
|
||||||
|
createExternalRoomKey,
|
||||||
|
indexExternalObjectsByIfcGuid,
|
||||||
|
projectInitialExternalObject,
|
||||||
|
} from "../../external-model/domain/external-model-matching.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
|
export class ExternalInitialImportProjectCommandRepository
|
||||||
|
implements ExternalInitialImportProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const currentResult = new ExternalModelStateRepository(tx)
|
||||||
|
.getByProject(input.projectId);
|
||||||
|
if (!currentResult.projectExists) throw new Error("Project not found.");
|
||||||
|
const expected = input.command.payload.expected;
|
||||||
|
if (!sameState(currentResult.state, expected)) {
|
||||||
|
throw new Error("External model state changed before initial import.");
|
||||||
|
}
|
||||||
|
const target = input.command.payload.target;
|
||||||
|
if (!isEmptyExternalModelState(target)) {
|
||||||
|
validatePopulatedTarget(tx, input, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceExternalState(tx, input.projectId, target);
|
||||||
|
const persisted = new ExternalModelStateRepository(tx)
|
||||||
|
.getByProject(input.projectId).state;
|
||||||
|
if (!sameState(persisted, target)) {
|
||||||
|
throw new Error("Persisted external initial import differs from its target.");
|
||||||
|
}
|
||||||
|
return createExternalInitialImportProjectCommand(target, currentResult.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePopulatedTarget(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
const source = target.source!;
|
||||||
|
const batch = target.importBatches[0];
|
||||||
|
if (source.projectId !== input.projectId) {
|
||||||
|
throw new Error("External initial import belongs to a different project.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.source === "user" &&
|
||||||
|
batch.appliedProjectRevision !== input.expectedRevision + 1
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import revision is invalid.");
|
||||||
|
}
|
||||||
|
const configuration = database
|
||||||
|
.select()
|
||||||
|
.from(externalCsvConfigurations)
|
||||||
|
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!configuration ||
|
||||||
|
configuration.configurationVersion !== batch.configurationVersion ||
|
||||||
|
canonicalJson(configuration.configuration) !==
|
||||||
|
canonicalJson(batch.configurationSnapshot)
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import configuration changed before apply.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
|
||||||
|
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (sha256 !== batch.sha256) {
|
||||||
|
throw new Error("External initial import checksum is invalid.");
|
||||||
|
}
|
||||||
|
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
|
||||||
|
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
|
||||||
|
throw new Error("External initial import document differs from the original bytes.");
|
||||||
|
}
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: batch.fileName,
|
||||||
|
bytes,
|
||||||
|
configuration: batch.configurationSnapshot,
|
||||||
|
});
|
||||||
|
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
|
||||||
|
if (targetByIfcGuid.size !== preview.objects.length) {
|
||||||
|
throw new Error("External initial import must contain every CSV object exactly once.");
|
||||||
|
}
|
||||||
|
const mappingsById = new Map(
|
||||||
|
target.roomMappings.map((mapping) => [mapping.id, mapping])
|
||||||
|
);
|
||||||
|
const expectedRoomKeys = new Set<string>();
|
||||||
|
for (const candidate of preview.objects) {
|
||||||
|
const object = targetByIfcGuid.get(candidate.ifcGuid);
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
|
||||||
|
}
|
||||||
|
const projected = projectInitialExternalObject(
|
||||||
|
candidate,
|
||||||
|
batch.configurationSnapshot
|
||||||
|
);
|
||||||
|
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
|
||||||
|
throw new Error("External initial import source values differ from the CSV.");
|
||||||
|
}
|
||||||
|
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
|
||||||
|
if (
|
||||||
|
!object.overriddenFields.includes(field) &&
|
||||||
|
canonicalJson(object.planningValues[field]) !==
|
||||||
|
canonicalJson(projected.planningValues[field])
|
||||||
|
) {
|
||||||
|
throw new Error(`External initial planning field ${field} changed without an override.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||||
|
if (roomKey === null) {
|
||||||
|
if (object.externalRoomMappingId !== null) {
|
||||||
|
throw new Error("External object without a source room must not use a room mapping.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expectedRoomKeys.add(roomKey);
|
||||||
|
const mapping = object.externalRoomMappingId === null
|
||||||
|
? null
|
||||||
|
: mappingsById.get(object.externalRoomMappingId);
|
||||||
|
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
|
||||||
|
throw new Error("External object source room mapping is incomplete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target.roomMappings.length !== expectedRoomKeys.size ||
|
||||||
|
target.roomMappings.some(
|
||||||
|
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import room mappings do not match CSV rooms.");
|
||||||
|
}
|
||||||
|
validateInternalLinks(database, input.projectId, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInternalLinks(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
const roomIds = new Set(
|
||||||
|
database.select({ id: rooms.id }).from(rooms)
|
||||||
|
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
const boardIds = new Set(
|
||||||
|
database.select({ id: distributionBoards.id }).from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
const deviceIds = new Set(
|
||||||
|
database.select({ id: projectDevices.id }).from(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
for (const mapping of target.roomMappings) {
|
||||||
|
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
|
||||||
|
throw new Error("External room mapping references another project.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mapping.defaultDistributionBoardId !== null &&
|
||||||
|
!boardIds.has(mapping.defaultDistributionBoardId)
|
||||||
|
) {
|
||||||
|
throw new Error("External room mapping distribution board belongs to another project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const object of target.objects) {
|
||||||
|
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
|
||||||
|
throw new Error("External object distribution board belongs to another project.");
|
||||||
|
}
|
||||||
|
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
|
||||||
|
throw new Error("External object project device belongs to another project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceExternalState(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
database.delete(externalModelSources)
|
||||||
|
.where(eq(externalModelSources.projectId, projectId)).run();
|
||||||
|
if (target.source === null) return;
|
||||||
|
database.insert(externalModelSources).values(target.source).run();
|
||||||
|
database.insert(externalImportBatches).values(
|
||||||
|
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
|
||||||
|
...batch,
|
||||||
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
|
}))
|
||||||
|
).run();
|
||||||
|
if (target.roomMappings.length) {
|
||||||
|
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||||
|
}
|
||||||
|
if (target.objects.length) {
|
||||||
|
database.insert(externalModelObjects).values(target.objects).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCanonicalBase64(value: string) {
|
||||||
|
const bytes = Buffer.from(value, "base64");
|
||||||
|
if (bytes.toString("base64") !== value) {
|
||||||
|
throw new Error("External initial import bytes are not canonical base64.");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
|
||||||
|
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
|
||||||
|
const byId = <T extends { id: string }>(entries: readonly T[]) =>
|
||||||
|
[...entries].sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
return {
|
||||||
|
source: state.source,
|
||||||
|
importBatches: byId(state.importBatches),
|
||||||
|
roomMappings: byId(state.roomMappings),
|
||||||
|
objects: byId(state.objects),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record).sort().map(
|
||||||
|
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||||
|
).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
assertExternalModelConfigurationUpdateProjectCommand,
|
|
||||||
createExternalModelConfigurationUpdateProjectCommand,
|
|
||||||
type ExternalModelConfigurationSnapshot,
|
|
||||||
} from "../../domain/models/external-model-configuration-project-command.model.js";
|
|
||||||
import type {
|
|
||||||
ExecuteExternalModelConfigurationCommandInput,
|
|
||||||
ExternalModelConfigurationProjectCommandStore,
|
|
||||||
} from "../../domain/ports/external-model-configuration-project-command.store.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
|
||||||
import { projects } from "../schema/projects.js";
|
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
||||||
|
|
||||||
export class ExternalModelConfigurationProjectCommandRepository
|
|
||||||
implements ExternalModelConfigurationProjectCommandStore {
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
execute(input: ExecuteExternalModelConfigurationCommandInput) {
|
|
||||||
assertExternalModelConfigurationUpdateProjectCommand(input.command);
|
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
|
||||||
const project = tx.select({ id: projects.id }).from(projects)
|
|
||||||
.where(eq(projects.id, input.projectId)).get();
|
|
||||||
if (!project) throw new Error("Project not found.");
|
|
||||||
const current = tx.select().from(externalModelConfigurations)
|
|
||||||
.where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
|
||||||
assertSameSnapshot(current, input.command.payload.expected);
|
|
||||||
const target = input.command.payload.target;
|
|
||||||
if (target !== null && target.projectId !== input.projectId) {
|
|
||||||
throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
|
|
||||||
}
|
|
||||||
if (sameSnapshot(current, target)) {
|
|
||||||
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
|
|
||||||
}
|
|
||||||
if (target === null) {
|
|
||||||
tx.delete(externalModelConfigurations)
|
|
||||||
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
|
|
||||||
} else if (current === null) {
|
|
||||||
tx.insert(externalModelConfigurations).values(target).run();
|
|
||||||
} else {
|
|
||||||
const updated = tx.update(externalModelConfigurations)
|
|
||||||
.set({ configuration: target.configuration })
|
|
||||||
.where(eq(externalModelConfigurations.id, current.id)).run();
|
|
||||||
if (updated.changes !== 1) {
|
|
||||||
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createExternalModelConfigurationUpdateProjectCommand(target, current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSameSnapshot(
|
|
||||||
current: ExternalModelConfigurationSnapshot | null,
|
|
||||||
expected: ExternalModelConfigurationSnapshot | null
|
|
||||||
) {
|
|
||||||
if (!sameSnapshot(current, expected)) {
|
|
||||||
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameSnapshot(
|
|
||||||
left: ExternalModelConfigurationSnapshot | null,
|
|
||||||
right: ExternalModelConfigurationSnapshot | null
|
|
||||||
) {
|
|
||||||
return canonicalJson(left) === canonicalJson(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
||||||
if (value !== null && typeof value === "object") {
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
return `{${Object.keys(record).sort().map(
|
|
||||||
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
||||||
).join(",")}}`;
|
|
||||||
}
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
@ -55,10 +55,7 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
.select()
|
.select()
|
||||||
.from(externalModelObjects)
|
.from(externalModelObjects)
|
||||||
.where(eq(externalModelObjects.sourceId, source.id))
|
.where(eq(externalModelObjects.sourceId, source.id))
|
||||||
.orderBy(
|
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
|
||||||
asc(externalModelObjects.worksharingId),
|
|
||||||
asc(externalModelObjects.id)
|
|
||||||
)
|
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,10 +64,7 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
source,
|
source,
|
||||||
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalContentBase64:
|
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
|
||||||
originalBytes === null
|
|
||||||
? null
|
|
||||||
: Buffer.from(originalBytes).toString("base64"),
|
|
||||||
})),
|
})),
|
||||||
roomMappings,
|
roomMappings,
|
||||||
objects,
|
objects,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
|
||||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||||
const markers = new Set(
|
const markers = new Set(
|
||||||
input.allLinkedObjects.map((object) =>
|
input.allLinkedObjects.map((object) =>
|
||||||
scalarText(object.acceptedSourceValues.selectionMarker).trim()
|
object.acceptedSourceValues.selectionMarker.trim()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (markers.size > 1) {
|
if (markers.size > 1) {
|
||||||
|
|
@ -118,17 +118,13 @@ function matchesRowPlanningValues(
|
||||||
row.category === planning.category &&
|
row.category === planning.category &&
|
||||||
row.connectionKind === planning.connectionKind &&
|
row.connectionKind === planning.connectionKind &&
|
||||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||||
row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
|
row.simultaneityFactor === planning.simultaneityFactor &&
|
||||||
row.cosPhi === planning.cosPhi &&
|
row.cosPhi === planning.cosPhi &&
|
||||||
row.costGroup === planning.costGroup &&
|
row.costGroup === planning.costGroup &&
|
||||||
row.remark === planning.remark
|
row.remark === planning.remark
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scalarText(value: string | number | boolean | null) {
|
|
||||||
return value === null ? "" : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
function canonicalJson(value: unknown): string {
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
if (value !== null && typeof value === "object") {
|
if (value !== null && typeof value === "object") {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
|
@ -241,7 +241,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
buildings: byId(state.buildings),
|
buildings: byId(state.buildings),
|
||||||
floors: byId(state.floors),
|
floors: byId(state.floors),
|
||||||
rooms: byId(state.rooms),
|
rooms: byId(state.rooms),
|
||||||
externalModelConfiguration: state.externalModelConfiguration,
|
externalCsvConfiguration: state.externalCsvConfiguration,
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: state.externalModel.source,
|
source: state.externalModel.source,
|
||||||
importBatches: byId(state.externalModel.importBatches),
|
importBatches: byId(state.externalModel.importBatches),
|
||||||
|
|
@ -283,8 +283,8 @@ function replaceProjectState(
|
||||||
.where(eq(externalModelSources.projectId, state.project.id))
|
.where(eq(externalModelSources.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalModelConfigurations)
|
.delete(externalCsvConfigurations)
|
||||||
.where(eq(externalModelConfigurations.projectId, state.project.id))
|
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
|
|
@ -330,10 +330,10 @@ function replaceProjectState(
|
||||||
|
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
if (state.externalModelConfiguration !== null) {
|
if (state.externalCsvConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalModelConfigurations)
|
.insert(externalCsvConfigurations)
|
||||||
.values(state.externalModelConfiguration)
|
.values(state.externalCsvConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
|
|
@ -387,10 +387,7 @@ function replaceProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes:
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
originalContentBase64 === null
|
|
||||||
? null
|
|
||||||
: Buffer.from(originalContentBase64, "base64"),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
export interface PersistedProjectStateSnapshot {
|
export interface PersistedProjectStateSnapshot {
|
||||||
|
|
@ -66,10 +66,10 @@ export function readProjectStateSnapshot(
|
||||||
.where(eq(distributionBoards.projectId, projectId))
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||||
.all();
|
.all();
|
||||||
const externalModelConfiguration = database
|
const externalCsvConfiguration = database
|
||||||
.select()
|
.select()
|
||||||
.from(externalModelConfigurations)
|
.from(externalCsvConfigurations)
|
||||||
.where(eq(externalModelConfigurations.projectId, projectId))
|
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
const externalModel = new ExternalModelStateRepository(database)
|
const externalModel = new ExternalModelStateRepository(database)
|
||||||
.getByProject(projectId).state;
|
.getByProject(projectId).state;
|
||||||
|
|
@ -266,7 +266,7 @@ export function readProjectStateSnapshot(
|
||||||
projectEditor: project.projectEditor,
|
projectEditor: project.projectEditor,
|
||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
externalModelConfiguration,
|
externalCsvConfiguration,
|
||||||
externalModel,
|
externalModel,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
circuitSections: sectionRows,
|
circuitSections: sectionRows,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
|
@ -163,10 +163,10 @@ function insertProjectState(
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({ ...state.project, currentRevision: 0 })
|
.values({ ...state.project, currentRevision: 0 })
|
||||||
.run();
|
.run();
|
||||||
if (state.externalModelConfiguration !== null) {
|
if (state.externalCsvConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalModelConfigurations)
|
.insert(externalCsvConfigurations)
|
||||||
.values(state.externalModelConfiguration)
|
.values(state.externalCsvConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
|
|
@ -240,10 +240,7 @@ function insertProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes:
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
originalContentBase64 === null
|
|
||||||
? null
|
|
||||||
: Buffer.from(originalContentBase64, "base64"),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
22
src/db/schema/external-csv-configurations.ts
Normal file
22
src/db/schema/external-csv-configurations.ts
Normal file
|
|
@ -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
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
import type {
|
||||||
|
ExternalCsvConfiguration,
|
||||||
|
ExternalCsvDocument,
|
||||||
|
} from "../../external-model/csv/external-csv-contracts.js";
|
||||||
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
|
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
|
||||||
import type { ExternalXlsxDocument } from "../../external-model/xlsx/external-xlsx-contracts.js";
|
|
||||||
import { externalModelSources } from "./external-model-sources.js";
|
import { externalModelSources } from "./external-model-sources.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
|
@ -19,13 +21,15 @@ export const externalImportBatches = sqliteTable(
|
||||||
importedAtIso: text("imported_at_iso").notNull(),
|
importedAtIso: text("imported_at_iso").notNull(),
|
||||||
fileName: text("file_name").notNull(),
|
fileName: text("file_name").notNull(),
|
||||||
sha256: text("sha256").notNull(),
|
sha256: text("sha256").notNull(),
|
||||||
objectCount: integer("object_count").notNull(),
|
appliedProjectRevision: integer("applied_project_revision").notNull(),
|
||||||
appliedProjectRevision: integer("applied_project_revision"),
|
configurationVersion: integer("configuration_version").notNull(),
|
||||||
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
||||||
.$type<ExternalModelConfiguration>(),
|
.$type<ExternalCsvConfiguration>()
|
||||||
originalBytes: blob("original_bytes", { mode: "buffer" }),
|
.notNull(),
|
||||||
|
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
|
||||||
document: text("document", { mode: "json" })
|
document: text("document", { mode: "json" })
|
||||||
.$type<ExternalXlsxDocument>(),
|
.$type<ExternalCsvDocument>()
|
||||||
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("external_import_batches_project_revision_idx").on(
|
index("external_import_batches_project_revision_idx").on(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
export const externalModelConfigurations = sqliteTable(
|
|
||||||
"external_model_configurations",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
configuration: text("configuration", { mode: "json" })
|
|
||||||
.$type<ExternalModelConfiguration>()
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("external_model_configurations_project_id_unique").on(
|
|
||||||
table.projectId
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
@ -23,9 +23,7 @@ export const externalModelObjects = sqliteTable(
|
||||||
sourceId: text("source_id")
|
sourceId: text("source_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
||||||
worksharingId: text("worksharing_id").notNull(),
|
ifcGuid: text("ifc_guid").notNull(),
|
||||||
ifcGuid: text("ifc_guid"),
|
|
||||||
deviceTypeId: text("device_type_id").notNull(),
|
|
||||||
lastSeenImportBatchId: text("last_seen_import_batch_id")
|
lastSeenImportBatchId: text("last_seen_import_batch_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
||||||
|
|
@ -64,27 +62,17 @@ export const externalModelObjects = sqliteTable(
|
||||||
.default("present"),
|
.default("present"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("external_model_objects_source_worksharing_id_unique").on(
|
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
|
||||||
table.sourceId,
|
table.sourceId,
|
||||||
table.worksharingId
|
table.ifcGuid
|
||||||
),
|
),
|
||||||
index("external_model_objects_project_presence_idx").on(
|
index("external_model_objects_project_presence_idx").on(
|
||||||
table.projectId,
|
table.projectId,
|
||||||
table.presenceStatus
|
table.presenceStatus
|
||||||
),
|
),
|
||||||
index("external_model_objects_project_device_type_idx").on(
|
|
||||||
table.projectId,
|
|
||||||
table.deviceTypeId
|
|
||||||
),
|
|
||||||
index("external_model_objects_external_room_mapping_idx").on(
|
|
||||||
table.externalRoomMappingId
|
|
||||||
),
|
|
||||||
index("external_model_objects_distribution_board_idx").on(
|
index("external_model_objects_distribution_board_idx").on(
|
||||||
table.distributionBoardId
|
table.distributionBoardId
|
||||||
),
|
),
|
||||||
index("external_model_objects_linked_project_device_idx").on(
|
|
||||||
table.linkedProjectDeviceId
|
|
||||||
),
|
|
||||||
index("external_model_objects_circuit_device_row_idx").on(
|
index("external_model_objects_circuit_device_row_idx").on(
|
||||||
table.circuitDeviceRowId
|
table.circuitDeviceRowId
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ export const externalModelSources = sqliteTable(
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
sourceType: text("source_type", { enum: externalModelSourceTypes })
|
sourceType: text("source_type", { enum: externalModelSourceTypes })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("revit_xlsx"),
|
.default("revit_csv"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("external_model_sources_project_unique").on(table.projectId),
|
uniqueIndex("external_model_sources_project_type_unique").on(
|
||||||
|
table.projectId,
|
||||||
|
table.sourceType
|
||||||
|
),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import {
|
|
||||||
assertExternalModelConfiguration,
|
|
||||||
type ExternalModelConfiguration,
|
|
||||||
} from "../../external-model/configuration/external-model-configuration.js";
|
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
|
||||||
|
|
||||||
export const externalModelConfigurationUpdateCommandType =
|
|
||||||
"external-model-configuration.update" as const;
|
|
||||||
export const externalModelConfigurationCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationSnapshot {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
configuration: ExternalModelConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationUpdatePayload {
|
|
||||||
expected: ExternalModelConfigurationSnapshot | null;
|
|
||||||
target: ExternalModelConfigurationSnapshot | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<ExternalModelConfigurationUpdatePayload> {
|
|
||||||
schemaVersion: typeof externalModelConfigurationCommandSchemaVersion;
|
|
||||||
type: typeof externalModelConfigurationUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalModelConfigurationUpdateProjectCommand(
|
|
||||||
expected: ExternalModelConfigurationSnapshot | null,
|
|
||||||
target: ExternalModelConfigurationSnapshot | null
|
|
||||||
): ExternalModelConfigurationUpdateProjectCommand {
|
|
||||||
const command: ExternalModelConfigurationUpdateProjectCommand = {
|
|
||||||
schemaVersion: externalModelConfigurationCommandSchemaVersion,
|
|
||||||
type: externalModelConfigurationUpdateCommandType,
|
|
||||||
payload: { expected, target },
|
|
||||||
};
|
|
||||||
assertExternalModelConfigurationUpdateProjectCommand(command);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertExternalModelConfigurationUpdateProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ExternalModelConfigurationUpdateProjectCommand {
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== externalModelConfigurationCommandSchemaVersion ||
|
|
||||||
command.type !== externalModelConfigurationUpdateCommandType ||
|
|
||||||
!isRecord(command.payload) ||
|
|
||||||
Object.keys(command.payload).length !== 2
|
|
||||||
) {
|
|
||||||
throw new Error("Nicht unterstützter Revit-XLSX-Konfigurationscommand.");
|
|
||||||
}
|
|
||||||
assertSnapshot(command.payload.expected, "expected");
|
|
||||||
assertSnapshot(command.payload.target, "target");
|
|
||||||
if (command.payload.expected === null && command.payload.target === null) {
|
|
||||||
throw new Error("Der Konfigurationscommand muss den Zustand ändern.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
command.payload.expected !== null &&
|
|
||||||
command.payload.target !== null &&
|
|
||||||
(command.payload.expected.id !== command.payload.target.id ||
|
|
||||||
command.payload.expected.projectId !== command.payload.target.projectId)
|
|
||||||
) {
|
|
||||||
throw new Error("Die Identität der Revit-XLSX-Konfiguration muss stabil bleiben.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSnapshot(
|
|
||||||
value: unknown,
|
|
||||||
field: string
|
|
||||||
): asserts value is ExternalModelConfigurationSnapshot | null {
|
|
||||||
if (value === null) return;
|
|
||||||
if (!isRecord(value) || Object.keys(value).length !== 3) {
|
|
||||||
throw new Error(`${field} muss eine vollständige Konfiguration oder null sein.`);
|
|
||||||
}
|
|
||||||
assertId(value.id, `${field}.id`);
|
|
||||||
assertId(value.projectId, `${field}.projectId`);
|
|
||||||
assertExternalModelConfiguration(value.configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertId(value: unknown, field: string) {
|
|
||||||
if (typeof value !== "string" || !value.trim()) {
|
|
||||||
throw new Error(`${field} muss eine nichtleere ID sein.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
@ -28,10 +28,6 @@ import {
|
||||||
} from "../services/project-voltage.service.js";
|
} from "../services/project-voltage.service.js";
|
||||||
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
||||||
import {
|
|
||||||
validateExternalModelConfiguration,
|
|
||||||
type ExternalModelConfiguration,
|
|
||||||
} from "../../external-model/configuration/external-model-configuration.js";
|
|
||||||
import {
|
import {
|
||||||
externalImportKinds,
|
externalImportKinds,
|
||||||
externalModelSourceTypes,
|
externalModelSourceTypes,
|
||||||
|
|
@ -39,8 +35,7 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 10 as const;
|
export const projectStateSnapshotSchemaVersion = 9 as const;
|
||||||
const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const;
|
|
||||||
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
|
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
|
||||||
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
||||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||||
|
|
@ -428,14 +423,14 @@ const externalCsvDocumentSchema = z.object({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const legacyExternalModelSourceSchema = z.object({
|
const externalModelSourceSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
sourceType: z.literal("revit_csv"),
|
sourceType: z.enum(externalModelSourceTypes),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
const legacyExternalImportBatchSchema = z.object({
|
const externalImportBatchSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
sourceId: idSchema,
|
sourceId: idSchema,
|
||||||
|
|
@ -466,7 +461,7 @@ const externalRoomMappingSchema = z.object({
|
||||||
defaultDistributionBoardId: idSchema.nullable(),
|
defaultDistributionBoardId: idSchema.nullable(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
const legacyExternalPlanningFieldNames = [
|
const externalPlanningFieldNames = [
|
||||||
"displayName",
|
"displayName",
|
||||||
"internalDeviceType",
|
"internalDeviceType",
|
||||||
"category",
|
"category",
|
||||||
|
|
@ -479,7 +474,7 @@ const legacyExternalPlanningFieldNames = [
|
||||||
"remark",
|
"remark",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const legacyExternalModelObjectSchema = z.object({
|
const externalModelObjectSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
sourceId: idSchema,
|
sourceId: idSchema,
|
||||||
|
|
@ -509,170 +504,6 @@ const legacyExternalModelObjectSchema = z.object({
|
||||||
costGroup: nullableStringSchema,
|
costGroup: nullableStringSchema,
|
||||||
remark: nullableStringSchema,
|
remark: nullableStringSchema,
|
||||||
}).strict(),
|
}).strict(),
|
||||||
overriddenFields: z.array(z.enum(legacyExternalPlanningFieldNames)).refine(
|
|
||||||
(values) => new Set(values).size === values.length
|
|
||||||
),
|
|
||||||
externalRoomMappingId: idSchema.nullable(),
|
|
||||||
distributionBoardId: idSchema.nullable(),
|
|
||||||
linkedProjectDeviceId: idSchema.nullable(),
|
|
||||||
circuitDeviceRowId: idSchema.nullable(),
|
|
||||||
presenceStatus: z.enum(externalObjectPresenceStatuses),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
const legacyExternalModelStateSchema = z.object({
|
|
||||||
source: legacyExternalModelSourceSchema.nullable(),
|
|
||||||
importBatches: z.array(legacyExternalImportBatchSchema),
|
|
||||||
roomMappings: z.array(externalRoomMappingSchema),
|
|
||||||
objects: z.array(legacyExternalModelObjectSchema),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
const externalModelConfigurationSnapshotSchema = z.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
configuration: z.custom<ExternalModelConfiguration>(
|
|
||||||
(value) => validateExternalModelConfiguration(value).success
|
|
||||||
),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
const externalXlsxScalarSchema = z.union([
|
|
||||||
z.string(),
|
|
||||||
finiteNumberSchema,
|
|
||||||
z.boolean(),
|
|
||||||
z.null(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const externalXlsxDocumentSchema = z.object({
|
|
||||||
sheetName: z.string().min(1).max(31),
|
|
||||||
headerRowNumber: z.number().int().positive(),
|
|
||||||
firstDataRowNumber: z.number().int().positive(),
|
|
||||||
firstDataColumnNumber: z.number().int().positive(),
|
|
||||||
columns: z.array(z.object({
|
|
||||||
columnNumber: z.number().int().positive(),
|
|
||||||
header: z.string(),
|
|
||||||
comment: z.string().nullable(),
|
|
||||||
width: finiteNumberSchema.positive().nullable(),
|
|
||||||
}).strict()),
|
|
||||||
rows: z.array(z.object({
|
|
||||||
rowNumber: z.number().int().positive(),
|
|
||||||
classification: z.enum(["object", "suspect-object", "passthrough"]),
|
|
||||||
cells: z.array(z.object({
|
|
||||||
columnNumber: z.number().int().positive(),
|
|
||||||
value: externalXlsxScalarSchema,
|
|
||||||
valueType: z.enum(["empty", "string", "number", "boolean"]),
|
|
||||||
numberFormat: z.string().nullable(),
|
|
||||||
formula: z.string().nullable(),
|
|
||||||
usedCachedFormulaValue: z.boolean(),
|
|
||||||
}).strict()),
|
|
||||||
}).strict()),
|
|
||||||
issues: z.array(z.object({
|
|
||||||
code: z.enum([
|
|
||||||
"cached-formula-value-used",
|
|
||||||
"formula-without-cached-value",
|
|
||||||
"suspect-object-skipped",
|
|
||||||
"duplicate-ifc-guid",
|
|
||||||
"optional-return-column-missing",
|
|
||||||
]),
|
|
||||||
message: z.string().min(1),
|
|
||||||
rowNumber: z.number().int().positive().optional(),
|
|
||||||
columnNumber: z.number().int().positive().optional(),
|
|
||||||
field: z.enum([
|
|
||||||
"worksharingId", "ifcGuid", "roomNumber", "roomName",
|
|
||||||
"familyAndType", "selectionMarker", "socketCount", "sourceRemark",
|
|
||||||
"circuitCombined", "boardName", "circuitIdentifier", "designation",
|
|
||||||
"electricalPowerW", "voltageV", "currentA",
|
|
||||||
]).optional(),
|
|
||||||
}).strict()),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
const externalModelSourceSchema = z.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
sourceType: z.enum(externalModelSourceTypes),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
const externalImportBatchSchema = z.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
sourceId: idSchema,
|
|
||||||
importKind: z.enum(externalImportKinds),
|
|
||||||
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
|
|
||||||
fileName: z.string().trim().min(1),
|
|
||||||
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
||||||
objectCount: z.number().int().nonnegative(),
|
|
||||||
appliedProjectRevision: z.number().int().nonnegative().nullable(),
|
|
||||||
configurationSnapshot: z.custom<ExternalModelConfiguration>(
|
|
||||||
(value) => value === null || validateExternalModelConfiguration(value).success
|
|
||||||
).nullable(),
|
|
||||||
originalContentBase64: z.string().min(1).regex(
|
|
||||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
||||||
).nullable(),
|
|
||||||
document: externalXlsxDocumentSchema.nullable(),
|
|
||||||
}).strict().superRefine((batch, context) => {
|
|
||||||
const complete = [
|
|
||||||
batch.appliedProjectRevision,
|
|
||||||
batch.configurationSnapshot,
|
|
||||||
batch.originalContentBase64,
|
|
||||||
batch.document,
|
|
||||||
].map((value) => value !== null);
|
|
||||||
if (!complete.every((value) => value === complete[0])) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Importbatch muss vollständig oder auf Metadaten reduziert sein.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const externalPlanningFieldNames = [
|
|
||||||
"displayName",
|
|
||||||
"category",
|
|
||||||
"connectionKind",
|
|
||||||
"effectiveQuantity",
|
|
||||||
"powerPerUnitW",
|
|
||||||
"simultaneityFactor",
|
|
||||||
"cosPhi",
|
|
||||||
"costGroup",
|
|
||||||
"remark",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const externalModelObjectSchema = z.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
sourceId: idSchema,
|
|
||||||
worksharingId: z.string().regex(
|
|
||||||
/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/
|
|
||||||
),
|
|
||||||
ifcGuid: externalIfcGuidSchema.nullable(),
|
|
||||||
deviceTypeId: idSchema,
|
|
||||||
lastSeenImportBatchId: idSchema,
|
|
||||||
lastAcceptedImportBatchId: idSchema,
|
|
||||||
acceptedSourceValues: z.object({
|
|
||||||
rowNumber: z.number().int().positive(),
|
|
||||||
roomNumber: externalXlsxScalarSchema,
|
|
||||||
roomName: externalXlsxScalarSchema,
|
|
||||||
familyAndType: z.string().min(1),
|
|
||||||
selectionMarker: externalXlsxScalarSchema,
|
|
||||||
socketCount: externalXlsxScalarSchema,
|
|
||||||
sourceRemark: externalXlsxScalarSchema,
|
|
||||||
circuitCombined: externalXlsxScalarSchema,
|
|
||||||
boardName: externalXlsxScalarSchema,
|
|
||||||
circuitIdentifier: externalXlsxScalarSchema,
|
|
||||||
designation: externalXlsxScalarSchema,
|
|
||||||
electricalPowerW: externalXlsxScalarSchema,
|
|
||||||
voltageV: externalXlsxScalarSchema,
|
|
||||||
currentA: externalXlsxScalarSchema,
|
|
||||||
}).strict(),
|
|
||||||
planningValues: z.object({
|
|
||||||
displayName: nullableStringSchema,
|
|
||||||
category: z.enum(circuitGroupCategories).nullable(),
|
|
||||||
connectionKind: nullableStringSchema,
|
|
||||||
effectiveQuantity: finiteNumberSchema.positive(),
|
|
||||||
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
|
|
||||||
simultaneityFactor: finiteNumberSchema.min(0).max(1).nullable(),
|
|
||||||
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
|
|
||||||
costGroup: nullableStringSchema,
|
|
||||||
remark: nullableStringSchema,
|
|
||||||
}).strict(),
|
|
||||||
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
|
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
|
||||||
(values) => new Set(values).size === values.length
|
(values) => new Set(values).size === values.length
|
||||||
),
|
),
|
||||||
|
|
@ -688,21 +519,7 @@ const externalModelStateSchema = z.object({
|
||||||
importBatches: z.array(externalImportBatchSchema),
|
importBatches: z.array(externalImportBatchSchema),
|
||||||
roomMappings: z.array(externalRoomMappingSchema),
|
roomMappings: z.array(externalRoomMappingSchema),
|
||||||
objects: z.array(externalModelObjectSchema),
|
objects: z.array(externalModelObjectSchema),
|
||||||
}).strict().superRefine((state, context) => {
|
}).strict();
|
||||||
const fullBatchIndexes = state.importBatches.flatMap((batch, index) =>
|
|
||||||
batch.originalContentBase64 === null ? [] : [index]
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
state.importBatches.length > 0 &&
|
|
||||||
(fullBatchIndexes.length !== 1 ||
|
|
||||||
fullBatchIndexes[0] !== state.importBatches.length - 1)
|
|
||||||
) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Nur der letzte Importbatch darf vollständig gespeichert sein.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function parseExternalModelStateSnapshot(
|
export function parseExternalModelStateSnapshot(
|
||||||
value: unknown
|
value: unknown
|
||||||
|
|
@ -748,19 +565,8 @@ export const projectStateSnapshotSchema = z
|
||||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
|
|
||||||
externalModel: externalModelStateSchema,
|
|
||||||
...projectStateSnapshotContents,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const legacyCsvProjectStateSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion),
|
|
||||||
project: projectSchema,
|
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -771,7 +577,7 @@ const roomAreaProjectStateSnapshotSchema = z
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -782,7 +588,7 @@ const buildingProjectStateSnapshotSchema = z
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...buildingProjectStateSnapshotContents,
|
...buildingProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -793,7 +599,7 @@ const coverProjectStateSnapshotSchema = z
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -804,7 +610,7 @@ const boardInfoProjectStateSnapshotSchema = z
|
||||||
project: publicBuildingProjectSchema,
|
project: publicBuildingProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -815,7 +621,7 @@ const previousProjectStateSnapshotSchema = z
|
||||||
project: publicBuildingProjectSchema,
|
project: publicBuildingProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: legacyExternalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -878,39 +684,6 @@ export function parseProjectStateSnapshot(
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value);
|
|
||||||
if (
|
|
||||||
legacyCsv !== null &&
|
|
||||||
typeof legacyCsv === "object" &&
|
|
||||||
!Array.isArray(legacyCsv) &&
|
|
||||||
(legacyCsv as { schemaVersion?: unknown }).schemaVersion ===
|
|
||||||
legacyCsvProjectStateSnapshotSchemaVersion
|
|
||||||
) {
|
|
||||||
const previous = legacyCsvProjectStateSnapshotSchema.parse(legacyCsv);
|
|
||||||
if (
|
|
||||||
previous.externalCsvConfiguration !== null ||
|
|
||||||
!isEmptyExternalModelState(previous.externalModel)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Dieser alte Projektstand enthält Revit-CSV-Daten und kann nicht auf die Revit-XLSX-Struktur aktualisiert werden."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
externalCsvConfiguration: _externalCsvConfiguration,
|
|
||||||
externalModel: _legacyExternalModel,
|
|
||||||
...remaining
|
|
||||||
} = previous;
|
|
||||||
return {
|
|
||||||
...remaining,
|
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
|
||||||
externalModelConfiguration: null,
|
|
||||||
externalModel: emptyExternalModelState(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return legacyCsv;
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown {
|
|
||||||
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
|
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
|
||||||
if (
|
if (
|
||||||
roomArea !== null &&
|
roomArea !== null &&
|
||||||
|
|
@ -922,7 +695,7 @@ function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown {
|
||||||
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
|
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
distributionBoards: withEmptyDistributionBoardFeeding(
|
distributionBoards: withEmptyDistributionBoardFeeding(
|
||||||
previous.distributionBoards
|
previous.distributionBoards
|
||||||
),
|
),
|
||||||
|
|
@ -1057,20 +830,6 @@ function emptyExternalModelState() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEmptyExternalModelState(state: {
|
|
||||||
source: unknown;
|
|
||||||
importBatches: unknown[];
|
|
||||||
roomMappings: unknown[];
|
|
||||||
objects: unknown[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
state.source === null &&
|
|
||||||
state.importBatches.length === 0 &&
|
|
||||||
state.roomMappings.length === 0 &&
|
|
||||||
state.objects.length === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSnapshotPhaseTypes(
|
function normalizeSnapshotPhaseTypes(
|
||||||
snapshot: ProjectStateSnapshot
|
snapshot: ProjectStateSnapshot
|
||||||
): ProjectStateSnapshot {
|
): ProjectStateSnapshot {
|
||||||
|
|
@ -1132,10 +891,10 @@ function assertProjectStateSnapshotRelations(
|
||||||
) {
|
) {
|
||||||
const projectId = snapshot.project.id;
|
const projectId = snapshot.project.id;
|
||||||
if (
|
if (
|
||||||
snapshot.externalModelConfiguration !== null &&
|
snapshot.externalCsvConfiguration !== null &&
|
||||||
snapshot.externalModelConfiguration.projectId !== projectId
|
snapshot.externalCsvConfiguration.projectId !== projectId
|
||||||
) {
|
) {
|
||||||
throw new Error("Snapshot Revit-XLSX configuration belongs to a different project.");
|
throw new Error("Snapshot external CSV configuration belongs to a different project.");
|
||||||
}
|
}
|
||||||
const boardIds = uniqueIds(
|
const boardIds = uniqueIds(
|
||||||
snapshot.distributionBoards,
|
snapshot.distributionBoards,
|
||||||
|
|
@ -1476,12 +1235,6 @@ function assertExternalModelRelations(
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (snapshot.externalModelConfiguration === null) {
|
|
||||||
throw new Error("Snapshot external model requires a Revit-XLSX configuration.");
|
|
||||||
}
|
|
||||||
if (importBatches.length === 0) {
|
|
||||||
throw new Error("Snapshot external model source requires an import batch.");
|
|
||||||
}
|
|
||||||
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
||||||
const batchIds = uniqueIds(importBatches, "external import batch");
|
const batchIds = uniqueIds(importBatches, "external import batch");
|
||||||
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
||||||
|
|
@ -1517,23 +1270,17 @@ function assertExternalModelRelations(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const worksharingIds = new Set<string>();
|
const ifcGuids = new Set<string>();
|
||||||
const deviceTypeIds = new Set(
|
|
||||||
snapshot.externalModelConfiguration.configuration.deviceTypes.map(
|
|
||||||
(deviceType) => deviceType.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
for (const object of objects) {
|
for (const object of objects) {
|
||||||
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
||||||
if (object.sourceId !== source.id) {
|
if (object.sourceId !== source.id) {
|
||||||
throw new Error("Snapshot external model object belongs to a different source.");
|
throw new Error("Snapshot external model object belongs to a different source.");
|
||||||
}
|
}
|
||||||
assertUniqueKey(
|
assertUniqueKey(
|
||||||
worksharingIds,
|
ifcGuids,
|
||||||
object.worksharingId,
|
object.ifcGuid,
|
||||||
"Snapshot contains duplicate external Worksharing-IDs."
|
"Snapshot contains duplicate external IFCGUIDs."
|
||||||
);
|
);
|
||||||
assertReference(deviceTypeIds, object.deviceTypeId, "external object device type");
|
|
||||||
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
||||||
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
||||||
if (object.externalRoomMappingId !== null) {
|
if (object.externalRoomMappingId !== null) {
|
||||||
|
|
|
||||||
|
|
@ -146,35 +146,16 @@ export function remapProjectState(
|
||||||
createId
|
createId
|
||||||
);
|
);
|
||||||
const externalObjectIds = idMap(source.externalModel.objects, createId);
|
const externalObjectIds = idMap(source.externalModel.objects, createId);
|
||||||
const configurations = [
|
|
||||||
source.externalModelConfiguration?.configuration ?? null,
|
|
||||||
...source.externalModel.importBatches.map(
|
|
||||||
(batch) => batch.configurationSnapshot
|
|
||||||
),
|
|
||||||
].filter((configuration) => configuration !== null);
|
|
||||||
const deviceTypeIds = uniqueIdMap(
|
|
||||||
configurations.flatMap((configuration) => configuration.deviceTypes),
|
|
||||||
createId
|
|
||||||
);
|
|
||||||
const familyRuleIds = uniqueIdMap(
|
|
||||||
configurations.flatMap((configuration) => configuration.familyTypeRules),
|
|
||||||
createId
|
|
||||||
);
|
|
||||||
return parseProjectStateSnapshot({
|
return parseProjectStateSnapshot({
|
||||||
...source,
|
...source,
|
||||||
project: { ...source.project, id: targetProjectId, name },
|
project: { ...source.project, id: targetProjectId, name },
|
||||||
externalModelConfiguration:
|
externalCsvConfiguration:
|
||||||
source.externalModelConfiguration === null
|
source.externalCsvConfiguration === null
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
...source.externalModelConfiguration,
|
...source.externalCsvConfiguration,
|
||||||
id: createId(),
|
id: createId(),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
configuration: remapExternalModelConfiguration(
|
|
||||||
source.externalModelConfiguration.configuration,
|
|
||||||
deviceTypeIds,
|
|
||||||
familyRuleIds
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: source.externalModel.source === null
|
source: source.externalModel.source === null
|
||||||
|
|
@ -189,14 +170,6 @@ export function remapProjectState(
|
||||||
id: requiredId(externalBatchIds, batch.id),
|
id: requiredId(externalBatchIds, batch.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
sourceId: externalSourceId!,
|
sourceId: externalSourceId!,
|
||||||
configurationSnapshot:
|
|
||||||
batch.configurationSnapshot === null
|
|
||||||
? null
|
|
||||||
: remapExternalModelConfiguration(
|
|
||||||
batch.configurationSnapshot,
|
|
||||||
deviceTypeIds,
|
|
||||||
familyRuleIds
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
|
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
|
||||||
...mapping,
|
...mapping,
|
||||||
|
|
@ -217,7 +190,6 @@ export function remapProjectState(
|
||||||
id: requiredId(externalObjectIds, object.id),
|
id: requiredId(externalObjectIds, object.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
sourceId: externalSourceId!,
|
sourceId: externalSourceId!,
|
||||||
deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId),
|
|
||||||
lastSeenImportBatchId: requiredId(
|
lastSeenImportBatchId: requiredId(
|
||||||
externalBatchIds,
|
externalBatchIds,
|
||||||
object.lastSeenImportBatchId
|
object.lastSeenImportBatchId
|
||||||
|
|
@ -350,36 +322,6 @@ export function remapProjectState(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remapExternalModelConfiguration(
|
|
||||||
configuration: NonNullable<
|
|
||||||
ProjectStateSnapshot["externalModelConfiguration"]
|
|
||||||
>["configuration"],
|
|
||||||
deviceTypeIds: ReadonlyMap<string, string>,
|
|
||||||
familyRuleIds: ReadonlyMap<string, string>
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...configuration,
|
|
||||||
deviceTypes: configuration.deviceTypes.map((deviceType) => ({
|
|
||||||
...deviceType,
|
|
||||||
id: requiredId(deviceTypeIds, deviceType.id),
|
|
||||||
})),
|
|
||||||
familyTypeRules: configuration.familyTypeRules.map((rule) => ({
|
|
||||||
...rule,
|
|
||||||
id: requiredId(familyRuleIds, rule.id),
|
|
||||||
deviceTypeId: requiredId(deviceTypeIds, rule.deviceTypeId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueIdMap(
|
|
||||||
entries: ReadonlyArray<{ id: string }>,
|
|
||||||
createId: () => string
|
|
||||||
) {
|
|
||||||
return new Map(
|
|
||||||
[...new Set(entries.map((entry) => entry.id))].map((id) => [id, createId()])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function idMap(
|
function idMap(
|
||||||
entries: ReadonlyArray<{ id: string }>,
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
createId: () => string
|
createId: () => string
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/domain/ports/external-csv-configuration.reader.ts
Normal file
10
src/domain/ports/external-csv-configuration.reader.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { ExternalModelConfigurationUpdateProjectCommand } from "../models/external-model-configuration-project-command.model.js";
|
|
||||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
|
||||||
|
|
||||||
export interface ExecuteExternalModelConfigurationCommandInput {
|
|
||||||
projectId: string;
|
|
||||||
expectedRevision: number;
|
|
||||||
source: ProjectRevisionSource;
|
|
||||||
description?: string;
|
|
||||||
actorId?: string;
|
|
||||||
historyTargetChangeSetId?: string;
|
|
||||||
command: ExternalModelConfigurationUpdateProjectCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationProjectCommandStore {
|
|
||||||
execute(input: ExecuteExternalModelConfigurationCommandInput): {
|
|
||||||
revision: AppendedProjectRevision;
|
|
||||||
inverse: ExternalModelConfigurationUpdateProjectCommand;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { ExternalModelConfigurationSnapshot } from "../models/external-model-configuration-project-command.model.js";
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationReadResult {
|
|
||||||
projectExists: boolean;
|
|
||||||
configuration: ExternalModelConfigurationSnapshot | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalModelConfigurationReader {
|
|
||||||
getByProject(projectId: string): ExternalModelConfigurationReadResult;
|
|
||||||
}
|
|
||||||
|
|
@ -173,10 +173,15 @@ import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
|
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
|
||||||
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
||||||
import {
|
import {
|
||||||
assertExternalModelConfigurationUpdateProjectCommand,
|
assertExternalCsvConfigurationUpdateProjectCommand,
|
||||||
externalModelConfigurationUpdateCommandType,
|
externalCsvConfigurationUpdateCommandType,
|
||||||
} from "../models/external-model-configuration-project-command.model.js";
|
} from "../models/external-csv-configuration-project-command.model.js";
|
||||||
import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.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 {
|
import {
|
||||||
assertExternalObjectRowAssignmentProjectCommand,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
externalObjectRowAssignmentCommandType,
|
externalObjectRowAssignmentCommandType,
|
||||||
|
|
@ -238,7 +243,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore,
|
private readonly historyStore: ProjectHistoryStore,
|
||||||
private readonly externalModelConfigurationStore?: ExternalModelConfigurationProjectCommandStore,
|
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
||||||
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
|
|
@ -302,8 +308,6 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assertNoLegacyExternalCsvHistoryCommand(step.command);
|
|
||||||
|
|
||||||
return this.dispatch({
|
return this.dispatch({
|
||||||
...input,
|
...input,
|
||||||
source: direction,
|
source: direction,
|
||||||
|
|
@ -621,12 +625,22 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
case externalModelConfigurationUpdateCommandType: {
|
case externalCsvConfigurationUpdateCommandType: {
|
||||||
assertExternalModelConfigurationUpdateProjectCommand(input.command);
|
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
|
||||||
if (!this.externalModelConfigurationStore) {
|
if (!this.externalCsvConfigurationStore) {
|
||||||
throw new Error("External model configuration store is not available.");
|
throw new Error("External CSV configuration store is not available.");
|
||||||
}
|
}
|
||||||
return this.externalModelConfigurationStore.execute({
|
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,
|
...input,
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
|
|
@ -706,16 +720,3 @@ function assertExpectedRevision(expectedRevision: number) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertNoLegacyExternalCsvHistoryCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
command.type === "external-csv-configuration.update" ||
|
|
||||||
command.type === "external-import.apply-initial"
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Dieser Verlaufseintrag stammt aus der alten Revit-CSV-Anbindung und kann nach der Umstellung auf Linear-XLSX nicht mehr ausgef\u00fchrt werden."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
91
src/external-model/application/external-csv-preview.ts
Normal file
91
src/external-model/application/external-csv-preview.ts
Normal file
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
164
src/external-model/application/external-initial-import-plan.ts
Normal file
164
src/external-model/application/external-initial-import-plan.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
130
src/external-model/application/external-initial-import-target.ts
Normal file
130
src/external-model/application/external-initial-import-target.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
|
||||||
import {
|
|
||||||
createDefaultExternalXlsxTransportConfiguration,
|
|
||||||
type ExternalXlsxColumnConfiguration,
|
|
||||||
type ExternalXlsxTransportConfiguration,
|
|
||||||
} from "../xlsx/external-xlsx-contracts.js";
|
|
||||||
|
|
||||||
export const externalModelConfigurationSchemaVersion = 1 as const;
|
|
||||||
export const externalQuantityRules = ["one", "socketCount"] as const;
|
|
||||||
export type ExternalQuantityRule = (typeof externalQuantityRules)[number];
|
|
||||||
|
|
||||||
export interface ExternalDeviceTypeDefaults {
|
|
||||||
powerPerUnitW: number | null;
|
|
||||||
simultaneityFactor: number | null;
|
|
||||||
cosPhi: number | null;
|
|
||||||
costGroup: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalDeviceTypeConfiguration {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: CircuitGroupCategory;
|
|
||||||
defaults: ExternalDeviceTypeDefaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalFamilyTypeRule {
|
|
||||||
id: string;
|
|
||||||
exactFamilyAndType: string;
|
|
||||||
deviceTypeId: string;
|
|
||||||
quantityRule: ExternalQuantityRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalModelConfiguration
|
|
||||||
extends ExternalXlsxTransportConfiguration {
|
|
||||||
schemaVersion: typeof externalModelConfigurationSchemaVersion;
|
|
||||||
deviceTypes: ExternalDeviceTypeConfiguration[];
|
|
||||||
familyTypeRules: ExternalFamilyTypeRule[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration {
|
|
||||||
return {
|
|
||||||
schemaVersion: externalModelConfigurationSchemaVersion,
|
|
||||||
...createDefaultExternalXlsxTransportConfiguration(),
|
|
||||||
deviceTypes: [],
|
|
||||||
familyTypeRules: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertExternalModelConfiguration(
|
|
||||||
value: unknown
|
|
||||||
): asserts value is ExternalModelConfiguration {
|
|
||||||
if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) {
|
|
||||||
throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration.");
|
|
||||||
}
|
|
||||||
assertExactKeys(value, [
|
|
||||||
"schemaVersion",
|
|
||||||
"layout",
|
|
||||||
"circuitMode",
|
|
||||||
"columns",
|
|
||||||
"deviceTypes",
|
|
||||||
"familyTypeRules",
|
|
||||||
], "Revit-XLSX-Konfiguration");
|
|
||||||
assertLayout(value.layout);
|
|
||||||
if (value.circuitMode !== "combined" && value.circuitMode !== "separate") {
|
|
||||||
throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig.");
|
|
||||||
}
|
|
||||||
assertColumns(value.columns, value.circuitMode);
|
|
||||||
if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) {
|
|
||||||
throw new Error("Gerätearten und Familienregeln müssen Listen sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceTypeIds = new Set<string>();
|
|
||||||
const deviceTypeNames = new Set<string>();
|
|
||||||
for (const [index, deviceType] of value.deviceTypes.entries()) {
|
|
||||||
assertDeviceType(deviceType, index);
|
|
||||||
assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID");
|
|
||||||
assertUnique(deviceTypeNames, deviceType.name, "Geräteartname");
|
|
||||||
}
|
|
||||||
const ruleIds = new Set<string>();
|
|
||||||
const familyNames = new Set<string>();
|
|
||||||
for (const [index, rule] of value.familyTypeRules.entries()) {
|
|
||||||
assertFamilyRule(rule, index);
|
|
||||||
assertUnique(ruleIds, rule.id, "Familienregel-ID");
|
|
||||||
assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ");
|
|
||||||
if (!deviceTypeIds.has(rule.deviceTypeId)) {
|
|
||||||
throw new Error(
|
|
||||||
`Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateExternalModelConfiguration(value: unknown):
|
|
||||||
| { success: true; data: ExternalModelConfiguration }
|
|
||||||
| { success: false; error: string } {
|
|
||||||
try {
|
|
||||||
assertExternalModelConfiguration(value);
|
|
||||||
return { success: true, data: value };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Ungültige Konfiguration.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLayout(value: unknown) {
|
|
||||||
if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt.");
|
|
||||||
assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout");
|
|
||||||
assertPositiveInteger(value.headerRow, "Kopfzeile");
|
|
||||||
assertPositiveInteger(value.firstDataRow, "erste Datenzeile");
|
|
||||||
assertPositiveInteger(value.firstDataColumn, "erste Datenspalte");
|
|
||||||
if ((value.firstDataRow as number) <= (value.headerRow as number)) {
|
|
||||||
throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertColumns(
|
|
||||||
value: unknown,
|
|
||||||
circuitMode: ExternalModelConfiguration["circuitMode"]
|
|
||||||
) {
|
|
||||||
if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt.");
|
|
||||||
const keys = [
|
|
||||||
"worksharingId",
|
|
||||||
"ifcGuid",
|
|
||||||
"roomNumber",
|
|
||||||
"roomName",
|
|
||||||
"familyAndType",
|
|
||||||
"selectionMarker",
|
|
||||||
"socketCount",
|
|
||||||
"sourceRemark",
|
|
||||||
"circuitCombined",
|
|
||||||
"boardName",
|
|
||||||
"circuitIdentifier",
|
|
||||||
"designation",
|
|
||||||
"electricalPowerW",
|
|
||||||
"voltageV",
|
|
||||||
"currentA",
|
|
||||||
] satisfies (keyof ExternalXlsxColumnConfiguration)[];
|
|
||||||
assertExactKeys(value, keys, "XLSX-Spaltenzuordnung");
|
|
||||||
const mappedNames = new Set<string>();
|
|
||||||
for (const key of keys) {
|
|
||||||
const columnName = value[key];
|
|
||||||
const required =
|
|
||||||
key === "worksharingId" ||
|
|
||||||
key === "familyAndType" ||
|
|
||||||
(circuitMode === "combined" && key === "circuitCombined") ||
|
|
||||||
(circuitMode === "separate" &&
|
|
||||||
(key === "boardName" || key === "circuitIdentifier"));
|
|
||||||
if (columnName === null && !required) continue;
|
|
||||||
assertNonEmptyString(columnName, `Spalte ${key}`);
|
|
||||||
assertUnique(mappedNames, columnName, "Spaltenzuordnung");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration {
|
|
||||||
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
|
|
||||||
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`);
|
|
||||||
assertUuid(value.id, `Geräteart ${index + 1}.id`);
|
|
||||||
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
|
|
||||||
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {
|
|
||||||
throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`);
|
|
||||||
}
|
|
||||||
if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`);
|
|
||||||
assertExactKeys(
|
|
||||||
value.defaults,
|
|
||||||
["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"],
|
|
||||||
`Geräteart ${index + 1}.defaults`
|
|
||||||
);
|
|
||||||
assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung");
|
|
||||||
assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF");
|
|
||||||
assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi");
|
|
||||||
if (value.defaults.costGroup !== null) {
|
|
||||||
assertNonEmptyString(value.defaults.costGroup, "Kostengruppe");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule {
|
|
||||||
if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`);
|
|
||||||
assertExactKeys(
|
|
||||||
value,
|
|
||||||
["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"],
|
|
||||||
`Familienregel ${index + 1}`
|
|
||||||
);
|
|
||||||
assertUuid(value.id, `Familienregel ${index + 1}.id`);
|
|
||||||
assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`);
|
|
||||||
assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`);
|
|
||||||
if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) {
|
|
||||||
throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPositiveInteger(value: unknown, label: string) {
|
|
||||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
||||||
throw new Error(`${label} muss eine positive Ganzzahl sein.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNullableNumber(
|
|
||||||
value: unknown,
|
|
||||||
minimum: number,
|
|
||||||
maximum: number,
|
|
||||||
label: string
|
|
||||||
) {
|
|
||||||
if (value === null) return;
|
|
||||||
if (
|
|
||||||
typeof value !== "number" ||
|
|
||||||
!Number.isFinite(value) ||
|
|
||||||
value < minimum ||
|
|
||||||
value > maximum
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} ist ungültig.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNonEmptyString(value: unknown, label: string): asserts value is string {
|
|
||||||
if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
|
|
||||||
throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUuid(value: unknown, label: string): asserts value is string {
|
|
||||||
if (
|
|
||||||
typeof value !== "string" ||
|
|
||||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
||||||
) {
|
|
||||||
throw new Error(`${label} muss eine UUID sein.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUnique(values: Set<string>, value: string, label: string) {
|
|
||||||
if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`);
|
|
||||||
values.add(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string) {
|
|
||||||
const actual = Object.keys(value).sort();
|
|
||||||
const expected = [...keys].sort();
|
|
||||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
||||||
throw new Error(`${label} enthält unbekannte oder fehlende Felder.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
356
src/external-model/csv/external-csv-transport.ts
Normal file
356
src/external-model/csv/external-csv-transport.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
|
||||||
import type {
|
import type {
|
||||||
ExternalXlsxDocument,
|
ExternalCsvConfiguration,
|
||||||
ExternalXlsxScalar,
|
ExternalCsvDocument,
|
||||||
} from "../xlsx/external-xlsx-contracts.js";
|
} from "../csv/external-csv-contracts.js";
|
||||||
|
|
||||||
export const externalModelSourceTypes = ["revit_xlsx"] as const;
|
export const externalModelSourceTypes = ["revit_csv"] as const;
|
||||||
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
|
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
|
||||||
|
|
||||||
export const externalImportKinds = ["initial", "follow_up"] as const;
|
export const externalImportKinds = ["initial", "follow_up"] as const;
|
||||||
|
|
@ -30,11 +29,11 @@ export interface ExternalImportBatchSnapshot {
|
||||||
importedAtIso: string;
|
importedAtIso: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
objectCount: number;
|
appliedProjectRevision: number;
|
||||||
appliedProjectRevision: number | null;
|
configurationVersion: number;
|
||||||
configurationSnapshot: ExternalModelConfiguration | null;
|
configurationSnapshot: ExternalCsvConfiguration;
|
||||||
originalContentBase64: string | null;
|
originalContentBase64: string;
|
||||||
document: ExternalXlsxDocument | null;
|
document: ExternalCsvDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalRoomMappingSnapshot {
|
export interface ExternalRoomMappingSnapshot {
|
||||||
|
|
@ -51,28 +50,24 @@ export interface ExternalRoomMappingSnapshot {
|
||||||
|
|
||||||
export interface ExternalModelObjectSourceValues {
|
export interface ExternalModelObjectSourceValues {
|
||||||
rowNumber: number;
|
rowNumber: number;
|
||||||
roomNumber: ExternalXlsxScalar;
|
roomNumber: string;
|
||||||
roomName: ExternalXlsxScalar;
|
roomName: string;
|
||||||
familyAndType: string;
|
familyAndType: string;
|
||||||
selectionMarker: ExternalXlsxScalar;
|
selectionMarker: string;
|
||||||
socketCount: ExternalXlsxScalar;
|
circuitIdentifier: string;
|
||||||
sourceRemark: ExternalXlsxScalar;
|
power: string;
|
||||||
circuitCombined: ExternalXlsxScalar;
|
quantity: string | null;
|
||||||
boardName: ExternalXlsxScalar;
|
additionalSourceValues: Record<string, string>;
|
||||||
circuitIdentifier: ExternalXlsxScalar;
|
|
||||||
designation: ExternalXlsxScalar;
|
|
||||||
electricalPowerW: ExternalXlsxScalar;
|
|
||||||
voltageV: ExternalXlsxScalar;
|
|
||||||
currentA: ExternalXlsxScalar;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalModelObjectPlanningValues {
|
export interface ExternalModelObjectPlanningValues {
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
|
internalDeviceType: string | null;
|
||||||
category: CircuitGroupCategory | null;
|
category: CircuitGroupCategory | null;
|
||||||
connectionKind: string | null;
|
connectionKind: string | null;
|
||||||
effectiveQuantity: number;
|
effectiveQuantity: number;
|
||||||
powerPerUnitW: number | null;
|
powerPerUnitW: number | null;
|
||||||
simultaneityFactor: number | null;
|
simultaneityFactor: number;
|
||||||
cosPhi: number | null;
|
cosPhi: number | null;
|
||||||
costGroup: string | null;
|
costGroup: string | null;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
|
|
@ -85,9 +80,7 @@ export interface ExternalModelObjectSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
worksharingId: string;
|
ifcGuid: string;
|
||||||
ifcGuid: string | null;
|
|
||||||
deviceTypeId: string;
|
|
||||||
lastSeenImportBatchId: string;
|
lastSeenImportBatchId: string;
|
||||||
lastAcceptedImportBatchId: string;
|
lastAcceptedImportBatchId: string;
|
||||||
acceptedSourceValues: ExternalModelObjectSourceValues;
|
acceptedSourceValues: ExternalModelObjectSourceValues;
|
||||||
|
|
|
||||||
147
src/external-model/domain/external-model-matching.ts
Normal file
147
src/external-model/domain/external-model-matching.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
export const MAX_EXTERNAL_XLSX_BYTES = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
export const DEFAULT_EXTERNAL_XLSX_LIMITS = {
|
|
||||||
maxRows: 50_000,
|
|
||||||
maxColumns: 256,
|
|
||||||
maxCells: 2_000_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ExternalXlsxScalar = string | number | boolean | null;
|
|
||||||
|
|
||||||
export interface ExternalXlsxCell {
|
|
||||||
columnNumber: number;
|
|
||||||
value: ExternalXlsxScalar;
|
|
||||||
valueType: "empty" | "string" | "number" | "boolean";
|
|
||||||
numberFormat: string | null;
|
|
||||||
formula: string | null;
|
|
||||||
usedCachedFormulaValue: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExternalXlsxRowClassification =
|
|
||||||
| "object"
|
|
||||||
| "suspect-object"
|
|
||||||
| "passthrough";
|
|
||||||
|
|
||||||
export interface ExternalXlsxRow {
|
|
||||||
rowNumber: number;
|
|
||||||
classification: ExternalXlsxRowClassification;
|
|
||||||
cells: ExternalXlsxCell[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalXlsxColumn {
|
|
||||||
columnNumber: number;
|
|
||||||
header: string;
|
|
||||||
comment: string | null;
|
|
||||||
width: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExternalXlsxIssueCode =
|
|
||||||
| "cached-formula-value-used"
|
|
||||||
| "formula-without-cached-value"
|
|
||||||
| "suspect-object-skipped"
|
|
||||||
| "duplicate-ifc-guid"
|
|
||||||
| "optional-return-column-missing";
|
|
||||||
|
|
||||||
export interface ExternalXlsxIssue {
|
|
||||||
code: ExternalXlsxIssueCode;
|
|
||||||
message: string;
|
|
||||||
rowNumber?: number;
|
|
||||||
columnNumber?: number;
|
|
||||||
field?: ExternalXlsxColumnKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalXlsxDocument {
|
|
||||||
sheetName: string;
|
|
||||||
headerRowNumber: number;
|
|
||||||
firstDataRowNumber: number;
|
|
||||||
firstDataColumnNumber: number;
|
|
||||||
columns: ExternalXlsxColumn[];
|
|
||||||
rows: ExternalXlsxRow[];
|
|
||||||
issues: ExternalXlsxIssue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalXlsxLayoutConfiguration {
|
|
||||||
headerRow: number;
|
|
||||||
firstDataRow: number;
|
|
||||||
firstDataColumn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExternalXlsxCircuitMode = "combined" | "separate";
|
|
||||||
|
|
||||||
export interface ExternalXlsxColumnConfiguration {
|
|
||||||
worksharingId: string;
|
|
||||||
ifcGuid: string | null;
|
|
||||||
roomNumber: string | null;
|
|
||||||
roomName: string | null;
|
|
||||||
familyAndType: string;
|
|
||||||
selectionMarker: string | null;
|
|
||||||
socketCount: string | null;
|
|
||||||
sourceRemark: string | null;
|
|
||||||
circuitCombined: string | null;
|
|
||||||
boardName: string | null;
|
|
||||||
circuitIdentifier: string | null;
|
|
||||||
designation: string | null;
|
|
||||||
electricalPowerW: string | null;
|
|
||||||
voltageV: string | null;
|
|
||||||
currentA: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExternalXlsxColumnKey = keyof ExternalXlsxColumnConfiguration;
|
|
||||||
|
|
||||||
export interface ExternalXlsxTransportConfiguration {
|
|
||||||
layout: ExternalXlsxLayoutConfiguration;
|
|
||||||
circuitMode: ExternalXlsxCircuitMode;
|
|
||||||
columns: ExternalXlsxColumnConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalXlsxLimits {
|
|
||||||
maxRows: number;
|
|
||||||
maxColumns: number;
|
|
||||||
maxCells: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExternalXlsxReturnValues = Partial<
|
|
||||||
Record<
|
|
||||||
| "circuitCombined"
|
|
||||||
| "boardName"
|
|
||||||
| "circuitIdentifier"
|
|
||||||
| "designation"
|
|
||||||
| "electricalPowerW"
|
|
||||||
| "voltageV"
|
|
||||||
| "currentA",
|
|
||||||
ExternalXlsxScalar
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ExternalXlsxReturnUpdate {
|
|
||||||
worksharingId: string;
|
|
||||||
values: ExternalXlsxReturnValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalXlsxReturnResult {
|
|
||||||
bytes: Uint8Array;
|
|
||||||
exportedObjectCount: number;
|
|
||||||
issues: ExternalXlsxIssue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultExternalXlsxTransportConfiguration(): ExternalXlsxTransportConfiguration {
|
|
||||||
return {
|
|
||||||
layout: {
|
|
||||||
headerRow: 1,
|
|
||||||
firstDataRow: 3,
|
|
||||||
firstDataColumn: 1,
|
|
||||||
},
|
|
||||||
circuitMode: "combined",
|
|
||||||
columns: {
|
|
||||||
worksharingId: "Worksharing-ID",
|
|
||||||
ifcGuid: "IfcGUID",
|
|
||||||
roomNumber: "MEP-Raum: Nummer",
|
|
||||||
roomName: "MEP-Raum: Name",
|
|
||||||
familyAndType: "Familie und Typ",
|
|
||||||
selectionMarker: "CAx_Auswahlkenner",
|
|
||||||
socketCount: "CAX_AnzahlObjekte",
|
|
||||||
sourceRemark: "CAx_Anmerkung",
|
|
||||||
circuitCombined: "kbp_Stromkreisnummer",
|
|
||||||
boardName: "kbp-E-Verteiler",
|
|
||||||
circuitIdentifier: "kbp-E-Stromkreis",
|
|
||||||
designation: "kbp-E-Bezeichnung",
|
|
||||||
electricalPowerW: "kbp-E-Elektrische Leistung",
|
|
||||||
voltageV: "kbp-E-Spannung",
|
|
||||||
currentA: "kbp-E-Stromstärke",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,582 +0,0 @@
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import type { CellValue, Worksheet } from "exceljs";
|
|
||||||
import {
|
|
||||||
DEFAULT_EXTERNAL_XLSX_LIMITS,
|
|
||||||
MAX_EXTERNAL_XLSX_BYTES,
|
|
||||||
type ExternalXlsxCell,
|
|
||||||
type ExternalXlsxColumnKey,
|
|
||||||
type ExternalXlsxDocument,
|
|
||||||
type ExternalXlsxIssue,
|
|
||||||
type ExternalXlsxLimits,
|
|
||||||
type ExternalXlsxReturnResult,
|
|
||||||
type ExternalXlsxReturnUpdate,
|
|
||||||
type ExternalXlsxScalar,
|
|
||||||
type ExternalXlsxTransportConfiguration,
|
|
||||||
} from "./external-xlsx-contracts.js";
|
|
||||||
|
|
||||||
export type ExternalXlsxErrorCode =
|
|
||||||
| "invalid-file-extension"
|
|
||||||
| "file-too-large"
|
|
||||||
| "invalid-workbook"
|
|
||||||
| "worksheet-count"
|
|
||||||
| "worksheet-too-large"
|
|
||||||
| "invalid-layout"
|
|
||||||
| "header-not-found"
|
|
||||||
| "ambiguous-header"
|
|
||||||
| "required-formula-result-missing"
|
|
||||||
| "invalid-worksharing-id"
|
|
||||||
| "duplicate-worksharing-id"
|
|
||||||
| "return-circuit-column-missing"
|
|
||||||
| "unknown-worksharing-id";
|
|
||||||
|
|
||||||
export class ExternalXlsxError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: ExternalXlsxErrorCode,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ExternalXlsxError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParseExternalXlsxInput {
|
|
||||||
fileName: string;
|
|
||||||
bytes: Uint8Array;
|
|
||||||
configuration: ExternalXlsxTransportConfiguration;
|
|
||||||
limits?: ExternalXlsxLimits;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExternalXlsxReturnInput extends ParseExternalXlsxInput {
|
|
||||||
selectedWorksharingIds?: readonly string[];
|
|
||||||
updates?: readonly ExternalXlsxReturnUpdate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseExternalXlsx(
|
|
||||||
input: ParseExternalXlsxInput
|
|
||||||
): Promise<ExternalXlsxDocument> {
|
|
||||||
assertUpload(input.fileName, input.bytes);
|
|
||||||
assertConfiguration(input.configuration);
|
|
||||||
const workbook = await loadWorkbook(input.bytes);
|
|
||||||
const worksheet = getOnlyWorksheet(workbook);
|
|
||||||
assertWorksheetLimits(
|
|
||||||
worksheet,
|
|
||||||
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
|
|
||||||
);
|
|
||||||
return parseWorksheet(worksheet, input.configuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createExternalXlsxReturn(
|
|
||||||
input: CreateExternalXlsxReturnInput
|
|
||||||
): Promise<ExternalXlsxReturnResult> {
|
|
||||||
assertUpload(input.fileName, input.bytes);
|
|
||||||
assertConfiguration(input.configuration);
|
|
||||||
const workbook = await loadWorkbook(input.bytes);
|
|
||||||
const worksheet = getOnlyWorksheet(workbook);
|
|
||||||
assertWorksheetLimits(
|
|
||||||
worksheet,
|
|
||||||
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
|
|
||||||
);
|
|
||||||
const document = parseWorksheet(worksheet, input.configuration);
|
|
||||||
const headerLookup = createHeaderLookup(worksheet, input.configuration);
|
|
||||||
const issues = [...document.issues];
|
|
||||||
const returnColumns = resolveReturnColumns(
|
|
||||||
headerLookup,
|
|
||||||
input.configuration,
|
|
||||||
issues
|
|
||||||
);
|
|
||||||
const worksharingIdColumn = headerLookup.get(
|
|
||||||
input.configuration.columns.worksharingId
|
|
||||||
)!;
|
|
||||||
const objectRows = document.rows.filter((row) => row.classification === "object");
|
|
||||||
const objectRowById = new Map(
|
|
||||||
objectRows.map((row) => [
|
|
||||||
scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null),
|
|
||||||
row.rowNumber,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const selectedIds = input.selectedWorksharingIds
|
|
||||||
? new Set(input.selectedWorksharingIds)
|
|
||||||
: new Set(objectRowById.keys());
|
|
||||||
for (const worksharingId of selectedIds) {
|
|
||||||
if (!objectRowById.has(worksharingId)) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"unknown-worksharing-id",
|
|
||||||
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updates = new Map(
|
|
||||||
(input.updates ?? []).map((update) => [update.worksharingId, update.values])
|
|
||||||
);
|
|
||||||
for (const worksharingId of updates.keys()) {
|
|
||||||
if (!objectRowById.has(worksharingId)) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"unknown-worksharing-id",
|
|
||||||
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = objectRows.filter((row) => {
|
|
||||||
const id = scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null);
|
|
||||||
return selectedIds.has(id);
|
|
||||||
});
|
|
||||||
const lastColumn = worksheet.columnCount;
|
|
||||||
const snapshots = selectedRows.map((row) =>
|
|
||||||
captureRow(worksheet, row.rowNumber, lastColumn)
|
|
||||||
);
|
|
||||||
snapshots.forEach((snapshot, index) => {
|
|
||||||
restoreRow(
|
|
||||||
worksheet,
|
|
||||||
input.configuration.layout.firstDataRow + index,
|
|
||||||
snapshot,
|
|
||||||
lastColumn
|
|
||||||
);
|
|
||||||
const destinationRow = worksheet.getRow(
|
|
||||||
input.configuration.layout.firstDataRow + index
|
|
||||||
);
|
|
||||||
const id = scalarToIdentity(destinationRow.getCell(worksharingIdColumn).value);
|
|
||||||
const values = updates.get(id);
|
|
||||||
if (!values) return;
|
|
||||||
for (const [field, value] of Object.entries(values) as [
|
|
||||||
keyof typeof values,
|
|
||||||
ExternalXlsxScalar,
|
|
||||||
][]) {
|
|
||||||
const columnNumber = returnColumns.get(field);
|
|
||||||
if (columnNumber !== undefined) {
|
|
||||||
destinationRow.getCell(columnNumber).value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRemovedRow =
|
|
||||||
input.configuration.layout.firstDataRow + snapshots.length;
|
|
||||||
const removeCount = Math.max(0, worksheet.rowCount - firstRemovedRow + 1);
|
|
||||||
// ExcelJS 4.4 does not shrink its internal row array when a multi-row
|
|
||||||
// splice removes the complete tail. Removing the tail one row at a time
|
|
||||||
// produces an actual OOXML deletion instead of serialized empty rows.
|
|
||||||
for (let index = 0; index < removeCount; index += 1) {
|
|
||||||
worksheet.spliceRows(worksheet.rowCount, 1);
|
|
||||||
}
|
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
|
||||||
return {
|
|
||||||
bytes: new Uint8Array(buffer),
|
|
||||||
exportedObjectCount: snapshots.length,
|
|
||||||
issues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorksheet(
|
|
||||||
worksheet: Worksheet,
|
|
||||||
configuration: ExternalXlsxTransportConfiguration
|
|
||||||
): ExternalXlsxDocument {
|
|
||||||
const headerLookup = createHeaderLookup(worksheet, configuration);
|
|
||||||
const issues: ExternalXlsxIssue[] = [];
|
|
||||||
const worksharingIdColumn = headerLookup.get(configuration.columns.worksharingId)!;
|
|
||||||
const familyAndTypeColumn = headerLookup.get(configuration.columns.familyAndType)!;
|
|
||||||
const ifcGuidColumn = configuration.columns.ifcGuid
|
|
||||||
? headerLookup.get(configuration.columns.ifcGuid)
|
|
||||||
: undefined;
|
|
||||||
const seenWorksharingIds = new Set<string>();
|
|
||||||
const seenIfcGuids = new Set<string>();
|
|
||||||
const rows = [] as ExternalXlsxDocument["rows"];
|
|
||||||
|
|
||||||
for (
|
|
||||||
let rowNumber = configuration.layout.firstDataRow;
|
|
||||||
rowNumber <= worksheet.rowCount;
|
|
||||||
rowNumber += 1
|
|
||||||
) {
|
|
||||||
const row = worksheet.getRow(rowNumber);
|
|
||||||
const cells: ExternalXlsxCell[] = [];
|
|
||||||
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
|
|
||||||
cells.push(
|
|
||||||
toExternalCell(row.getCell(columnNumber), rowNumber, columnNumber, issues)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const worksharingIdCell = cells[worksharingIdColumn - 1];
|
|
||||||
const familyAndTypeCell = cells[familyAndTypeColumn - 1];
|
|
||||||
for (const [field, requiredCell] of [
|
|
||||||
["Worksharing-ID", worksharingIdCell],
|
|
||||||
["Familie und Typ", familyAndTypeCell],
|
|
||||||
] as const) {
|
|
||||||
if (requiredCell?.formula != null && !requiredCell.usedCachedFormulaValue) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"required-formula-result-missing",
|
|
||||||
`Das Pflichtfeld ${field} hat in Zeile ${rowNumber} kein gespeichertes Formelergebnis.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const worksharingId = scalarToIdentity(worksharingIdCell?.value ?? null);
|
|
||||||
const familyAndType = scalarToIdentity(familyAndTypeCell?.value ?? null);
|
|
||||||
if (!worksharingId) {
|
|
||||||
const classification = familyAndType ? "suspect-object" : "passthrough";
|
|
||||||
if (classification === "suspect-object") {
|
|
||||||
issues.push({
|
|
||||||
code: "suspect-object-skipped",
|
|
||||||
message: `Zeile ${rowNumber} enthält Familie und Typ, aber keine Worksharing-ID, und wird übersprungen.`,
|
|
||||||
rowNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
rows.push({ rowNumber, classification, cells });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isWorksharingId(worksharingId)) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"invalid-worksharing-id",
|
|
||||||
`Zeile ${rowNumber} enthält eine ungültige Worksharing-ID.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seenWorksharingIds.has(worksharingId)) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"duplicate-worksharing-id",
|
|
||||||
`Die Worksharing-ID kommt mehrfach vor: ${worksharingId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
seenWorksharingIds.add(worksharingId);
|
|
||||||
if (ifcGuidColumn !== undefined) {
|
|
||||||
const ifcGuid = scalarToIdentity(cells[ifcGuidColumn - 1]?.value ?? null);
|
|
||||||
if (ifcGuid) {
|
|
||||||
if (seenIfcGuids.has(ifcGuid)) {
|
|
||||||
issues.push({
|
|
||||||
code: "duplicate-ifc-guid",
|
|
||||||
message: `IfcGUID kommt mehrfach vor: ${ifcGuid}`,
|
|
||||||
rowNumber,
|
|
||||||
columnNumber: ifcGuidColumn,
|
|
||||||
field: "ifcGuid",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
seenIfcGuids.add(ifcGuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows.push({ rowNumber, classification: "object", cells });
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerRow = worksheet.getRow(configuration.layout.headerRow);
|
|
||||||
const columns = [] as ExternalXlsxDocument["columns"];
|
|
||||||
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
|
|
||||||
const cell = headerRow.getCell(columnNumber);
|
|
||||||
columns.push({
|
|
||||||
columnNumber,
|
|
||||||
header: scalarToIdentity(toScalar(cell.value).value),
|
|
||||||
comment: getCommentText(cell.note),
|
|
||||||
width: worksheet.getColumn(columnNumber).width ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sheetName: worksheet.name,
|
|
||||||
headerRowNumber: configuration.layout.headerRow,
|
|
||||||
firstDataRowNumber: configuration.layout.firstDataRow,
|
|
||||||
firstDataColumnNumber: configuration.layout.firstDataColumn,
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
issues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHeaderLookup(
|
|
||||||
worksheet: Worksheet,
|
|
||||||
configuration: ExternalXlsxTransportConfiguration
|
|
||||||
) {
|
|
||||||
const headers = new Map<string, number[]>();
|
|
||||||
const row = worksheet.getRow(configuration.layout.headerRow);
|
|
||||||
for (
|
|
||||||
let columnNumber = configuration.layout.firstDataColumn;
|
|
||||||
columnNumber <= worksheet.columnCount;
|
|
||||||
columnNumber += 1
|
|
||||||
) {
|
|
||||||
const header = scalarToIdentity(toScalar(row.getCell(columnNumber).value).value);
|
|
||||||
if (!header) continue;
|
|
||||||
headers.set(header, [...(headers.get(header) ?? []), columnNumber]);
|
|
||||||
}
|
|
||||||
const configured = Object.entries(configuration.columns) as [
|
|
||||||
ExternalXlsxColumnKey,
|
|
||||||
string | null,
|
|
||||||
][];
|
|
||||||
const required = new Set<ExternalXlsxColumnKey>(["worksharingId", "familyAndType"]);
|
|
||||||
const lookup = new Map<string, number>();
|
|
||||||
for (const [field, header] of configured) {
|
|
||||||
if (header === null) continue;
|
|
||||||
const matches = headers.get(header) ?? [];
|
|
||||||
if (matches.length > 1) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"ambiguous-header",
|
|
||||||
`Die konfigurierte Kopfzelle ist nicht eindeutig: ${header}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (matches.length === 0 && required.has(field)) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"header-not-found",
|
|
||||||
`Die konfigurierte Pflichtspalte fehlt: ${header}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (matches.length === 1) lookup.set(header, matches[0]);
|
|
||||||
}
|
|
||||||
return lookup;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReturnColumns(
|
|
||||||
headerLookup: Map<string, number>,
|
|
||||||
configuration: ExternalXlsxTransportConfiguration,
|
|
||||||
issues: ExternalXlsxIssue[]
|
|
||||||
) {
|
|
||||||
const result = new Map<keyof ExternalXlsxReturnUpdate["values"], number>();
|
|
||||||
const circuitFields: ("circuitCombined" | "boardName" | "circuitIdentifier")[] =
|
|
||||||
configuration.circuitMode === "combined"
|
|
||||||
? ["circuitCombined"]
|
|
||||||
: ["boardName", "circuitIdentifier"];
|
|
||||||
for (const field of circuitFields) {
|
|
||||||
const header = configuration.columns[field];
|
|
||||||
const columnNumber = header === null ? undefined : headerLookup.get(header);
|
|
||||||
if (columnNumber === undefined) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"return-circuit-column-missing",
|
|
||||||
`Die Stromkreisspalte des gewählten Modus fehlt: ${field}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result.set(field, columnNumber);
|
|
||||||
}
|
|
||||||
const optionalFields = [
|
|
||||||
"designation",
|
|
||||||
"electricalPowerW",
|
|
||||||
"voltageV",
|
|
||||||
"currentA",
|
|
||||||
] as const;
|
|
||||||
for (const field of optionalFields) {
|
|
||||||
const header = configuration.columns[field];
|
|
||||||
const columnNumber = header === null ? undefined : headerLookup.get(header);
|
|
||||||
if (columnNumber === undefined) {
|
|
||||||
issues.push({
|
|
||||||
code: "optional-return-column-missing",
|
|
||||||
message: `Die optionale Rückgabespalte fehlt und wird übersprungen: ${field}`,
|
|
||||||
field,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result.set(field, columnNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toExternalCell(
|
|
||||||
cell: ExcelJS.Cell,
|
|
||||||
rowNumber: number,
|
|
||||||
columnNumber: number,
|
|
||||||
issues: ExternalXlsxIssue[]
|
|
||||||
): ExternalXlsxCell {
|
|
||||||
const converted = toScalar(cell.value);
|
|
||||||
if (converted.formula !== null) {
|
|
||||||
issues.push({
|
|
||||||
code: converted.usedCachedFormulaValue
|
|
||||||
? "cached-formula-value-used"
|
|
||||||
: "formula-without-cached-value",
|
|
||||||
message: converted.usedCachedFormulaValue
|
|
||||||
? `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} wird mit ihrem gespeicherten Ergebnis gelesen.`
|
|
||||||
: `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} hat kein nutzbares gespeichertes Ergebnis und bleibt leer.`,
|
|
||||||
rowNumber,
|
|
||||||
columnNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
columnNumber,
|
|
||||||
value: converted.value,
|
|
||||||
valueType: getScalarValueType(converted.value),
|
|
||||||
numberFormat: cell.numFmt || null,
|
|
||||||
formula: converted.formula,
|
|
||||||
usedCachedFormulaValue: converted.usedCachedFormulaValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toScalar(value: CellValue): {
|
|
||||||
value: ExternalXlsxScalar;
|
|
||||||
formula: string | null;
|
|
||||||
usedCachedFormulaValue: boolean;
|
|
||||||
} {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return { value: null, formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof value === "string" ||
|
|
||||||
typeof value === "number" ||
|
|
||||||
typeof value === "boolean"
|
|
||||||
) {
|
|
||||||
return { value, formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return { value: value.toISOString(), formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
if ("formula" in value || "sharedFormula" in value) {
|
|
||||||
const formula =
|
|
||||||
("formula" in value ? value.formula : value.sharedFormula) ?? "";
|
|
||||||
const result = "result" in value ? value.result : undefined;
|
|
||||||
if (
|
|
||||||
typeof result === "string" ||
|
|
||||||
typeof result === "number" ||
|
|
||||||
typeof result === "boolean"
|
|
||||||
) {
|
|
||||||
return { value: result, formula, usedCachedFormulaValue: true };
|
|
||||||
}
|
|
||||||
return { value: null, formula, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
if ("error" in value) {
|
|
||||||
return { value: null, formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
if ("richText" in value) {
|
|
||||||
return {
|
|
||||||
value: value.richText.map((part) => part.text).join(""),
|
|
||||||
formula: null,
|
|
||||||
usedCachedFormulaValue: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if ("text" in value) {
|
|
||||||
return { value: value.text, formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
return { value: null, formula: null, usedCachedFormulaValue: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureRow(worksheet: Worksheet, rowNumber: number, lastColumn: number) {
|
|
||||||
const row = worksheet.getRow(rowNumber);
|
|
||||||
return {
|
|
||||||
height: row.height,
|
|
||||||
cells: Array.from({ length: lastColumn }, (_, index) => {
|
|
||||||
const cell = row.getCell(index + 1);
|
|
||||||
const converted = toScalar(cell.value);
|
|
||||||
return {
|
|
||||||
value: converted.formula === null ? cloneValue(cell.value) : converted.value,
|
|
||||||
style: structuredClone(cell.style),
|
|
||||||
note: cell.note === undefined ? undefined : structuredClone(cell.note),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreRow(
|
|
||||||
worksheet: Worksheet,
|
|
||||||
rowNumber: number,
|
|
||||||
snapshot: ReturnType<typeof captureRow>,
|
|
||||||
lastColumn: number
|
|
||||||
) {
|
|
||||||
const row = worksheet.getRow(rowNumber);
|
|
||||||
row.height = snapshot.height;
|
|
||||||
for (let columnNumber = 1; columnNumber <= lastColumn; columnNumber += 1) {
|
|
||||||
const cell = row.getCell(columnNumber);
|
|
||||||
const source = snapshot.cells[columnNumber - 1];
|
|
||||||
cell.value = source.value;
|
|
||||||
cell.style = structuredClone(source.style);
|
|
||||||
if (source.note === undefined) {
|
|
||||||
// ExcelJS has no public comment-removal API. Its note setter creates a
|
|
||||||
// Note even for undefined, which would add an empty comment to the cell.
|
|
||||||
(cell as unknown as { _comment?: unknown })._comment = undefined;
|
|
||||||
} else {
|
|
||||||
cell.note = source.note;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneValue(value: CellValue): CellValue {
|
|
||||||
if (value instanceof Date) return new Date(value);
|
|
||||||
if (value && typeof value === "object") return structuredClone(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCommentText(note: ExcelJS.Cell["note"]): string | null {
|
|
||||||
if (note === undefined) return null;
|
|
||||||
if (typeof note === "string") return note;
|
|
||||||
return note.texts?.map((part) => part.text).join("") ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScalarValueType(value: ExternalXlsxScalar): ExternalXlsxCell["valueType"] {
|
|
||||||
if (value === null) return "empty";
|
|
||||||
if (typeof value === "string") return "string";
|
|
||||||
if (typeof value === "number") return "number";
|
|
||||||
return "boolean";
|
|
||||||
}
|
|
||||||
|
|
||||||
function scalarToIdentity(value: ExternalXlsxScalar | CellValue): string {
|
|
||||||
const scalar =
|
|
||||||
typeof value === "object" && value !== null ? toScalar(value).value : value;
|
|
||||||
return typeof scalar === "string" ? scalar : scalar === null ? "" : String(scalar);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWorksharingId(value: string) {
|
|
||||||
return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/.test(
|
|
||||||
value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUpload(fileName: string, bytes: Uint8Array) {
|
|
||||||
if (!fileName.toLocaleLowerCase("en-US").endsWith(".xlsx")) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"invalid-file-extension",
|
|
||||||
"Es werden ausschließlich .xlsx-Dateien unterstützt."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (bytes.byteLength > MAX_EXTERNAL_XLSX_BYTES) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"file-too-large",
|
|
||||||
"Die XLSX-Datei darf höchstens 10 MiB groß sein."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkbook(bytes: Uint8Array) {
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
try {
|
|
||||||
await workbook.xlsx.load(
|
|
||||||
Buffer.from(bytes) as unknown as Parameters<typeof workbook.xlsx.load>[0]
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"invalid-workbook",
|
|
||||||
`Die XLSX-Datei konnte nicht gelesen werden: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return workbook;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOnlyWorksheet(workbook: ExcelJS.Workbook) {
|
|
||||||
if (workbook.worksheets.length !== 1) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"worksheet-count",
|
|
||||||
"Die XLSX-Datei muss genau ein Arbeitsblatt enthalten."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return workbook.worksheets[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertWorksheetLimits(worksheet: Worksheet, limits: ExternalXlsxLimits) {
|
|
||||||
const rowCount = worksheet.rowCount;
|
|
||||||
const columnCount = worksheet.columnCount;
|
|
||||||
if (
|
|
||||||
rowCount > limits.maxRows ||
|
|
||||||
columnCount > limits.maxColumns ||
|
|
||||||
rowCount * columnCount > limits.maxCells
|
|
||||||
) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"worksheet-too-large",
|
|
||||||
`Das Arbeitsblatt überschreitet die Grenzen (${rowCount} Zeilen, ${columnCount} Spalten).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertConfiguration(configuration: ExternalXlsxTransportConfiguration) {
|
|
||||||
const { headerRow, firstDataRow, firstDataColumn } = configuration.layout;
|
|
||||||
if (
|
|
||||||
!Number.isInteger(headerRow) ||
|
|
||||||
headerRow < 1 ||
|
|
||||||
!Number.isInteger(firstDataRow) ||
|
|
||||||
firstDataRow <= headerRow ||
|
|
||||||
!Number.isInteger(firstDataColumn) ||
|
|
||||||
firstDataColumn < 1
|
|
||||||
) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"invalid-layout",
|
|
||||||
"Kopfzeile, erste Datenzeile oder erste Datenspalte sind ungültig."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!configuration.columns.worksharingId || !configuration.columns.familyAndType) {
|
|
||||||
throw new ExternalXlsxError(
|
|
||||||
"invalid-layout",
|
|
||||||
"Worksharing-ID und Familie und Typ müssen konfiguriert sein."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
666
src/frontend/components/revit-csv-modal.tsx
Normal file
666
src/frontend/components/revit-csv-modal.tsx
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
|
||||||
import type { ExternalXlsxColumnKey } from "../../external-model/xlsx/external-xlsx-contracts";
|
|
||||||
import { FormModal } from "./form-modal";
|
|
||||||
|
|
||||||
const labels: Record<ExternalXlsxColumnKey, string> = {
|
|
||||||
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "Raumnummer",
|
|
||||||
roomName: "Raumname", familyAndType: "Familie und Typ", selectionMarker: "Auswahlkenner",
|
|
||||||
socketCount: "Anzahl Objekte", sourceRemark: "Quellbemerkung", circuitCombined: "Stromkreis kombiniert",
|
|
||||||
boardName: "Verteiler", circuitIdentifier: "Stromkreis", designation: "Bezeichnung",
|
|
||||||
electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: {
|
|
||||||
configuration: ExternalModelConfiguration;
|
|
||||||
isSaving: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise<string>;
|
|
||||||
onSave: (configuration: ExternalModelConfiguration) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState(() => structuredClone(configuration));
|
|
||||||
const [sampleStatus, setSampleStatus] = useState<string | null>(null);
|
|
||||||
const columnKeys = Object.keys(draft.columns) as ExternalXlsxColumnKey[];
|
|
||||||
|
|
||||||
async function inspectSample(file: File | undefined) {
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
setSampleStatus(await onInspectSample(file, draft));
|
|
||||||
} catch (error) {
|
|
||||||
setSampleStatus(error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <FormModal title="Revit-XLSX-Einstellungen" description="Layout und Spalten der Linear-Bauteilliste" isSaving={isSaving} onClose={onClose} submitDisabled={JSON.stringify(draft) === JSON.stringify(configuration)} submitLabel="Einstellungen speichern" onSubmit={(event: FormEvent) => { event.preventDefault(); void onSave(draft); }}>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12"><label className="form-label" htmlFor="revit-sample">Musterdatei prüfen</label><input id="revit-sample" className="form-control" type="file" accept=".xlsx" onChange={(event) => void inspectSample(event.target.files?.[0])} />{sampleStatus ? <div className="form-text">{sampleStatus}</div> : null}</div>
|
|
||||||
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) => <div className="col-md-4" key={key}><label className="form-label" htmlFor={`layout-${key}`}>{key === "headerRow" ? "Kopfzeile" : key === "firstDataRow" ? "Erste Datenzeile" : "Erste Datenspalte"}</label><input id={`layout-${key}`} className="form-control" min={1} type="number" value={draft.layout[key]} onChange={(event) => setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} /></div>)}
|
|
||||||
<div className="col-12"><label className="form-label" htmlFor="circuit-mode">Stromkreisspalten</label><select id="circuit-mode" className="form-select" value={draft.circuitMode} onChange={(event) => setDraft({ ...draft, circuitMode: event.target.value as ExternalModelConfiguration["circuitMode"] })}><option value="combined">Kombinierte Stromkreisspalte</option><option value="separate">Verteiler und Stromkreis getrennt</option></select></div>
|
|
||||||
<div className="col-12"><h3 className="fs-6 mb-0">Spaltenzuordnung</h3></div>
|
|
||||||
{columnKeys.map((key) => <div className="col-md-6" key={key}><label className="form-label" htmlFor={`column-${key}`}>{labels[key]}</label><input id={`column-${key}`} className="form-control" value={draft.columns[key] ?? ""} onChange={(event) => setDraft({ ...draft, columns: { ...draft.columns, [key]: event.target.value.trim() ? event.target.value : null } })} /></div>)}
|
|
||||||
</div>
|
|
||||||
</FormModal>;
|
|
||||||
}
|
|
||||||
|
|
@ -14,13 +14,7 @@ import type {
|
||||||
ExternalCsvConfiguration,
|
ExternalCsvConfiguration,
|
||||||
ExternalCsvDialect,
|
ExternalCsvDialect,
|
||||||
} from "../external-model/csv/external-csv-contracts";
|
} from "../external-model/csv/external-csv-contracts";
|
||||||
import type { ExternalModelConfiguration } from "../external-model/configuration/external-model-configuration";
|
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
|
||||||
|
|
||||||
export interface ExternalModelConfigurationSnapshotDto {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
configuration: ExternalModelConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationSnapshotDto {
|
export interface ExternalCsvConfigurationSnapshotDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -58,80 +52,9 @@ export interface ExternalCsvPreviewDto {
|
||||||
suspectRowNumbers: number[];
|
suspectRowNumbers: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalInitialImportPlanDto {
|
export type ExternalInitialImportPlanDto = ReturnType<
|
||||||
configurationVersion: number;
|
typeof createExternalInitialImportPlan
|
||||||
fileName: string;
|
>;
|
||||||
sha256: string;
|
|
||||||
byteLength: number;
|
|
||||||
rowCount: number;
|
|
||||||
objectCount: number;
|
|
||||||
passthroughCount: number;
|
|
||||||
suspectObjectCount: number;
|
|
||||||
suspectRowNumbers: number[];
|
|
||||||
sourceRooms: Array<{
|
|
||||||
sourceRoomKey: string;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
objectCount: number;
|
|
||||||
suggestedRoomId: string | null;
|
|
||||||
matchStatus: "exact-number" | "ambiguous-number" | "new";
|
|
||||||
candidateRoomIds: string[];
|
|
||||||
}>;
|
|
||||||
familyGroups: Array<{
|
|
||||||
familyAndType: string;
|
|
||||||
objectCount: number;
|
|
||||||
classified: boolean;
|
|
||||||
category: string | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
internalDeviceType: string | null;
|
|
||||||
}>;
|
|
||||||
issueCounts: {
|
|
||||||
unknownFamilyAndType: number;
|
|
||||||
invalidPower: number;
|
|
||||||
invalidQuantity: number;
|
|
||||||
missingRoom: number;
|
|
||||||
};
|
|
||||||
objects: Array<{
|
|
||||||
ifcGuid: string;
|
|
||||||
sourceRoomKey: string | null;
|
|
||||||
sourceValues: ExternalCsvPreviewObjectDto;
|
|
||||||
suggestedPlanningValues: {
|
|
||||||
displayName: string | null;
|
|
||||||
internalDeviceType: string | null;
|
|
||||||
category: CircuitGroupCategory | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
effectiveQuantity: number;
|
|
||||||
powerPerUnitW: number | null;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi: number | null;
|
|
||||||
costGroup: string | null;
|
|
||||||
remark: string | null;
|
|
||||||
};
|
|
||||||
issues: string[];
|
|
||||||
}>;
|
|
||||||
existing: {
|
|
||||||
floors: Array<{ id: string; name: string; sortOrder: number }>;
|
|
||||||
rooms: Array<{
|
|
||||||
id: string;
|
|
||||||
floorId: string | null;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
}>;
|
|
||||||
distributionBoards: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
floorId: string | null;
|
|
||||||
}>;
|
|
||||||
projectDevices: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
category: string | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
powerPerUnit: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplyExternalInitialImportInput {
|
export interface ApplyExternalInitialImportInput {
|
||||||
expectedRevision: number;
|
expectedRevision: number;
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,18 @@ import type {
|
||||||
RoomDto,
|
RoomDto,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
ProjectCircuitTreesResponseDto,
|
ProjectCircuitTreesResponseDto,
|
||||||
ExternalModelConfigurationSnapshotDto,
|
ExternalCsvConfigurationSnapshotDto,
|
||||||
|
ExternalCsvPreviewDto,
|
||||||
|
ExternalInitialImportPlanDto,
|
||||||
|
ApplyExternalInitialImportInput,
|
||||||
|
ExternalInitialImportResultDto,
|
||||||
CreateEstimateBalanceInput,
|
CreateEstimateBalanceInput,
|
||||||
EstimateBalanceCommandResultDto,
|
EstimateBalanceCommandResultDto,
|
||||||
EstimateBalanceDto,
|
EstimateBalanceDto,
|
||||||
EstimateBalanceUpdateResultDto,
|
EstimateBalanceUpdateResultDto,
|
||||||
EstimateComparisonDto,
|
EstimateComparisonDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
||||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||||
import {
|
import {
|
||||||
createCircuitUpdateProjectCommand,
|
createCircuitUpdateProjectCommand,
|
||||||
|
|
@ -164,40 +168,6 @@ export function getProject(projectId: string) {
|
||||||
return request<ProjectDto>(`/api/projects/${projectId}`);
|
return request<ProjectDto>(`/api/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExternalModelConfiguration(projectId: string) {
|
|
||||||
return request<{ configuration: ExternalModelConfigurationSnapshotDto | null }>(
|
|
||||||
`/api/projects/${projectId}/external-model/configuration`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateExternalModelConfiguration(
|
|
||||||
projectId: string,
|
|
||||||
expectedRevision: number,
|
|
||||||
configuration: ExternalModelConfiguration
|
|
||||||
) {
|
|
||||||
return request<{
|
|
||||||
configuration: ExternalModelConfigurationSnapshotDto;
|
|
||||||
history: ProjectHistoryStateDto;
|
|
||||||
}>(`/api/projects/${projectId}/external-model/configuration`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ expectedRevision, configuration }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectExternalModelWorkbook(
|
|
||||||
projectId: string,
|
|
||||||
file: File,
|
|
||||||
configuration: ExternalModelConfiguration
|
|
||||||
) {
|
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
||||||
return request<{ sheetName: string; columnCount: number; objectCount: number }>(
|
|
||||||
`/api/projects/${projectId}/external-model/configuration/detect`,
|
|
||||||
{ method: "POST", body: JSON.stringify({ fileName: file.name, contentBase64: btoa(binary), configuration }) }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProjectHistory(projectId: string) {
|
export function getProjectHistory(projectId: string) {
|
||||||
return request<ProjectHistoryStateDto>(
|
return request<ProjectHistoryStateDto>(
|
||||||
`/api/projects/${projectId}/history`
|
`/api/projects/${projectId}/history`
|
||||||
|
|
@ -499,6 +469,12 @@ export function deleteDocumentLogo() {
|
||||||
return request<void>(documentLogoUrl, { method: "DELETE" });
|
return request<void>(documentLogoUrl, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExternalCsvConfiguration(projectId: string) {
|
||||||
|
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
|
||||||
|
`/api/projects/${projectId}/external-csv/configuration`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getExternalCircuitListObjects(
|
export function getExternalCircuitListObjects(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
circuitListId: string
|
circuitListId: string
|
||||||
|
|
@ -508,6 +484,57 @@ export function getExternalCircuitListObjects(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateExternalCsvConfiguration(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
configuration: ExternalCsvConfiguration
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
ProjectCommandResultDto & { configuration: ExternalCsvConfigurationSnapshotDto }
|
||||||
|
>(`/api/projects/${projectId}/external-csv/configuration`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ expectedRevision, configuration }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewExternalCsv(
|
||||||
|
projectId: string,
|
||||||
|
fileName: string,
|
||||||
|
contentBase64: string
|
||||||
|
) {
|
||||||
|
return request<ExternalCsvPreviewDto>(
|
||||||
|
`/api/projects/${projectId}/external-csv/preview`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ fileName, contentBase64 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planExternalInitialImport(
|
||||||
|
projectId: string,
|
||||||
|
fileName: string,
|
||||||
|
contentBase64: string
|
||||||
|
) {
|
||||||
|
return request<ExternalInitialImportPlanDto>(
|
||||||
|
`/api/projects/${projectId}/external-csv/initial-import/plan`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ fileName, contentBase64 }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyExternalInitialImport(
|
||||||
|
projectId: string,
|
||||||
|
input: ApplyExternalInitialImportInput
|
||||||
|
) {
|
||||||
|
return request<ExternalInitialImportResultDto>(
|
||||||
|
`/api/projects/${projectId}/external-csv/initial-import/apply`,
|
||||||
|
{ method: "POST", body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function copyDistributionBoard(
|
export function copyDistributionBoard(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
distributionBoardId: string,
|
distributionBoardId: string,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ const commandTypeLabels: Record<string, string> = {
|
||||||
"project.restore-state": "Projektstand wiederhergestellt",
|
"project.restore-state": "Projektstand wiederhergestellt",
|
||||||
"circuit-protection.update": "Stromkreisschutz bearbeitet",
|
"circuit-protection.update": "Stromkreisschutz bearbeitet",
|
||||||
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
|
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
|
||||||
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
|
|
||||||
"external-import.apply-initial": "Revit-Erstimport übernommen",
|
"external-import.apply-initial": "Revit-Erstimport übernommen",
|
||||||
"external-object.assign-to-new-circuit":
|
"external-object.assign-to-new-circuit":
|
||||||
"Externes Objekt in neuen Stromkreis übernommen",
|
"Externes Objekt in neuen Stromkreis übernommen",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
|
|
||||||
|
|
||||||
export function createDefaultRevitXlsxConfiguration(): ExternalModelConfiguration {
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
layout: { headerRow: 1, firstDataRow: 3, firstDataColumn: 1 },
|
|
||||||
circuitMode: "combined",
|
|
||||||
columns: {
|
|
||||||
worksharingId: "Worksharing-ID", ifcGuid: "IfcGUID", roomNumber: "MEP-Raum: Nummer",
|
|
||||||
roomName: "MEP-Raum: Name", familyAndType: "Familie und Typ", selectionMarker: "CAx_Auswahlkenner",
|
|
||||||
socketCount: "CAX_AnzahlObjekte", sourceRemark: "CAx_Anmerkung", circuitCombined: "kbp_Stromkreisnummer",
|
|
||||||
boardName: "kbp-E-Verteiler", circuitIdentifier: "kbp-E-Stromkreis", designation: "kbp-E-Bezeichnung",
|
|
||||||
electricalPowerW: "kbp-E-Elektrische Leistung", voltageV: "kbp-E-Spannung", currentA: "kbp-E-Stromstärke",
|
|
||||||
},
|
|
||||||
deviceTypes: [], familyTypeRules: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
|
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||||
import { UserRepository } from "../../db/repositories/user.repository.js";
|
import { UserRepository } from "../../db/repositories/user.repository.js";
|
||||||
|
|
@ -36,8 +36,8 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||||
export const projectRepository = new ProjectRepository(db);
|
export const projectRepository = new ProjectRepository(db);
|
||||||
export const roomRepository = new RoomRepository(db);
|
export const roomRepository = new RoomRepository(db);
|
||||||
export const externalModelConfigurationRepository =
|
export const externalCsvConfigurationRepository =
|
||||||
new ExternalModelConfigurationRepository(db);
|
new ExternalCsvConfigurationRepository(db);
|
||||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||||
export const userRepository = new UserRepository(db);
|
export const userRepository = new UserRepository(db);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
|
||||||
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.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 { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||||
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
||||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
|
|
@ -70,8 +71,10 @@ export const projectSettingsProjectCommandStore =
|
||||||
export const projectStateRestoreCommandStore =
|
export const projectStateRestoreCommandStore =
|
||||||
new ProjectStateRestoreCommandRepository(db);
|
new ProjectStateRestoreCommandRepository(db);
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const externalModelConfigurationProjectCommandStore =
|
export const externalCsvConfigurationProjectCommandStore =
|
||||||
new ExternalModelConfigurationProjectCommandRepository(db);
|
new ExternalCsvConfigurationProjectCommandRepository(db);
|
||||||
|
export const externalInitialImportProjectCommandStore =
|
||||||
|
new ExternalInitialImportProjectCommandRepository(db);
|
||||||
export const externalObjectRowAssignmentProjectCommandStore =
|
export const externalObjectRowAssignmentProjectCommandStore =
|
||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
|
|
@ -103,7 +106,8 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
circuitGroupSubtreeProjectCommandStore,
|
circuitGroupSubtreeProjectCommandStore,
|
||||||
circuitProtectionProjectCommandStore,
|
circuitProtectionProjectCommandStore,
|
||||||
projectHistoryStore,
|
projectHistoryStore,
|
||||||
externalModelConfigurationProjectCommandStore,
|
externalCsvConfigurationProjectCommandStore,
|
||||||
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore,
|
externalObjectNewRowProjectCommandStore,
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore,
|
||||||
|
|
|
||||||
298
src/server/controllers/external-csv.controller.ts
Normal file
298
src/server/controllers/external-csv.controller.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.js";
|
|
||||||
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
|
||||||
import { parseExternalXlsx } from "../../external-model/xlsx/external-xlsx-transport.js";
|
|
||||||
import { externalModelConfigurationRepository } from "../composition/application-repositories.js";
|
|
||||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
|
||||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
|
||||||
|
|
||||||
export function getExternalModelConfiguration(req: Request, res: Response) {
|
|
||||||
const projectId = String(req.params.projectId ?? "");
|
|
||||||
const result = externalModelConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
|
||||||
return res.json({ configuration: result.configuration });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateExternalModelConfiguration(req: Request, res: Response) {
|
|
||||||
const projectId = String(req.params.projectId ?? "");
|
|
||||||
const expectedRevision = req.body?.expectedRevision;
|
|
||||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
||||||
return res.status(400).json({ error: "Ungültige Projektrevision." });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
assertExternalModelConfiguration(req.body?.configuration);
|
|
||||||
const current = externalModelConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!current.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
|
||||||
const target = {
|
|
||||||
id: current.configuration?.id ?? randomUUID(),
|
|
||||||
projectId,
|
|
||||||
configuration: req.body.configuration,
|
|
||||||
};
|
|
||||||
const result = projectCommandService.executeUser({
|
|
||||||
projectId,
|
|
||||||
expectedRevision,
|
|
||||||
description: "Revit-XLSX-Einstellungen gespeichert",
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(current.configuration, target),
|
|
||||||
});
|
|
||||||
return res.json({ configuration: target, history: result.history });
|
|
||||||
} catch (error) {
|
|
||||||
return respondWithProjectCommandError(error, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectExternalModelWorkbook(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
assertExternalModelConfiguration(req.body?.configuration);
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: req.body?.fileName,
|
|
||||||
bytes: Buffer.from(req.body?.contentBase64 ?? "", "base64"),
|
|
||||||
configuration: req.body.configuration,
|
|
||||||
});
|
|
||||||
return res.json({
|
|
||||||
sheetName: document.sheetName,
|
|
||||||
columnCount: document.columns.length,
|
|
||||||
objectCount: document.rows.filter((row) => row.classification === "object").length,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Musterdatei konnte nicht gelesen werden." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,10 +54,13 @@ import {
|
||||||
importProjectTransferAsNewProject,
|
importProjectTransferAsNewProject,
|
||||||
} from "../controllers/project-transfer.controller.js";
|
} from "../controllers/project-transfer.controller.js";
|
||||||
import {
|
import {
|
||||||
getExternalModelConfiguration,
|
getExternalCsvConfiguration,
|
||||||
detectExternalModelWorkbook,
|
applyExternalInitialImport,
|
||||||
updateExternalModelConfiguration,
|
previewExternalCsv,
|
||||||
} from "../controllers/external-model-configuration.controller.js";
|
planExternalInitialImport,
|
||||||
|
updateExternalCsvConfiguration,
|
||||||
|
getExternalCircuitListObjects,
|
||||||
|
} from "../controllers/external-csv.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
|
|
@ -67,6 +70,21 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
|
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
|
||||||
|
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
|
||||||
|
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
|
||||||
|
projectRouter.post(
|
||||||
|
"/:projectId/external-csv/initial-import/plan",
|
||||||
|
planExternalInitialImport
|
||||||
|
);
|
||||||
|
projectRouter.get(
|
||||||
|
"/:projectId/circuit-lists/:circuitListId/external-objects",
|
||||||
|
getExternalCircuitListObjects
|
||||||
|
);
|
||||||
|
projectRouter.post(
|
||||||
|
"/:projectId/external-csv/initial-import/apply",
|
||||||
|
applyExternalInitialImport
|
||||||
|
);
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
|
|
@ -79,9 +97,6 @@ projectRouter.post(
|
||||||
restoreProjectSnapshot
|
restoreProjectSnapshot
|
||||||
);
|
);
|
||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/external-model/configuration", getExternalModelConfiguration);
|
|
||||||
projectRouter.put("/:projectId/external-model/configuration", updateExternalModelConfiguration);
|
|
||||||
projectRouter.post("/:projectId/external-model/configuration/detect", detectExternalModelWorkbook);
|
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
projectRouter.post(
|
projectRouter.post(
|
||||||
|
|
|
||||||
40
src/shared/validation/external-csv.schemas.ts
Normal file
40
src/shared/validation/external-csv.schemas.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Base64 length of the 18 MiB transport limit documented for CSV uploads
|
||||||
|
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
|
||||||
|
// padding). Keep in sync with the controller's raw byte-length check.
|
||||||
|
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
|
||||||
|
|
||||||
|
export const updateExternalCsvConfigurationSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
configuration: z.unknown(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const previewExternalCsvSchema = z
|
||||||
|
.object({
|
||||||
|
fileName: z.string().trim().min(1).max(255),
|
||||||
|
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const planExternalInitialImportSchema = previewExternalCsvSchema;
|
||||||
|
|
||||||
|
export const applyExternalInitialImportSchema = z.object({
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
expectedConfigurationVersion: z.number().int().positive(),
|
||||||
|
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
fileName: z.string().trim().min(1).max(255),
|
||||||
|
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
||||||
|
sourceName: z.string().trim().min(1).max(200),
|
||||||
|
roomDecisions: z.array(z.object({
|
||||||
|
sourceRoomKey: z.string().trim().min(1),
|
||||||
|
roomId: z.string().trim().min(1).nullable(),
|
||||||
|
defaultDistributionBoardId: z.string().trim().min(1).nullable(),
|
||||||
|
}).strict()),
|
||||||
|
familyProjectDeviceDecisions: z.array(z.object({
|
||||||
|
familyAndType: z.string(),
|
||||||
|
projectDeviceId: z.string().trim().min(1).nullable(),
|
||||||
|
}).strict()),
|
||||||
|
}).strict();
|
||||||
105
tests/external-circuit-list-objects.test.ts
Normal file
105
tests/external-circuit-list-objects.test.ts
Normal file
|
|
@ -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: [] }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
tests/external-csv-api-contract.test.ts
Normal file
83
tests/external-csv-api-contract.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
applyExternalInitialImportSchema,
|
||||||
|
MAX_CSV_CONTENT_BASE64_LENGTH,
|
||||||
|
planExternalInitialImportSchema,
|
||||||
|
previewExternalCsvSchema,
|
||||||
|
updateExternalCsvConfigurationSchema,
|
||||||
|
} from "../src/shared/validation/external-csv.schemas.js";
|
||||||
|
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
||||||
|
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
describe("external CSV API contracts", () => {
|
||||||
|
it("accepts strict update and preview request envelopes", () => {
|
||||||
|
assert.equal(
|
||||||
|
updateExternalCsvConfigurationSchema.safeParse({
|
||||||
|
expectedRevision: 4,
|
||||||
|
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
planExternalInitialImportSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
applyExternalInitialImportSchema.safeParse({
|
||||||
|
expectedRevision: 5,
|
||||||
|
expectedConfigurationVersion: 2,
|
||||||
|
expectedSha256: "a".repeat(64),
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
sourceName: "Revit-Gesamtmodell",
|
||||||
|
roomDecisions: [],
|
||||||
|
familyProjectDeviceDecisions: [],
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale-shaped, oversized and unknown request fields", () => {
|
||||||
|
assert.equal(
|
||||||
|
updateExternalCsvConfigurationSchema.safeParse({
|
||||||
|
expectedRevision: -1,
|
||||||
|
configuration: {},
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
apply: true,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
planExternalInitialImportSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
decisions: [],
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
56
tests/external-csv-preview.test.ts
Normal file
56
tests/external-csv-preview.test.ts
Normal file
|
|
@ -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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
tests/external-csv-transport.test.ts
Normal file
146
tests/external-csv-transport.test.ts
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
149
tests/external-initial-import-plan.test.ts
Normal file
149
tests/external-initial-import-plan.test.ts
Normal file
|
|
@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
297
tests/external-initial-import-project-command.repository.test.ts
Normal file
297
tests/external-initial-import-project-command.repository.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||||
|
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createEmptyExternalModelState,
|
||||||
|
createExternalInitialImportProjectCommand,
|
||||||
|
} from "../src/domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
||||||
|
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||||
|
import {
|
||||||
|
createExternalRoomKey,
|
||||||
|
projectInitialExternalObject,
|
||||||
|
} from "../src/external-model/domain/external-model-matching.js";
|
||||||
|
import {
|
||||||
|
externalCsvTestConfiguration,
|
||||||
|
quotedRevitCsv,
|
||||||
|
utf8Bytes,
|
||||||
|
} from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
function createTestContext() {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||||
|
const configuration = configuredCsv();
|
||||||
|
context.db.insert(externalCsvConfigurations).values({
|
||||||
|
id: "config-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
configurationVersion: 1,
|
||||||
|
configuration,
|
||||||
|
}).run();
|
||||||
|
return { context, configuration };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredCsv() {
|
||||||
|
const configuration = structuredClone(externalCsvTestConfiguration);
|
||||||
|
configuration.familyTypeRules.push(
|
||||||
|
{
|
||||||
|
exactFamilyAndType: "Steckdose: Doppelsteckdose",
|
||||||
|
internalDeviceType: "Steckdose",
|
||||||
|
connectionKind: "socket",
|
||||||
|
category: "single_phase",
|
||||||
|
quantityRule: { kind: "mapped-column" },
|
||||||
|
displayNameSuggestion: { kind: "selection-marker" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exactFamilyAndType: "Steckdose: Standard",
|
||||||
|
internalDeviceType: "Steckdose",
|
||||||
|
connectionKind: "socket",
|
||||||
|
category: "single_phase",
|
||||||
|
quantityRule: { kind: "fixed", quantity: 1 },
|
||||||
|
displayNameSuggestion: { kind: "selection-marker" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialState(
|
||||||
|
configuration: ReturnType<typeof configuredCsv>
|
||||||
|
): ExternalModelStateSnapshot {
|
||||||
|
const bytes = utf8Bytes(quotedRevitCsv, true);
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
bytes,
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
const roomKeys = [...new Set(preview.objects.map((object) =>
|
||||||
|
createExternalRoomKey(object.roomNumber, object.roomName)
|
||||||
|
).filter((key): key is string => key !== null))];
|
||||||
|
const roomMappings = roomKeys.map((key, index) => {
|
||||||
|
const object = preview.objects.find(
|
||||||
|
(candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key
|
||||||
|
)!;
|
||||||
|
return {
|
||||||
|
id: `mapping-${index + 1}`,
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
normalizedSourceRoomKey: key,
|
||||||
|
sourceFloorName: null,
|
||||||
|
sourceRoomNumber: object.roomNumber,
|
||||||
|
sourceRoomName: object.roomName,
|
||||||
|
roomId: null,
|
||||||
|
defaultDistributionBoardId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const mappingByKey = new Map(
|
||||||
|
roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
id: "source-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Revit-Gesamtmodell",
|
||||||
|
sourceType: "revit_csv",
|
||||||
|
},
|
||||||
|
importBatches: [{
|
||||||
|
id: "batch-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
importKind: "initial",
|
||||||
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
|
fileName: "revit.csv",
|
||||||
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
appliedProjectRevision: 1,
|
||||||
|
configurationVersion: 1,
|
||||||
|
configurationSnapshot: configuration,
|
||||||
|
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
||||||
|
document: parseExternalCsv(bytes, configuration),
|
||||||
|
}],
|
||||||
|
roomMappings,
|
||||||
|
objects: preview.objects.map((candidate, index) => {
|
||||||
|
const projected = projectInitialExternalObject(candidate, configuration);
|
||||||
|
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||||
|
return {
|
||||||
|
id: `object-${index + 1}`,
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
ifcGuid: projected.ifcGuid,
|
||||||
|
lastSeenImportBatchId: "batch-1",
|
||||||
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
|
acceptedSourceValues: projected.sourceValues,
|
||||||
|
planningValues: projected.planningValues,
|
||||||
|
overriddenFields: [],
|
||||||
|
externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!,
|
||||||
|
distributionBoardId: null,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
circuitDeviceRowId: null,
|
||||||
|
presenceStatus: "present",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("external initial import project command", () => {
|
||||||
|
it("commits the complete import and supports persisted undo and redo", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target = initialState(configuration);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
description: "Revit-Erstimport übernehmen",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
target
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.revision.revisionNumber, 1);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.objects.length,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.objects.every((object) => object.circuitDeviceRowId === null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: undone.inverse,
|
||||||
|
});
|
||||||
|
const redone = new ExternalModelStateRepository(context.db)
|
||||||
|
.getByProject("project-1").state;
|
||||||
|
assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]);
|
||||||
|
assert.equal(redone.importBatches[0].configurationVersion, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a target state with zero classified objects before writing anything", () => {
|
||||||
|
// assertPopulatedInitialState requires at least one object, so a CSV
|
||||||
|
// that classified none must be rejected at command construction, not
|
||||||
|
// reach the persistence layer's bulk insert.
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target: ExternalModelStateSnapshot = {
|
||||||
|
...initialState(configuration),
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
target
|
||||||
|
),
|
||||||
|
/at least one object/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects changed state, checksum drift and row assignment", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target = initialState(configuration);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
const assigned = structuredClone(target);
|
||||||
|
assigned.objects[0].circuitDeviceRowId = "row-1";
|
||||||
|
assert.throws(
|
||||||
|
() => createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
assigned
|
||||||
|
),
|
||||||
|
/must not assign circuit device rows/
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidHash = structuredClone(target);
|
||||||
|
invalidHash.importBatches[0].sha256 = "b".repeat(64);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
invalidHash
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/checksum is invalid/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back all external rows when late history persistence fails", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_external_import_history
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'late history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
initialState(configuration)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/late history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
|
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
|
|
||||||
import { ExternalModelConfigurationRepository } from "../src/db/repositories/external-model-configuration.repository.js";
|
|
||||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
|
||||||
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
|
||||||
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
|
|
||||||
import {
|
|
||||||
assertExternalModelConfiguration,
|
|
||||||
createDefaultExternalModelConfiguration,
|
|
||||||
} from "../src/external-model/configuration/external-model-configuration.js";
|
|
||||||
|
|
||||||
const deviceTypeId = "10000000-0000-4000-8000-000000000001";
|
|
||||||
const ruleId = "20000000-0000-4000-8000-000000000001";
|
|
||||||
|
|
||||||
function configuration() {
|
|
||||||
const value = createDefaultExternalModelConfiguration();
|
|
||||||
value.deviceTypes.push({
|
|
||||||
id: deviceTypeId,
|
|
||||||
name: "Steckdose",
|
|
||||||
category: "single_phase",
|
|
||||||
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
|
|
||||||
});
|
|
||||||
value.familyTypeRules.push({
|
|
||||||
id: ruleId,
|
|
||||||
exactFamilyAndType: "Steckdose: Standard",
|
|
||||||
deviceTypeId,
|
|
||||||
quantityRule: "socketCount",
|
|
||||||
});
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshot(projectId = "project-1") {
|
|
||||||
return { id: "config-1", projectId, configuration: configuration() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("external model configuration", () => {
|
|
||||||
it("validates the single Worksharing-ID mapping and exact family rules", () => {
|
|
||||||
const value = configuration();
|
|
||||||
assert.doesNotThrow(() => assertExternalModelConfiguration(value));
|
|
||||||
assert.equal("idColumn" in value.layout, false);
|
|
||||||
|
|
||||||
const duplicate = structuredClone(value);
|
|
||||||
duplicate.columns.ifcGuid = duplicate.columns.worksharingId;
|
|
||||||
assert.throws(() => assertExternalModelConfiguration(duplicate), /nicht eindeutig/);
|
|
||||||
|
|
||||||
const unknownType = structuredClone(value);
|
|
||||||
unknownType.familyTypeRules[0]!.deviceTypeId = "30000000-0000-4000-8000-000000000001";
|
|
||||||
assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists changes without an independent configuration revision", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ExternalModelConfigurationProjectCommandRepository(context.db);
|
|
||||||
const initial = snapshot();
|
|
||||||
const inserted = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(null, initial),
|
|
||||||
});
|
|
||||||
assert.equal(inserted.revision.revisionNumber, 1);
|
|
||||||
assert.equal("configurationVersion" in initial, false);
|
|
||||||
|
|
||||||
const target = structuredClone(initial);
|
|
||||||
target.configuration.deviceTypes[0]!.name = "Steckdose allgemein";
|
|
||||||
const updated = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
|
|
||||||
});
|
|
||||||
assert.equal(updated.revision.revisionNumber, 2);
|
|
||||||
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 2,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
|
|
||||||
}),
|
|
||||||
/zwischenzeitlich ge.*ndert/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes missing projects and remaps configuration identities on duplicate", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const reader = new ExternalModelConfigurationRepository(context.db);
|
|
||||||
assert.deepEqual(reader.getByProject("project-1"), { projectExists: true, configuration: null });
|
|
||||||
assert.deepEqual(reader.getByProject("missing"), { projectExists: false, configuration: null });
|
|
||||||
|
|
||||||
new ExternalModelConfigurationProjectCommandRepository(context.db).execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(null, snapshot()),
|
|
||||||
});
|
|
||||||
const transferRepository = new ProjectTransferRepository(context.db);
|
|
||||||
const transfer = transferRepository.exportProject("project-1");
|
|
||||||
assert.ok(transfer);
|
|
||||||
const duplicate = transferRepository.importDuplicate(transfer);
|
|
||||||
const copied = context.db.select().from(externalModelConfigurations)
|
|
||||||
.where(eq(externalModelConfigurations.projectId, duplicate.projectId)).get();
|
|
||||||
assert.ok(copied);
|
|
||||||
assert.notEqual(copied.id, "config-1");
|
|
||||||
assert.notEqual(copied.configuration.deviceTypes[0]!.id, deviceTypeId);
|
|
||||||
assert.equal(
|
|
||||||
copied.configuration.familyTypeRules[0]!.deviceTypeId,
|
|
||||||
copied.configuration.deviceTypes[0]!.id
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
431
tests/external-model-foundation.test.ts
Normal file
431
tests/external-model-foundation.test.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
|
||||||
import { parseExternalModelStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
|
||||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
|
||||||
|
|
||||||
const lowerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff";
|
|
||||||
const upperId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-FFFFFFFF";
|
|
||||||
|
|
||||||
function fullBatch(id: string, importedAtIso: string) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
projectId: "p",
|
|
||||||
sourceId: "s",
|
|
||||||
importKind: "follow_up" as const,
|
|
||||||
importedAtIso,
|
|
||||||
fileName: `${id}.xlsx`,
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
objectCount: 1,
|
|
||||||
appliedProjectRevision: 3,
|
|
||||||
configurationSnapshot: createDefaultExternalModelConfiguration(),
|
|
||||||
originalContentBase64: "AA==",
|
|
||||||
document: {
|
|
||||||
sheetName: "Bauteilliste",
|
|
||||||
headerRowNumber: 1,
|
|
||||||
firstDataRowNumber: 3,
|
|
||||||
firstDataColumnNumber: 1,
|
|
||||||
columns: [],
|
|
||||||
rows: [],
|
|
||||||
issues: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Linear XLSX external model foundation", () => {
|
|
||||||
it("keeps only the newest batch complete", () => {
|
|
||||||
const newest = fullBatch("new", "2026-02-01T00:00:00.000Z");
|
|
||||||
const older = {
|
|
||||||
...fullBatch("old", "2026-01-01T00:00:00.000Z"),
|
|
||||||
appliedProjectRevision: null,
|
|
||||||
configurationSnapshot: null,
|
|
||||||
originalContentBase64: null,
|
|
||||||
document: null,
|
|
||||||
};
|
|
||||||
assert.doesNotThrow(() => parseExternalModelStateSnapshot({
|
|
||||||
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
|
|
||||||
importBatches: [older, newest],
|
|
||||||
roomMappings: [],
|
|
||||||
objects: [],
|
|
||||||
}));
|
|
||||||
assert.throws(() => parseExternalModelStateSnapshot({
|
|
||||||
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
|
|
||||||
importBatches: [newest, fullBatch("newer", "2026-03-01T00:00:00.000Z")],
|
|
||||||
roomMappings: [],
|
|
||||||
objects: [],
|
|
||||||
}), /Nur der letzte Importbatch/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compares Worksharing-IDs exactly and allows duplicate IfcGUID values", () => {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
try {
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
context.sqlite.exec(`
|
|
||||||
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
|
|
||||||
INSERT INTO external_model_sources (id, project_id, name) VALUES ('s', 'p', 'Revit');
|
|
||||||
INSERT INTO external_import_batches
|
|
||||||
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, object_count)
|
|
||||||
VALUES ('b', 'p', 's', 'initial', '2026-01-01T00:00:00.000Z', 'model.xlsx', '${"a".repeat(64)}', 2);
|
|
||||||
`);
|
|
||||||
const insert = context.sqlite.prepare(`
|
|
||||||
INSERT INTO external_model_objects
|
|
||||||
(id, project_id, source_id, worksharing_id, ifc_guid, device_type_id,
|
|
||||||
last_seen_import_batch_id, last_accepted_import_batch_id,
|
|
||||||
accepted_source_values, planning_values, overridden_fields)
|
|
||||||
VALUES (?, 'p', 's', ?, 'same-IfcGUID', 'type', 'b', 'b', '{}', '{}', '[]')
|
|
||||||
`);
|
|
||||||
insert.run("o1", lowerId);
|
|
||||||
insert.run("o2", upperId);
|
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2);
|
|
||||||
assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
externalObjectAssignToNewCircuitCommandType,
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
||||||
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||||
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
function createFixture(initialObjectRowId: string | null = "row-1") {
|
function createFixture(initialObjectRowId: string | null = "row-1") {
|
||||||
const context = createDatabaseContext(":memory:");
|
const context = createDatabaseContext(":memory:");
|
||||||
|
|
@ -62,16 +63,20 @@ function createFixture(initialObjectRowId: string | null = "row-1") {
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run();
|
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run();
|
||||||
database.insert(externalImportBatches).values({
|
database.insert(externalImportBatches).values({
|
||||||
id: "batch-1",
|
id: "batch-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
importKind: "initial",
|
importKind: "initial",
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
fileName: "revit.xlsx",
|
fileName: "revit.csv",
|
||||||
sha256: "a".repeat(64),
|
sha256: "a".repeat(64),
|
||||||
objectCount: 1,
|
appliedProjectRevision: 0,
|
||||||
|
configurationVersion: 1,
|
||||||
|
configurationSnapshot: externalCsvTestConfiguration,
|
||||||
|
originalBytes: Buffer.from("test"),
|
||||||
|
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
||||||
}).run();
|
}).run();
|
||||||
database.insert(externalRoomMappings).values({
|
database.insert(externalRoomMappings).values({
|
||||||
id: "mapping-1",
|
id: "mapping-1",
|
||||||
|
|
@ -93,9 +98,7 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
|
||||||
id: "object-1",
|
id: "object-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
|
|
||||||
ifcGuid: "ifc-1",
|
ifcGuid: "ifc-1",
|
||||||
deviceTypeId: "device-type-1",
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
lastSeenImportBatchId: "batch-1",
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
acceptedSourceValues: {
|
acceptedSourceValues: {
|
||||||
|
|
@ -104,18 +107,14 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
|
||||||
roomName: "Büro",
|
roomName: "Büro",
|
||||||
familyAndType: "Steckdose: Standard",
|
familyAndType: "Steckdose: Standard",
|
||||||
selectionMarker: "Steckdose",
|
selectionMarker: "Steckdose",
|
||||||
socketCount: "2",
|
|
||||||
sourceRemark: null,
|
|
||||||
circuitCombined: "-2F1.1",
|
|
||||||
boardName: "UV 1",
|
|
||||||
circuitIdentifier: "-2F1.1",
|
circuitIdentifier: "-2F1.1",
|
||||||
designation: null,
|
power: "120",
|
||||||
electricalPowerW: "120",
|
quantity: "2",
|
||||||
voltageV: 230,
|
additionalSourceValues: {},
|
||||||
currentA: null,
|
|
||||||
},
|
},
|
||||||
planningValues: {
|
planningValues: {
|
||||||
displayName: "Steckdose",
|
displayName: "Steckdose",
|
||||||
|
internalDeviceType: "socket",
|
||||||
category: "single_phase",
|
category: "single_phase",
|
||||||
connectionKind: "socket",
|
connectionKind: "socket",
|
||||||
effectiveQuantity: 2,
|
effectiveQuantity: 2,
|
||||||
|
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import {
|
|
||||||
createExternalXlsxReturn,
|
|
||||||
ExternalXlsxError,
|
|
||||||
parseExternalXlsx,
|
|
||||||
} from "../src/external-model/xlsx/external-xlsx-transport.js";
|
|
||||||
import {
|
|
||||||
createRevitXlsxFixture,
|
|
||||||
externalXlsxTestConfiguration,
|
|
||||||
firstWorksharingId,
|
|
||||||
secondWorksharingId,
|
|
||||||
} from "./fixtures/revit-xlsx-fixtures.js";
|
|
||||||
|
|
||||||
describe("external XLSX transport", () => {
|
|
||||||
it("reads typed cells and classifies ID-less rows without blocking summaries", async () => {
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture(),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(document.sheetName, "Linear Export");
|
|
||||||
assert.equal(document.headerRowNumber, 1);
|
|
||||||
assert.equal(document.firstDataRowNumber, 3);
|
|
||||||
assert.equal(document.firstDataColumnNumber, 1);
|
|
||||||
assert.deepEqual(
|
|
||||||
document.rows.map((row) => row.classification),
|
|
||||||
["object", "object", "suspect-object", "passthrough"]
|
|
||||||
);
|
|
||||||
assert.equal(document.rows[0].cells[5].value, 2);
|
|
||||||
assert.equal(document.rows[0].cells[15].value, true);
|
|
||||||
assert.equal(document.columns[0].comment, "Kommentar Worksharing-ID");
|
|
||||||
assert.equal(document.columns[0].width, 12);
|
|
||||||
assert.deepEqual(
|
|
||||||
document.issues.map((issue) => issue.code),
|
|
||||||
["suspect-object-skipped"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves editable layout positions without a separate ID column", async () => {
|
|
||||||
const configuration = {
|
|
||||||
...externalXlsxTestConfiguration,
|
|
||||||
layout: { headerRow: 2, firstDataRow: 5, firstDataColumn: 2 },
|
|
||||||
};
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture(configuration.layout),
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(document.headerRowNumber, 2);
|
|
||||||
assert.equal(document.firstDataRowNumber, 5);
|
|
||||||
assert.equal(document.firstDataColumnNumber, 2);
|
|
||||||
assert.equal(document.columns[1].header, "Worksharing-ID");
|
|
||||||
assert.equal(document.rows[0].cells[1].value, firstWorksharingId);
|
|
||||||
assert.deepEqual(
|
|
||||||
document.rows.map((row) => row.classification),
|
|
||||||
["object", "object", "suspect-object", "passthrough"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compares Worksharing-ID and IfcGUID exactly and case-sensitively", async () => {
|
|
||||||
const lowerCaseId = secondWorksharingId.toLowerCase();
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture({
|
|
||||||
secondWorksharingId: lowerCaseId,
|
|
||||||
firstIfcGuid: "IfcGUID",
|
|
||||||
secondIfcGuid: "IFCGUID",
|
|
||||||
}),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
document.rows.filter((row) => row.classification === "object").length,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
document.issues.some((issue) => issue.code === "duplicate-ifc-guid"),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture({ duplicateWorksharingId: true }),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
}),
|
|
||||||
isXlsxError("duplicate-worksharing-id")
|
|
||||||
);
|
|
||||||
|
|
||||||
const duplicateIfcDocument = await parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture({
|
|
||||||
firstIfcGuid: "same",
|
|
||||||
secondIfcGuid: "same",
|
|
||||||
}),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
duplicateIfcDocument.issues.filter(
|
|
||||||
(issue) => issue.code === "duplicate-ifc-guid"
|
|
||||||
).length,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses cached formula values without evaluating formulas", async () => {
|
|
||||||
const document = await parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture({ includeFormula: true }),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
});
|
|
||||||
const powerCell = document.rows[0].cells[11];
|
|
||||||
assert.equal(powerCell.value, 250);
|
|
||||||
assert.equal(powerCell.formula, "125*2");
|
|
||||||
assert.equal(powerCell.usedCachedFormulaValue, true);
|
|
||||||
assert.equal(
|
|
||||||
document.issues.some(
|
|
||||||
(issue) => issue.code === "cached-formula-value-used"
|
|
||||||
),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks a required formula without a cached result", async () => {
|
|
||||||
await assert.rejects(
|
|
||||||
parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: await createRevitXlsxFixture({
|
|
||||||
includeRequiredFormulaWithoutResult: true,
|
|
||||||
}),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
}),
|
|
||||||
isXlsxError("required-formula-result-missing")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes selected object rows in source order and preserves workbook structure", async () => {
|
|
||||||
const source = await createRevitXlsxFixture({ includeFormula: true });
|
|
||||||
const result = await createExternalXlsxReturn({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: source,
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
selectedWorksharingIds: [secondWorksharingId, firstWorksharingId],
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
worksharingId: firstWorksharingId,
|
|
||||||
values: {
|
|
||||||
circuitCombined: "UV99-3F7",
|
|
||||||
electricalPowerW: 321.5,
|
|
||||||
voltageV: 400,
|
|
||||||
currentA: 0.46,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.exportedObjectCount, 2);
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
await workbook.xlsx.load(result.bytes as never);
|
|
||||||
const worksheet = workbook.worksheets[0];
|
|
||||||
assert.equal(worksheet.name, "Linear Export");
|
|
||||||
assert.equal(worksheet.rowCount, 4);
|
|
||||||
assert.equal(worksheet.getCell("A3").value, firstWorksharingId);
|
|
||||||
assert.equal(worksheet.getCell("A4").value, secondWorksharingId);
|
|
||||||
assert.equal(worksheet.getCell("H3").value, "UV99-3F7");
|
|
||||||
assert.equal(worksheet.getCell("L3").value, 321.5);
|
|
||||||
assert.equal(worksheet.getCell("M3").value, 400);
|
|
||||||
assert.equal(worksheet.getCell("N3").value, 0.46);
|
|
||||||
assert.equal(worksheet.getCell("P4").value, "bleibt");
|
|
||||||
assert.equal(worksheet.getCell("L3").numFmt, "0.00");
|
|
||||||
assert.equal(worksheet.getColumn(1).width, 12);
|
|
||||||
assert.equal(
|
|
||||||
typeof worksheet.getCell("A1").note === "string"
|
|
||||||
? worksheet.getCell("A1").note
|
|
||||||
: worksheet.getCell("A1").note?.texts?.[0]?.text,
|
|
||||||
"Kommentar Worksharing-ID"
|
|
||||||
);
|
|
||||||
const commentedCells: string[] = [];
|
|
||||||
worksheet.eachRow({ includeEmpty: true }, (row) => {
|
|
||||||
row.eachCell({ includeEmpty: true }, (cell) => {
|
|
||||||
if (cell.note !== undefined) commentedCells.push(cell.address);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
commentedCells,
|
|
||||||
Array.from({ length: 16 }, (_, index) =>
|
|
||||||
worksheet.getCell(1, index + 1).address
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for missing optional return columns and blocks missing circuit columns", async () => {
|
|
||||||
const source = await createRevitXlsxFixture();
|
|
||||||
const optionalMissing = {
|
|
||||||
...externalXlsxTestConfiguration,
|
|
||||||
columns: {
|
|
||||||
...externalXlsxTestConfiguration.columns,
|
|
||||||
designation: "Nicht vorhanden",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = await createExternalXlsxReturn({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: source,
|
|
||||||
configuration: optionalMissing,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
result.issues.some(
|
|
||||||
(issue) =>
|
|
||||||
issue.code === "optional-return-column-missing" &&
|
|
||||||
issue.field === "designation"
|
|
||||||
),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
createExternalXlsxReturn({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: source,
|
|
||||||
configuration: {
|
|
||||||
...externalXlsxTestConfiguration,
|
|
||||||
columns: {
|
|
||||||
...externalXlsxTestConfiguration.columns,
|
|
||||||
circuitCombined: "Nicht vorhanden",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
isXlsxError("return-circuit-column-missing")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces extension, byte size and post-read sheet limits", async () => {
|
|
||||||
const source = await createRevitXlsxFixture();
|
|
||||||
await assert.rejects(
|
|
||||||
parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsm",
|
|
||||||
bytes: source,
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
}),
|
|
||||||
isXlsxError("invalid-file-extension")
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: new Uint8Array(10 * 1024 * 1024 + 1),
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
}),
|
|
||||||
isXlsxError("file-too-large")
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
parseExternalXlsx({
|
|
||||||
fileName: "linear.xlsx",
|
|
||||||
bytes: source,
|
|
||||||
configuration: externalXlsxTestConfiguration,
|
|
||||||
limits: { maxRows: 3, maxColumns: 100, maxCells: 1_000 },
|
|
||||||
}),
|
|
||||||
isXlsxError("worksheet-too-large")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function isXlsxError(code: ExternalXlsxError["code"]) {
|
|
||||||
return (error: unknown) =>
|
|
||||||
error instanceof ExternalXlsxError && error.code === code;
|
|
||||||
}
|
|
||||||
134
tests/fixtures/revit-xlsx-fixtures.ts
vendored
134
tests/fixtures/revit-xlsx-fixtures.ts
vendored
|
|
@ -1,134 +0,0 @@
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import {
|
|
||||||
createDefaultExternalXlsxTransportConfiguration,
|
|
||||||
type ExternalXlsxTransportConfiguration,
|
|
||||||
} from "../../src/external-model/xlsx/external-xlsx-contracts.js";
|
|
||||||
|
|
||||||
export const firstWorksharingId =
|
|
||||||
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000001";
|
|
||||||
export const secondWorksharingId =
|
|
||||||
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000002";
|
|
||||||
|
|
||||||
export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration =
|
|
||||||
createDefaultExternalXlsxTransportConfiguration();
|
|
||||||
|
|
||||||
export async function createRevitXlsxFixture(options?: {
|
|
||||||
duplicateWorksharingId?: boolean;
|
|
||||||
secondWorksharingId?: string;
|
|
||||||
firstIfcGuid?: string;
|
|
||||||
secondIfcGuid?: string;
|
|
||||||
includeFormula?: boolean;
|
|
||||||
includeRequiredFormulaWithoutResult?: boolean;
|
|
||||||
headerRow?: number;
|
|
||||||
firstDataRow?: number;
|
|
||||||
firstDataColumn?: number;
|
|
||||||
}) {
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const worksheet = workbook.addWorksheet("Linear Export");
|
|
||||||
const headerRow = options?.headerRow ?? 1;
|
|
||||||
const firstDataRow = options?.firstDataRow ?? 3;
|
|
||||||
const firstDataColumn = options?.firstDataColumn ?? 1;
|
|
||||||
const headers = [
|
|
||||||
"Worksharing-ID",
|
|
||||||
"CAx_Auswahlkenner",
|
|
||||||
"MEP-Raum: Nummer",
|
|
||||||
"MEP-Raum: Name",
|
|
||||||
"Familie und Typ",
|
|
||||||
"CAX_AnzahlObjekte",
|
|
||||||
"CAx_Anmerkung",
|
|
||||||
"kbp_Stromkreisnummer",
|
|
||||||
"kbp-E-Verteiler",
|
|
||||||
"kbp-E-Stromkreis",
|
|
||||||
"kbp-E-Bezeichnung",
|
|
||||||
"kbp-E-Elektrische Leistung",
|
|
||||||
"kbp-E-Spannung",
|
|
||||||
"kbp-E-Stromstärke",
|
|
||||||
"IfcGUID",
|
|
||||||
"Unbekannt",
|
|
||||||
];
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
const columnNumber = firstDataColumn + index;
|
|
||||||
const cell = worksheet.getCell(headerRow, columnNumber);
|
|
||||||
cell.value = header;
|
|
||||||
cell.note = `Kommentar ${header}`;
|
|
||||||
cell.font = { bold: true };
|
|
||||||
worksheet.getColumn(columnNumber).width = 12 + index;
|
|
||||||
});
|
|
||||||
|
|
||||||
setRowValues(worksheet, firstDataRow, firstDataColumn, [
|
|
||||||
firstWorksharingId,
|
|
||||||
"Steckdose",
|
|
||||||
"01.101",
|
|
||||||
"Büro",
|
|
||||||
"Steckdose: Standard",
|
|
||||||
2,
|
|
||||||
"Quelle A",
|
|
||||||
"UV01-2F1",
|
|
||||||
"UV01",
|
|
||||||
"-2F1",
|
|
||||||
"Arbeitsplatz",
|
|
||||||
250,
|
|
||||||
230,
|
|
||||||
1.09,
|
|
||||||
options?.firstIfcGuid ?? "IfcCaseSensitive",
|
|
||||||
true,
|
|
||||||
]);
|
|
||||||
worksheet.getCell(firstDataRow, firstDataColumn + 11).numFmt = "0.00";
|
|
||||||
worksheet.getCell(firstDataRow, firstDataColumn + 12).numFmt = "0";
|
|
||||||
worksheet.getCell(firstDataRow, firstDataColumn + 13).numFmt = "0.00";
|
|
||||||
if (options?.includeFormula) {
|
|
||||||
worksheet.getCell(firstDataRow, firstDataColumn + 11).value = {
|
|
||||||
formula: "125*2",
|
|
||||||
result: 250,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (options?.includeRequiredFormulaWithoutResult) {
|
|
||||||
worksheet.getCell(firstDataRow, firstDataColumn).value = { formula: "\"\"" };
|
|
||||||
}
|
|
||||||
|
|
||||||
setRowValues(worksheet, firstDataRow + 1, firstDataColumn, [
|
|
||||||
options?.duplicateWorksharingId
|
|
||||||
? firstWorksharingId
|
|
||||||
: options?.secondWorksharingId ?? secondWorksharingId,
|
|
||||||
"Leuchte",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
"Leuchte: Decke",
|
|
||||||
1,
|
|
||||||
"Quelle B",
|
|
||||||
"UV01-1F1",
|
|
||||||
"UV01",
|
|
||||||
"-1F1",
|
|
||||||
"Beleuchtung",
|
|
||||||
42.5,
|
|
||||||
230,
|
|
||||||
0.18,
|
|
||||||
options?.secondIfcGuid ?? "IfcCaseSensitive2",
|
|
||||||
"bleibt",
|
|
||||||
]);
|
|
||||||
worksheet.getCell(firstDataRow + 1, firstDataColumn + 11).numFmt = "0.00";
|
|
||||||
setRowValues(worksheet, firstDataRow + 2, firstDataColumn, [
|
|
||||||
null,
|
|
||||||
"verdächtig",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
"Gerät: Ohne ID",
|
|
||||||
]);
|
|
||||||
setRowValues(worksheet, firstDataRow + 3, firstDataColumn, [
|
|
||||||
null,
|
|
||||||
"Summe: 292,5 W",
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new Uint8Array(await workbook.xlsx.writeBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRowValues(
|
|
||||||
worksheet: ExcelJS.Worksheet,
|
|
||||||
rowNumber: number,
|
|
||||||
firstColumn: number,
|
|
||||||
values: ExcelJS.CellValue[]
|
|
||||||
) {
|
|
||||||
values.forEach((value, index) => {
|
|
||||||
worksheet.getCell(rowNumber, firstColumn + index).value = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { it } from "node:test";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
|
||||||
|
|
||||||
it("replaces legacy CSV state and resets linked row quantities to their manual share", () => {
|
|
||||||
const source = path.resolve("src", "db", "migrations");
|
|
||||||
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-"));
|
|
||||||
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
|
|
||||||
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
|
|
||||||
mkdirSync(path.join(staging, "meta"));
|
|
||||||
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
|
|
||||||
for (const entry of journal.entries) {
|
|
||||||
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
try {
|
|
||||||
migrate(context.db, { migrationsFolder: staging });
|
|
||||||
context.sqlite.exec(`
|
|
||||||
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
|
|
||||||
INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV');
|
|
||||||
INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste');
|
|
||||||
INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.');
|
|
||||||
INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1');
|
|
||||||
INSERT INTO circuit_device_rows
|
|
||||||
(id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor)
|
|
||||||
VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1);
|
|
||||||
INSERT INTO external_csv_configurations
|
|
||||||
(id, project_id, configuration_version, configuration)
|
|
||||||
VALUES ('cfg', 'p', 3, '{}');
|
|
||||||
INSERT INTO external_model_sources (id, project_id, name, source_type)
|
|
||||||
VALUES ('src', 'p', 'Revit', 'revit_csv');
|
|
||||||
INSERT INTO external_import_batches
|
|
||||||
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256,
|
|
||||||
applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version)
|
|
||||||
VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv',
|
|
||||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3);
|
|
||||||
INSERT INTO external_model_objects
|
|
||||||
(id, project_id, source_id, ifc_guid, last_seen_import_batch_id,
|
|
||||||
last_accepted_import_batch_id, accepted_source_values, planning_values,
|
|
||||||
overridden_fields, circuit_device_row_id)
|
|
||||||
VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r');
|
|
||||||
`);
|
|
||||||
|
|
||||||
migrate(context.db, { migrationsFolder: source });
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
(context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0);
|
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0);
|
|
||||||
assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/);
|
|
||||||
|
|
||||||
const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>;
|
|
||||||
assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1);
|
|
||||||
assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0);
|
|
||||||
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
|
||||||
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
rmSync(staging, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates and verifies a backup before the Linear-XLSX migration", () => {
|
|
||||||
const source = path.resolve("src", "db", "migrations");
|
|
||||||
const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-"));
|
|
||||||
const staging = path.join(root, "migrations");
|
|
||||||
const dataDirectory = path.join(root, "data");
|
|
||||||
mkdirSync(path.join(staging, "meta"), { recursive: true });
|
|
||||||
mkdirSync(dataDirectory, { recursive: true });
|
|
||||||
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
|
|
||||||
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
|
|
||||||
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
|
|
||||||
for (const entry of journal.entries) {
|
|
||||||
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const databasePath = path.join(dataDirectory, "leistungsbilanz.db");
|
|
||||||
const context = createDatabaseContext(databasePath);
|
|
||||||
try {
|
|
||||||
migrate(context.db, { migrationsFolder: staging });
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], {
|
|
||||||
cwd: path.resolve("."),
|
|
||||||
env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory },
|
|
||||||
encoding: "utf8",
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
||||||
assert.match(result.stdout, /Verified pre-migration backup created/);
|
|
||||||
const backups = readdirSync(path.join(dataDirectory, "backups"));
|
|
||||||
assert.equal(backups.length, 1);
|
|
||||||
|
|
||||||
const migrated = createDatabaseContext(databasePath);
|
|
||||||
try {
|
|
||||||
assert.ok(migrated.sqlite.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'"
|
|
||||||
).get());
|
|
||||||
} finally {
|
|
||||||
migrated.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
|
||||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||||
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
|
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
|
@ -44,7 +44,7 @@ import { projects } from "../src/db/schema/projects.js";
|
||||||
import { buildings } from "../src/db/schema/buildings.js";
|
import { buildings } from "../src/db/schema/buildings.js";
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { rooms } from "../src/db/schema/rooms.js";
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
||||||
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
|
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
|
||||||
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
||||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
|
@ -90,8 +90,9 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p
|
||||||
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
||||||
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||||
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
||||||
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
|
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
|
||||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
||||||
|
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
||||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
|
@ -165,7 +166,7 @@ function createService(context: DatabaseContext) {
|
||||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||||
new CircuitProtectionProjectCommandRepository(context.db),
|
new CircuitProtectionProjectCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db),
|
new ProjectHistoryRepository(context.db),
|
||||||
new ExternalModelConfigurationProjectCommandRepository(context.db)
|
new ExternalCsvConfigurationProjectCommandRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1126,62 +1127,31 @@ describe("project command service", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches external XLSX configuration updates through persistent history", () => {
|
it("dispatches external CSV configuration updates through persistent history", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
const service = createService(context);
|
const service = createService(context);
|
||||||
const target = {
|
const target = {
|
||||||
id: "external-config-1",
|
id: "external-config-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
configuration: createDefaultExternalModelConfiguration(),
|
configurationVersion: 1,
|
||||||
|
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
||||||
};
|
};
|
||||||
const updated = service.executeUser({
|
const updated = service.executeUser({
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
expectedRevision: 0,
|
expectedRevision: 0,
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(null, target),
|
command: createExternalCsvConfigurationUpdateProjectCommand(null, target),
|
||||||
});
|
});
|
||||||
assert.equal(updated.history.currentRevision, 1);
|
assert.equal(updated.history.currentRevision, 1);
|
||||||
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
|
||||||
|
|
||||||
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
|
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
|
||||||
assert.equal(undone.history.currentRevision, 2);
|
assert.equal(undone.history.currentRevision, 2);
|
||||||
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 0);
|
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0);
|
||||||
|
|
||||||
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
|
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
|
||||||
assert.equal(redone.history.currentRevision, 3);
|
assert.equal(redone.history.currentRevision, 3);
|
||||||
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects legacy CSV commands stored in project history with a clear error", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const service = createService(context);
|
|
||||||
service.executeUser({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
command: createExternalModelConfigurationUpdateProjectCommand(null, {
|
|
||||||
id: "external-config-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
configuration: createDefaultExternalModelConfiguration(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
context.db.update(projectChangeSets).set({
|
|
||||||
inversePayloadJson: JSON.stringify({
|
|
||||||
schemaVersion: 1,
|
|
||||||
type: "external-import.apply-initial",
|
|
||||||
payload: {},
|
|
||||||
}),
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => service.undo({ projectId: "project-1", expectedRevision: 1 }),
|
|
||||||
/alten Revit-CSV-Anbindung/
|
|
||||||
);
|
|
||||||
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
|
||||||
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
|
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { externalModelSources } from "../src/db/schema/external-model-sources.js
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -177,7 +178,7 @@ function linkExternalObject(
|
||||||
id: "source-1",
|
id: "source-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
name: "Revit",
|
name: "Revit",
|
||||||
sourceType: "revit_xlsx",
|
sourceType: "revit_csv",
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
@ -188,9 +189,13 @@ function linkExternalObject(
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
importKind: "initial",
|
importKind: "initial",
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
fileName: "revit.xlsx",
|
fileName: "revit.csv",
|
||||||
sha256: "a".repeat(64),
|
sha256: "a".repeat(64),
|
||||||
objectCount: 1,
|
appliedProjectRevision: 0,
|
||||||
|
configurationVersion: 1,
|
||||||
|
configurationSnapshot: externalCsvTestConfiguration,
|
||||||
|
originalBytes: Buffer.from("test"),
|
||||||
|
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
@ -199,9 +204,7 @@ function linkExternalObject(
|
||||||
id: "object-1",
|
id: "object-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
|
|
||||||
ifcGuid: "ifc-1",
|
ifcGuid: "ifc-1",
|
||||||
deviceTypeId: "device-type-1",
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
lastSeenImportBatchId: "batch-1",
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
acceptedSourceValues: {
|
acceptedSourceValues: {
|
||||||
|
|
@ -210,18 +213,14 @@ function linkExternalObject(
|
||||||
roomName: "Büro",
|
roomName: "Büro",
|
||||||
familyAndType: "Leuchte: Standard",
|
familyAndType: "Leuchte: Standard",
|
||||||
selectionMarker: "Leuchte",
|
selectionMarker: "Leuchte",
|
||||||
socketCount: String(effectiveQuantity),
|
|
||||||
sourceRemark: null,
|
|
||||||
circuitCombined: "-1F1",
|
|
||||||
boardName: "UV 1",
|
|
||||||
circuitIdentifier: "-1F1",
|
circuitIdentifier: "-1F1",
|
||||||
designation: null,
|
power: "30",
|
||||||
electricalPowerW: "30",
|
quantity: String(effectiveQuantity),
|
||||||
voltageV: 230,
|
additionalSourceValues: {},
|
||||||
currentA: null,
|
|
||||||
},
|
},
|
||||||
planningValues: {
|
planningValues: {
|
||||||
displayName: "Leuchte",
|
displayName: "Leuchte",
|
||||||
|
internalDeviceType: "luminaire",
|
||||||
category: "single_phase",
|
category: "single_phase",
|
||||||
connectionKind: "fixed",
|
connectionKind: "fixed",
|
||||||
effectiveQuantity,
|
effectiveQuantity,
|
||||||
|
|
|
||||||
|
|
@ -465,8 +465,6 @@ describe("project snapshot repository", () => {
|
||||||
rooms: Array<Record<string, unknown>>;
|
rooms: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
payload.schemaVersion = 5;
|
payload.schemaVersion = 5;
|
||||||
(payload as Record<string, unknown>).externalCsvConfiguration = null;
|
|
||||||
delete (payload as Record<string, unknown>).externalModelConfiguration;
|
|
||||||
delete payload.buildings;
|
delete payload.buildings;
|
||||||
for (const floor of payload.floors) {
|
for (const floor of payload.floors) {
|
||||||
delete floor.buildingId;
|
delete floor.buildingId;
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,11 @@ import {
|
||||||
} from "../src/domain/models/project-state-snapshot.model.js";
|
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
|
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
|
||||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||||
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
|
||||||
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
/** Removes the fields introduced by snapshot schema version 9. */
|
/** Removes the fields introduced by snapshot schema version 9. */
|
||||||
function withoutVersionNineFields<
|
function withoutVersionNineFields<
|
||||||
T extends { distributionBoards: object[] },
|
T extends { distributionBoards: object[] },
|
||||||
>(snapshot: T): T {
|
>(snapshot: T): T {
|
||||||
const record = snapshot as Record<string, unknown>;
|
|
||||||
record.externalCsvConfiguration = null;
|
|
||||||
delete record.externalModelConfiguration;
|
|
||||||
for (const board of snapshot.distributionBoards) {
|
for (const board of snapshot.distributionBoards) {
|
||||||
delete (board as Record<string, unknown>).feedingDistributionBoardId;
|
delete (board as Record<string, unknown>).feedingDistributionBoardId;
|
||||||
delete (board as Record<string, unknown>).feedingCircuitId;
|
delete (board as Record<string, unknown>).feedingCircuitId;
|
||||||
|
|
@ -82,7 +77,7 @@ function minimalSnapshot() {
|
||||||
projectEditor: null as string | null,
|
projectEditor: null as string | null,
|
||||||
},
|
},
|
||||||
distributionBoards: [],
|
distributionBoards: [],
|
||||||
externalModelConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -320,7 +315,6 @@ describe("project state snapshot model", () => {
|
||||||
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||||
delete (baseline as Record<string, unknown>).externalModelConfiguration;
|
|
||||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||||
delete (baseline as Record<string, unknown>).externalModel;
|
delete (baseline as Record<string, unknown>).externalModel;
|
||||||
|
|
||||||
|
|
@ -328,7 +322,7 @@ describe("project state snapshot model", () => {
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.project.isPublicBuilding, false);
|
assert.equal(upgraded.project.isPublicBuilding, false);
|
||||||
assert.equal(upgraded.externalModelConfiguration, null);
|
assert.equal(upgraded.externalCsvConfiguration, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
||||||
|
|
@ -340,7 +334,7 @@ describe("project state snapshot model", () => {
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.externalModelConfiguration, null);
|
assert.equal(upgraded.externalCsvConfiguration, null);
|
||||||
assert.deepEqual(upgraded.externalModel, {
|
assert.deepEqual(upgraded.externalModel, {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -357,7 +351,7 @@ describe("project state snapshot model", () => {
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.externalModelConfiguration, null);
|
assert.equal(upgraded.externalCsvConfiguration, null);
|
||||||
assert.deepEqual(upgraded.externalModel, {
|
assert.deepEqual(upgraded.externalModel, {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -366,28 +360,6 @@ describe("project state snapshot model", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects legacy CSV configuration and imported state with a German migration error", () => {
|
|
||||||
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
|
|
||||||
configured.schemaVersion = 9 as typeof configured.schemaVersion;
|
|
||||||
configured.externalCsvConfiguration = {
|
|
||||||
id: "legacy",
|
|
||||||
projectId: "project-1",
|
|
||||||
configurationVersion: 1,
|
|
||||||
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
|
||||||
};
|
|
||||||
assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/);
|
|
||||||
|
|
||||||
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
|
|
||||||
imported.schemaVersion = 9 as typeof imported.schemaVersion;
|
|
||||||
imported.externalModel.source = {
|
|
||||||
id: "legacy-source",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Legacy",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
};
|
|
||||||
assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
||||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||||
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
||||||
|
|
@ -595,7 +567,7 @@ describe("project state snapshot model", () => {
|
||||||
});
|
});
|
||||||
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
|
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
|
||||||
|
|
||||||
const previous = withoutVersionNineFields(structuredClone(current));
|
const previous = structuredClone(current);
|
||||||
previous.schemaVersion = 7 as typeof previous.schemaVersion;
|
previous.schemaVersion = 7 as typeof previous.schemaVersion;
|
||||||
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
|
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
|
||||||
251
tests/revit-csv-frontend-api.test.ts
Normal file
251
tests/revit-csv-frontend-api.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import {
|
||||||
|
createRevitProjectDevicePrefill,
|
||||||
|
RevitCsvImportPlan,
|
||||||
|
RevitCsvModal,
|
||||||
|
} from "../src/frontend/components/revit-csv-modal.js";
|
||||||
|
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
|
||||||
|
import { RoomModal } from "../src/frontend/components/room-modal.js";
|
||||||
|
import {
|
||||||
|
applyExternalInitialImport,
|
||||||
|
getExternalCsvConfiguration,
|
||||||
|
getExternalCircuitListObjects,
|
||||||
|
planExternalInitialImport,
|
||||||
|
previewExternalCsv,
|
||||||
|
updateExternalCsvConfiguration,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import type { ExternalInitialImportPlanDto } from "../src/frontend/types.js";
|
||||||
|
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
||||||
|
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
describe("Revit CSV frontend API", () => {
|
||||||
|
it("uses the project-scoped configuration, preview and import endpoints", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({ configuration: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getExternalCsvConfiguration("project-1");
|
||||||
|
await getExternalCircuitListObjects("project-1", "list-1");
|
||||||
|
await updateExternalCsvConfiguration("project-1", 7, configuration);
|
||||||
|
await previewExternalCsv("project-1", "revit.csv", "YWJj");
|
||||||
|
await planExternalInitialImport("project-1", "revit.csv", "YWJj");
|
||||||
|
await applyExternalInitialImport("project-1", {
|
||||||
|
expectedRevision: 8,
|
||||||
|
expectedConfigurationVersion: 2,
|
||||||
|
expectedSha256: "sha",
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
sourceName: "Revit-Gesamtmodell",
|
||||||
|
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
|
||||||
|
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/external-csv/configuration",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/circuit-lists/list-1/external-objects",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/external-csv/configuration",
|
||||||
|
method: "PUT",
|
||||||
|
body: { expectedRevision: 7, configuration },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/external-csv/preview",
|
||||||
|
method: "POST",
|
||||||
|
body: { fileName: "revit.csv", contentBase64: "YWJj" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/external-csv/initial-import/plan",
|
||||||
|
method: "POST",
|
||||||
|
body: { fileName: "revit.csv", contentBase64: "YWJj" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/external-csv/initial-import/apply",
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
expectedRevision: 8,
|
||||||
|
expectedConfigurationVersion: 2,
|
||||||
|
expectedSha256: "sha",
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
sourceName: "Revit-Gesamtmodell",
|
||||||
|
roomDecisions: [{ sourceRoomKey: "101|büro", roomId: "room-1", defaultDistributionBoardId: "board-1" }],
|
||||||
|
familyProjectDeviceDecisions: [{ familyAndType: "Steckdose : Standard", projectDeviceId: "device-1" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Revit CSV modal presentation", () => {
|
||||||
|
it("uses the viewport-wide dialog variant", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(RevitCsvModal, {
|
||||||
|
currentRevision: 0,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onCreateProjectDevice: async () => ({} as never),
|
||||||
|
onCreateRoom: async () => ({} as never),
|
||||||
|
onRevisionChange: () => undefined,
|
||||||
|
projectId: "project-1",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /max-width:calc\(100vw - 2rem\)/);
|
||||||
|
assert.doesNotMatch(markup, /modal-lg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the initial import decisions without individual object rows", () => {
|
||||||
|
const plan: ExternalInitialImportPlanDto = {
|
||||||
|
configurationVersion: 2,
|
||||||
|
fileName: "revit.csv",
|
||||||
|
sha256: "sha",
|
||||||
|
byteLength: 100,
|
||||||
|
rowCount: 2,
|
||||||
|
objectCount: 1,
|
||||||
|
passthroughCount: 1,
|
||||||
|
suspectObjectCount: 0,
|
||||||
|
suspectRowNumbers: [],
|
||||||
|
sourceRooms: [{
|
||||||
|
sourceRoomKey: "101|büro",
|
||||||
|
roomNumber: "101",
|
||||||
|
roomName: "Büro",
|
||||||
|
objectCount: 1,
|
||||||
|
suggestedRoomId: "room-1",
|
||||||
|
matchStatus: "exact-number",
|
||||||
|
candidateRoomIds: ["room-1"],
|
||||||
|
}],
|
||||||
|
familyGroups: [{
|
||||||
|
familyAndType: "Steckdose : Standard",
|
||||||
|
objectCount: 1,
|
||||||
|
classified: true,
|
||||||
|
category: "single_phase",
|
||||||
|
connectionKind: null,
|
||||||
|
internalDeviceType: "socket",
|
||||||
|
}],
|
||||||
|
issueCounts: { unknownFamilyAndType: 0, invalidPower: 0, invalidQuantity: 0, missingRoom: 0 },
|
||||||
|
objects: [],
|
||||||
|
existing: {
|
||||||
|
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
|
||||||
|
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }],
|
||||||
|
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
|
||||||
|
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const markup = renderToStaticMarkup(createElement(RevitCsvImportPlan, {
|
||||||
|
familyLinks: { "Steckdose : Standard": "device-1" },
|
||||||
|
isApplying: false,
|
||||||
|
onApply: () => undefined,
|
||||||
|
onCreateProjectDevice: () => undefined,
|
||||||
|
onCreateRoom: () => undefined,
|
||||||
|
onFamilyLinkChange: () => undefined,
|
||||||
|
onRoomDecisionChange: () => undefined,
|
||||||
|
plan,
|
||||||
|
roomDecisions: { "101|büro": { roomId: "room-1", defaultDistributionBoardId: "board-1" } },
|
||||||
|
setSourceName: () => undefined,
|
||||||
|
sourceName: "Revit-Gesamtmodell",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(markup, /Erstimport vorbereiten/);
|
||||||
|
assert.match(markup, /101 · Büro/);
|
||||||
|
assert.match(markup, /Steckdose : Standard/);
|
||||||
|
assert.match(markup, /Erstimport übernehmen/);
|
||||||
|
assert.match(markup, /Raum 101 neu anlegen/);
|
||||||
|
assert.match(markup, /ProjectDevice für Steckdose : Standard neu anlegen/);
|
||||||
|
assert.doesNotMatch(markup, /IFC-GUID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills the existing room and ProjectDevice forms from Revit decisions", () => {
|
||||||
|
const roomMarkup = renderToStaticMarkup(createElement(RoomModal, {
|
||||||
|
floors: [{ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 0 }],
|
||||||
|
initialValues: { roomNumber: "101", roomName: "Büro" },
|
||||||
|
isSaving: false,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onSave: async () => undefined,
|
||||||
|
}));
|
||||||
|
const deviceMarkup = renderToStaticMarkup(createElement(ProjectDeviceModal, {
|
||||||
|
globalDevices: [],
|
||||||
|
initialValues: {
|
||||||
|
name: "socket",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
category: "single_phase",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
isSaving: false,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onImportGlobal: async () => undefined,
|
||||||
|
onSave: async () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(roomMarkup, /value="101"/);
|
||||||
|
assert.match(roomMarkup, /value="Büro"/);
|
||||||
|
assert.match(deviceMarkup, /value="socket"/);
|
||||||
|
assert.match(deviceMarkup, /value="Steckdose"/);
|
||||||
|
assert.match(deviceMarkup, /value="2"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts mapped Revit watts into ProjectDevice kilowatts", () => {
|
||||||
|
const plan = {
|
||||||
|
familyGroups: [{
|
||||||
|
familyAndType: "Steckdose : Standard",
|
||||||
|
internalDeviceType: "socket",
|
||||||
|
category: "single_phase",
|
||||||
|
connectionKind: "230 V",
|
||||||
|
}],
|
||||||
|
objects: [{
|
||||||
|
sourceValues: { familyAndType: "Steckdose : Standard" },
|
||||||
|
suggestedPlanningValues: {
|
||||||
|
displayName: "Arbeitsplatzsteckdose",
|
||||||
|
effectiveQuantity: 4,
|
||||||
|
powerPerUnitW: 250,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
costGroup: "440",
|
||||||
|
remark: "Aus Revit",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
} as ExternalInitialImportPlanDto;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
createRevitProjectDevicePrefill(plan, "Steckdose : Standard"),
|
||||||
|
{
|
||||||
|
name: "socket",
|
||||||
|
displayName: "Arbeitsplatzsteckdose",
|
||||||
|
connectionKind: "230 V",
|
||||||
|
category: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.25,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
costGroup: "440",
|
||||||
|
remark: "Aus Revit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
|
||||||
import { detectExternalModelWorkbook, getExternalModelConfiguration, updateExternalModelConfiguration } from "../src/frontend/utils/api.js";
|
|
||||||
|
|
||||||
describe("Revit XLSX settings", () => {
|
|
||||||
it("uses the project-scoped configuration endpoint with expectedRevision", async () => {
|
|
||||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
|
||||||
return new Response(JSON.stringify({ configuration: null, history: { currentRevision: 8 } }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const configuration = createDefaultExternalModelConfiguration();
|
|
||||||
await getExternalModelConfiguration("project-1");
|
|
||||||
await updateExternalModelConfiguration("project-1", 7, configuration);
|
|
||||||
await detectExternalModelWorkbook("project-1", new File([new Uint8Array([1, 2])], "sample.xlsx"), configuration);
|
|
||||||
assert.deepEqual(requests, [
|
|
||||||
{ url: "/api/projects/project-1/external-model/configuration", method: "GET", body: undefined },
|
|
||||||
{ url: "/api/projects/project-1/external-model/configuration", method: "PUT", body: { expectedRevision: 7, configuration } },
|
|
||||||
{ url: "/api/projects/project-1/external-model/configuration/detect", method: "POST", body: { fileName: "sample.xlsx", contentBase64: "AQI=", configuration } },
|
|
||||||
]);
|
|
||||||
} finally { globalThis.fetch = originalFetch; }
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the Revit route and removes the old project modal", () => {
|
|
||||||
const projectPage = readFileSync("src/app/projects/[projectId]/page.tsx", "utf8");
|
|
||||||
const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8");
|
|
||||||
const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8");
|
|
||||||
assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/);
|
|
||||||
assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/);
|
|
||||||
assert.match(revitPage, /Importstatus/);
|
|
||||||
assert.match(settings, /Musterdatei prüfen/);
|
|
||||||
assert.match(settings, /Kombinierte Stromkreisspalte/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue