Replace Revit CSV backend with Linear XLSX
This commit is contained in:
parent
52d3b1a270
commit
1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions
32
AGENTS.md
32
AGENTS.md
|
|
@ -295,7 +295,10 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 9 additionally stores
|
Current snapshot schema version 10 stores the Linear-XLSX configuration and
|
||||||
|
external state with Worksharing-ID identity. Empty versions 1 to 9 are upgraded;
|
||||||
|
versions containing legacy CSV configuration or external CSV state are rejected
|
||||||
|
with a German migration error. Version 9 additionally stores
|
||||||
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
`feedingDistributionBoardId` and `feedingCircuitId` on distribution boards;
|
||||||
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
versions 1 to 8 are upgraded with both references set to `null`. Version 8
|
||||||
stores the optional net room area `rooms[].areaM2` (see
|
stores the optional net room area `rooms[].areaM2` (see
|
||||||
|
|
@ -307,8 +310,8 @@ distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
||||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||||
with `null` for these fields.
|
with `null` for these fields.
|
||||||
Stored named and automatic snapshots of every supported version (1 to 9)
|
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
|
||||||
remain restorable; they are upgraded while parsing. Version 5 stores
|
they contain no legacy CSV state; they are upgraded while parsing. Version 5 stores
|
||||||
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
external-model source, import batches, room mappings and objects. Version 3
|
external-model source, import batches, room mappings and objects. Version 3
|
||||||
|
|
@ -320,17 +323,17 @@ 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.
|
||||||
The current runtime still contains the legacy Revit-CSV/IfcGUID foundation:
|
R3 replaced the runtime configuration and persistence foundation with Linear
|
||||||
project configuration, preview, confirmed initial import, external objects and
|
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
|
||||||
the three row-assignment commands. It remains the audited starting point, not
|
the old development-only external CSV data after resetting linked row totals to
|
||||||
the Phase-14 target. Do not extend its CSV transport or IFCGUID identity model.
|
their manual share. Snapshot schema 10, restore and project transfer use the
|
||||||
The approved cutover is Linear XLSX with exact case-sensitive Worksharing-ID
|
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
|
||||||
identity. See `docs/spec/revit-excel-integration-requirements.md` and
|
rejected with a German error. The backend CSV parser and routes are removed.
|
||||||
|
The old project-page frontend remains only until its replacement in R4; do not
|
||||||
|
reconnect it to runtime CSV endpoints. See
|
||||||
|
`docs/spec/revit-excel-integration-requirements.md` and
|
||||||
`docs/spec/revit-excel-phase-14-plan.md`.
|
`docs/spec/revit-excel-phase-14-plan.md`.
|
||||||
|
|
||||||
During R1/R2 no database or runtime schema changes are made. R3 will replace
|
|
||||||
the legacy configuration and transport additively, reject non-empty legacy CSV
|
|
||||||
snapshot/history state with a German error, and introduce snapshot schema 10.
|
|
||||||
The target configuration has no independent version counter; `expectedRevision`
|
The target configuration has no independent version counter; `expectedRevision`
|
||||||
invalidates stale plans, and each import batch stores a configuration copy.
|
invalidates stale plans, and each import batch stores a configuration copy.
|
||||||
Only the newest import batch retains workbook bytes and the full matrix; older
|
Only the newest import batch retains workbook bytes and the full matrix; older
|
||||||
|
|
@ -582,9 +585,8 @@ Critical target rules:
|
||||||
produce warnings and are skipped. Only missing circuit columns for the
|
produce warnings and are skipped. Only missing circuit columns for the
|
||||||
selected combined/separate mode block export.
|
selected combined/separate mode block export.
|
||||||
- Missing GZF defaults visibly to 1 without separate confirmation.
|
- Missing GZF defaults visibly to 1 without separate confirmation.
|
||||||
- R2 must generate a changed return workbook from the local sample. The user
|
- The corrected R2 return workbook was successfully imported into Linear on
|
||||||
tests it in Linear before R3. If Linear rejects it, propose direct OOXML
|
2026-09-30; R3 is complete and R4 is the next package.
|
||||||
modification of the original workbook before starting R3.
|
|
||||||
- All confirmed writes use typed project commands with `expectedRevision`,
|
- All confirmed writes use typed project commands with `expectedRevision`,
|
||||||
complete inverse data and the shared project-command transaction boundary.
|
complete inverse data and the shared project-command transaction boundary.
|
||||||
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
- Do not add automatic circuit planning, Revit circuit adoption, multi-source
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -9,12 +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 bisherige Revit-CSV-/IfcGUID-Grundlage mit Vorschau und
|
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
|
||||||
bestätigtem Erstimport ist noch im Code vorhanden. Der freigegebene
|
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
|
||||||
Phase-14-Plan ersetzt sie schrittweise durch den XLSX-Austausch des
|
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R3 sind abgeschlossen;
|
||||||
Linear-Plugins mit Worksharing-ID als externem Schlüssel. R0 ist abgeschlossen;
|
die eigene Revit-Seite, Erstimport, Editorzuordnung, Rückgabe und Folgeimport
|
||||||
R1 aktualisiert die Dokumentation. Editorzuordnung, Folgeimport und Rückexport
|
folgen in R4 bis R10.
|
||||||
sind noch nicht als neuer Excel-Pfad ausgeliefert.
|
|
||||||
|
|
||||||
## Unterstützter Arbeitsablauf
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -41,9 +40,9 @@ sind noch nicht als neuer Excel-Pfad ausgeliefert.
|
||||||
- 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
|
||||||
- den vorhandenen Legacy-Revit-CSV-Erstimport verwenden; der Zielpfad über
|
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
|
||||||
Linear-XLSX, eigene Revit-Seite, Editorzuordnung, Rückgabe und Folgeimport
|
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
|
||||||
wird gemäß Phase-14-Plan umgesetzt
|
Phase-14-Plan in den nächsten Paketen angebunden
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,9 @@ Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
|
||||||
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
|
||||||
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
|
||||||
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
Abgang zugerechnet. Eine Verknüpfung ohne Stromkreis bildet nur die Hierarchie.
|
||||||
|
Snapshot-Schema 10 enthält zusätzlich die Linear-XLSX-Konfiguration und den
|
||||||
|
externen Zustand mit Worksharing-ID-Identität. Leere Versionen 1–9 werden
|
||||||
|
hochgestuft; Versionen mit tatsächlichem Legacy-CSV-Zustand werden abgelehnt.
|
||||||
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
Snapshot-Schema 9 enthält die beiden Einspeisungsverweise; Versionen 1–8 werden
|
||||||
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
|
||||||
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
remappen die Verweise. Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
||||||
|
|
@ -520,9 +523,9 @@ Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
|
||||||
Projekts; ältere Versionen werden mit `null`
|
Projekts; ältere Versionen werden mit `null`
|
||||||
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||||
enthält bereits den vollständigen externen Modellzustand
|
enthielt bereits den damaligen externen CSV-Modellzustand
|
||||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
|
||||||
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
||||||
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||||
1:1-Schutzgerätedaten. Capture,
|
1:1-Schutzgerätedaten. Capture,
|
||||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||||
|
|
@ -640,7 +643,34 @@ 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: Legacy-Ausgangslage und freigegebener Excel-Cutover
|
## Revit-Anbindung: Linear-XLSX-Grundlage nach R3
|
||||||
|
|
||||||
|
R3 hat den Backend- und Persistenz-Cutover abgeschlossen. Migration `0014`
|
||||||
|
entfernt die alten externen CSV-Entwicklungsdaten, nachdem verknüpfte
|
||||||
|
Gerätezeilen auf `manualQuantity` zurückgesetzt wurden. Die neue
|
||||||
|
`external_model_configurations`-Tabelle besitzt keinen eigenen Versionszähler;
|
||||||
|
Änderungen laufen als Projekt-Commands über `expectedRevision`.
|
||||||
|
|
||||||
|
Externe Objekte werden je Quelle durch `worksharing_id` exakt und
|
||||||
|
case-sensitiv identifiziert. `ifc_guid` ist nullable und nicht eindeutig.
|
||||||
|
Importbatches speichern eine Konfigurationskopie; nur der neueste Batch darf
|
||||||
|
Originalbytes und die vollständige XLSX-Matrix enthalten, ältere Batches nur
|
||||||
|
Datum, Dateiname, SHA-256 und Objektzahl. Snapshot-Schema 10, Restore und
|
||||||
|
Projekttransfer verwenden diesen Zustand und remappen alle internen
|
||||||
|
Konfigurations-, Gerätearten-, Regel- und Objekt-UUIDs. Leere Snapshots 1–9
|
||||||
|
werden hochgestuft; alte Snapshots mit CSV-Konfiguration oder externem
|
||||||
|
CSV-Zustand sowie alte CSV-History-Commands werden mit einer deutschen Meldung
|
||||||
|
abgelehnt.
|
||||||
|
|
||||||
|
Der XLSX-Adapter liegt unter `src/external-model/xlsx`. Der Backend-CSV-Parser,
|
||||||
|
seine Endpunkte und Laufzeitverträge sind entfernt. Die noch sichtbare alte
|
||||||
|
Projektseiten-Oberfläche ist bis R4 ohne Backend-Anbindung und wird dort durch
|
||||||
|
die Route `/projects/:projectId/revit` ersetzt.
|
||||||
|
|
||||||
|
### Historische Ausgangslage vor R3
|
||||||
|
|
||||||
|
Der folgende Text beschreibt den Stand nach R1 und ist keine aktuelle
|
||||||
|
Laufzeitdokumentation mehr.
|
||||||
|
|
||||||
Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen
|
Der nachfolgende Abschnitt beschreibt den am Ende von R1 noch vorhandenen
|
||||||
Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte
|
Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,9 @@ 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
|
Datei wird nicht committed. Der Nutzer testet sie in Linear. R3 darf erst nach
|
||||||
erfolgreichem Linear-Rückimport beginnen.
|
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
|
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
|
||||||
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
|
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
|
||||||
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
|
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
|
| 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. |
|
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
|
||||||
| R2 | implementiert, Linear-Abnahme ausstehend | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt. R3 bleibt bis zum erfolgreichen Linear-Test gesperrt. |
|
| 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 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. |
|
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
|
||||||
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. |
|
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. |
|
||||||
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
|
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
|
||||||
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
|
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"test": "node scripts/run-tests.mjs",
|
"test": "node scripts/run-tests.mjs",
|
||||||
"test:watch": "node scripts/run-tests.mjs --watch",
|
"test:watch": "node scripts/run-tests.mjs --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "node scripts/run-migrations.js",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
|
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const dataDir = path.resolve('data');
|
const dataDir = path.resolve(
|
||||||
|
process.env.LEISTUNGSBILANZ_DATA_DIR || 'data'
|
||||||
|
);
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
|
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
|
||||||
const db = drizzle(sqlite);
|
|
||||||
|
|
||||||
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
|
async function run() {
|
||||||
sqlite.close();
|
const sqlite = new Database(databasePath);
|
||||||
|
try {
|
||||||
|
if (requiresLinearXlsxBackup(sqlite)) {
|
||||||
|
const backupDir = path.join(dataDir, 'backups');
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupPath = path.join(
|
||||||
|
backupDir,
|
||||||
|
`leistungsbilanz-before-linear-xlsx-${timestamp}.db`
|
||||||
|
);
|
||||||
|
await sqlite.backup(backupPath);
|
||||||
|
verifyBackup(backupPath);
|
||||||
|
console.log(`Verified pre-migration backup created: ${backupPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Migrations applied');
|
migrate(drizzle(sqlite), {
|
||||||
|
migrationsFolder: path.resolve('src/db/migrations'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
console.log('Migrations applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresLinearXlsxBackup(sqlite) {
|
||||||
|
const tables = new Set(
|
||||||
|
sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||||
|
.all()
|
||||||
|
.map((row) => row.name)
|
||||||
|
);
|
||||||
|
return tables.has('external_csv_configurations') &&
|
||||||
|
!tables.has('external_model_configurations');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyBackup(backupPath) {
|
||||||
|
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const integrity = backup.pragma('integrity_check', { simple: true });
|
||||||
|
const foreignKeyViolations = backup.pragma('foreign_key_check');
|
||||||
|
if (integrity !== 'ok' || foreignKeyViolations.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Pre-migration backup verification failed: integrity=${integrity}, ` +
|
||||||
|
`foreign-key violations=${foreignKeyViolations.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backup.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error('Migration failed:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
97
src/db/migrations/0014_linear_xlsx_external_model.sql
Normal file
97
src/db/migrations/0014_linear_xlsx_external_model.sql
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
UPDATE `circuit_device_rows`
|
||||||
|
SET `quantity` = `manual_quantity`
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `external_model_objects`
|
||||||
|
WHERE `external_model_objects`.`circuit_device_row_id` = `circuit_device_rows`.`id`
|
||||||
|
);--> statement-breakpoint
|
||||||
|
DROP TABLE `external_model_objects`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_room_mappings`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_import_batches`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_model_sources`;--> statement-breakpoint
|
||||||
|
DROP TABLE `external_csv_configurations`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_model_configurations` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`configuration` text NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `external_model_configurations_project_id_unique` ON `external_model_configurations` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_model_sources` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`source_type` text DEFAULT 'revit_xlsx' NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `external_model_sources_project_unique` ON `external_model_sources` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_import_batches` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_id` text NOT NULL,
|
||||||
|
`import_kind` text NOT NULL,
|
||||||
|
`imported_at_iso` text NOT NULL,
|
||||||
|
`file_name` text NOT NULL,
|
||||||
|
`sha256` text NOT NULL,
|
||||||
|
`object_count` integer NOT NULL,
|
||||||
|
`applied_project_revision` integer,
|
||||||
|
`configuration_snapshot` text,
|
||||||
|
`original_bytes` blob,
|
||||||
|
`document` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_import_batches_project_revision_idx` ON `external_import_batches` (`project_id`,`applied_project_revision`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_import_batches_source_imported_idx` ON `external_import_batches` (`source_id`,`imported_at_iso`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_room_mappings` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_id` text NOT NULL,
|
||||||
|
`normalized_source_room_key` text NOT NULL,
|
||||||
|
`source_floor_name` text,
|
||||||
|
`source_room_number` text NOT NULL,
|
||||||
|
`source_room_name` text NOT NULL,
|
||||||
|
`room_id` text,
|
||||||
|
`default_distribution_board_id` text,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`default_distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `external_room_mappings_source_key_unique` ON `external_room_mappings` (`source_id`,`normalized_source_room_key`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_room_mappings_project_room_idx` ON `external_room_mappings` (`project_id`,`room_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `external_model_objects` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`source_id` text NOT NULL,
|
||||||
|
`worksharing_id` text NOT NULL,
|
||||||
|
`ifc_guid` text,
|
||||||
|
`device_type_id` text NOT NULL,
|
||||||
|
`last_seen_import_batch_id` text NOT NULL,
|
||||||
|
`last_accepted_import_batch_id` text NOT NULL,
|
||||||
|
`accepted_source_values` text NOT NULL,
|
||||||
|
`planning_values` text NOT NULL,
|
||||||
|
`overridden_fields` text NOT NULL,
|
||||||
|
`external_room_mapping_id` text,
|
||||||
|
`distribution_board_id` text,
|
||||||
|
`linked_project_device_id` text,
|
||||||
|
`circuit_device_row_id` text,
|
||||||
|
`presence_status` text DEFAULT 'present' NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`source_id`) REFERENCES `external_model_sources`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`last_seen_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||||
|
FOREIGN KEY (`last_accepted_import_batch_id`) REFERENCES `external_import_batches`(`id`) ON UPDATE no action ON DELETE restrict,
|
||||||
|
FOREIGN KEY (`external_room_mapping_id`) REFERENCES `external_room_mappings`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `external_model_objects_source_worksharing_id_unique` ON `external_model_objects` (`source_id`,`worksharing_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_project_presence_idx` ON `external_model_objects` (`project_id`,`presence_status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_project_device_type_idx` ON `external_model_objects` (`project_id`,`device_type_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_external_room_mapping_idx` ON `external_model_objects` (`external_room_mapping_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_distribution_board_idx` ON `external_model_objects` (`distribution_board_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_linked_project_device_idx` ON `external_model_objects` (`linked_project_device_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `external_model_objects_circuit_device_row_idx` ON `external_model_objects` (`circuit_device_row_id`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
3100
src/db/migrations/meta/0014_snapshot.json
Normal file
3100
src/db/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,13 @@
|
||||||
"when": 1790624623732,
|
"when": 1790624623732,
|
||||||
"tag": "0013_users",
|
"tag": "0013_users",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790748107566,
|
||||||
|
"tag": "0014_linear_xlsx_external_model",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
assertExternalCsvConfigurationUpdateProjectCommand,
|
|
||||||
createExternalCsvConfigurationUpdateProjectCommand,
|
|
||||||
type ExternalCsvConfigurationSnapshot,
|
|
||||||
} from "../../domain/models/external-csv-configuration-project-command.model.js";
|
|
||||||
import type {
|
|
||||||
ExecuteExternalCsvConfigurationCommandInput,
|
|
||||||
ExternalCsvConfigurationProjectCommandStore,
|
|
||||||
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
|
||||||
import { projects } from "../schema/projects.js";
|
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
||||||
|
|
||||||
export class ExternalCsvConfigurationProjectCommandRepository
|
|
||||||
implements ExternalCsvConfigurationProjectCommandStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
|
|
||||||
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
|
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
|
||||||
const project = tx
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(eq(projects.id, input.projectId))
|
|
||||||
.get();
|
|
||||||
if (!project) {
|
|
||||||
throw new Error("Project not found.");
|
|
||||||
}
|
|
||||||
const current = tx
|
|
||||||
.select()
|
|
||||||
.from(externalCsvConfigurations)
|
|
||||||
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
|
||||||
.get() ?? null;
|
|
||||||
assertSameSnapshot(current, input.command.payload.expected);
|
|
||||||
const target = input.command.payload.target;
|
|
||||||
if (target !== null && target.projectId !== input.projectId) {
|
|
||||||
throw new Error("External CSV configuration belongs to a different project.");
|
|
||||||
}
|
|
||||||
if (sameSnapshot(current, target)) {
|
|
||||||
throw new Error("External CSV configuration did not change.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
input.source === "user" &&
|
|
||||||
target !== null &&
|
|
||||||
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
|
|
||||||
) {
|
|
||||||
throw new Error("External CSV configuration version must advance by one.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === null) {
|
|
||||||
tx.delete(externalCsvConfigurations)
|
|
||||||
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
|
||||||
.run();
|
|
||||||
} else if (current === null) {
|
|
||||||
tx.insert(externalCsvConfigurations).values(target).run();
|
|
||||||
} else {
|
|
||||||
const updated = tx
|
|
||||||
.update(externalCsvConfigurations)
|
|
||||||
.set({
|
|
||||||
configurationVersion: target.configurationVersion,
|
|
||||||
configuration: target.configuration,
|
|
||||||
})
|
|
||||||
.where(eq(externalCsvConfigurations.id, current.id))
|
|
||||||
.run();
|
|
||||||
if (updated.changes !== 1) {
|
|
||||||
throw new Error("External CSV configuration changed before update.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSameSnapshot(
|
|
||||||
current: ExternalCsvConfigurationSnapshot | null,
|
|
||||||
expected: ExternalCsvConfigurationSnapshot | null
|
|
||||||
) {
|
|
||||||
if (!sameSnapshot(current, expected)) {
|
|
||||||
throw new Error("External CSV configuration changed before update.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameSnapshot(
|
|
||||||
left: ExternalCsvConfigurationSnapshot | null,
|
|
||||||
right: ExternalCsvConfigurationSnapshot | null
|
|
||||||
) {
|
|
||||||
return canonicalJson(left) === canonicalJson(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return `[${value.map(canonicalJson).join(",")}]`;
|
|
||||||
}
|
|
||||||
if (value !== null && typeof value === "object") {
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
return `{${Object.keys(record)
|
|
||||||
.sort()
|
|
||||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
||||||
.join(",")}}`;
|
|
||||||
}
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
assertExternalInitialImportProjectCommand,
|
|
||||||
createExternalInitialImportProjectCommand,
|
|
||||||
isEmptyExternalModelState,
|
|
||||||
} from "../../domain/models/external-initial-import-project-command.model.js";
|
|
||||||
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
|
|
||||||
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
|
||||||
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
|
|
||||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
|
||||||
import {
|
|
||||||
createExternalRoomKey,
|
|
||||||
indexExternalObjectsByIfcGuid,
|
|
||||||
projectInitialExternalObject,
|
|
||||||
} from "../../external-model/domain/external-model-matching.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
|
||||||
import { rooms } from "../schema/rooms.js";
|
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
|
||||||
|
|
||||||
export class ExternalInitialImportProjectCommandRepository
|
|
||||||
implements ExternalInitialImportProjectCommandStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
|
||||||
assertExternalInitialImportProjectCommand(input.command);
|
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
|
||||||
const currentResult = new ExternalModelStateRepository(tx)
|
|
||||||
.getByProject(input.projectId);
|
|
||||||
if (!currentResult.projectExists) throw new Error("Project not found.");
|
|
||||||
const expected = input.command.payload.expected;
|
|
||||||
if (!sameState(currentResult.state, expected)) {
|
|
||||||
throw new Error("External model state changed before initial import.");
|
|
||||||
}
|
|
||||||
const target = input.command.payload.target;
|
|
||||||
if (!isEmptyExternalModelState(target)) {
|
|
||||||
validatePopulatedTarget(tx, input, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceExternalState(tx, input.projectId, target);
|
|
||||||
const persisted = new ExternalModelStateRepository(tx)
|
|
||||||
.getByProject(input.projectId).state;
|
|
||||||
if (!sameState(persisted, target)) {
|
|
||||||
throw new Error("Persisted external initial import differs from its target.");
|
|
||||||
}
|
|
||||||
return createExternalInitialImportProjectCommand(target, currentResult.state);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePopulatedTarget(
|
|
||||||
database: AppDatabase,
|
|
||||||
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
|
|
||||||
target: ExternalModelStateSnapshot
|
|
||||||
) {
|
|
||||||
const source = target.source!;
|
|
||||||
const batch = target.importBatches[0];
|
|
||||||
if (source.projectId !== input.projectId) {
|
|
||||||
throw new Error("External initial import belongs to a different project.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
input.source === "user" &&
|
|
||||||
batch.appliedProjectRevision !== input.expectedRevision + 1
|
|
||||||
) {
|
|
||||||
throw new Error("External initial import revision is invalid.");
|
|
||||||
}
|
|
||||||
const configuration = database
|
|
||||||
.select()
|
|
||||||
.from(externalCsvConfigurations)
|
|
||||||
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
|
||||||
.get();
|
|
||||||
if (
|
|
||||||
!configuration ||
|
|
||||||
configuration.configurationVersion !== batch.configurationVersion ||
|
|
||||||
canonicalJson(configuration.configuration) !==
|
|
||||||
canonicalJson(batch.configurationSnapshot)
|
|
||||||
) {
|
|
||||||
throw new Error("External initial import configuration changed before apply.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
|
|
||||||
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
||||||
if (sha256 !== batch.sha256) {
|
|
||||||
throw new Error("External initial import checksum is invalid.");
|
|
||||||
}
|
|
||||||
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
|
|
||||||
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
|
|
||||||
throw new Error("External initial import document differs from the original bytes.");
|
|
||||||
}
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: batch.fileName,
|
|
||||||
bytes,
|
|
||||||
configuration: batch.configurationSnapshot,
|
|
||||||
});
|
|
||||||
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
|
|
||||||
if (targetByIfcGuid.size !== preview.objects.length) {
|
|
||||||
throw new Error("External initial import must contain every CSV object exactly once.");
|
|
||||||
}
|
|
||||||
const mappingsById = new Map(
|
|
||||||
target.roomMappings.map((mapping) => [mapping.id, mapping])
|
|
||||||
);
|
|
||||||
const expectedRoomKeys = new Set<string>();
|
|
||||||
for (const candidate of preview.objects) {
|
|
||||||
const object = targetByIfcGuid.get(candidate.ifcGuid);
|
|
||||||
if (!object) {
|
|
||||||
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
|
|
||||||
}
|
|
||||||
const projected = projectInitialExternalObject(
|
|
||||||
candidate,
|
|
||||||
batch.configurationSnapshot
|
|
||||||
);
|
|
||||||
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
|
|
||||||
throw new Error("External initial import source values differ from the CSV.");
|
|
||||||
}
|
|
||||||
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
|
|
||||||
if (
|
|
||||||
!object.overriddenFields.includes(field) &&
|
|
||||||
canonicalJson(object.planningValues[field]) !==
|
|
||||||
canonicalJson(projected.planningValues[field])
|
|
||||||
) {
|
|
||||||
throw new Error(`External initial planning field ${field} changed without an override.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
|
||||||
if (roomKey === null) {
|
|
||||||
if (object.externalRoomMappingId !== null) {
|
|
||||||
throw new Error("External object without a source room must not use a room mapping.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
expectedRoomKeys.add(roomKey);
|
|
||||||
const mapping = object.externalRoomMappingId === null
|
|
||||||
? null
|
|
||||||
: mappingsById.get(object.externalRoomMappingId);
|
|
||||||
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
|
|
||||||
throw new Error("External object source room mapping is incomplete.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
target.roomMappings.length !== expectedRoomKeys.size ||
|
|
||||||
target.roomMappings.some(
|
|
||||||
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error("External initial import room mappings do not match CSV rooms.");
|
|
||||||
}
|
|
||||||
validateInternalLinks(database, input.projectId, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateInternalLinks(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
target: ExternalModelStateSnapshot
|
|
||||||
) {
|
|
||||||
const roomIds = new Set(
|
|
||||||
database.select({ id: rooms.id }).from(rooms)
|
|
||||||
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
|
|
||||||
);
|
|
||||||
const boardIds = new Set(
|
|
||||||
database.select({ id: distributionBoards.id }).from(distributionBoards)
|
|
||||||
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
|
|
||||||
);
|
|
||||||
const deviceIds = new Set(
|
|
||||||
database.select({ id: projectDevices.id }).from(projectDevices)
|
|
||||||
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
|
|
||||||
);
|
|
||||||
for (const mapping of target.roomMappings) {
|
|
||||||
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
|
|
||||||
throw new Error("External room mapping references another project.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
mapping.defaultDistributionBoardId !== null &&
|
|
||||||
!boardIds.has(mapping.defaultDistributionBoardId)
|
|
||||||
) {
|
|
||||||
throw new Error("External room mapping distribution board belongs to another project.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const object of target.objects) {
|
|
||||||
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
|
|
||||||
throw new Error("External object distribution board belongs to another project.");
|
|
||||||
}
|
|
||||||
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
|
|
||||||
throw new Error("External object project device belongs to another project.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceExternalState(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
target: ExternalModelStateSnapshot
|
|
||||||
) {
|
|
||||||
database.delete(externalModelSources)
|
|
||||||
.where(eq(externalModelSources.projectId, projectId)).run();
|
|
||||||
if (target.source === null) return;
|
|
||||||
database.insert(externalModelSources).values(target.source).run();
|
|
||||||
database.insert(externalImportBatches).values(
|
|
||||||
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
|
|
||||||
...batch,
|
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
|
||||||
}))
|
|
||||||
).run();
|
|
||||||
if (target.roomMappings.length) {
|
|
||||||
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
|
||||||
}
|
|
||||||
if (target.objects.length) {
|
|
||||||
database.insert(externalModelObjects).values(target.objects).run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeCanonicalBase64(value: string) {
|
|
||||||
const bytes = Buffer.from(value, "base64");
|
|
||||||
if (bytes.toString("base64") !== value) {
|
|
||||||
throw new Error("External initial import bytes are not canonical base64.");
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
|
|
||||||
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
|
|
||||||
const byId = <T extends { id: string }>(entries: readonly T[]) =>
|
|
||||||
[...entries].sort((left, right) => left.id.localeCompare(right.id));
|
|
||||||
return {
|
|
||||||
source: state.source,
|
|
||||||
importBatches: byId(state.importBatches),
|
|
||||||
roomMappings: byId(state.roomMappings),
|
|
||||||
objects: byId(state.objects),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
||||||
if (value !== null && typeof value === "object") {
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
return `{${Object.keys(record).sort().map(
|
|
||||||
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
||||||
).join(",")}}`;
|
|
||||||
}
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertExternalModelConfigurationUpdateProjectCommand,
|
||||||
|
createExternalModelConfigurationUpdateProjectCommand,
|
||||||
|
type ExternalModelConfigurationSnapshot,
|
||||||
|
} from "../../domain/models/external-model-configuration-project-command.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteExternalModelConfigurationCommandInput,
|
||||||
|
ExternalModelConfigurationProjectCommandStore,
|
||||||
|
} from "../../domain/ports/external-model-configuration-project-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
|
export class ExternalModelConfigurationProjectCommandRepository
|
||||||
|
implements ExternalModelConfigurationProjectCommandStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteExternalModelConfigurationCommandInput) {
|
||||||
|
assertExternalModelConfigurationUpdateProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const project = tx.select({ id: projects.id }).from(projects)
|
||||||
|
.where(eq(projects.id, input.projectId)).get();
|
||||||
|
if (!project) throw new Error("Project not found.");
|
||||||
|
const current = tx.select().from(externalModelConfigurations)
|
||||||
|
.where(eq(externalModelConfigurations.projectId, input.projectId)).get() ?? null;
|
||||||
|
assertSameSnapshot(current, input.command.payload.expected);
|
||||||
|
const target = input.command.payload.target;
|
||||||
|
if (target !== null && target.projectId !== input.projectId) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration gehört zu einem anderen Projekt.");
|
||||||
|
}
|
||||||
|
if (sameSnapshot(current, target)) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde nicht geändert.");
|
||||||
|
}
|
||||||
|
if (target === null) {
|
||||||
|
tx.delete(externalModelConfigurations)
|
||||||
|
.where(eq(externalModelConfigurations.projectId, input.projectId)).run();
|
||||||
|
} else if (current === null) {
|
||||||
|
tx.insert(externalModelConfigurations).values(target).run();
|
||||||
|
} else {
|
||||||
|
const updated = tx.update(externalModelConfigurations)
|
||||||
|
.set({ configuration: target.configuration })
|
||||||
|
.where(eq(externalModelConfigurations.id, current.id)).run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createExternalModelConfigurationUpdateProjectCommand(target, current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSameSnapshot(
|
||||||
|
current: ExternalModelConfigurationSnapshot | null,
|
||||||
|
expected: ExternalModelConfigurationSnapshot | null
|
||||||
|
) {
|
||||||
|
if (!sameSnapshot(current, expected)) {
|
||||||
|
throw new Error("Die Revit-XLSX-Konfiguration wurde zwischenzeitlich geändert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSnapshot(
|
||||||
|
left: ExternalModelConfigurationSnapshot | null,
|
||||||
|
right: ExternalModelConfigurationSnapshot | null
|
||||||
|
) {
|
||||||
|
return canonicalJson(left) === canonicalJson(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record).sort().map(
|
||||||
|
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||||
|
).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
import type { ExternalModelConfigurationReader } from "../../domain/ports/external-model-configuration.reader.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
export class ExternalCsvConfigurationRepository
|
export class ExternalModelConfigurationRepository
|
||||||
implements ExternalCsvConfigurationReader
|
implements ExternalModelConfigurationReader {
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
getByProject(projectId: string) {
|
getByProject(projectId: string) {
|
||||||
|
|
@ -15,13 +14,11 @@ export class ExternalCsvConfigurationRepository
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.id, projectId))
|
.where(eq(projects.id, projectId))
|
||||||
.get();
|
.get();
|
||||||
if (!project) {
|
if (!project) return { projectExists: false, configuration: null };
|
||||||
return { projectExists: false, configuration: null };
|
|
||||||
}
|
|
||||||
const configuration = this.database
|
const configuration = this.database
|
||||||
.select()
|
.select()
|
||||||
.from(externalCsvConfigurations)
|
.from(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
.where(eq(externalModelConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
return { projectExists: true, configuration };
|
return { projectExists: true, configuration };
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
.select()
|
.select()
|
||||||
.from(externalModelObjects)
|
.from(externalModelObjects)
|
||||||
.where(eq(externalModelObjects.sourceId, source.id))
|
.where(eq(externalModelObjects.sourceId, source.id))
|
||||||
.orderBy(asc(externalModelObjects.ifcGuid), asc(externalModelObjects.id))
|
.orderBy(
|
||||||
|
asc(externalModelObjects.worksharingId),
|
||||||
|
asc(externalModelObjects.id)
|
||||||
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -64,7 +67,10 @@ export class ExternalModelStateRepository implements ExternalModelStateReader {
|
||||||
source,
|
source,
|
||||||
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
importBatches: batches.map(({ originalBytes, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalContentBase64: Buffer.from(originalBytes).toString("base64"),
|
originalContentBase64:
|
||||||
|
originalBytes === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalBytes).toString("base64"),
|
||||||
})),
|
})),
|
||||||
roomMappings,
|
roomMappings,
|
||||||
objects,
|
objects,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function assertExternalObjectsCompatibleWithRow(input: {
|
||||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||||
const markers = new Set(
|
const markers = new Set(
|
||||||
input.allLinkedObjects.map((object) =>
|
input.allLinkedObjects.map((object) =>
|
||||||
object.acceptedSourceValues.selectionMarker.trim()
|
scalarText(object.acceptedSourceValues.selectionMarker).trim()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (markers.size > 1) {
|
if (markers.size > 1) {
|
||||||
|
|
@ -118,13 +118,17 @@ function matchesRowPlanningValues(
|
||||||
row.category === planning.category &&
|
row.category === planning.category &&
|
||||||
row.connectionKind === planning.connectionKind &&
|
row.connectionKind === planning.connectionKind &&
|
||||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
row.simultaneityFactor === (planning.simultaneityFactor ?? 1) &&
|
||||||
row.cosPhi === planning.cosPhi &&
|
row.cosPhi === planning.cosPhi &&
|
||||||
row.costGroup === planning.costGroup &&
|
row.costGroup === planning.costGroup &&
|
||||||
row.remark === planning.remark
|
row.remark === planning.remark
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scalarText(value: string | number | boolean | null) {
|
||||||
|
return value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalJson(value: unknown): string {
|
function canonicalJson(value: unknown): string {
|
||||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
if (value !== null && typeof value === "object") {
|
if (value !== null && typeof value === "object") {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
|
@ -241,7 +241,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
buildings: byId(state.buildings),
|
buildings: byId(state.buildings),
|
||||||
floors: byId(state.floors),
|
floors: byId(state.floors),
|
||||||
rooms: byId(state.rooms),
|
rooms: byId(state.rooms),
|
||||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
externalModelConfiguration: state.externalModelConfiguration,
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: state.externalModel.source,
|
source: state.externalModel.source,
|
||||||
importBatches: byId(state.externalModel.importBatches),
|
importBatches: byId(state.externalModel.importBatches),
|
||||||
|
|
@ -283,8 +283,8 @@ function replaceProjectState(
|
||||||
.where(eq(externalModelSources.projectId, state.project.id))
|
.where(eq(externalModelSources.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalCsvConfigurations)
|
.delete(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
.where(eq(externalModelConfigurations.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
database
|
||||||
.delete(distributionBoards)
|
.delete(distributionBoards)
|
||||||
|
|
@ -330,10 +330,10 @@ function replaceProjectState(
|
||||||
|
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
if (state.externalCsvConfiguration !== null) {
|
if (state.externalModelConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalCsvConfigurations)
|
.insert(externalModelConfigurations)
|
||||||
.values(state.externalCsvConfiguration)
|
.values(state.externalModelConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
|
|
@ -387,7 +387,10 @@ function replaceProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
originalBytes:
|
||||||
|
originalContentBase64 === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalContentBase64, "base64"),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
export interface PersistedProjectStateSnapshot {
|
export interface PersistedProjectStateSnapshot {
|
||||||
|
|
@ -66,10 +66,10 @@ export function readProjectStateSnapshot(
|
||||||
.where(eq(distributionBoards.projectId, projectId))
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||||
.all();
|
.all();
|
||||||
const externalCsvConfiguration = database
|
const externalModelConfiguration = database
|
||||||
.select()
|
.select()
|
||||||
.from(externalCsvConfigurations)
|
.from(externalModelConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
.where(eq(externalModelConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
const externalModel = new ExternalModelStateRepository(database)
|
const externalModel = new ExternalModelStateRepository(database)
|
||||||
.getByProject(projectId).state;
|
.getByProject(projectId).state;
|
||||||
|
|
@ -266,7 +266,7 @@ export function readProjectStateSnapshot(
|
||||||
projectEditor: project.projectEditor,
|
projectEditor: project.projectEditor,
|
||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
externalCsvConfiguration,
|
externalModelConfiguration,
|
||||||
externalModel,
|
externalModel,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
circuitSections: sectionRows,
|
circuitSections: sectionRows,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../schema/external-model-configurations.js";
|
||||||
import { externalImportBatches } from "../schema/external-import-batches.js";
|
import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
|
|
@ -163,10 +163,10 @@ function insertProjectState(
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({ ...state.project, currentRevision: 0 })
|
.values({ ...state.project, currentRevision: 0 })
|
||||||
.run();
|
.run();
|
||||||
if (state.externalCsvConfiguration !== null) {
|
if (state.externalModelConfiguration !== null) {
|
||||||
database
|
database
|
||||||
.insert(externalCsvConfigurations)
|
.insert(externalModelConfigurations)
|
||||||
.values(state.externalCsvConfiguration)
|
.values(state.externalModelConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, buildings, state.buildings);
|
insertMany(database, buildings, state.buildings);
|
||||||
|
|
@ -240,7 +240,10 @@ function insertProjectState(
|
||||||
state.externalModel.importBatches.map(
|
state.externalModel.importBatches.map(
|
||||||
({ originalContentBase64, ...batch }) => ({
|
({ originalContentBase64, ...batch }) => ({
|
||||||
...batch,
|
...batch,
|
||||||
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
originalBytes:
|
||||||
|
originalContentBase64 === null
|
||||||
|
? null
|
||||||
|
: Buffer.from(originalContentBase64, "base64"),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
export const externalCsvConfigurations = sqliteTable(
|
|
||||||
"external_csv_configurations",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
configurationVersion: integer("configuration_version").notNull(),
|
|
||||||
configuration: text("configuration", { mode: "json" })
|
|
||||||
.$type<ExternalCsvConfiguration>()
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
uniqueIndex("external_csv_configurations_project_id_unique").on(
|
|
||||||
table.projectId
|
|
||||||
),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import type {
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
ExternalCsvConfiguration,
|
|
||||||
ExternalCsvDocument,
|
|
||||||
} from "../../external-model/csv/external-csv-contracts.js";
|
|
||||||
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
|
import { externalImportKinds } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import type { ExternalXlsxDocument } from "../../external-model/xlsx/external-xlsx-contracts.js";
|
||||||
import { externalModelSources } from "./external-model-sources.js";
|
import { externalModelSources } from "./external-model-sources.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
|
@ -21,15 +19,13 @@ export const externalImportBatches = sqliteTable(
|
||||||
importedAtIso: text("imported_at_iso").notNull(),
|
importedAtIso: text("imported_at_iso").notNull(),
|
||||||
fileName: text("file_name").notNull(),
|
fileName: text("file_name").notNull(),
|
||||||
sha256: text("sha256").notNull(),
|
sha256: text("sha256").notNull(),
|
||||||
appliedProjectRevision: integer("applied_project_revision").notNull(),
|
objectCount: integer("object_count").notNull(),
|
||||||
configurationVersion: integer("configuration_version").notNull(),
|
appliedProjectRevision: integer("applied_project_revision"),
|
||||||
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
||||||
.$type<ExternalCsvConfiguration>()
|
.$type<ExternalModelConfiguration>(),
|
||||||
.notNull(),
|
originalBytes: blob("original_bytes", { mode: "buffer" }),
|
||||||
originalBytes: blob("original_bytes", { mode: "buffer" }).notNull(),
|
|
||||||
document: text("document", { mode: "json" })
|
document: text("document", { mode: "json" })
|
||||||
.$type<ExternalCsvDocument>()
|
.$type<ExternalXlsxDocument>(),
|
||||||
.notNull(),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("external_import_batches_project_revision_idx").on(
|
index("external_import_batches_project_revision_idx").on(
|
||||||
|
|
|
||||||
21
src/db/schema/external-model-configurations.ts
Normal file
21
src/db/schema/external-model-configurations.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||||
|
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
export const externalModelConfigurations = sqliteTable(
|
||||||
|
"external_model_configurations",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
configuration: text("configuration", { mode: "json" })
|
||||||
|
.$type<ExternalModelConfiguration>()
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("external_model_configurations_project_id_unique").on(
|
||||||
|
table.projectId
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
@ -23,7 +23,9 @@ export const externalModelObjects = sqliteTable(
|
||||||
sourceId: text("source_id")
|
sourceId: text("source_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
.references(() => externalModelSources.id, { onDelete: "cascade" }),
|
||||||
ifcGuid: text("ifc_guid").notNull(),
|
worksharingId: text("worksharing_id").notNull(),
|
||||||
|
ifcGuid: text("ifc_guid"),
|
||||||
|
deviceTypeId: text("device_type_id").notNull(),
|
||||||
lastSeenImportBatchId: text("last_seen_import_batch_id")
|
lastSeenImportBatchId: text("last_seen_import_batch_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
|
||||||
|
|
@ -62,17 +64,27 @@ export const externalModelObjects = sqliteTable(
|
||||||
.default("present"),
|
.default("present"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
|
uniqueIndex("external_model_objects_source_worksharing_id_unique").on(
|
||||||
table.sourceId,
|
table.sourceId,
|
||||||
table.ifcGuid
|
table.worksharingId
|
||||||
),
|
),
|
||||||
index("external_model_objects_project_presence_idx").on(
|
index("external_model_objects_project_presence_idx").on(
|
||||||
table.projectId,
|
table.projectId,
|
||||||
table.presenceStatus
|
table.presenceStatus
|
||||||
),
|
),
|
||||||
|
index("external_model_objects_project_device_type_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.deviceTypeId
|
||||||
|
),
|
||||||
|
index("external_model_objects_external_room_mapping_idx").on(
|
||||||
|
table.externalRoomMappingId
|
||||||
|
),
|
||||||
index("external_model_objects_distribution_board_idx").on(
|
index("external_model_objects_distribution_board_idx").on(
|
||||||
table.distributionBoardId
|
table.distributionBoardId
|
||||||
),
|
),
|
||||||
|
index("external_model_objects_linked_project_device_idx").on(
|
||||||
|
table.linkedProjectDeviceId
|
||||||
|
),
|
||||||
index("external_model_objects_circuit_device_row_idx").on(
|
index("external_model_objects_circuit_device_row_idx").on(
|
||||||
table.circuitDeviceRowId
|
table.circuitDeviceRowId
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,9 @@ export const externalModelSources = sqliteTable(
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
sourceType: text("source_type", { enum: externalModelSourceTypes })
|
sourceType: text("source_type", { enum: externalModelSourceTypes })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("revit_csv"),
|
.default("revit_xlsx"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex("external_model_sources_project_type_unique").on(
|
uniqueIndex("external_model_sources_project_unique").on(table.projectId),
|
||||||
table.projectId,
|
|
||||||
table.sourceType
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
|
||||||
|
|
||||||
export const externalCsvConfigurationUpdateCommandType =
|
|
||||||
"external-csv-configuration.update" as const;
|
|
||||||
export const externalCsvConfigurationCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationSnapshot {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
configurationVersion: number;
|
|
||||||
configuration: ExternalCsvConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationUpdatePayload {
|
|
||||||
expected: ExternalCsvConfigurationSnapshot | null;
|
|
||||||
target: ExternalCsvConfigurationSnapshot | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<ExternalCsvConfigurationUpdatePayload> {
|
|
||||||
schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion;
|
|
||||||
type: typeof externalCsvConfigurationUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalCsvConfigurationUpdateProjectCommand(
|
|
||||||
expected: ExternalCsvConfigurationSnapshot | null,
|
|
||||||
target: ExternalCsvConfigurationSnapshot | null
|
|
||||||
): ExternalCsvConfigurationUpdateProjectCommand {
|
|
||||||
const command: ExternalCsvConfigurationUpdateProjectCommand = {
|
|
||||||
schemaVersion: externalCsvConfigurationCommandSchemaVersion,
|
|
||||||
type: externalCsvConfigurationUpdateCommandType,
|
|
||||||
payload: { expected, target },
|
|
||||||
};
|
|
||||||
assertExternalCsvConfigurationUpdateProjectCommand(command);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertExternalCsvConfigurationUpdateProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ExternalCsvConfigurationUpdateProjectCommand {
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion ||
|
|
||||||
command.type !== externalCsvConfigurationUpdateCommandType ||
|
|
||||||
!isRecord(command.payload)
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported external CSV configuration update command.");
|
|
||||||
}
|
|
||||||
assertSnapshot(command.payload.expected, "expected");
|
|
||||||
assertSnapshot(command.payload.target, "target");
|
|
||||||
if (command.payload.expected === null && command.payload.target === null) {
|
|
||||||
throw new Error("External CSV configuration command must change state.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
command.payload.expected !== null &&
|
|
||||||
command.payload.target !== null &&
|
|
||||||
(command.payload.expected.id !== command.payload.target.id ||
|
|
||||||
command.payload.expected.projectId !== command.payload.target.projectId)
|
|
||||||
) {
|
|
||||||
throw new Error("External CSV configuration identity must remain stable.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSnapshot(
|
|
||||||
value: unknown,
|
|
||||||
field: string
|
|
||||||
): asserts value is ExternalCsvConfigurationSnapshot | null {
|
|
||||||
if (value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new Error(`${field} external CSV configuration must be an object or null.`);
|
|
||||||
}
|
|
||||||
assertId(value.id, `${field}.id`);
|
|
||||||
assertId(value.projectId, `${field}.projectId`);
|
|
||||||
if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) {
|
|
||||||
throw new Error(`${field}.configurationVersion must be a positive integer.`);
|
|
||||||
}
|
|
||||||
assertExternalCsvConfiguration(value.configuration);
|
|
||||||
if (Object.keys(value).length !== 4) {
|
|
||||||
throw new Error(`${field} external CSV configuration contains unknown fields.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertId(value: unknown, field: string) {
|
|
||||||
if (typeof value !== "string" || !value.trim()) {
|
|
||||||
throw new Error(`${field} must be a non-empty string.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
|
||||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
|
||||||
|
|
||||||
export const externalInitialImportCommandType =
|
|
||||||
"external-import.apply-initial" as const;
|
|
||||||
export const externalInitialImportCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ExternalInitialImportPayload {
|
|
||||||
expected: ExternalModelStateSnapshot;
|
|
||||||
target: ExternalModelStateSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalInitialImportProjectCommand
|
|
||||||
extends SerializedProjectCommand<ExternalInitialImportPayload> {
|
|
||||||
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
|
|
||||||
type: typeof externalInitialImportCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalInitialImportProjectCommand(
|
|
||||||
expected: ExternalModelStateSnapshot,
|
|
||||||
target: ExternalModelStateSnapshot
|
|
||||||
): ExternalInitialImportProjectCommand {
|
|
||||||
const command: ExternalInitialImportProjectCommand = {
|
|
||||||
schemaVersion: externalInitialImportCommandSchemaVersion,
|
|
||||||
type: externalInitialImportCommandType,
|
|
||||||
payload: { expected, target },
|
|
||||||
};
|
|
||||||
assertExternalInitialImportProjectCommand(command);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertExternalInitialImportProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ExternalInitialImportProjectCommand {
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
|
|
||||||
command.type !== externalInitialImportCommandType ||
|
|
||||||
!isRecord(command.payload) ||
|
|
||||||
Object.keys(command.payload).length !== 2
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported external initial import command.");
|
|
||||||
}
|
|
||||||
const expected = parseExternalModelStateSnapshot(command.payload.expected);
|
|
||||||
const target = parseExternalModelStateSnapshot(command.payload.target);
|
|
||||||
const expectedIsEmpty = isEmptyExternalModelState(expected);
|
|
||||||
const targetIsEmpty = isEmptyExternalModelState(target);
|
|
||||||
if (expectedIsEmpty === targetIsEmpty) {
|
|
||||||
throw new Error("External initial import must transition between empty and populated state.");
|
|
||||||
}
|
|
||||||
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
|
|
||||||
return { source: null, importBatches: [], roomMappings: [], objects: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
|
|
||||||
return (
|
|
||||||
state.source === null &&
|
|
||||||
state.importBatches.length === 0 &&
|
|
||||||
state.roomMappings.length === 0 &&
|
|
||||||
state.objects.length === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
|
|
||||||
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
|
|
||||||
throw new Error("External initial import requires one source, one batch and at least one object.");
|
|
||||||
}
|
|
||||||
const source = state.source;
|
|
||||||
const batch = state.importBatches[0];
|
|
||||||
if (
|
|
||||||
batch.importKind !== "initial" ||
|
|
||||||
batch.projectId !== source.projectId ||
|
|
||||||
batch.sourceId !== source.id
|
|
||||||
) {
|
|
||||||
throw new Error("External initial import batch identity is invalid.");
|
|
||||||
}
|
|
||||||
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
|
|
||||||
const roomKeys = new Set<string>();
|
|
||||||
for (const mapping of state.roomMappings) {
|
|
||||||
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
|
|
||||||
throw new Error("External initial room mapping identity is invalid.");
|
|
||||||
}
|
|
||||||
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
|
|
||||||
throw new Error("External initial import contains duplicate room keys.");
|
|
||||||
}
|
|
||||||
roomKeys.add(mapping.normalizedSourceRoomKey);
|
|
||||||
}
|
|
||||||
uniqueIds(state.objects, "object");
|
|
||||||
const ifcGuids = new Set<string>();
|
|
||||||
for (const object of state.objects) {
|
|
||||||
if (
|
|
||||||
object.projectId !== source.projectId ||
|
|
||||||
object.sourceId !== source.id ||
|
|
||||||
object.lastSeenImportBatchId !== batch.id ||
|
|
||||||
object.lastAcceptedImportBatchId !== batch.id
|
|
||||||
) {
|
|
||||||
throw new Error("External initial object identity is invalid.");
|
|
||||||
}
|
|
||||||
if (ifcGuids.has(object.ifcGuid)) {
|
|
||||||
throw new Error("External initial import contains duplicate IFCGUIDs.");
|
|
||||||
}
|
|
||||||
ifcGuids.add(object.ifcGuid);
|
|
||||||
if (
|
|
||||||
object.externalRoomMappingId !== null &&
|
|
||||||
!mappingIds.has(object.externalRoomMappingId)
|
|
||||||
) {
|
|
||||||
throw new Error("External initial object room mapping is invalid.");
|
|
||||||
}
|
|
||||||
if (object.circuitDeviceRowId !== null) {
|
|
||||||
throw new Error("External initial import must not assign circuit device rows.");
|
|
||||||
}
|
|
||||||
if (object.presenceStatus !== "present") {
|
|
||||||
throw new Error("External initial import objects must be present.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (ids.has(entry.id)) {
|
|
||||||
throw new Error(`External initial import contains duplicate ${label} ids.`);
|
|
||||||
}
|
|
||||||
ids.add(entry.id);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import {
|
||||||
|
assertExternalModelConfiguration,
|
||||||
|
type ExternalModelConfiguration,
|
||||||
|
} from "../../external-model/configuration/external-model-configuration.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const externalModelConfigurationUpdateCommandType =
|
||||||
|
"external-model-configuration.update" as const;
|
||||||
|
export const externalModelConfigurationCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationSnapshot {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
configuration: ExternalModelConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationUpdatePayload {
|
||||||
|
expected: ExternalModelConfigurationSnapshot | null;
|
||||||
|
target: ExternalModelConfigurationSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<ExternalModelConfigurationUpdatePayload> {
|
||||||
|
schemaVersion: typeof externalModelConfigurationCommandSchemaVersion;
|
||||||
|
type: typeof externalModelConfigurationUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalModelConfigurationUpdateProjectCommand(
|
||||||
|
expected: ExternalModelConfigurationSnapshot | null,
|
||||||
|
target: ExternalModelConfigurationSnapshot | null
|
||||||
|
): ExternalModelConfigurationUpdateProjectCommand {
|
||||||
|
const command: ExternalModelConfigurationUpdateProjectCommand = {
|
||||||
|
schemaVersion: externalModelConfigurationCommandSchemaVersion,
|
||||||
|
type: externalModelConfigurationUpdateCommandType,
|
||||||
|
payload: { expected, target },
|
||||||
|
};
|
||||||
|
assertExternalModelConfigurationUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalModelConfigurationUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ExternalModelConfigurationUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== externalModelConfigurationCommandSchemaVersion ||
|
||||||
|
command.type !== externalModelConfigurationUpdateCommandType ||
|
||||||
|
!isRecord(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 2
|
||||||
|
) {
|
||||||
|
throw new Error("Nicht unterstützter Revit-XLSX-Konfigurationscommand.");
|
||||||
|
}
|
||||||
|
assertSnapshot(command.payload.expected, "expected");
|
||||||
|
assertSnapshot(command.payload.target, "target");
|
||||||
|
if (command.payload.expected === null && command.payload.target === null) {
|
||||||
|
throw new Error("Der Konfigurationscommand muss den Zustand ändern.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.payload.expected !== null &&
|
||||||
|
command.payload.target !== null &&
|
||||||
|
(command.payload.expected.id !== command.payload.target.id ||
|
||||||
|
command.payload.expected.projectId !== command.payload.target.projectId)
|
||||||
|
) {
|
||||||
|
throw new Error("Die Identität der Revit-XLSX-Konfiguration muss stabil bleiben.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSnapshot(
|
||||||
|
value: unknown,
|
||||||
|
field: string
|
||||||
|
): asserts value is ExternalModelConfigurationSnapshot | null {
|
||||||
|
if (value === null) return;
|
||||||
|
if (!isRecord(value) || Object.keys(value).length !== 3) {
|
||||||
|
throw new Error(`${field} muss eine vollständige Konfiguration oder null sein.`);
|
||||||
|
}
|
||||||
|
assertId(value.id, `${field}.id`);
|
||||||
|
assertId(value.projectId, `${field}.projectId`);
|
||||||
|
assertExternalModelConfiguration(value.configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertId(value: unknown, field: string) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${field} muss eine nichtleere ID sein.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,10 @@ import {
|
||||||
} from "../services/project-voltage.service.js";
|
} from "../services/project-voltage.service.js";
|
||||||
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
||||||
|
import {
|
||||||
|
validateExternalModelConfiguration,
|
||||||
|
type ExternalModelConfiguration,
|
||||||
|
} from "../../external-model/configuration/external-model-configuration.js";
|
||||||
import {
|
import {
|
||||||
externalImportKinds,
|
externalImportKinds,
|
||||||
externalModelSourceTypes,
|
externalModelSourceTypes,
|
||||||
|
|
@ -35,7 +39,8 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 9 as const;
|
export const projectStateSnapshotSchemaVersion = 10 as const;
|
||||||
|
const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const;
|
||||||
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
|
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
|
||||||
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
||||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||||
|
|
@ -423,14 +428,14 @@ const externalCsvDocumentSchema = z.object({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const externalModelSourceSchema = z.object({
|
const legacyExternalModelSourceSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
sourceType: z.enum(externalModelSourceTypes),
|
sourceType: z.literal("revit_csv"),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
const externalImportBatchSchema = z.object({
|
const legacyExternalImportBatchSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
sourceId: idSchema,
|
sourceId: idSchema,
|
||||||
|
|
@ -461,7 +466,7 @@ const externalRoomMappingSchema = z.object({
|
||||||
defaultDistributionBoardId: idSchema.nullable(),
|
defaultDistributionBoardId: idSchema.nullable(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
const externalPlanningFieldNames = [
|
const legacyExternalPlanningFieldNames = [
|
||||||
"displayName",
|
"displayName",
|
||||||
"internalDeviceType",
|
"internalDeviceType",
|
||||||
"category",
|
"category",
|
||||||
|
|
@ -474,7 +479,7 @@ const externalPlanningFieldNames = [
|
||||||
"remark",
|
"remark",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const externalModelObjectSchema = z.object({
|
const legacyExternalModelObjectSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
sourceId: idSchema,
|
sourceId: idSchema,
|
||||||
|
|
@ -504,6 +509,170 @@ const externalModelObjectSchema = z.object({
|
||||||
costGroup: nullableStringSchema,
|
costGroup: nullableStringSchema,
|
||||||
remark: nullableStringSchema,
|
remark: nullableStringSchema,
|
||||||
}).strict(),
|
}).strict(),
|
||||||
|
overriddenFields: z.array(z.enum(legacyExternalPlanningFieldNames)).refine(
|
||||||
|
(values) => new Set(values).size === values.length
|
||||||
|
),
|
||||||
|
externalRoomMappingId: idSchema.nullable(),
|
||||||
|
distributionBoardId: idSchema.nullable(),
|
||||||
|
linkedProjectDeviceId: idSchema.nullable(),
|
||||||
|
circuitDeviceRowId: idSchema.nullable(),
|
||||||
|
presenceStatus: z.enum(externalObjectPresenceStatuses),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const legacyExternalModelStateSchema = z.object({
|
||||||
|
source: legacyExternalModelSourceSchema.nullable(),
|
||||||
|
importBatches: z.array(legacyExternalImportBatchSchema),
|
||||||
|
roomMappings: z.array(externalRoomMappingSchema),
|
||||||
|
objects: z.array(legacyExternalModelObjectSchema),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalModelConfigurationSnapshotSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
configuration: z.custom<ExternalModelConfiguration>(
|
||||||
|
(value) => validateExternalModelConfiguration(value).success
|
||||||
|
),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalXlsxScalarSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
finiteNumberSchema,
|
||||||
|
z.boolean(),
|
||||||
|
z.null(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const externalXlsxDocumentSchema = z.object({
|
||||||
|
sheetName: z.string().min(1).max(31),
|
||||||
|
headerRowNumber: z.number().int().positive(),
|
||||||
|
firstDataRowNumber: z.number().int().positive(),
|
||||||
|
firstDataColumnNumber: z.number().int().positive(),
|
||||||
|
columns: z.array(z.object({
|
||||||
|
columnNumber: z.number().int().positive(),
|
||||||
|
header: z.string(),
|
||||||
|
comment: z.string().nullable(),
|
||||||
|
width: finiteNumberSchema.positive().nullable(),
|
||||||
|
}).strict()),
|
||||||
|
rows: z.array(z.object({
|
||||||
|
rowNumber: z.number().int().positive(),
|
||||||
|
classification: z.enum(["object", "suspect-object", "passthrough"]),
|
||||||
|
cells: z.array(z.object({
|
||||||
|
columnNumber: z.number().int().positive(),
|
||||||
|
value: externalXlsxScalarSchema,
|
||||||
|
valueType: z.enum(["empty", "string", "number", "boolean"]),
|
||||||
|
numberFormat: z.string().nullable(),
|
||||||
|
formula: z.string().nullable(),
|
||||||
|
usedCachedFormulaValue: z.boolean(),
|
||||||
|
}).strict()),
|
||||||
|
}).strict()),
|
||||||
|
issues: z.array(z.object({
|
||||||
|
code: z.enum([
|
||||||
|
"cached-formula-value-used",
|
||||||
|
"formula-without-cached-value",
|
||||||
|
"suspect-object-skipped",
|
||||||
|
"duplicate-ifc-guid",
|
||||||
|
"optional-return-column-missing",
|
||||||
|
]),
|
||||||
|
message: z.string().min(1),
|
||||||
|
rowNumber: z.number().int().positive().optional(),
|
||||||
|
columnNumber: z.number().int().positive().optional(),
|
||||||
|
field: z.enum([
|
||||||
|
"worksharingId", "ifcGuid", "roomNumber", "roomName",
|
||||||
|
"familyAndType", "selectionMarker", "socketCount", "sourceRemark",
|
||||||
|
"circuitCombined", "boardName", "circuitIdentifier", "designation",
|
||||||
|
"electricalPowerW", "voltageV", "currentA",
|
||||||
|
]).optional(),
|
||||||
|
}).strict()),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalModelSourceSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
sourceType: z.enum(externalModelSourceTypes),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalImportBatchSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
sourceId: idSchema,
|
||||||
|
importKind: z.enum(externalImportKinds),
|
||||||
|
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
|
||||||
|
fileName: z.string().trim().min(1),
|
||||||
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
objectCount: z.number().int().nonnegative(),
|
||||||
|
appliedProjectRevision: z.number().int().nonnegative().nullable(),
|
||||||
|
configurationSnapshot: z.custom<ExternalModelConfiguration>(
|
||||||
|
(value) => value === null || validateExternalModelConfiguration(value).success
|
||||||
|
).nullable(),
|
||||||
|
originalContentBase64: z.string().min(1).regex(
|
||||||
|
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||||
|
).nullable(),
|
||||||
|
document: externalXlsxDocumentSchema.nullable(),
|
||||||
|
}).strict().superRefine((batch, context) => {
|
||||||
|
const complete = [
|
||||||
|
batch.appliedProjectRevision,
|
||||||
|
batch.configurationSnapshot,
|
||||||
|
batch.originalContentBase64,
|
||||||
|
batch.document,
|
||||||
|
].map((value) => value !== null);
|
||||||
|
if (!complete.every((value) => value === complete[0])) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Importbatch muss vollständig oder auf Metadaten reduziert sein.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalPlanningFieldNames = [
|
||||||
|
"displayName",
|
||||||
|
"category",
|
||||||
|
"connectionKind",
|
||||||
|
"effectiveQuantity",
|
||||||
|
"powerPerUnitW",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"costGroup",
|
||||||
|
"remark",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const externalModelObjectSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
sourceId: idSchema,
|
||||||
|
worksharingId: z.string().regex(
|
||||||
|
/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/
|
||||||
|
),
|
||||||
|
ifcGuid: externalIfcGuidSchema.nullable(),
|
||||||
|
deviceTypeId: idSchema,
|
||||||
|
lastSeenImportBatchId: idSchema,
|
||||||
|
lastAcceptedImportBatchId: idSchema,
|
||||||
|
acceptedSourceValues: z.object({
|
||||||
|
rowNumber: z.number().int().positive(),
|
||||||
|
roomNumber: externalXlsxScalarSchema,
|
||||||
|
roomName: externalXlsxScalarSchema,
|
||||||
|
familyAndType: z.string().min(1),
|
||||||
|
selectionMarker: externalXlsxScalarSchema,
|
||||||
|
socketCount: externalXlsxScalarSchema,
|
||||||
|
sourceRemark: externalXlsxScalarSchema,
|
||||||
|
circuitCombined: externalXlsxScalarSchema,
|
||||||
|
boardName: externalXlsxScalarSchema,
|
||||||
|
circuitIdentifier: externalXlsxScalarSchema,
|
||||||
|
designation: externalXlsxScalarSchema,
|
||||||
|
electricalPowerW: externalXlsxScalarSchema,
|
||||||
|
voltageV: externalXlsxScalarSchema,
|
||||||
|
currentA: externalXlsxScalarSchema,
|
||||||
|
}).strict(),
|
||||||
|
planningValues: z.object({
|
||||||
|
displayName: nullableStringSchema,
|
||||||
|
category: z.enum(circuitGroupCategories).nullable(),
|
||||||
|
connectionKind: nullableStringSchema,
|
||||||
|
effectiveQuantity: finiteNumberSchema.positive(),
|
||||||
|
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
|
||||||
|
simultaneityFactor: finiteNumberSchema.min(0).max(1).nullable(),
|
||||||
|
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
|
||||||
|
costGroup: nullableStringSchema,
|
||||||
|
remark: nullableStringSchema,
|
||||||
|
}).strict(),
|
||||||
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
|
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
|
||||||
(values) => new Set(values).size === values.length
|
(values) => new Set(values).size === values.length
|
||||||
),
|
),
|
||||||
|
|
@ -519,7 +688,21 @@ const externalModelStateSchema = z.object({
|
||||||
importBatches: z.array(externalImportBatchSchema),
|
importBatches: z.array(externalImportBatchSchema),
|
||||||
roomMappings: z.array(externalRoomMappingSchema),
|
roomMappings: z.array(externalRoomMappingSchema),
|
||||||
objects: z.array(externalModelObjectSchema),
|
objects: z.array(externalModelObjectSchema),
|
||||||
}).strict();
|
}).strict().superRefine((state, context) => {
|
||||||
|
const fullBatchIndexes = state.importBatches.flatMap((batch, index) =>
|
||||||
|
batch.originalContentBase64 === null ? [] : [index]
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
state.importBatches.length > 0 &&
|
||||||
|
(fullBatchIndexes.length !== 1 ||
|
||||||
|
fullBatchIndexes[0] !== state.importBatches.length - 1)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Nur der letzte Importbatch darf vollständig gespeichert sein.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function parseExternalModelStateSnapshot(
|
export function parseExternalModelStateSnapshot(
|
||||||
value: unknown
|
value: unknown
|
||||||
|
|
@ -565,19 +748,30 @@ export const projectStateSnapshotSchema = z
|
||||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const legacyCsvProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
|
externalModel: legacyExternalModelStateSchema,
|
||||||
|
...projectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const roomAreaProjectStateSnapshotSchema = z
|
const roomAreaProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: legacyExternalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -588,7 +782,7 @@ const buildingProjectStateSnapshotSchema = z
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: legacyExternalModelStateSchema,
|
||||||
...buildingProjectStateSnapshotContents,
|
...buildingProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -599,7 +793,7 @@ const coverProjectStateSnapshotSchema = z
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: legacyExternalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -610,7 +804,7 @@ const boardInfoProjectStateSnapshotSchema = z
|
||||||
project: publicBuildingProjectSchema,
|
project: publicBuildingProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: legacyExternalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -621,7 +815,7 @@ const previousProjectStateSnapshotSchema = z
|
||||||
project: publicBuildingProjectSchema,
|
project: publicBuildingProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: legacyExternalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...previousProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -684,6 +878,39 @@ export function parseProjectStateSnapshot(
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
|
const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value);
|
||||||
|
if (
|
||||||
|
legacyCsv !== null &&
|
||||||
|
typeof legacyCsv === "object" &&
|
||||||
|
!Array.isArray(legacyCsv) &&
|
||||||
|
(legacyCsv as { schemaVersion?: unknown }).schemaVersion ===
|
||||||
|
legacyCsvProjectStateSnapshotSchemaVersion
|
||||||
|
) {
|
||||||
|
const previous = legacyCsvProjectStateSnapshotSchema.parse(legacyCsv);
|
||||||
|
if (
|
||||||
|
previous.externalCsvConfiguration !== null ||
|
||||||
|
!isEmptyExternalModelState(previous.externalModel)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Dieser alte Projektstand enthält Revit-CSV-Daten und kann nicht auf die Revit-XLSX-Struktur aktualisiert werden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
externalCsvConfiguration: _externalCsvConfiguration,
|
||||||
|
externalModel: _legacyExternalModel,
|
||||||
|
...remaining
|
||||||
|
} = previous;
|
||||||
|
return {
|
||||||
|
...remaining,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
externalModelConfiguration: null,
|
||||||
|
externalModel: emptyExternalModelState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return legacyCsv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeToLegacyCsvProjectStateSnapshot(value: unknown): unknown {
|
||||||
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
|
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
|
||||||
if (
|
if (
|
||||||
roomArea !== null &&
|
roomArea !== null &&
|
||||||
|
|
@ -695,7 +922,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
|
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion,
|
||||||
distributionBoards: withEmptyDistributionBoardFeeding(
|
distributionBoards: withEmptyDistributionBoardFeeding(
|
||||||
previous.distributionBoards
|
previous.distributionBoards
|
||||||
),
|
),
|
||||||
|
|
@ -830,6 +1057,20 @@ function emptyExternalModelState() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmptyExternalModelState(state: {
|
||||||
|
source: unknown;
|
||||||
|
importBatches: unknown[];
|
||||||
|
roomMappings: unknown[];
|
||||||
|
objects: unknown[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
state.source === null &&
|
||||||
|
state.importBatches.length === 0 &&
|
||||||
|
state.roomMappings.length === 0 &&
|
||||||
|
state.objects.length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSnapshotPhaseTypes(
|
function normalizeSnapshotPhaseTypes(
|
||||||
snapshot: ProjectStateSnapshot
|
snapshot: ProjectStateSnapshot
|
||||||
): ProjectStateSnapshot {
|
): ProjectStateSnapshot {
|
||||||
|
|
@ -891,10 +1132,10 @@ function assertProjectStateSnapshotRelations(
|
||||||
) {
|
) {
|
||||||
const projectId = snapshot.project.id;
|
const projectId = snapshot.project.id;
|
||||||
if (
|
if (
|
||||||
snapshot.externalCsvConfiguration !== null &&
|
snapshot.externalModelConfiguration !== null &&
|
||||||
snapshot.externalCsvConfiguration.projectId !== projectId
|
snapshot.externalModelConfiguration.projectId !== projectId
|
||||||
) {
|
) {
|
||||||
throw new Error("Snapshot external CSV configuration belongs to a different project.");
|
throw new Error("Snapshot Revit-XLSX configuration belongs to a different project.");
|
||||||
}
|
}
|
||||||
const boardIds = uniqueIds(
|
const boardIds = uniqueIds(
|
||||||
snapshot.distributionBoards,
|
snapshot.distributionBoards,
|
||||||
|
|
@ -1235,6 +1476,12 @@ function assertExternalModelRelations(
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (snapshot.externalModelConfiguration === null) {
|
||||||
|
throw new Error("Snapshot external model requires a Revit-XLSX configuration.");
|
||||||
|
}
|
||||||
|
if (importBatches.length === 0) {
|
||||||
|
throw new Error("Snapshot external model source requires an import batch.");
|
||||||
|
}
|
||||||
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
||||||
const batchIds = uniqueIds(importBatches, "external import batch");
|
const batchIds = uniqueIds(importBatches, "external import batch");
|
||||||
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
||||||
|
|
@ -1270,17 +1517,23 @@ function assertExternalModelRelations(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ifcGuids = new Set<string>();
|
const worksharingIds = new Set<string>();
|
||||||
|
const deviceTypeIds = new Set(
|
||||||
|
snapshot.externalModelConfiguration.configuration.deviceTypes.map(
|
||||||
|
(deviceType) => deviceType.id
|
||||||
|
)
|
||||||
|
);
|
||||||
for (const object of objects) {
|
for (const object of objects) {
|
||||||
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
||||||
if (object.sourceId !== source.id) {
|
if (object.sourceId !== source.id) {
|
||||||
throw new Error("Snapshot external model object belongs to a different source.");
|
throw new Error("Snapshot external model object belongs to a different source.");
|
||||||
}
|
}
|
||||||
assertUniqueKey(
|
assertUniqueKey(
|
||||||
ifcGuids,
|
worksharingIds,
|
||||||
object.ifcGuid,
|
object.worksharingId,
|
||||||
"Snapshot contains duplicate external IFCGUIDs."
|
"Snapshot contains duplicate external Worksharing-IDs."
|
||||||
);
|
);
|
||||||
|
assertReference(deviceTypeIds, object.deviceTypeId, "external object device type");
|
||||||
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
||||||
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
||||||
if (object.externalRoomMappingId !== null) {
|
if (object.externalRoomMappingId !== null) {
|
||||||
|
|
|
||||||
|
|
@ -146,16 +146,35 @@ export function remapProjectState(
|
||||||
createId
|
createId
|
||||||
);
|
);
|
||||||
const externalObjectIds = idMap(source.externalModel.objects, createId);
|
const externalObjectIds = idMap(source.externalModel.objects, createId);
|
||||||
|
const configurations = [
|
||||||
|
source.externalModelConfiguration?.configuration ?? null,
|
||||||
|
...source.externalModel.importBatches.map(
|
||||||
|
(batch) => batch.configurationSnapshot
|
||||||
|
),
|
||||||
|
].filter((configuration) => configuration !== null);
|
||||||
|
const deviceTypeIds = uniqueIdMap(
|
||||||
|
configurations.flatMap((configuration) => configuration.deviceTypes),
|
||||||
|
createId
|
||||||
|
);
|
||||||
|
const familyRuleIds = uniqueIdMap(
|
||||||
|
configurations.flatMap((configuration) => configuration.familyTypeRules),
|
||||||
|
createId
|
||||||
|
);
|
||||||
return parseProjectStateSnapshot({
|
return parseProjectStateSnapshot({
|
||||||
...source,
|
...source,
|
||||||
project: { ...source.project, id: targetProjectId, name },
|
project: { ...source.project, id: targetProjectId, name },
|
||||||
externalCsvConfiguration:
|
externalModelConfiguration:
|
||||||
source.externalCsvConfiguration === null
|
source.externalModelConfiguration === null
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
...source.externalCsvConfiguration,
|
...source.externalModelConfiguration,
|
||||||
id: createId(),
|
id: createId(),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
|
configuration: remapExternalModelConfiguration(
|
||||||
|
source.externalModelConfiguration.configuration,
|
||||||
|
deviceTypeIds,
|
||||||
|
familyRuleIds
|
||||||
|
),
|
||||||
},
|
},
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: source.externalModel.source === null
|
source: source.externalModel.source === null
|
||||||
|
|
@ -170,6 +189,14 @@ export function remapProjectState(
|
||||||
id: requiredId(externalBatchIds, batch.id),
|
id: requiredId(externalBatchIds, batch.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
sourceId: externalSourceId!,
|
sourceId: externalSourceId!,
|
||||||
|
configurationSnapshot:
|
||||||
|
batch.configurationSnapshot === null
|
||||||
|
? null
|
||||||
|
: remapExternalModelConfiguration(
|
||||||
|
batch.configurationSnapshot,
|
||||||
|
deviceTypeIds,
|
||||||
|
familyRuleIds
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
|
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
|
||||||
...mapping,
|
...mapping,
|
||||||
|
|
@ -190,6 +217,7 @@ export function remapProjectState(
|
||||||
id: requiredId(externalObjectIds, object.id),
|
id: requiredId(externalObjectIds, object.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
sourceId: externalSourceId!,
|
sourceId: externalSourceId!,
|
||||||
|
deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId),
|
||||||
lastSeenImportBatchId: requiredId(
|
lastSeenImportBatchId: requiredId(
|
||||||
externalBatchIds,
|
externalBatchIds,
|
||||||
object.lastSeenImportBatchId
|
object.lastSeenImportBatchId
|
||||||
|
|
@ -322,6 +350,36 @@ export function remapProjectState(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remapExternalModelConfiguration(
|
||||||
|
configuration: NonNullable<
|
||||||
|
ProjectStateSnapshot["externalModelConfiguration"]
|
||||||
|
>["configuration"],
|
||||||
|
deviceTypeIds: ReadonlyMap<string, string>,
|
||||||
|
familyRuleIds: ReadonlyMap<string, string>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...configuration,
|
||||||
|
deviceTypes: configuration.deviceTypes.map((deviceType) => ({
|
||||||
|
...deviceType,
|
||||||
|
id: requiredId(deviceTypeIds, deviceType.id),
|
||||||
|
})),
|
||||||
|
familyTypeRules: configuration.familyTypeRules.map((rule) => ({
|
||||||
|
...rule,
|
||||||
|
id: requiredId(familyRuleIds, rule.id),
|
||||||
|
deviceTypeId: requiredId(deviceTypeIds, rule.deviceTypeId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIdMap(
|
||||||
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
|
createId: () => string
|
||||||
|
) {
|
||||||
|
return new Map(
|
||||||
|
[...new Set(entries.map((entry) => entry.id))].map((id) => [id, createId()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function idMap(
|
function idMap(
|
||||||
entries: ReadonlyArray<{ id: string }>,
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
createId: () => string
|
createId: () => string
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js";
|
|
||||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
|
||||||
|
|
||||||
export interface ExecuteExternalCsvConfigurationCommandInput {
|
|
||||||
projectId: string;
|
|
||||||
expectedRevision: number;
|
|
||||||
source: ProjectRevisionSource;
|
|
||||||
description?: string;
|
|
||||||
actorId?: string;
|
|
||||||
historyTargetChangeSetId?: string;
|
|
||||||
command: ExternalCsvConfigurationUpdateProjectCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationProjectCommandStore {
|
|
||||||
execute(input: ExecuteExternalCsvConfigurationCommandInput): {
|
|
||||||
revision: AppendedProjectRevision;
|
|
||||||
inverse: ExternalCsvConfigurationUpdateProjectCommand;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js";
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationReadResult {
|
|
||||||
projectExists: boolean;
|
|
||||||
configuration: ExternalCsvConfigurationSnapshot | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationReader {
|
|
||||||
getByProject(projectId: string): ExternalCsvConfigurationReadResult;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js";
|
|
||||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
|
||||||
|
|
||||||
export interface ExecuteExternalInitialImportCommandInput {
|
|
||||||
projectId: string;
|
|
||||||
expectedRevision: number;
|
|
||||||
source: ProjectRevisionSource;
|
|
||||||
description?: string;
|
|
||||||
actorId?: string;
|
|
||||||
historyTargetChangeSetId?: string;
|
|
||||||
command: ExternalInitialImportProjectCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalInitialImportProjectCommandStore {
|
|
||||||
execute(input: ExecuteExternalInitialImportCommandInput): {
|
|
||||||
revision: AppendedProjectRevision;
|
|
||||||
inverse: ExternalInitialImportProjectCommand;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ExternalModelConfigurationUpdateProjectCommand } from "../models/external-model-configuration-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteExternalModelConfigurationCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ExternalModelConfigurationUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationProjectCommandStore {
|
||||||
|
execute(input: ExecuteExternalModelConfigurationCommandInput): {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ExternalModelConfigurationUpdateProjectCommand;
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/domain/ports/external-model-configuration.reader.ts
Normal file
10
src/domain/ports/external-model-configuration.reader.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { ExternalModelConfigurationSnapshot } from "../models/external-model-configuration-project-command.model.js";
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationReadResult {
|
||||||
|
projectExists: boolean;
|
||||||
|
configuration: ExternalModelConfigurationSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalModelConfigurationReader {
|
||||||
|
getByProject(projectId: string): ExternalModelConfigurationReadResult;
|
||||||
|
}
|
||||||
|
|
@ -173,15 +173,10 @@ import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
|
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
|
||||||
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
||||||
import {
|
import {
|
||||||
assertExternalCsvConfigurationUpdateProjectCommand,
|
assertExternalModelConfigurationUpdateProjectCommand,
|
||||||
externalCsvConfigurationUpdateCommandType,
|
externalModelConfigurationUpdateCommandType,
|
||||||
} from "../models/external-csv-configuration-project-command.model.js";
|
} from "../models/external-model-configuration-project-command.model.js";
|
||||||
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js";
|
import type { ExternalModelConfigurationProjectCommandStore } from "../ports/external-model-configuration-project-command.store.js";
|
||||||
import {
|
|
||||||
assertExternalInitialImportProjectCommand,
|
|
||||||
externalInitialImportCommandType,
|
|
||||||
} from "../models/external-initial-import-project-command.model.js";
|
|
||||||
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
|
||||||
import {
|
import {
|
||||||
assertExternalObjectRowAssignmentProjectCommand,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
externalObjectRowAssignmentCommandType,
|
externalObjectRowAssignmentCommandType,
|
||||||
|
|
@ -243,8 +238,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore,
|
private readonly historyStore: ProjectHistoryStore,
|
||||||
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
private readonly externalModelConfigurationStore?: ExternalModelConfigurationProjectCommandStore,
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
|
|
@ -308,6 +302,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNoLegacyExternalCsvHistoryCommand(step.command);
|
||||||
|
|
||||||
return this.dispatch({
|
return this.dispatch({
|
||||||
...input,
|
...input,
|
||||||
source: direction,
|
source: direction,
|
||||||
|
|
@ -625,22 +621,12 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
case externalCsvConfigurationUpdateCommandType: {
|
case externalModelConfigurationUpdateCommandType: {
|
||||||
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
|
assertExternalModelConfigurationUpdateProjectCommand(input.command);
|
||||||
if (!this.externalCsvConfigurationStore) {
|
if (!this.externalModelConfigurationStore) {
|
||||||
throw new Error("External CSV configuration store is not available.");
|
throw new Error("External model configuration store is not available.");
|
||||||
}
|
}
|
||||||
return this.externalCsvConfigurationStore.execute({
|
return this.externalModelConfigurationStore.execute({
|
||||||
...input,
|
|
||||||
command: input.command,
|
|
||||||
}).revision;
|
|
||||||
}
|
|
||||||
case externalInitialImportCommandType: {
|
|
||||||
assertExternalInitialImportProjectCommand(input.command);
|
|
||||||
if (!this.externalInitialImportStore) {
|
|
||||||
throw new Error("External initial import store is not available.");
|
|
||||||
}
|
|
||||||
return this.externalInitialImportStore.execute({
|
|
||||||
...input,
|
...input,
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
|
|
@ -720,3 +706,16 @@ function assertExpectedRevision(expectedRevision: number) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertNoLegacyExternalCsvHistoryCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.type === "external-csv-configuration.update" ||
|
||||||
|
command.type === "external-import.apply-initial"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Dieser Verlaufseintrag stammt aus der alten Revit-CSV-Anbindung und kann nach der Umstellung auf Linear-XLSX nicht mehr ausgef\u00fchrt werden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import type {
|
|
||||||
ExternalModelStateSnapshot,
|
|
||||||
} from "../domain/external-model-contracts.js";
|
|
||||||
|
|
||||||
export interface ExternalCircuitListObjectDto {
|
|
||||||
id: string;
|
|
||||||
ifcGuid: string;
|
|
||||||
selectionMarker: string;
|
|
||||||
familyAndType: string;
|
|
||||||
sourceCircuitIdentifier: string;
|
|
||||||
displayName: string | null;
|
|
||||||
category: string | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
effectiveQuantity: number;
|
|
||||||
powerPerUnitW: number | null;
|
|
||||||
sourceRoomNumber: string;
|
|
||||||
sourceRoomName: string;
|
|
||||||
roomId: string | null;
|
|
||||||
linkedProjectDeviceId: string | null;
|
|
||||||
circuitDeviceRowId: string | null;
|
|
||||||
presenceStatus: "present" | "missing";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCircuitListObjectsDto {
|
|
||||||
sourceName: string | null;
|
|
||||||
objectCount: number;
|
|
||||||
unassignedObjectCount: number;
|
|
||||||
objects: ExternalCircuitListObjectDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalCircuitListObjects(input: {
|
|
||||||
state: ExternalModelStateSnapshot;
|
|
||||||
distributionBoardId: string;
|
|
||||||
}): ExternalCircuitListObjectsDto {
|
|
||||||
const mappingById = new Map(
|
|
||||||
input.state.roomMappings.map((mapping) => [mapping.id, mapping])
|
|
||||||
);
|
|
||||||
const objects = input.state.objects
|
|
||||||
.filter((object) => object.distributionBoardId === input.distributionBoardId)
|
|
||||||
.map((object) => {
|
|
||||||
const mapping = object.externalRoomMappingId === null
|
|
||||||
? null
|
|
||||||
: mappingById.get(object.externalRoomMappingId) ?? null;
|
|
||||||
return {
|
|
||||||
id: object.id,
|
|
||||||
ifcGuid: object.ifcGuid,
|
|
||||||
selectionMarker: object.acceptedSourceValues.selectionMarker,
|
|
||||||
familyAndType: object.acceptedSourceValues.familyAndType,
|
|
||||||
sourceCircuitIdentifier: object.acceptedSourceValues.circuitIdentifier,
|
|
||||||
displayName: object.planningValues.displayName,
|
|
||||||
category: object.planningValues.category,
|
|
||||||
connectionKind: object.planningValues.connectionKind,
|
|
||||||
effectiveQuantity: object.planningValues.effectiveQuantity,
|
|
||||||
powerPerUnitW: object.planningValues.powerPerUnitW,
|
|
||||||
sourceRoomNumber:
|
|
||||||
mapping?.sourceRoomNumber ?? object.acceptedSourceValues.roomNumber,
|
|
||||||
sourceRoomName:
|
|
||||||
mapping?.sourceRoomName ?? object.acceptedSourceValues.roomName,
|
|
||||||
roomId: mapping?.roomId ?? null,
|
|
||||||
linkedProjectDeviceId: object.linkedProjectDeviceId,
|
|
||||||
circuitDeviceRowId: object.circuitDeviceRowId,
|
|
||||||
presenceStatus: object.presenceStatus,
|
|
||||||
} satisfies ExternalCircuitListObjectDto;
|
|
||||||
})
|
|
||||||
.sort(compareExternalObjects);
|
|
||||||
return {
|
|
||||||
sourceName: input.state.source?.name ?? null,
|
|
||||||
objectCount: objects.length,
|
|
||||||
unassignedObjectCount: objects.filter(
|
|
||||||
(object) => object.circuitDeviceRowId === null
|
|
||||||
).length,
|
|
||||||
objects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareExternalObjects(
|
|
||||||
left: ExternalCircuitListObjectDto,
|
|
||||||
right: ExternalCircuitListObjectDto
|
|
||||||
) {
|
|
||||||
const assignmentOrder = Number(left.circuitDeviceRowId !== null) -
|
|
||||||
Number(right.circuitDeviceRowId !== null);
|
|
||||||
if (assignmentOrder !== 0) return assignmentOrder;
|
|
||||||
return left.sourceRoomNumber.localeCompare(right.sourceRoomNumber, "de", {
|
|
||||||
numeric: true,
|
|
||||||
}) || left.selectionMarker.localeCompare(right.selectionMarker, "de", {
|
|
||||||
numeric: true,
|
|
||||||
}) || left.ifcGuid.localeCompare(right.ifcGuid);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
|
||||||
import { parseExternalCsv } from "../csv/external-csv-transport.js";
|
|
||||||
|
|
||||||
export interface ExternalCsvPreviewObject {
|
|
||||||
rowNumber: number;
|
|
||||||
ifcGuid: string;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
familyAndType: string;
|
|
||||||
selectionMarker: string;
|
|
||||||
circuitIdentifier: string;
|
|
||||||
power: string;
|
|
||||||
quantity: string | null;
|
|
||||||
additionalSourceValues: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalCsvPreview {
|
|
||||||
fileName: string;
|
|
||||||
byteLength: number;
|
|
||||||
sha256: string;
|
|
||||||
dialect: ReturnType<typeof parseExternalCsv>["dialect"];
|
|
||||||
headerRowNumber: number;
|
|
||||||
headerColumns: string[];
|
|
||||||
rowCount: number;
|
|
||||||
objectCount: number;
|
|
||||||
passthroughCount: number;
|
|
||||||
nonEmptyPassthroughCount: number;
|
|
||||||
suspectObjectCount: number;
|
|
||||||
objects: ExternalCsvPreviewObject[];
|
|
||||||
suspectRowNumbers: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalCsvPreview(input: {
|
|
||||||
fileName: string;
|
|
||||||
bytes: Uint8Array;
|
|
||||||
configuration: ExternalCsvConfiguration;
|
|
||||||
}): ExternalCsvPreview {
|
|
||||||
const document = parseExternalCsv(input.bytes, input.configuration);
|
|
||||||
const header = document.rows[document.headerRowIndex];
|
|
||||||
const headerIndex = new Map(
|
|
||||||
header.cells.map((cell, index) => [cell.value, index])
|
|
||||||
);
|
|
||||||
const valueAt = (row: (typeof document.rows)[number], column: string) =>
|
|
||||||
row.cells[headerIndex.get(column)!]?.value ?? "";
|
|
||||||
const objects = document.rows
|
|
||||||
.filter((row) => row.classification === "object")
|
|
||||||
.map((row): ExternalCsvPreviewObject => ({
|
|
||||||
rowNumber: row.index + 1,
|
|
||||||
ifcGuid: valueAt(row, input.configuration.columns.ifcGuid),
|
|
||||||
roomNumber: valueAt(row, input.configuration.columns.roomNumber),
|
|
||||||
roomName: valueAt(row, input.configuration.columns.roomName),
|
|
||||||
familyAndType: valueAt(row, input.configuration.columns.familyAndType),
|
|
||||||
selectionMarker: valueAt(row, input.configuration.columns.selectionMarker),
|
|
||||||
circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier),
|
|
||||||
power: valueAt(row, input.configuration.columns.power),
|
|
||||||
quantity:
|
|
||||||
input.configuration.columns.quantity === null
|
|
||||||
? null
|
|
||||||
: valueAt(row, input.configuration.columns.quantity),
|
|
||||||
additionalSourceValues: Object.fromEntries(
|
|
||||||
input.configuration.additionalSourceMappings.map((mapping) => [
|
|
||||||
mapping.targetField,
|
|
||||||
valueAt(row, mapping.sourceColumn),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
const passthroughRows = document.rows.filter(
|
|
||||||
(row) => row.classification === "passthrough"
|
|
||||||
);
|
|
||||||
const suspectRows = document.rows.filter(
|
|
||||||
(row) => row.classification === "suspect-object"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
fileName: input.fileName,
|
|
||||||
byteLength: input.bytes.byteLength,
|
|
||||||
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
|
||||||
dialect: document.dialect,
|
|
||||||
headerRowNumber: document.headerRowIndex + 1,
|
|
||||||
headerColumns: header.cells.map((cell) => cell.value),
|
|
||||||
rowCount: document.rows.length,
|
|
||||||
objectCount: objects.length,
|
|
||||||
passthroughCount: passthroughRows.length,
|
|
||||||
nonEmptyPassthroughCount: passthroughRows.filter((row) =>
|
|
||||||
row.cells.some((cell) => cell.value !== "")
|
|
||||||
).length,
|
|
||||||
suspectObjectCount: suspectRows.length,
|
|
||||||
objects,
|
|
||||||
suspectRowNumbers: suspectRows.map((row) => row.index + 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
|
||||||
import {
|
|
||||||
createExternalRoomKey,
|
|
||||||
normalizeExternalRoomPart,
|
|
||||||
projectInitialExternalObject,
|
|
||||||
} from "../domain/external-model-matching.js";
|
|
||||||
import { createExternalCsvPreview } from "./external-csv-preview.js";
|
|
||||||
|
|
||||||
interface ExistingRoom {
|
|
||||||
id: string;
|
|
||||||
floorId: string | null;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExistingFloor {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExistingDistributionBoard {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
floorId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExistingProjectDevice {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
category: string | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
powerPerUnit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalInitialImportPlan(input: {
|
|
||||||
fileName: string;
|
|
||||||
bytes: Uint8Array;
|
|
||||||
configurationVersion: number;
|
|
||||||
configuration: ExternalCsvConfiguration;
|
|
||||||
rooms: ExistingRoom[];
|
|
||||||
floors: ExistingFloor[];
|
|
||||||
distributionBoards: ExistingDistributionBoard[];
|
|
||||||
projectDevices: ExistingProjectDevice[];
|
|
||||||
}) {
|
|
||||||
const preview = createExternalCsvPreview(input);
|
|
||||||
const roomCandidatesByNumber = new Map<string, ExistingRoom[]>();
|
|
||||||
for (const room of input.rooms) {
|
|
||||||
const key = normalizeExternalRoomPart(room.roomNumber);
|
|
||||||
if (!key) continue;
|
|
||||||
const candidates = roomCandidatesByNumber.get(key) ?? [];
|
|
||||||
candidates.push(room);
|
|
||||||
roomCandidatesByNumber.set(key, candidates);
|
|
||||||
}
|
|
||||||
|
|
||||||
const objects = preview.objects.map((object) => {
|
|
||||||
const projected = projectInitialExternalObject(object, input.configuration);
|
|
||||||
return {
|
|
||||||
ifcGuid: projected.ifcGuid,
|
|
||||||
sourceRoomKey: createExternalRoomKey(object.roomNumber, object.roomName),
|
|
||||||
sourceValues: projected.sourceValues,
|
|
||||||
suggestedPlanningValues: projected.planningValues,
|
|
||||||
issues: projected.issues,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const sourceRooms = new Map<string, {
|
|
||||||
sourceRoomKey: string;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
objectCount: number;
|
|
||||||
}>();
|
|
||||||
for (const object of objects) {
|
|
||||||
if (object.sourceRoomKey === null) continue;
|
|
||||||
const existing = sourceRooms.get(object.sourceRoomKey);
|
|
||||||
if (existing) {
|
|
||||||
existing.objectCount += 1;
|
|
||||||
} else {
|
|
||||||
sourceRooms.set(object.sourceRoomKey, {
|
|
||||||
sourceRoomKey: object.sourceRoomKey,
|
|
||||||
roomNumber: object.sourceValues.roomNumber,
|
|
||||||
roomName: object.sourceValues.roomName,
|
|
||||||
objectCount: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const roomPlans = [...sourceRooms.values()]
|
|
||||||
.map((room) => {
|
|
||||||
const candidates = roomCandidatesByNumber.get(
|
|
||||||
normalizeExternalRoomPart(room.roomNumber)
|
|
||||||
) ?? [];
|
|
||||||
return {
|
|
||||||
...room,
|
|
||||||
suggestedRoomId: candidates.length === 1 ? candidates[0].id : null,
|
|
||||||
matchStatus:
|
|
||||||
candidates.length === 1
|
|
||||||
? "exact-number"
|
|
||||||
: candidates.length > 1
|
|
||||||
? "ambiguous-number"
|
|
||||||
: "new",
|
|
||||||
candidateRoomIds: candidates.map((candidate) => candidate.id),
|
|
||||||
} as const;
|
|
||||||
})
|
|
||||||
.sort((left, right) =>
|
|
||||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
|
||||||
left.roomName.localeCompare(right.roomName, "de")
|
|
||||||
);
|
|
||||||
|
|
||||||
const familyGroups = new Map<string, {
|
|
||||||
familyAndType: string;
|
|
||||||
objectCount: number;
|
|
||||||
classified: boolean;
|
|
||||||
category: string | null;
|
|
||||||
connectionKind: string | null;
|
|
||||||
internalDeviceType: string | null;
|
|
||||||
}>();
|
|
||||||
for (const object of objects) {
|
|
||||||
const key = object.sourceValues.familyAndType;
|
|
||||||
const group = familyGroups.get(key);
|
|
||||||
if (group) {
|
|
||||||
group.objectCount += 1;
|
|
||||||
} else {
|
|
||||||
familyGroups.set(key, {
|
|
||||||
familyAndType: key,
|
|
||||||
objectCount: 1,
|
|
||||||
classified: !object.issues.includes("unknown-family-and-type"),
|
|
||||||
category: object.suggestedPlanningValues.category,
|
|
||||||
connectionKind: object.suggestedPlanningValues.connectionKind,
|
|
||||||
internalDeviceType: object.suggestedPlanningValues.internalDeviceType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
configurationVersion: input.configurationVersion,
|
|
||||||
fileName: preview.fileName,
|
|
||||||
sha256: preview.sha256,
|
|
||||||
byteLength: preview.byteLength,
|
|
||||||
rowCount: preview.rowCount,
|
|
||||||
objectCount: preview.objectCount,
|
|
||||||
passthroughCount: preview.passthroughCount,
|
|
||||||
suspectObjectCount: preview.suspectObjectCount,
|
|
||||||
suspectRowNumbers: preview.suspectRowNumbers,
|
|
||||||
sourceRooms: roomPlans,
|
|
||||||
familyGroups: [...familyGroups.values()].sort((left, right) =>
|
|
||||||
left.familyAndType.localeCompare(right.familyAndType, "de")
|
|
||||||
),
|
|
||||||
issueCounts: {
|
|
||||||
unknownFamilyAndType: objects.filter((object) =>
|
|
||||||
object.issues.includes("unknown-family-and-type")
|
|
||||||
).length,
|
|
||||||
invalidPower: objects.filter((object) => object.issues.includes("invalid-power")).length,
|
|
||||||
invalidQuantity: objects.filter((object) => object.issues.includes("invalid-quantity")).length,
|
|
||||||
missingRoom: objects.filter((object) => object.issues.includes("missing-room")).length,
|
|
||||||
},
|
|
||||||
objects,
|
|
||||||
existing: {
|
|
||||||
floors: input.floors,
|
|
||||||
rooms: input.rooms,
|
|
||||||
distributionBoards: input.distributionBoards,
|
|
||||||
projectDevices: input.projectDevices,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import type { ExternalCsvConfiguration, ExternalCsvDocument } from "../csv/external-csv-contracts.js";
|
|
||||||
import type { ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
|
|
||||||
import type { createExternalInitialImportPlan } from "./external-initial-import-plan.js";
|
|
||||||
|
|
||||||
type ExternalInitialImportPlan = ReturnType<typeof createExternalInitialImportPlan>;
|
|
||||||
|
|
||||||
export interface ExternalRoomDecision {
|
|
||||||
sourceRoomKey: string;
|
|
||||||
roomId: string | null;
|
|
||||||
defaultDistributionBoardId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalFamilyProjectDeviceDecision {
|
|
||||||
familyAndType: string;
|
|
||||||
projectDeviceId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildExternalInitialImportTarget(input: {
|
|
||||||
projectId: string;
|
|
||||||
expectedRevision: number;
|
|
||||||
sourceName: string;
|
|
||||||
importedAtIso: string;
|
|
||||||
configurationVersion: number;
|
|
||||||
configuration: ExternalCsvConfiguration;
|
|
||||||
originalContentBase64: string;
|
|
||||||
document: ExternalCsvDocument;
|
|
||||||
plan: ExternalInitialImportPlan;
|
|
||||||
roomDecisions: ExternalRoomDecision[];
|
|
||||||
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
|
|
||||||
createId: () => string;
|
|
||||||
}): ExternalModelStateSnapshot {
|
|
||||||
if (input.plan.familyGroups.some((group) => !group.classified)) {
|
|
||||||
throw new Error("Alle Familie-und-Typ-Werte müssen vor dem Erstimport klassifiziert sein.");
|
|
||||||
}
|
|
||||||
const roomDecisionByKey = exactDecisionMap(
|
|
||||||
input.plan.sourceRooms.map((room) => room.sourceRoomKey),
|
|
||||||
input.roomDecisions,
|
|
||||||
(decision) => decision.sourceRoomKey,
|
|
||||||
"Quellraum"
|
|
||||||
);
|
|
||||||
const familyDecisionByName = exactDecisionMap(
|
|
||||||
input.plan.familyGroups.map((group) => group.familyAndType),
|
|
||||||
input.familyProjectDeviceDecisions,
|
|
||||||
(decision) => decision.familyAndType,
|
|
||||||
"Familie und Typ"
|
|
||||||
);
|
|
||||||
const sourceId = input.createId();
|
|
||||||
const batchId = input.createId();
|
|
||||||
const roomMappingIdByKey = new Map(
|
|
||||||
input.plan.sourceRooms.map((room) => [room.sourceRoomKey, input.createId()])
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
source: {
|
|
||||||
id: sourceId,
|
|
||||||
projectId: input.projectId,
|
|
||||||
name: input.sourceName.trim(),
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
},
|
|
||||||
importBatches: [{
|
|
||||||
id: batchId,
|
|
||||||
projectId: input.projectId,
|
|
||||||
sourceId,
|
|
||||||
importKind: "initial",
|
|
||||||
importedAtIso: input.importedAtIso,
|
|
||||||
fileName: input.plan.fileName,
|
|
||||||
sha256: input.plan.sha256,
|
|
||||||
appliedProjectRevision: input.expectedRevision + 1,
|
|
||||||
configurationVersion: input.configurationVersion,
|
|
||||||
configurationSnapshot: input.configuration,
|
|
||||||
originalContentBase64: input.originalContentBase64,
|
|
||||||
document: input.document,
|
|
||||||
}],
|
|
||||||
roomMappings: input.plan.sourceRooms.map((room) => {
|
|
||||||
const decision = roomDecisionByKey.get(room.sourceRoomKey)!;
|
|
||||||
return {
|
|
||||||
id: roomMappingIdByKey.get(room.sourceRoomKey)!,
|
|
||||||
projectId: input.projectId,
|
|
||||||
sourceId,
|
|
||||||
normalizedSourceRoomKey: room.sourceRoomKey,
|
|
||||||
sourceFloorName: null,
|
|
||||||
sourceRoomNumber: room.roomNumber,
|
|
||||||
sourceRoomName: room.roomName,
|
|
||||||
roomId: decision.roomId,
|
|
||||||
defaultDistributionBoardId: decision.defaultDistributionBoardId,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
objects: input.plan.objects.map((object) => ({
|
|
||||||
id: input.createId(),
|
|
||||||
projectId: input.projectId,
|
|
||||||
sourceId,
|
|
||||||
ifcGuid: object.ifcGuid,
|
|
||||||
lastSeenImportBatchId: batchId,
|
|
||||||
lastAcceptedImportBatchId: batchId,
|
|
||||||
acceptedSourceValues: object.sourceValues,
|
|
||||||
planningValues: object.suggestedPlanningValues,
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId:
|
|
||||||
object.sourceRoomKey === null
|
|
||||||
? null
|
|
||||||
: roomMappingIdByKey.get(object.sourceRoomKey)!,
|
|
||||||
distributionBoardId: null,
|
|
||||||
linkedProjectDeviceId:
|
|
||||||
familyDecisionByName.get(object.sourceValues.familyAndType)!
|
|
||||||
.projectDeviceId,
|
|
||||||
circuitDeviceRowId: null,
|
|
||||||
presenceStatus: "present",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function exactDecisionMap<T>(
|
|
||||||
expectedKeys: string[],
|
|
||||||
decisions: T[],
|
|
||||||
keyOf: (decision: T) => string,
|
|
||||||
label: string
|
|
||||||
) {
|
|
||||||
const expected = new Set(expectedKeys);
|
|
||||||
const result = new Map<string, T>();
|
|
||||||
for (const decision of decisions) {
|
|
||||||
const key = keyOf(decision);
|
|
||||||
if (!expected.has(key) || result.has(key)) {
|
|
||||||
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
|
|
||||||
}
|
|
||||||
result.set(key, decision);
|
|
||||||
}
|
|
||||||
if (result.size !== expected.size) {
|
|
||||||
throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
246
src/external-model/configuration/external-model-configuration.ts
Normal file
246
src/external-model/configuration/external-model-configuration.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
import {
|
||||||
|
createDefaultExternalXlsxTransportConfiguration,
|
||||||
|
type ExternalXlsxColumnConfiguration,
|
||||||
|
type ExternalXlsxTransportConfiguration,
|
||||||
|
} from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
|
||||||
|
export const externalModelConfigurationSchemaVersion = 1 as const;
|
||||||
|
export const externalQuantityRules = ["one", "socketCount"] as const;
|
||||||
|
export type ExternalQuantityRule = (typeof externalQuantityRules)[number];
|
||||||
|
|
||||||
|
export interface ExternalDeviceTypeDefaults {
|
||||||
|
powerPerUnitW: number | null;
|
||||||
|
simultaneityFactor: number | null;
|
||||||
|
cosPhi: number | null;
|
||||||
|
costGroup: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalDeviceTypeConfiguration {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: CircuitGroupCategory;
|
||||||
|
defaults: ExternalDeviceTypeDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalFamilyTypeRule {
|
||||||
|
id: string;
|
||||||
|
exactFamilyAndType: string;
|
||||||
|
deviceTypeId: string;
|
||||||
|
quantityRule: ExternalQuantityRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalModelConfiguration
|
||||||
|
extends ExternalXlsxTransportConfiguration {
|
||||||
|
schemaVersion: typeof externalModelConfigurationSchemaVersion;
|
||||||
|
deviceTypes: ExternalDeviceTypeConfiguration[];
|
||||||
|
familyTypeRules: ExternalFamilyTypeRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration {
|
||||||
|
return {
|
||||||
|
schemaVersion: externalModelConfigurationSchemaVersion,
|
||||||
|
...createDefaultExternalXlsxTransportConfiguration(),
|
||||||
|
deviceTypes: [],
|
||||||
|
familyTypeRules: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalModelConfiguration(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is ExternalModelConfiguration {
|
||||||
|
if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) {
|
||||||
|
throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration.");
|
||||||
|
}
|
||||||
|
assertExactKeys(value, [
|
||||||
|
"schemaVersion",
|
||||||
|
"layout",
|
||||||
|
"circuitMode",
|
||||||
|
"columns",
|
||||||
|
"deviceTypes",
|
||||||
|
"familyTypeRules",
|
||||||
|
], "Revit-XLSX-Konfiguration");
|
||||||
|
assertLayout(value.layout);
|
||||||
|
if (value.circuitMode !== "combined" && value.circuitMode !== "separate") {
|
||||||
|
throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig.");
|
||||||
|
}
|
||||||
|
assertColumns(value.columns, value.circuitMode);
|
||||||
|
if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) {
|
||||||
|
throw new Error("Gerätearten und Familienregeln müssen Listen sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTypeIds = new Set<string>();
|
||||||
|
const deviceTypeNames = new Set<string>();
|
||||||
|
for (const [index, deviceType] of value.deviceTypes.entries()) {
|
||||||
|
assertDeviceType(deviceType, index);
|
||||||
|
assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID");
|
||||||
|
assertUnique(deviceTypeNames, deviceType.name, "Geräteartname");
|
||||||
|
}
|
||||||
|
const ruleIds = new Set<string>();
|
||||||
|
const familyNames = new Set<string>();
|
||||||
|
for (const [index, rule] of value.familyTypeRules.entries()) {
|
||||||
|
assertFamilyRule(rule, index);
|
||||||
|
assertUnique(ruleIds, rule.id, "Familienregel-ID");
|
||||||
|
assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ");
|
||||||
|
if (!deviceTypeIds.has(rule.deviceTypeId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateExternalModelConfiguration(value: unknown):
|
||||||
|
| { success: true; data: ExternalModelConfiguration }
|
||||||
|
| { success: false; error: string } {
|
||||||
|
try {
|
||||||
|
assertExternalModelConfiguration(value);
|
||||||
|
return { success: true, data: value };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Ungültige Konfiguration.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLayout(value: unknown) {
|
||||||
|
if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt.");
|
||||||
|
assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout");
|
||||||
|
assertPositiveInteger(value.headerRow, "Kopfzeile");
|
||||||
|
assertPositiveInteger(value.firstDataRow, "erste Datenzeile");
|
||||||
|
assertPositiveInteger(value.firstDataColumn, "erste Datenspalte");
|
||||||
|
if ((value.firstDataRow as number) <= (value.headerRow as number)) {
|
||||||
|
throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertColumns(
|
||||||
|
value: unknown,
|
||||||
|
circuitMode: ExternalModelConfiguration["circuitMode"]
|
||||||
|
) {
|
||||||
|
if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt.");
|
||||||
|
const keys = [
|
||||||
|
"worksharingId",
|
||||||
|
"ifcGuid",
|
||||||
|
"roomNumber",
|
||||||
|
"roomName",
|
||||||
|
"familyAndType",
|
||||||
|
"selectionMarker",
|
||||||
|
"socketCount",
|
||||||
|
"sourceRemark",
|
||||||
|
"circuitCombined",
|
||||||
|
"boardName",
|
||||||
|
"circuitIdentifier",
|
||||||
|
"designation",
|
||||||
|
"electricalPowerW",
|
||||||
|
"voltageV",
|
||||||
|
"currentA",
|
||||||
|
] satisfies (keyof ExternalXlsxColumnConfiguration)[];
|
||||||
|
assertExactKeys(value, keys, "XLSX-Spaltenzuordnung");
|
||||||
|
const mappedNames = new Set<string>();
|
||||||
|
for (const key of keys) {
|
||||||
|
const columnName = value[key];
|
||||||
|
const required =
|
||||||
|
key === "worksharingId" ||
|
||||||
|
key === "familyAndType" ||
|
||||||
|
(circuitMode === "combined" && key === "circuitCombined") ||
|
||||||
|
(circuitMode === "separate" &&
|
||||||
|
(key === "boardName" || key === "circuitIdentifier"));
|
||||||
|
if (columnName === null && !required) continue;
|
||||||
|
assertNonEmptyString(columnName, `Spalte ${key}`);
|
||||||
|
assertUnique(mappedNames, columnName, "Spaltenzuordnung");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration {
|
||||||
|
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
|
||||||
|
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`);
|
||||||
|
assertUuid(value.id, `Geräteart ${index + 1}.id`);
|
||||||
|
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
|
||||||
|
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {
|
||||||
|
throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`);
|
||||||
|
}
|
||||||
|
if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`);
|
||||||
|
assertExactKeys(
|
||||||
|
value.defaults,
|
||||||
|
["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"],
|
||||||
|
`Geräteart ${index + 1}.defaults`
|
||||||
|
);
|
||||||
|
assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung");
|
||||||
|
assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF");
|
||||||
|
assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi");
|
||||||
|
if (value.defaults.costGroup !== null) {
|
||||||
|
assertNonEmptyString(value.defaults.costGroup, "Kostengruppe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule {
|
||||||
|
if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`);
|
||||||
|
assertExactKeys(
|
||||||
|
value,
|
||||||
|
["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"],
|
||||||
|
`Familienregel ${index + 1}`
|
||||||
|
);
|
||||||
|
assertUuid(value.id, `Familienregel ${index + 1}.id`);
|
||||||
|
assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`);
|
||||||
|
assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`);
|
||||||
|
if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) {
|
||||||
|
throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPositiveInteger(value: unknown, label: string) {
|
||||||
|
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||||
|
throw new Error(`${label} muss eine positive Ganzzahl sein.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNullableNumber(
|
||||||
|
value: unknown,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (value === null) return;
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isFinite(value) ||
|
||||||
|
value < minimum ||
|
||||||
|
value > maximum
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} ist ungültig.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNonEmptyString(value: unknown, label: string): asserts value is string {
|
||||||
|
if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
|
||||||
|
throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUuid(value: unknown, label: string): asserts value is string {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} muss eine UUID sein.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUnique(values: Set<string>, value: string, label: string) {
|
||||||
|
if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`);
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string) {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||||
|
throw new Error(`${label} enthält unbekannte oder fehlende Felder.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
import { assertExternalCsvConfiguration } from "./external-csv-configuration.js";
|
|
||||||
import type {
|
|
||||||
ExternalCsvCell,
|
|
||||||
ExternalCsvConfiguration,
|
|
||||||
ExternalCsvDocument,
|
|
||||||
ExternalCsvLineEnding,
|
|
||||||
ExternalCsvRow,
|
|
||||||
} from "./external-csv-contracts.js";
|
|
||||||
|
|
||||||
export type ExternalCsvParseErrorCode =
|
|
||||||
| "invalid-encoding"
|
|
||||||
| "invalid-csv"
|
|
||||||
| "header-not-found"
|
|
||||||
| "ambiguous-header"
|
|
||||||
| "invalid-ifc-guid"
|
|
||||||
| "duplicate-ifc-guid";
|
|
||||||
|
|
||||||
export class ExternalCsvParseError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: ExternalCsvParseErrorCode,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ExternalCsvParseError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseExternalCsv(
|
|
||||||
input: Uint8Array,
|
|
||||||
configuration: ExternalCsvConfiguration
|
|
||||||
): ExternalCsvDocument {
|
|
||||||
assertExternalCsvConfiguration(configuration);
|
|
||||||
const hasBom = hasUtf8Bom(input);
|
|
||||||
const contentBytes = hasBom ? input.subarray(3) : input;
|
|
||||||
let text: string;
|
|
||||||
try {
|
|
||||||
text = new TextDecoder("utf-8", { fatal: true }).decode(contentBytes);
|
|
||||||
} catch {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-encoding",
|
|
||||||
"CSV is not valid UTF-8."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseRows(text, configuration.delimiter);
|
|
||||||
const headerMatches = findHeaderRows(parsed.rows, configuration);
|
|
||||||
if (headerMatches.length === 0) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"header-not-found",
|
|
||||||
"CSV does not contain exactly the configured required columns in one header row."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (headerMatches.length > 1) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"ambiguous-header",
|
|
||||||
"CSV contains more than one possible header row."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerRowIndex = headerMatches[0];
|
|
||||||
const configuredColumnNames = [
|
|
||||||
...Object.values(configuration.columns).filter(
|
|
||||||
(column): column is string => column !== null
|
|
||||||
),
|
|
||||||
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
|
|
||||||
];
|
|
||||||
const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value);
|
|
||||||
if (
|
|
||||||
configuredColumnNames.some(
|
|
||||||
(columnName) => headerValues.filter((value) => value === columnName).length !== 1
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"ambiguous-header",
|
|
||||||
"CSV header contains a configured column more than once."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const headerLookup = createHeaderLookup(parsed.rows[headerRowIndex]);
|
|
||||||
const ifcGuidColumnIndex = headerLookup.get(configuration.columns.ifcGuid)!;
|
|
||||||
const objectBearingColumnIndexes = [
|
|
||||||
configuration.columns.roomNumber,
|
|
||||||
configuration.columns.roomName,
|
|
||||||
configuration.columns.familyAndType,
|
|
||||||
configuration.columns.selectionMarker,
|
|
||||||
configuration.columns.power,
|
|
||||||
...(configuration.columns.quantity === null
|
|
||||||
? []
|
|
||||||
: [configuration.columns.quantity]),
|
|
||||||
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
|
|
||||||
].map((column) => headerLookup.get(column)!);
|
|
||||||
const seenIfcGuids = new Set<string>();
|
|
||||||
|
|
||||||
const rows: ExternalCsvRow[] = parsed.rows.map((cells, index) => {
|
|
||||||
if (index < headerRowIndex) {
|
|
||||||
return { index, cells, classification: "metadata" };
|
|
||||||
}
|
|
||||||
if (index === headerRowIndex) {
|
|
||||||
return { index, cells, classification: "header" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawIfcGuid = cells[ifcGuidColumnIndex]?.value ?? "";
|
|
||||||
const ifcGuid = rawIfcGuid.trim();
|
|
||||||
if (ifcGuid) {
|
|
||||||
if (ifcGuid !== rawIfcGuid || !isIfcGuid(ifcGuid)) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-ifc-guid",
|
|
||||||
`CSV row ${index + 1} contains an invalid IfcGUID.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seenIfcGuids.has(ifcGuid)) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"duplicate-ifc-guid",
|
|
||||||
`CSV contains the IfcGUID more than once: ${ifcGuid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
seenIfcGuids.add(ifcGuid);
|
|
||||||
return { index, cells, classification: "object" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const looksLikeObject = objectBearingColumnIndexes.some(
|
|
||||||
(columnIndex) => (cells[columnIndex]?.value.trim() ?? "") !== ""
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
cells,
|
|
||||||
classification: looksLikeObject ? "suspect-object" : "passthrough",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dialect: {
|
|
||||||
encoding: "utf-8",
|
|
||||||
hasBom,
|
|
||||||
delimiter: configuration.delimiter,
|
|
||||||
lineEnding: resolveLineEnding(parsed.lineEndings),
|
|
||||||
quoteCharacter: '"',
|
|
||||||
quoteAllFields:
|
|
||||||
rows.flatMap((row) => row.cells).length > 0 &&
|
|
||||||
rows.flatMap((row) => row.cells).every((cell) => cell.wasQuoted),
|
|
||||||
hasTrailingLineEnding: parsed.hasTrailingLineEnding,
|
|
||||||
},
|
|
||||||
headerRowIndex,
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeExternalCsv(document: ExternalCsvDocument): Uint8Array {
|
|
||||||
assertSerializableDocument(document);
|
|
||||||
const serializedRows = document.rows.map((row) =>
|
|
||||||
row.cells
|
|
||||||
.map((cell) => serializeCell(cell, document.dialect.delimiter))
|
|
||||||
.join(document.dialect.delimiter)
|
|
||||||
);
|
|
||||||
let text = serializedRows.join(document.dialect.lineEnding);
|
|
||||||
if (document.dialect.hasTrailingLineEnding && document.rows.length > 0) {
|
|
||||||
text += document.dialect.lineEnding;
|
|
||||||
}
|
|
||||||
const encoded = new TextEncoder().encode(text);
|
|
||||||
if (!document.dialect.hasBom) {
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
const result = new Uint8Array(encoded.length + 3);
|
|
||||||
result.set([0xef, 0xbb, 0xbf]);
|
|
||||||
result.set(encoded, 3);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRows(text: string, delimiter: string) {
|
|
||||||
const rows: ExternalCsvCell[][] = [];
|
|
||||||
const lineEndings: ExternalCsvLineEnding[] = [];
|
|
||||||
let cells: ExternalCsvCell[] = [];
|
|
||||||
let value = "";
|
|
||||||
let wasQuoted = false;
|
|
||||||
let inQuotes = false;
|
|
||||||
let afterClosingQuote = false;
|
|
||||||
let fieldStarted = false;
|
|
||||||
let hasTrailingLineEnding = false;
|
|
||||||
|
|
||||||
const finishCell = () => {
|
|
||||||
cells.push({ value, wasQuoted });
|
|
||||||
value = "";
|
|
||||||
wasQuoted = false;
|
|
||||||
inQuotes = false;
|
|
||||||
afterClosingQuote = false;
|
|
||||||
fieldStarted = false;
|
|
||||||
};
|
|
||||||
const finishRow = (lineEnding: ExternalCsvLineEnding) => {
|
|
||||||
finishCell();
|
|
||||||
rows.push(cells);
|
|
||||||
cells = [];
|
|
||||||
lineEndings.push(lineEnding);
|
|
||||||
hasTrailingLineEnding = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let index = 0; index < text.length; index += 1) {
|
|
||||||
const character = text[index];
|
|
||||||
if (inQuotes) {
|
|
||||||
if (character === '"') {
|
|
||||||
if (text[index + 1] === '"') {
|
|
||||||
value += '"';
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
inQuotes = false;
|
|
||||||
afterClosingQuote = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
value += character;
|
|
||||||
}
|
|
||||||
hasTrailingLineEnding = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (afterClosingQuote) {
|
|
||||||
if (character !== delimiter && character !== "\r" && character !== "\n") {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-csv",
|
|
||||||
"CSV contains characters after a closing quote."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (character === delimiter) {
|
|
||||||
finishCell();
|
|
||||||
hasTrailingLineEnding = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "\r" || character === "\n") {
|
|
||||||
const lineEnding: ExternalCsvLineEnding =
|
|
||||||
character === "\r" && text[index + 1] === "\n"
|
|
||||||
? "\r\n"
|
|
||||||
: character;
|
|
||||||
if (lineEnding === "\r\n") {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
finishRow(lineEnding);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === '"') {
|
|
||||||
if (fieldStarted || value.length > 0 || afterClosingQuote) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-csv",
|
|
||||||
"CSV contains a quote inside an unquoted field."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
wasQuoted = true;
|
|
||||||
inQuotes = true;
|
|
||||||
fieldStarted = true;
|
|
||||||
hasTrailingLineEnding = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (afterClosingQuote) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-csv",
|
|
||||||
"CSV contains invalid data after a quoted field."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
value += character;
|
|
||||||
fieldStarted = true;
|
|
||||||
hasTrailingLineEnding = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inQuotes) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-csv",
|
|
||||||
"CSV contains an unterminated quoted field."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasTrailingLineEnding || cells.length > 0 || fieldStarted || value.length > 0) {
|
|
||||||
finishCell();
|
|
||||||
rows.push(cells);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rows, lineEndings, hasTrailingLineEnding };
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHeaderRows(
|
|
||||||
rows: ExternalCsvCell[][],
|
|
||||||
configuration: ExternalCsvConfiguration
|
|
||||||
) {
|
|
||||||
const requiredColumns = new Set([
|
|
||||||
...Object.values(configuration.columns).filter(
|
|
||||||
(column): column is string => column !== null
|
|
||||||
),
|
|
||||||
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
|
|
||||||
]);
|
|
||||||
const matches: number[] = [];
|
|
||||||
rows.forEach((row, index) => {
|
|
||||||
const values = new Set(row.map((cell) => cell.value));
|
|
||||||
if ([...requiredColumns].every((column) => values.has(column))) {
|
|
||||||
matches.push(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHeaderLookup(cells: ExternalCsvCell[]) {
|
|
||||||
return new Map(cells.map((cell, index) => [cell.value, index]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIfcGuid(value: string) {
|
|
||||||
return /^[0-9A-Za-z_$]{22}$/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLineEnding(lineEndings: ExternalCsvLineEnding[]): ExternalCsvLineEnding {
|
|
||||||
if (lineEndings.length === 0) {
|
|
||||||
return "\r\n";
|
|
||||||
}
|
|
||||||
const unique = new Set(lineEndings);
|
|
||||||
if (unique.size !== 1) {
|
|
||||||
throw new ExternalCsvParseError(
|
|
||||||
"invalid-csv",
|
|
||||||
"CSV contains mixed line endings that cannot be preserved reliably."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lineEndings[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeCell(cell: ExternalCsvCell, delimiter: string) {
|
|
||||||
const mustQuote =
|
|
||||||
cell.wasQuoted ||
|
|
||||||
cell.value.includes(delimiter) ||
|
|
||||||
cell.value.includes('"') ||
|
|
||||||
cell.value.includes("\r") ||
|
|
||||||
cell.value.includes("\n");
|
|
||||||
if (!mustQuote) {
|
|
||||||
return cell.value;
|
|
||||||
}
|
|
||||||
return `"${cell.value.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSerializableDocument(document: ExternalCsvDocument) {
|
|
||||||
if (document.dialect.encoding !== "utf-8") {
|
|
||||||
throw new Error("Only UTF-8 external CSV documents can be serialized.");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
document.headerRowIndex < 0 ||
|
|
||||||
document.headerRowIndex >= document.rows.length ||
|
|
||||||
document.rows[document.headerRowIndex]?.classification !== "header"
|
|
||||||
) {
|
|
||||||
throw new Error("External CSV document has an invalid header row.");
|
|
||||||
}
|
|
||||||
document.rows.forEach((row, index) => {
|
|
||||||
if (row.index !== index) {
|
|
||||||
throw new Error("External CSV row indexes must be contiguous and stable.");
|
|
||||||
}
|
|
||||||
for (const cell of row.cells) {
|
|
||||||
if (typeof cell.value !== "string" || typeof cell.wasQuoted !== "boolean") {
|
|
||||||
throw new Error("External CSV document contains an invalid cell.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUtf8Bom(input: Uint8Array) {
|
|
||||||
return input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||||
|
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
|
||||||
import type {
|
import type {
|
||||||
ExternalCsvConfiguration,
|
ExternalXlsxDocument,
|
||||||
ExternalCsvDocument,
|
ExternalXlsxScalar,
|
||||||
} from "../csv/external-csv-contracts.js";
|
} from "../xlsx/external-xlsx-contracts.js";
|
||||||
|
|
||||||
export const externalModelSourceTypes = ["revit_csv"] as const;
|
export const externalModelSourceTypes = ["revit_xlsx"] as const;
|
||||||
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
|
export type ExternalModelSourceType = (typeof externalModelSourceTypes)[number];
|
||||||
|
|
||||||
export const externalImportKinds = ["initial", "follow_up"] as const;
|
export const externalImportKinds = ["initial", "follow_up"] as const;
|
||||||
|
|
@ -29,11 +30,11 @@ export interface ExternalImportBatchSnapshot {
|
||||||
importedAtIso: string;
|
importedAtIso: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
appliedProjectRevision: number;
|
objectCount: number;
|
||||||
configurationVersion: number;
|
appliedProjectRevision: number | null;
|
||||||
configurationSnapshot: ExternalCsvConfiguration;
|
configurationSnapshot: ExternalModelConfiguration | null;
|
||||||
originalContentBase64: string;
|
originalContentBase64: string | null;
|
||||||
document: ExternalCsvDocument;
|
document: ExternalXlsxDocument | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalRoomMappingSnapshot {
|
export interface ExternalRoomMappingSnapshot {
|
||||||
|
|
@ -50,24 +51,28 @@ export interface ExternalRoomMappingSnapshot {
|
||||||
|
|
||||||
export interface ExternalModelObjectSourceValues {
|
export interface ExternalModelObjectSourceValues {
|
||||||
rowNumber: number;
|
rowNumber: number;
|
||||||
roomNumber: string;
|
roomNumber: ExternalXlsxScalar;
|
||||||
roomName: string;
|
roomName: ExternalXlsxScalar;
|
||||||
familyAndType: string;
|
familyAndType: string;
|
||||||
selectionMarker: string;
|
selectionMarker: ExternalXlsxScalar;
|
||||||
circuitIdentifier: string;
|
socketCount: ExternalXlsxScalar;
|
||||||
power: string;
|
sourceRemark: ExternalXlsxScalar;
|
||||||
quantity: string | null;
|
circuitCombined: ExternalXlsxScalar;
|
||||||
additionalSourceValues: Record<string, string>;
|
boardName: ExternalXlsxScalar;
|
||||||
|
circuitIdentifier: ExternalXlsxScalar;
|
||||||
|
designation: ExternalXlsxScalar;
|
||||||
|
electricalPowerW: ExternalXlsxScalar;
|
||||||
|
voltageV: ExternalXlsxScalar;
|
||||||
|
currentA: ExternalXlsxScalar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalModelObjectPlanningValues {
|
export interface ExternalModelObjectPlanningValues {
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
internalDeviceType: string | null;
|
|
||||||
category: CircuitGroupCategory | null;
|
category: CircuitGroupCategory | null;
|
||||||
connectionKind: string | null;
|
connectionKind: string | null;
|
||||||
effectiveQuantity: number;
|
effectiveQuantity: number;
|
||||||
powerPerUnitW: number | null;
|
powerPerUnitW: number | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number | null;
|
||||||
cosPhi: number | null;
|
cosPhi: number | null;
|
||||||
costGroup: string | null;
|
costGroup: string | null;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
|
|
@ -80,7 +85,9 @@ export interface ExternalModelObjectSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
ifcGuid: string;
|
worksharingId: string;
|
||||||
|
ifcGuid: string | null;
|
||||||
|
deviceTypeId: string;
|
||||||
lastSeenImportBatchId: string;
|
lastSeenImportBatchId: string;
|
||||||
lastAcceptedImportBatchId: string;
|
lastAcceptedImportBatchId: string;
|
||||||
acceptedSourceValues: ExternalModelObjectSourceValues;
|
acceptedSourceValues: ExternalModelObjectSourceValues;
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
|
||||||
import type {
|
|
||||||
ExternalModelObjectPlanningValues,
|
|
||||||
ExternalModelObjectSourceValues,
|
|
||||||
} from "./external-model-contracts.js";
|
|
||||||
|
|
||||||
export type InitialExternalObjectIssue =
|
|
||||||
| "unknown-family-and-type"
|
|
||||||
| "invalid-power"
|
|
||||||
| "invalid-quantity"
|
|
||||||
| "missing-room";
|
|
||||||
|
|
||||||
export interface InitialExternalObjectProjection {
|
|
||||||
ifcGuid: string;
|
|
||||||
sourceValues: ExternalModelObjectSourceValues;
|
|
||||||
planningValues: ExternalModelObjectPlanningValues;
|
|
||||||
issues: InitialExternalObjectIssue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalObjectImportCandidate {
|
|
||||||
rowNumber: number;
|
|
||||||
ifcGuid: string;
|
|
||||||
roomNumber: string;
|
|
||||||
roomName: string;
|
|
||||||
familyAndType: string;
|
|
||||||
selectionMarker: string;
|
|
||||||
circuitIdentifier: string;
|
|
||||||
power: string;
|
|
||||||
quantity: string | null;
|
|
||||||
additionalSourceValues: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeExternalRoomPart(value: string): string {
|
|
||||||
return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExternalRoomKey(
|
|
||||||
roomNumber: string,
|
|
||||||
roomName: string
|
|
||||||
): string | null {
|
|
||||||
const normalizedNumber = normalizeExternalRoomPart(roomNumber);
|
|
||||||
if (normalizedNumber) return `number:${normalizedNumber}`;
|
|
||||||
const normalizedName = normalizeExternalRoomPart(roomName);
|
|
||||||
return normalizedName ? `name:${normalizedName}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function indexExternalObjectsByIfcGuid<T extends { ifcGuid: string }>(
|
|
||||||
objects: readonly T[]
|
|
||||||
): Map<string, T> {
|
|
||||||
const index = new Map<string, T>();
|
|
||||||
for (const object of objects) {
|
|
||||||
if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) {
|
|
||||||
throw new Error("IfcGUID must be non-empty and must not contain outer whitespace.");
|
|
||||||
}
|
|
||||||
if (index.has(object.ifcGuid)) {
|
|
||||||
throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`);
|
|
||||||
}
|
|
||||||
index.set(object.ifcGuid, object);
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function projectInitialExternalObject(
|
|
||||||
object: ExternalObjectImportCandidate,
|
|
||||||
configuration: ExternalCsvConfiguration
|
|
||||||
): InitialExternalObjectProjection {
|
|
||||||
const rule = configuration.familyTypeRules.find(
|
|
||||||
(candidate) => candidate.exactFamilyAndType === object.familyAndType
|
|
||||||
);
|
|
||||||
const issues: InitialExternalObjectIssue[] = [];
|
|
||||||
if (!rule) issues.push("unknown-family-and-type");
|
|
||||||
if (!createExternalRoomKey(object.roomNumber, object.roomName)) {
|
|
||||||
issues.push("missing-room");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedPower = parseConfiguredNumber(object.power, configuration);
|
|
||||||
if (object.power.trim() && parsedPower === null) issues.push("invalid-power");
|
|
||||||
|
|
||||||
let effectiveQuantity = 1;
|
|
||||||
if (rule?.quantityRule.kind === "fixed") {
|
|
||||||
effectiveQuantity = rule.quantityRule.quantity;
|
|
||||||
} else if (rule?.quantityRule.kind === "mapped-column") {
|
|
||||||
const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration);
|
|
||||||
if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) {
|
|
||||||
issues.push("invalid-quantity");
|
|
||||||
} else {
|
|
||||||
effectiveQuantity = parsedQuantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ifcGuid: object.ifcGuid,
|
|
||||||
sourceValues: {
|
|
||||||
rowNumber: object.rowNumber,
|
|
||||||
roomNumber: object.roomNumber,
|
|
||||||
roomName: object.roomName,
|
|
||||||
familyAndType: object.familyAndType,
|
|
||||||
selectionMarker: object.selectionMarker,
|
|
||||||
circuitIdentifier: object.circuitIdentifier,
|
|
||||||
power: object.power,
|
|
||||||
quantity: object.quantity,
|
|
||||||
additionalSourceValues: { ...object.additionalSourceValues },
|
|
||||||
},
|
|
||||||
planningValues: {
|
|
||||||
displayName: rule
|
|
||||||
? resolveDisplayName(rule.displayNameSuggestion, object)
|
|
||||||
: null,
|
|
||||||
internalDeviceType: rule?.internalDeviceType ?? null,
|
|
||||||
category: rule?.category ?? null,
|
|
||||||
connectionKind: rule?.connectionKind ?? null,
|
|
||||||
effectiveQuantity,
|
|
||||||
powerPerUnitW:
|
|
||||||
parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: null,
|
|
||||||
costGroup: null,
|
|
||||||
remark: null,
|
|
||||||
},
|
|
||||||
issues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfiguredNumber(
|
|
||||||
value: string,
|
|
||||||
configuration: ExternalCsvConfiguration
|
|
||||||
): number | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const normalized = configuration.decimalSeparator === ","
|
|
||||||
? trimmed.replace(",", ".")
|
|
||||||
: trimmed;
|
|
||||||
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null;
|
|
||||||
const parsed = Number(normalized);
|
|
||||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDisplayName(
|
|
||||||
suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"],
|
|
||||||
object: ExternalObjectImportCandidate
|
|
||||||
): string | null {
|
|
||||||
if (suggestion === null) return null;
|
|
||||||
if (suggestion.kind === "fixed") return suggestion.value;
|
|
||||||
if (suggestion.kind === "selection-marker") {
|
|
||||||
return object.selectionMarker.trim() || null;
|
|
||||||
}
|
|
||||||
return object.familyAndType.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type {
|
||||||
ExternalCsvConfiguration,
|
ExternalCsvConfiguration,
|
||||||
ExternalCsvDialect,
|
ExternalCsvDialect,
|
||||||
} from "../external-model/csv/external-csv-contracts";
|
} from "../external-model/csv/external-csv-contracts";
|
||||||
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
|
|
||||||
|
|
||||||
export interface ExternalCsvConfigurationSnapshotDto {
|
export interface ExternalCsvConfigurationSnapshotDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -52,9 +51,80 @@ export interface ExternalCsvPreviewDto {
|
||||||
suspectRowNumbers: number[];
|
suspectRowNumbers: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExternalInitialImportPlanDto = ReturnType<
|
export interface ExternalInitialImportPlanDto {
|
||||||
typeof createExternalInitialImportPlan
|
configurationVersion: number;
|
||||||
>;
|
fileName: string;
|
||||||
|
sha256: string;
|
||||||
|
byteLength: number;
|
||||||
|
rowCount: number;
|
||||||
|
objectCount: number;
|
||||||
|
passthroughCount: number;
|
||||||
|
suspectObjectCount: number;
|
||||||
|
suspectRowNumbers: number[];
|
||||||
|
sourceRooms: Array<{
|
||||||
|
sourceRoomKey: string;
|
||||||
|
roomNumber: string;
|
||||||
|
roomName: string;
|
||||||
|
objectCount: number;
|
||||||
|
suggestedRoomId: string | null;
|
||||||
|
matchStatus: "exact-number" | "ambiguous-number" | "new";
|
||||||
|
candidateRoomIds: string[];
|
||||||
|
}>;
|
||||||
|
familyGroups: Array<{
|
||||||
|
familyAndType: string;
|
||||||
|
objectCount: number;
|
||||||
|
classified: boolean;
|
||||||
|
category: string | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
internalDeviceType: string | null;
|
||||||
|
}>;
|
||||||
|
issueCounts: {
|
||||||
|
unknownFamilyAndType: number;
|
||||||
|
invalidPower: number;
|
||||||
|
invalidQuantity: number;
|
||||||
|
missingRoom: number;
|
||||||
|
};
|
||||||
|
objects: Array<{
|
||||||
|
ifcGuid: string;
|
||||||
|
sourceRoomKey: string | null;
|
||||||
|
sourceValues: ExternalCsvPreviewObjectDto;
|
||||||
|
suggestedPlanningValues: {
|
||||||
|
displayName: string | null;
|
||||||
|
internalDeviceType: string | null;
|
||||||
|
category: CircuitGroupCategory | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
effectiveQuantity: number;
|
||||||
|
powerPerUnitW: number | null;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
costGroup: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
};
|
||||||
|
issues: string[];
|
||||||
|
}>;
|
||||||
|
existing: {
|
||||||
|
floors: Array<{ id: string; name: string; sortOrder: number }>;
|
||||||
|
rooms: Array<{
|
||||||
|
id: string;
|
||||||
|
floorId: string | null;
|
||||||
|
roomNumber: string;
|
||||||
|
roomName: string;
|
||||||
|
}>;
|
||||||
|
distributionBoards: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
floorId: string | null;
|
||||||
|
}>;
|
||||||
|
projectDevices: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
category: string | null;
|
||||||
|
connectionKind: string | null;
|
||||||
|
powerPerUnit: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApplyExternalInitialImportInput {
|
export interface ApplyExternalInitialImportInput {
|
||||||
expectedRevision: number;
|
expectedRevision: number;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
|
||||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||||
import { UserRepository } from "../../db/repositories/user.repository.js";
|
import { UserRepository } from "../../db/repositories/user.repository.js";
|
||||||
|
|
@ -36,8 +36,8 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||||
export const projectRepository = new ProjectRepository(db);
|
export const projectRepository = new ProjectRepository(db);
|
||||||
export const roomRepository = new RoomRepository(db);
|
export const roomRepository = new RoomRepository(db);
|
||||||
export const externalCsvConfigurationRepository =
|
export const externalModelConfigurationRepository =
|
||||||
new ExternalCsvConfigurationRepository(db);
|
new ExternalModelConfigurationRepository(db);
|
||||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||||
export const userRepository = new UserRepository(db);
|
export const userRepository = new UserRepository(db);
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
|
||||||
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
|
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
|
||||||
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
|
||||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||||
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
||||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
|
|
@ -71,10 +70,8 @@ export const projectSettingsProjectCommandStore =
|
||||||
export const projectStateRestoreCommandStore =
|
export const projectStateRestoreCommandStore =
|
||||||
new ProjectStateRestoreCommandRepository(db);
|
new ProjectStateRestoreCommandRepository(db);
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const externalCsvConfigurationProjectCommandStore =
|
export const externalModelConfigurationProjectCommandStore =
|
||||||
new ExternalCsvConfigurationProjectCommandRepository(db);
|
new ExternalModelConfigurationProjectCommandRepository(db);
|
||||||
export const externalInitialImportProjectCommandStore =
|
|
||||||
new ExternalInitialImportProjectCommandRepository(db);
|
|
||||||
export const externalObjectRowAssignmentProjectCommandStore =
|
export const externalObjectRowAssignmentProjectCommandStore =
|
||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
|
|
@ -106,8 +103,7 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
circuitGroupSubtreeProjectCommandStore,
|
circuitGroupSubtreeProjectCommandStore,
|
||||||
circuitProtectionProjectCommandStore,
|
circuitProtectionProjectCommandStore,
|
||||||
projectHistoryStore,
|
projectHistoryStore,
|
||||||
externalCsvConfigurationProjectCommandStore,
|
externalModelConfigurationProjectCommandStore,
|
||||||
externalInitialImportProjectCommandStore,
|
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore,
|
externalObjectNewRowProjectCommandStore,
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore,
|
||||||
|
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
import type { Request, Response } from "express";
|
|
||||||
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
|
||||||
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
|
|
||||||
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
|
||||||
import { createExternalInitialImportPlan } from "../../external-model/application/external-initial-import-plan.js";
|
|
||||||
import { buildExternalInitialImportTarget } from "../../external-model/application/external-initial-import-target.js";
|
|
||||||
import { createExternalCircuitListObjects } from "../../external-model/application/external-circuit-list-objects.js";
|
|
||||||
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
|
|
||||||
import {
|
|
||||||
createEmptyExternalModelState,
|
|
||||||
createExternalInitialImportProjectCommand,
|
|
||||||
} from "../../domain/models/external-initial-import-project-command.model.js";
|
|
||||||
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
|
|
||||||
import {
|
|
||||||
previewExternalCsvSchema,
|
|
||||||
planExternalInitialImportSchema,
|
|
||||||
applyExternalInitialImportSchema,
|
|
||||||
updateExternalCsvConfigurationSchema,
|
|
||||||
} from "../../shared/validation/external-csv.schemas.js";
|
|
||||||
import {
|
|
||||||
distributionBoardRepository,
|
|
||||||
externalCsvConfigurationRepository,
|
|
||||||
externalModelStateRepository,
|
|
||||||
floorRepository,
|
|
||||||
projectDeviceRepository,
|
|
||||||
roomRepository,
|
|
||||||
circuitListRepository,
|
|
||||||
} from "../composition/application-repositories.js";
|
|
||||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
|
||||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
|
||||||
|
|
||||||
export function getExternalCsvConfiguration(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const result = externalCsvConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!result.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
return res.json({ configuration: result.configuration });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExternalCircuitListObjects(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const { circuitListId } = req.params;
|
|
||||||
if (typeof circuitListId !== "string" || !circuitListId.trim()) {
|
|
||||||
return res.status(400).json({ error: "Invalid circuitListId" });
|
|
||||||
}
|
|
||||||
const circuitList = await circuitListRepository.findById(projectId, circuitListId);
|
|
||||||
if (!circuitList) {
|
|
||||||
return res.status(404).json({ error: "Circuit list not found" });
|
|
||||||
}
|
|
||||||
const state = externalModelStateRepository.getByProject(projectId);
|
|
||||||
if (!state.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
return res.json(createExternalCircuitListObjects({
|
|
||||||
state: state.state,
|
|
||||||
distributionBoardId: circuitList.distributionBoardId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateExternalCsvConfiguration(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
assertExternalCsvConfiguration(parsed.data.configuration);
|
|
||||||
const current = externalCsvConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!current.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
const target = {
|
|
||||||
id: current.configuration?.id ?? crypto.randomUUID(),
|
|
||||||
projectId,
|
|
||||||
configurationVersion:
|
|
||||||
(current.configuration?.configurationVersion ?? 0) + 1,
|
|
||||||
configuration: parsed.data.configuration,
|
|
||||||
};
|
|
||||||
const result = projectCommandService.executeUser({
|
|
||||||
projectId,
|
|
||||||
expectedRevision: parsed.data.expectedRevision,
|
|
||||||
description: "Revit-CSV-Konfiguration bearbeiten",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(
|
|
||||||
current.configuration,
|
|
||||||
target
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return res.json({ ...result, configuration: target });
|
|
||||||
} catch (error) {
|
|
||||||
return respondWithProjectCommandError(error, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function previewExternalCsv(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const parsed = previewExternalCsvSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
const stored = externalCsvConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!stored.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
if (!stored.configuration) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
|
|
||||||
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const bytes = decodeBase64(parsed.data.contentBase64);
|
|
||||||
return res.json(
|
|
||||||
createExternalCsvPreview({
|
|
||||||
fileName: parsed.data.fileName,
|
|
||||||
bytes,
|
|
||||||
configuration: stored.configuration.configuration,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ExternalCsvParseError) {
|
|
||||||
return res.status(400).json({ error: error.message, code: error.code });
|
|
||||||
}
|
|
||||||
return res.status(400).json({
|
|
||||||
error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function planExternalInitialImport(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const parsed = planExternalInitialImportSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
const stored = externalCsvConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!stored.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
if (!stored.configuration) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
|
|
||||||
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const externalState = externalModelStateRepository.getByProject(projectId);
|
|
||||||
if (externalState.state.source !== null) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Für dieses Projekt wurde bereits ein Revit-Modell importiert.",
|
|
||||||
code: "EXTERNAL_INITIAL_IMPORT_ALREADY_APPLIED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const bytes = decodeBase64(parsed.data.contentBase64);
|
|
||||||
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
|
|
||||||
floorRepository.listByProject(projectId),
|
|
||||||
roomRepository.listByProject(projectId),
|
|
||||||
distributionBoardRepository.listByProject(projectId),
|
|
||||||
projectDeviceRepository.listByProject(projectId),
|
|
||||||
]);
|
|
||||||
return res.json(createExternalInitialImportPlan({
|
|
||||||
fileName: parsed.data.fileName,
|
|
||||||
bytes,
|
|
||||||
configurationVersion: stored.configuration.configurationVersion,
|
|
||||||
configuration: stored.configuration.configuration,
|
|
||||||
floors,
|
|
||||||
rooms,
|
|
||||||
distributionBoards,
|
|
||||||
projectDevices,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ExternalCsvParseError) {
|
|
||||||
return res.status(400).json({ error: error.message, code: error.code });
|
|
||||||
}
|
|
||||||
return res.status(400).json({
|
|
||||||
error: error instanceof Error ? error.message : "Erstimport-Planung fehlgeschlagen.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyExternalInitialImport(req: Request, res: Response) {
|
|
||||||
const projectId = getProjectId(req, res);
|
|
||||||
if (!projectId) return;
|
|
||||||
const parsed = applyExternalInitialImportSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
const stored = externalCsvConfigurationRepository.getByProject(projectId);
|
|
||||||
if (!stored.projectExists) {
|
|
||||||
return res.status(404).json({ error: "Project not found" });
|
|
||||||
}
|
|
||||||
if (!stored.configuration) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
|
|
||||||
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stored.configuration.configurationVersion !==
|
|
||||||
parsed.data.expectedConfigurationVersion
|
|
||||||
) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Die Revit-CSV-Konfiguration wurde seit der Planung geändert.",
|
|
||||||
code: "EXTERNAL_CSV_CONFIGURATION_CHANGED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const bytes = decodeBase64(parsed.data.contentBase64);
|
|
||||||
const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([
|
|
||||||
floorRepository.listByProject(projectId),
|
|
||||||
roomRepository.listByProject(projectId),
|
|
||||||
distributionBoardRepository.listByProject(projectId),
|
|
||||||
projectDeviceRepository.listByProject(projectId),
|
|
||||||
]);
|
|
||||||
const plan = createExternalInitialImportPlan({
|
|
||||||
fileName: parsed.data.fileName,
|
|
||||||
bytes,
|
|
||||||
configurationVersion: stored.configuration.configurationVersion,
|
|
||||||
configuration: stored.configuration.configuration,
|
|
||||||
floors,
|
|
||||||
rooms,
|
|
||||||
distributionBoards,
|
|
||||||
projectDevices,
|
|
||||||
});
|
|
||||||
if (plan.sha256 !== parsed.data.expectedSha256) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Die CSV-Datei stimmt nicht mehr mit der geprüften Planung überein.",
|
|
||||||
code: "EXTERNAL_CSV_FILE_CHANGED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const target = buildExternalInitialImportTarget({
|
|
||||||
projectId,
|
|
||||||
expectedRevision: parsed.data.expectedRevision,
|
|
||||||
sourceName: parsed.data.sourceName,
|
|
||||||
importedAtIso: new Date().toISOString(),
|
|
||||||
configurationVersion: stored.configuration.configurationVersion,
|
|
||||||
configuration: stored.configuration.configuration,
|
|
||||||
originalContentBase64: parsed.data.contentBase64,
|
|
||||||
document: parseExternalCsv(bytes, stored.configuration.configuration),
|
|
||||||
plan,
|
|
||||||
roomDecisions: parsed.data.roomDecisions,
|
|
||||||
familyProjectDeviceDecisions:
|
|
||||||
parsed.data.familyProjectDeviceDecisions,
|
|
||||||
createId: () => crypto.randomUUID(),
|
|
||||||
});
|
|
||||||
const result = projectCommandService.executeUser({
|
|
||||||
projectId,
|
|
||||||
expectedRevision: parsed.data.expectedRevision,
|
|
||||||
description: `Revit-Erstimport ${parsed.data.fileName}`,
|
|
||||||
command: createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
target
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return res.status(201).json({
|
|
||||||
...result,
|
|
||||||
import: {
|
|
||||||
sourceId: target.source!.id,
|
|
||||||
importBatchId: target.importBatches[0].id,
|
|
||||||
objectCount: target.objects.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ExternalCsvParseError) {
|
|
||||||
return res.status(400).json({ error: error.message, code: error.code });
|
|
||||||
}
|
|
||||||
return respondWithProjectCommandError(error, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64(value: string) {
|
|
||||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
|
|
||||||
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
|
|
||||||
}
|
|
||||||
const bytes = Buffer.from(value, "base64");
|
|
||||||
if (bytes.toString("base64") !== value) {
|
|
||||||
throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert.");
|
|
||||||
}
|
|
||||||
if (bytes.length > 18 * 1024 * 1024) {
|
|
||||||
throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB.");
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectId(req: Request, res: Response) {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
|
||||||
res.status(400).json({ error: "Invalid projectId" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return projectId;
|
|
||||||
}
|
|
||||||
|
|
@ -53,14 +53,6 @@ import {
|
||||||
importProjectTransfer,
|
importProjectTransfer,
|
||||||
importProjectTransferAsNewProject,
|
importProjectTransferAsNewProject,
|
||||||
} from "../controllers/project-transfer.controller.js";
|
} from "../controllers/project-transfer.controller.js";
|
||||||
import {
|
|
||||||
getExternalCsvConfiguration,
|
|
||||||
applyExternalInitialImport,
|
|
||||||
previewExternalCsv,
|
|
||||||
planExternalInitialImport,
|
|
||||||
updateExternalCsvConfiguration,
|
|
||||||
getExternalCircuitListObjects,
|
|
||||||
} from "../controllers/external-csv.controller.js";
|
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
|
|
@ -70,21 +62,6 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
|
|
||||||
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
|
|
||||||
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
|
|
||||||
projectRouter.post(
|
|
||||||
"/:projectId/external-csv/initial-import/plan",
|
|
||||||
planExternalInitialImport
|
|
||||||
);
|
|
||||||
projectRouter.get(
|
|
||||||
"/:projectId/circuit-lists/:circuitListId/external-objects",
|
|
||||||
getExternalCircuitListObjects
|
|
||||||
);
|
|
||||||
projectRouter.post(
|
|
||||||
"/:projectId/external-csv/initial-import/apply",
|
|
||||||
applyExternalInitialImport
|
|
||||||
);
|
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// Base64 length of the 18 MiB transport limit documented for CSV uploads
|
|
||||||
// (18 * 1024 * 1024 bytes, evenly divisible by 3, so ceil(N/3)*4 with no
|
|
||||||
// padding). Keep in sync with the controller's raw byte-length check.
|
|
||||||
export const MAX_CSV_CONTENT_BASE64_LENGTH = 25_165_824;
|
|
||||||
|
|
||||||
export const updateExternalCsvConfigurationSchema = z
|
|
||||||
.object({
|
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
|
||||||
configuration: z.unknown(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const previewExternalCsvSchema = z
|
|
||||||
.object({
|
|
||||||
fileName: z.string().trim().min(1).max(255),
|
|
||||||
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const planExternalInitialImportSchema = previewExternalCsvSchema;
|
|
||||||
|
|
||||||
export const applyExternalInitialImportSchema = z.object({
|
|
||||||
expectedRevision: z.number().int().nonnegative(),
|
|
||||||
expectedConfigurationVersion: z.number().int().positive(),
|
|
||||||
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
||||||
fileName: z.string().trim().min(1).max(255),
|
|
||||||
contentBase64: z.string().min(1).max(MAX_CSV_CONTENT_BASE64_LENGTH),
|
|
||||||
sourceName: z.string().trim().min(1).max(200),
|
|
||||||
roomDecisions: z.array(z.object({
|
|
||||||
sourceRoomKey: z.string().trim().min(1),
|
|
||||||
roomId: z.string().trim().min(1).nullable(),
|
|
||||||
defaultDistributionBoardId: z.string().trim().min(1).nullable(),
|
|
||||||
}).strict()),
|
|
||||||
familyProjectDeviceDecisions: z.array(z.object({
|
|
||||||
familyAndType: z.string(),
|
|
||||||
projectDeviceId: z.string().trim().min(1).nullable(),
|
|
||||||
}).strict()),
|
|
||||||
}).strict();
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createExternalCircuitListObjects } from "../src/external-model/application/external-circuit-list-objects.js";
|
|
||||||
import type {
|
|
||||||
ExternalModelObjectSnapshot,
|
|
||||||
ExternalModelStateSnapshot,
|
|
||||||
} from "../src/external-model/domain/external-model-contracts.js";
|
|
||||||
|
|
||||||
function object(input: {
|
|
||||||
id: string;
|
|
||||||
distributionBoardId: string;
|
|
||||||
circuitDeviceRowId: string | null;
|
|
||||||
roomNumber: string;
|
|
||||||
}): ExternalModelObjectSnapshot {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
ifcGuid: `ifc-${input.id}`,
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
|
||||||
acceptedSourceValues: {
|
|
||||||
rowNumber: 2,
|
|
||||||
roomNumber: input.roomNumber,
|
|
||||||
roomName: "Büro",
|
|
||||||
familyAndType: "Steckdose: Standard",
|
|
||||||
selectionMarker: `S-${input.id}`,
|
|
||||||
circuitIdentifier: "-2F1.1",
|
|
||||||
power: "120",
|
|
||||||
quantity: "1",
|
|
||||||
additionalSourceValues: {},
|
|
||||||
},
|
|
||||||
planningValues: {
|
|
||||||
displayName: "Steckdose",
|
|
||||||
internalDeviceType: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
connectionKind: "socket",
|
|
||||||
effectiveQuantity: 1,
|
|
||||||
powerPerUnitW: 120,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: null,
|
|
||||||
costGroup: null,
|
|
||||||
remark: null,
|
|
||||||
},
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId: input.distributionBoardId === "board-1" ? "mapping-1" : null,
|
|
||||||
distributionBoardId: input.distributionBoardId,
|
|
||||||
linkedProjectDeviceId: null,
|
|
||||||
circuitDeviceRowId: input.circuitDeviceRowId,
|
|
||||||
presenceStatus: "present",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("external circuit-list object projection", () => {
|
|
||||||
it("returns only the board objects with unassigned objects first", () => {
|
|
||||||
const state: ExternalModelStateSnapshot = {
|
|
||||||
source: {
|
|
||||||
id: "source-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Revit-Gesamtmodell",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
},
|
|
||||||
importBatches: [],
|
|
||||||
roomMappings: [{
|
|
||||||
id: "mapping-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
normalizedSourceRoomKey: "number:101",
|
|
||||||
sourceFloorName: "EG",
|
|
||||||
sourceRoomNumber: "101",
|
|
||||||
sourceRoomName: "Besprechung",
|
|
||||||
roomId: "room-1",
|
|
||||||
defaultDistributionBoardId: "board-1",
|
|
||||||
}],
|
|
||||||
objects: [
|
|
||||||
object({ id: "assigned", distributionBoardId: "board-1", circuitDeviceRowId: "row-1", roomNumber: "102" }),
|
|
||||||
object({ id: "unassigned", distributionBoardId: "board-1", circuitDeviceRowId: null, roomNumber: "101" }),
|
|
||||||
object({ id: "other-board", distributionBoardId: "board-2", circuitDeviceRowId: null, roomNumber: "001" }),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createExternalCircuitListObjects({
|
|
||||||
state,
|
|
||||||
distributionBoardId: "board-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.sourceName, "Revit-Gesamtmodell");
|
|
||||||
assert.equal(result.objectCount, 2);
|
|
||||||
assert.equal(result.unassignedObjectCount, 1);
|
|
||||||
assert.deepEqual(result.objects.map(({ id }) => id), ["unassigned", "assigned"]);
|
|
||||||
assert.equal(result.objects[0].sourceRoomName, "Besprechung");
|
|
||||||
assert.equal(result.objects[0].roomId, "room-1");
|
|
||||||
assert.equal(result.objects[0].sourceCircuitIdentifier, "-2F1.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty projection before the optional Revit import", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
createExternalCircuitListObjects({
|
|
||||||
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
|
|
||||||
distributionBoardId: "board-1",
|
|
||||||
}),
|
|
||||||
{ sourceName: null, objectCount: 0, unassignedObjectCount: 0, objects: [] }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
applyExternalInitialImportSchema,
|
|
||||||
MAX_CSV_CONTENT_BASE64_LENGTH,
|
|
||||||
planExternalInitialImportSchema,
|
|
||||||
previewExternalCsvSchema,
|
|
||||||
updateExternalCsvConfigurationSchema,
|
|
||||||
} from "../src/shared/validation/external-csv.schemas.js";
|
|
||||||
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
|
||||||
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
describe("external CSV API contracts", () => {
|
|
||||||
it("accepts strict update and preview request envelopes", () => {
|
|
||||||
assert.equal(
|
|
||||||
updateExternalCsvConfigurationSchema.safeParse({
|
|
||||||
expectedRevision: 4,
|
|
||||||
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
|
||||||
}).success,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
previewExternalCsvSchema.safeParse({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "YWJj",
|
|
||||||
}).success,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
planExternalInitialImportSchema.safeParse({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "YWJj",
|
|
||||||
}).success,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
applyExternalInitialImportSchema.safeParse({
|
|
||||||
expectedRevision: 5,
|
|
||||||
expectedConfigurationVersion: 2,
|
|
||||||
expectedSha256: "a".repeat(64),
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "YWJj",
|
|
||||||
sourceName: "Revit-Gesamtmodell",
|
|
||||||
roomDecisions: [],
|
|
||||||
familyProjectDeviceDecisions: [],
|
|
||||||
}).success,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects stale-shaped, oversized and unknown request fields", () => {
|
|
||||||
assert.equal(
|
|
||||||
updateExternalCsvConfigurationSchema.safeParse({
|
|
||||||
expectedRevision: -1,
|
|
||||||
configuration: {},
|
|
||||||
}).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
previewExternalCsvSchema.safeParse({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "YWJj",
|
|
||||||
apply: true,
|
|
||||||
}).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
planExternalInitialImportSchema.safeParse({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "YWJj",
|
|
||||||
decisions: [],
|
|
||||||
}).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
previewExternalCsvSchema.safeParse({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
|
|
||||||
}).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import {
|
|
||||||
createDatabaseContext,
|
|
||||||
type DatabaseContext,
|
|
||||||
} from "../src/db/database-context.js";
|
|
||||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
|
|
||||||
import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js";
|
|
||||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
|
||||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
|
||||||
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
|
||||||
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
|
|
||||||
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
|
|
||||||
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
|
||||||
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, {
|
|
||||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
|
||||||
});
|
|
||||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshot(version = 1) {
|
|
||||||
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
|
|
||||||
configuration.familyTypeRules.push({
|
|
||||||
exactFamilyAndType: "Steckdose: Standard",
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: null,
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: { kind: "fixed", quantity: 1 },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: "external-config-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
configurationVersion: version,
|
|
||||||
configuration,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("external CSV configuration project command", () => {
|
|
||||||
it("persists insert and update with exact inverse commands", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
|
|
||||||
const initial = snapshot();
|
|
||||||
const inserted = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
description: "Revit-CSV-Konfiguration anlegen",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
|
|
||||||
});
|
|
||||||
assert.equal(inserted.revision.revisionNumber, 1);
|
|
||||||
assert.deepEqual(readConfiguration(context), initial);
|
|
||||||
assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null });
|
|
||||||
|
|
||||||
const target = structuredClone(initial);
|
|
||||||
target.configurationVersion = 2;
|
|
||||||
target.configuration.decimalSeparator = ".";
|
|
||||||
const updated = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(initial, target),
|
|
||||||
});
|
|
||||||
assert.equal(updated.revision.revisionNumber, 2);
|
|
||||||
assert.deepEqual(readConfiguration(context), target);
|
|
||||||
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects stale state and cross-project targets without a partial revision", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
|
|
||||||
const initial = snapshot();
|
|
||||||
repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)),
|
|
||||||
}),
|
|
||||||
/changed before update/
|
|
||||||
);
|
|
||||||
const foreign = snapshot(2);
|
|
||||||
foreign.projectId = "project-2";
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign),
|
|
||||||
}),
|
|
||||||
/identity must remain stable|different project/
|
|
||||||
);
|
|
||||||
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
|
|
||||||
|
|
||||||
const skippedVersion = snapshot(3);
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion),
|
|
||||||
}),
|
|
||||||
/version must advance by one/
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial),
|
|
||||||
}),
|
|
||||||
/did not change/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes configuration in snapshots and remaps it for project duplicates", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
|
|
||||||
const initial = snapshot();
|
|
||||||
repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = readProjectStateSnapshot(context.db, "project-1")?.state;
|
|
||||||
assert.deepEqual(state?.externalCsvConfiguration, initial);
|
|
||||||
const transferRepository = new ProjectTransferRepository(context.db);
|
|
||||||
const transfer = transferRepository.exportProject("project-1");
|
|
||||||
assert.ok(transfer);
|
|
||||||
const duplicate = transferRepository.importDuplicate(transfer);
|
|
||||||
const duplicatedConfiguration = context.db
|
|
||||||
.select()
|
|
||||||
.from(externalCsvConfigurations)
|
|
||||||
.where(eq(externalCsvConfigurations.projectId, duplicate.projectId))
|
|
||||||
.get();
|
|
||||||
assert.ok(duplicatedConfiguration);
|
|
||||||
assert.notEqual(duplicatedConfiguration.id, initial.id);
|
|
||||||
assert.equal(duplicatedConfiguration.projectId, duplicate.projectId);
|
|
||||||
assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes projects without configuration from unknown projects", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const reader = new ExternalCsvConfigurationRepository(context.db);
|
|
||||||
assert.deepEqual(reader.getByProject("project-1"), {
|
|
||||||
projectExists: true,
|
|
||||||
configuration: null,
|
|
||||||
});
|
|
||||||
assert.deepEqual(reader.getByProject("missing"), {
|
|
||||||
projectExists: false,
|
|
||||||
configuration: null,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores and undoes the complete configuration through snapshot history", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
|
|
||||||
const initial = snapshot();
|
|
||||||
configurationRepository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
|
|
||||||
});
|
|
||||||
const configuredState = readProjectStateSnapshot(context.db, "project-1");
|
|
||||||
assert.ok(configuredState);
|
|
||||||
configurationRepository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(initial, null),
|
|
||||||
});
|
|
||||||
const emptyState = readProjectStateSnapshot(context.db, "project-1");
|
|
||||||
assert.ok(emptyState);
|
|
||||||
|
|
||||||
const restoreRepository = new ProjectStateRestoreCommandRepository(context.db);
|
|
||||||
const restored = restoreRepository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 2,
|
|
||||||
source: "user",
|
|
||||||
command: createProjectStateRestoreCommand(
|
|
||||||
emptyState.payloadSha256,
|
|
||||||
configuredState.state
|
|
||||||
),
|
|
||||||
});
|
|
||||||
assert.deepEqual(readConfiguration(context), initial);
|
|
||||||
|
|
||||||
restoreRepository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 3,
|
|
||||||
source: "undo",
|
|
||||||
historyTargetChangeSetId: restored.revision.changeSetId,
|
|
||||||
command: restored.inverse,
|
|
||||||
});
|
|
||||||
assert.equal(readConfiguration(context), null);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function readConfiguration(context: DatabaseContext) {
|
|
||||||
return context.db
|
|
||||||
.select()
|
|
||||||
.from(externalCsvConfigurations)
|
|
||||||
.where(eq(externalCsvConfigurations.projectId, "project-1"))
|
|
||||||
.get() ?? null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
|
||||||
import {
|
|
||||||
externalCsvTestConfiguration,
|
|
||||||
firstIfcGuid,
|
|
||||||
quotedRevitCsv,
|
|
||||||
utf8Bytes,
|
|
||||||
} from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
describe("external CSV preview", () => {
|
|
||||||
it("projects transport statistics and mapped object source values without writes", () => {
|
|
||||||
const configuration = structuredClone(externalCsvTestConfiguration);
|
|
||||||
configuration.additionalSourceMappings.push({
|
|
||||||
sourceColumn: "Raumname",
|
|
||||||
targetField: "sourceRoomLabel",
|
|
||||||
});
|
|
||||||
// The additional mapping intentionally needs a distinct source column in
|
|
||||||
// persisted configuration, so use a separately named synthetic header.
|
|
||||||
configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert";
|
|
||||||
const csv = quotedRevitCsv
|
|
||||||
.replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"')
|
|
||||||
.replaceAll(';"2";"0Ab', ';"2";"A";"0Ab')
|
|
||||||
.replaceAll(';"1";"1Ab', ';"1";"B";"1Ab')
|
|
||||||
.replaceAll(';"1";""', ';"1";"C";""')
|
|
||||||
.replaceAll(';"";""\r\n', ';"";"";""\r\n');
|
|
||||||
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: "export.csv",
|
|
||||||
bytes: utf8Bytes(csv, true),
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(preview.fileName, "export.csv");
|
|
||||||
assert.equal(preview.headerRowNumber, 2);
|
|
||||||
assert.equal(preview.rowCount, 7);
|
|
||||||
assert.equal(preview.objectCount, 2);
|
|
||||||
assert.equal(preview.passthroughCount, 2);
|
|
||||||
assert.equal(preview.nonEmptyPassthroughCount, 1);
|
|
||||||
assert.equal(preview.suspectObjectCount, 1);
|
|
||||||
assert.deepEqual(preview.suspectRowNumbers, [6]);
|
|
||||||
assert.match(preview.sha256, /^[a-f0-9]{64}$/);
|
|
||||||
assert.deepEqual(preview.objects[0], {
|
|
||||||
rowNumber: 4,
|
|
||||||
ifcGuid: firstIfcGuid,
|
|
||||||
roomNumber: "01/101",
|
|
||||||
roomName: "Technik",
|
|
||||||
familyAndType: "Steckdose: Doppelsteckdose",
|
|
||||||
selectionMarker: "Arbeitsplatz",
|
|
||||||
circuitIdentifier: "UV_AV_01-2F1",
|
|
||||||
power: "120,5",
|
|
||||||
quantity: "2",
|
|
||||||
additionalSourceValues: { sourceRoomLabel: "A" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
ExternalCsvParseError,
|
|
||||||
parseExternalCsv,
|
|
||||||
serializeExternalCsv,
|
|
||||||
} from "../src/external-model/csv/external-csv-transport.js";
|
|
||||||
import {
|
|
||||||
externalCsvTestConfiguration,
|
|
||||||
firstIfcGuid,
|
|
||||||
quotedRevitCsv,
|
|
||||||
utf8Bytes,
|
|
||||||
} from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
describe("external CSV transport", () => {
|
|
||||||
it("detects the observed Revit dialect and classifies every row", () => {
|
|
||||||
const document = parseExternalCsv(
|
|
||||||
utf8Bytes(quotedRevitCsv, true),
|
|
||||||
externalCsvTestConfiguration
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(document.dialect, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
hasBom: true,
|
|
||||||
delimiter: ";",
|
|
||||||
lineEnding: "\r\n",
|
|
||||||
quoteCharacter: '"',
|
|
||||||
quoteAllFields: true,
|
|
||||||
hasTrailingLineEnding: true,
|
|
||||||
});
|
|
||||||
assert.equal(document.headerRowIndex, 1);
|
|
||||||
assert.deepEqual(
|
|
||||||
document.rows.map((row) => row.classification),
|
|
||||||
[
|
|
||||||
"metadata",
|
|
||||||
"header",
|
|
||||||
"passthrough",
|
|
||||||
"object",
|
|
||||||
"passthrough",
|
|
||||||
"suspect-object",
|
|
||||||
"object",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert.equal(document.rows[6].cells[2].value, 'Büro "Nord"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips BOM, CRLF, quotation, order and cell values byte for byte", () => {
|
|
||||||
const source = utf8Bytes(quotedRevitCsv, true);
|
|
||||||
const document = parseExternalCsv(source, externalCsvTestConfiguration);
|
|
||||||
|
|
||||||
assert.deepEqual(serializeExternalCsv(document), source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses exports without an optional quantity column", () => {
|
|
||||||
const configuration = {
|
|
||||||
...externalCsvTestConfiguration,
|
|
||||||
columns: {
|
|
||||||
...externalCsvTestConfiguration.columns,
|
|
||||||
quantity: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const csv = [
|
|
||||||
'"Projekt-Export";"";"";"";"";"";""',
|
|
||||||
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"IfcGUID"',
|
|
||||||
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Standard";"Arbeitsplatz";"100";"${firstIfcGuid}"`,
|
|
||||||
].join("\r\n") + "\r\n";
|
|
||||||
|
|
||||||
const document = parseExternalCsv(utf8Bytes(csv, true), configuration);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
document.rows.filter((row) => row.classification === "object").length,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves embedded delimiters and line endings inside quoted cells", () => {
|
|
||||||
const csv = quotedRevitCsv.replace(
|
|
||||||
'"Steckdose: Doppelsteckdose"',
|
|
||||||
'"Steckdose; Doppel\r\nsteckdose"'
|
|
||||||
);
|
|
||||||
const source = utf8Bytes(csv, true);
|
|
||||||
const document = parseExternalCsv(source, externalCsvTestConfiguration);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
document.rows[3].cells[3].value,
|
|
||||||
"Steckdose; Doppel\r\nsteckdose"
|
|
||||||
);
|
|
||||||
assert.deepEqual(serializeExternalCsv(document), source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks missing and ambiguous configured headers", () => {
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(quotedRevitCsv.replace("IfcGUID", "Andere ID")),
|
|
||||||
"header-not-found"
|
|
||||||
);
|
|
||||||
const duplicatedHeader = quotedRevitCsv.replace(
|
|
||||||
'"";"";"";"";"";"";"";""',
|
|
||||||
quotedRevitCsv.split("\r\n")[1]
|
|
||||||
);
|
|
||||||
assertParseError(utf8Bytes(duplicatedHeader), "ambiguous-header");
|
|
||||||
|
|
||||||
const duplicateConfiguredColumn = quotedRevitCsv.replace(
|
|
||||||
'"Stromkreis";"Raumnummer"',
|
|
||||||
'"Stromkreis";"Raumnummer";"Raumnummer"'
|
|
||||||
);
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(duplicateConfiguredColumn),
|
|
||||||
"ambiguous-header"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks invalid and duplicate non-empty IfcGUIDs", () => {
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, "not-an-ifc-guid")),
|
|
||||||
"invalid-ifc-guid"
|
|
||||||
);
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, ` ${firstIfcGuid}`)),
|
|
||||||
"invalid-ifc-guid"
|
|
||||||
);
|
|
||||||
const duplicate = quotedRevitCsv.replace(
|
|
||||||
"1AbCdEfGhIjKlMnOpQrStu",
|
|
||||||
firstIfcGuid
|
|
||||||
);
|
|
||||||
assertParseError(utf8Bytes(duplicate), "duplicate-ifc-guid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks invalid UTF-8, unterminated quotes and mixed row endings", () => {
|
|
||||||
assertParseError(new Uint8Array([0xff, 0xfe, 0xfd]), "invalid-encoding");
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(quotedRevitCsv.replace(/"\r\n$/, "\r\n")),
|
|
||||||
"invalid-csv"
|
|
||||||
);
|
|
||||||
assertParseError(
|
|
||||||
utf8Bytes(quotedRevitCsv.replace("\r\n", "\n")),
|
|
||||||
"invalid-csv"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function assertParseError(input: Uint8Array, code: ExternalCsvParseError["code"]) {
|
|
||||||
assert.throws(
|
|
||||||
() => parseExternalCsv(input, externalCsvTestConfiguration),
|
|
||||||
(error: unknown) => error instanceof ExternalCsvParseError && error.code === code
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js";
|
|
||||||
import { buildExternalInitialImportTarget } from "../src/external-model/application/external-initial-import-target.js";
|
|
||||||
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
|
||||||
import {
|
|
||||||
externalCsvTestConfiguration,
|
|
||||||
firstIfcGuid,
|
|
||||||
quotedRevitCsv,
|
|
||||||
utf8Bytes,
|
|
||||||
} from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
describe("external initial import plan", () => {
|
|
||||||
it("groups source rooms and exposes exact room and classification suggestions", () => {
|
|
||||||
const configuration = structuredClone(externalCsvTestConfiguration);
|
|
||||||
configuration.familyTypeRules.push({
|
|
||||||
exactFamilyAndType: "Steckdose: Doppelsteckdose",
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: { kind: "mapped-column" },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = createExternalInitialImportPlan({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes: utf8Bytes(quotedRevitCsv, true),
|
|
||||||
configurationVersion: 4,
|
|
||||||
configuration,
|
|
||||||
floors: [{ id: "floor-1", name: "EG", sortOrder: 10 }],
|
|
||||||
rooms: [{
|
|
||||||
id: "room-1",
|
|
||||||
floorId: "floor-1",
|
|
||||||
roomNumber: "01/101",
|
|
||||||
roomName: "Technik Bestand",
|
|
||||||
}],
|
|
||||||
distributionBoards: [{ id: "board-1", name: "UV 1", floorId: "floor-1" }],
|
|
||||||
projectDevices: [{
|
|
||||||
id: "device-1",
|
|
||||||
name: "socket",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
category: "single_phase",
|
|
||||||
connectionKind: "socket",
|
|
||||||
powerPerUnit: 0.12,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(plan.configurationVersion, 4);
|
|
||||||
assert.equal(plan.objectCount, 2);
|
|
||||||
assert.equal(plan.sourceRooms.length, 2);
|
|
||||||
assert.deepEqual(
|
|
||||||
plan.sourceRooms.map((room) => ({
|
|
||||||
number: room.roomNumber,
|
|
||||||
status: room.matchStatus,
|
|
||||||
suggested: room.suggestedRoomId,
|
|
||||||
})),
|
|
||||||
[
|
|
||||||
{ number: "01/101", status: "exact-number", suggested: "room-1" },
|
|
||||||
{ number: "01/103", status: "new", suggested: null },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert.equal(plan.familyGroups.length, 2);
|
|
||||||
assert.equal(plan.issueCounts.unknownFamilyAndType, 1);
|
|
||||||
assert.equal(plan.issueCounts.invalidPower, 0);
|
|
||||||
assert.equal(plan.issueCounts.invalidQuantity, 0);
|
|
||||||
assert.equal(plan.objects[0].ifcGuid, firstIfcGuid);
|
|
||||||
assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2);
|
|
||||||
assert.equal(plan.existing.distributionBoards[0].id, "board-1");
|
|
||||||
assert.equal(plan.existing.projectDevices[0].id, "device-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds a stable row-free target only after every family is classified", () => {
|
|
||||||
const configuration = structuredClone(externalCsvTestConfiguration);
|
|
||||||
for (const exactFamilyAndType of [
|
|
||||||
"Steckdose: Doppelsteckdose",
|
|
||||||
"Steckdose: Standard",
|
|
||||||
]) {
|
|
||||||
configuration.familyTypeRules.push({
|
|
||||||
exactFamilyAndType,
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: exactFamilyAndType.includes("Doppel")
|
|
||||||
? { kind: "mapped-column" }
|
|
||||||
: { kind: "fixed", quantity: 1 },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
|
||||||
const plan = createExternalInitialImportPlan({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes,
|
|
||||||
configurationVersion: 2,
|
|
||||||
configuration,
|
|
||||||
floors: [],
|
|
||||||
rooms: [],
|
|
||||||
distributionBoards: [],
|
|
||||||
projectDevices: [],
|
|
||||||
});
|
|
||||||
let id = 0;
|
|
||||||
const target = buildExternalInitialImportTarget({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 7,
|
|
||||||
sourceName: "Revit-Gesamtmodell",
|
|
||||||
importedAtIso: "2026-08-02T17:00:00.000Z",
|
|
||||||
configurationVersion: 2,
|
|
||||||
configuration,
|
|
||||||
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
|
||||||
document: parseExternalCsv(bytes, configuration),
|
|
||||||
plan,
|
|
||||||
roomDecisions: plan.sourceRooms.map((room) => ({
|
|
||||||
sourceRoomKey: room.sourceRoomKey,
|
|
||||||
roomId: null,
|
|
||||||
defaultDistributionBoardId: null,
|
|
||||||
})),
|
|
||||||
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
|
|
||||||
familyAndType: group.familyAndType,
|
|
||||||
projectDeviceId: null,
|
|
||||||
})),
|
|
||||||
createId: () => `generated-${++id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(target.importBatches[0].appliedProjectRevision, 8);
|
|
||||||
assert.equal(target.importBatches[0].configurationVersion, 2);
|
|
||||||
assert.equal(target.objects.length, 2);
|
|
||||||
assert.ok(target.objects.every((object) => object.circuitDeviceRowId === null));
|
|
||||||
assert.ok(target.objects.every((object) => object.overriddenFields.length === 0));
|
|
||||||
assert.throws(
|
|
||||||
() => buildExternalInitialImportTarget({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 7,
|
|
||||||
sourceName: "Revit-Gesamtmodell",
|
|
||||||
importedAtIso: "2026-08-02T17:00:00.000Z",
|
|
||||||
configurationVersion: 2,
|
|
||||||
configuration,
|
|
||||||
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
|
||||||
document: parseExternalCsv(bytes, configuration),
|
|
||||||
plan,
|
|
||||||
roomDecisions: [],
|
|
||||||
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
|
|
||||||
familyAndType: group.familyAndType,
|
|
||||||
projectDeviceId: null,
|
|
||||||
})),
|
|
||||||
createId: () => "unused",
|
|
||||||
}),
|
|
||||||
/Quellraum-Entscheidungen/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
|
||||||
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
|
||||||
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
|
||||||
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
|
||||||
import {
|
|
||||||
createEmptyExternalModelState,
|
|
||||||
createExternalInitialImportProjectCommand,
|
|
||||||
} from "../src/domain/models/external-initial-import-project-command.model.js";
|
|
||||||
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
|
||||||
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
|
||||||
import type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
|
||||||
import {
|
|
||||||
createExternalRoomKey,
|
|
||||||
projectInitialExternalObject,
|
|
||||||
} from "../src/external-model/domain/external-model-matching.js";
|
|
||||||
import {
|
|
||||||
externalCsvTestConfiguration,
|
|
||||||
quotedRevitCsv,
|
|
||||||
utf8Bytes,
|
|
||||||
} from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
function createTestContext() {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
const configuration = configuredCsv();
|
|
||||||
context.db.insert(externalCsvConfigurations).values({
|
|
||||||
id: "config-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
configurationVersion: 1,
|
|
||||||
configuration,
|
|
||||||
}).run();
|
|
||||||
return { context, configuration };
|
|
||||||
}
|
|
||||||
|
|
||||||
function configuredCsv() {
|
|
||||||
const configuration = structuredClone(externalCsvTestConfiguration);
|
|
||||||
configuration.familyTypeRules.push(
|
|
||||||
{
|
|
||||||
exactFamilyAndType: "Steckdose: Doppelsteckdose",
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: { kind: "mapped-column" },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
exactFamilyAndType: "Steckdose: Standard",
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: { kind: "fixed", quantity: 1 },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialState(
|
|
||||||
configuration: ReturnType<typeof configuredCsv>
|
|
||||||
): ExternalModelStateSnapshot {
|
|
||||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
const roomKeys = [...new Set(preview.objects.map((object) =>
|
|
||||||
createExternalRoomKey(object.roomNumber, object.roomName)
|
|
||||||
).filter((key): key is string => key !== null))];
|
|
||||||
const roomMappings = roomKeys.map((key, index) => {
|
|
||||||
const object = preview.objects.find(
|
|
||||||
(candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key
|
|
||||||
)!;
|
|
||||||
return {
|
|
||||||
id: `mapping-${index + 1}`,
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
normalizedSourceRoomKey: key,
|
|
||||||
sourceFloorName: null,
|
|
||||||
sourceRoomNumber: object.roomNumber,
|
|
||||||
sourceRoomName: object.roomName,
|
|
||||||
roomId: null,
|
|
||||||
defaultDistributionBoardId: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const mappingByKey = new Map(
|
|
||||||
roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id])
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
source: {
|
|
||||||
id: "source-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Revit-Gesamtmodell",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
},
|
|
||||||
importBatches: [{
|
|
||||||
id: "batch-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
importKind: "initial",
|
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
|
||||||
fileName: "revit.csv",
|
|
||||||
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
||||||
appliedProjectRevision: 1,
|
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: configuration,
|
|
||||||
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
|
||||||
document: parseExternalCsv(bytes, configuration),
|
|
||||||
}],
|
|
||||||
roomMappings,
|
|
||||||
objects: preview.objects.map((candidate, index) => {
|
|
||||||
const projected = projectInitialExternalObject(candidate, configuration);
|
|
||||||
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
|
||||||
return {
|
|
||||||
id: `object-${index + 1}`,
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
ifcGuid: projected.ifcGuid,
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
|
||||||
acceptedSourceValues: projected.sourceValues,
|
|
||||||
planningValues: projected.planningValues,
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!,
|
|
||||||
distributionBoardId: null,
|
|
||||||
linkedProjectDeviceId: null,
|
|
||||||
circuitDeviceRowId: null,
|
|
||||||
presenceStatus: "present",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("external initial import project command", () => {
|
|
||||||
it("commits the complete import and supports persisted undo and redo", () => {
|
|
||||||
const { context, configuration } = createTestContext();
|
|
||||||
try {
|
|
||||||
const target = initialState(configuration);
|
|
||||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
|
||||||
const inserted = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
description: "Revit-Erstimport übernehmen",
|
|
||||||
command: createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
target
|
|
||||||
),
|
|
||||||
});
|
|
||||||
assert.equal(inserted.revision.revisionNumber, 1);
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.objects.length,
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.objects.every((object) => object.circuitDeviceRowId === null)
|
|
||||||
);
|
|
||||||
|
|
||||||
const undone = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "undo",
|
|
||||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
|
||||||
command: inserted.inverse,
|
|
||||||
});
|
|
||||||
assert.equal(undone.revision.revisionNumber, 2);
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.source,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 2,
|
|
||||||
source: "redo",
|
|
||||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
|
||||||
command: undone.inverse,
|
|
||||||
});
|
|
||||||
const redone = new ExternalModelStateRepository(context.db)
|
|
||||||
.getByProject("project-1").state;
|
|
||||||
assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]);
|
|
||||||
assert.equal(redone.importBatches[0].configurationVersion, 1);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a target state with zero classified objects before writing anything", () => {
|
|
||||||
// assertPopulatedInitialState requires at least one object, so a CSV
|
|
||||||
// that classified none must be rejected at command construction, not
|
|
||||||
// reach the persistence layer's bulk insert.
|
|
||||||
const { context, configuration } = createTestContext();
|
|
||||||
try {
|
|
||||||
const target: ExternalModelStateSnapshot = {
|
|
||||||
...initialState(configuration),
|
|
||||||
roomMappings: [],
|
|
||||||
objects: [],
|
|
||||||
};
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
target
|
|
||||||
),
|
|
||||||
/at least one object/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects changed state, checksum drift and row assignment", () => {
|
|
||||||
const { context, configuration } = createTestContext();
|
|
||||||
try {
|
|
||||||
const target = initialState(configuration);
|
|
||||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
|
||||||
const assigned = structuredClone(target);
|
|
||||||
assigned.objects[0].circuitDeviceRowId = "row-1";
|
|
||||||
assert.throws(
|
|
||||||
() => createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
assigned
|
|
||||||
),
|
|
||||||
/must not assign circuit device rows/
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidHash = structuredClone(target);
|
|
||||||
invalidHash.importBatches[0].sha256 = "b".repeat(64);
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
invalidHash
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
/checksum is invalid/
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.source,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back all external rows when late history persistence fails", () => {
|
|
||||||
const { context, configuration } = createTestContext();
|
|
||||||
try {
|
|
||||||
context.sqlite.exec(`
|
|
||||||
CREATE TRIGGER fail_external_import_history
|
|
||||||
BEFORE INSERT ON project_change_sets
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'late history failure');
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
|
||||||
assert.throws(
|
|
||||||
() => repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createExternalInitialImportProjectCommand(
|
|
||||||
createEmptyExternalModelState(),
|
|
||||||
initialState(configuration)
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
/late history failure/
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.source,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
132
tests/external-model-configuration.test.ts
Normal file
132
tests/external-model-configuration.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
|
||||||
|
import { ExternalModelConfigurationRepository } from "../src/db/repositories/external-model-configuration.repository.js";
|
||||||
|
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||||
|
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertExternalModelConfiguration,
|
||||||
|
createDefaultExternalModelConfiguration,
|
||||||
|
} from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
|
|
||||||
|
const deviceTypeId = "10000000-0000-4000-8000-000000000001";
|
||||||
|
const ruleId = "20000000-0000-4000-8000-000000000001";
|
||||||
|
|
||||||
|
function configuration() {
|
||||||
|
const value = createDefaultExternalModelConfiguration();
|
||||||
|
value.deviceTypes.push({
|
||||||
|
id: deviceTypeId,
|
||||||
|
name: "Steckdose",
|
||||||
|
category: "single_phase",
|
||||||
|
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
|
||||||
|
});
|
||||||
|
value.familyTypeRules.push({
|
||||||
|
id: ruleId,
|
||||||
|
exactFamilyAndType: "Steckdose: Standard",
|
||||||
|
deviceTypeId,
|
||||||
|
quantityRule: "socketCount",
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(projectId = "project-1") {
|
||||||
|
return { id: "config-1", projectId, configuration: configuration() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("external model configuration", () => {
|
||||||
|
it("validates the single Worksharing-ID mapping and exact family rules", () => {
|
||||||
|
const value = configuration();
|
||||||
|
assert.doesNotThrow(() => assertExternalModelConfiguration(value));
|
||||||
|
assert.equal("idColumn" in value.layout, false);
|
||||||
|
|
||||||
|
const duplicate = structuredClone(value);
|
||||||
|
duplicate.columns.ifcGuid = duplicate.columns.worksharingId;
|
||||||
|
assert.throws(() => assertExternalModelConfiguration(duplicate), /nicht eindeutig/);
|
||||||
|
|
||||||
|
const unknownType = structuredClone(value);
|
||||||
|
unknownType.familyTypeRules[0]!.deviceTypeId = "30000000-0000-4000-8000-000000000001";
|
||||||
|
assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists changes without an independent configuration revision", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ExternalModelConfigurationProjectCommandRepository(context.db);
|
||||||
|
const initial = snapshot();
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalModelConfigurationUpdateProjectCommand(null, initial),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.revision.revisionNumber, 1);
|
||||||
|
assert.equal("configurationVersion" in initial, false);
|
||||||
|
|
||||||
|
const target = structuredClone(initial);
|
||||||
|
target.configuration.deviceTypes[0]!.name = "Steckdose allgemein";
|
||||||
|
const updated = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
|
||||||
|
});
|
||||||
|
assert.equal(updated.revision.revisionNumber, 2);
|
||||||
|
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
|
||||||
|
}),
|
||||||
|
/zwischenzeitlich ge.*ndert/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes missing projects and remaps configuration identities on duplicate", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const reader = new ExternalModelConfigurationRepository(context.db);
|
||||||
|
assert.deepEqual(reader.getByProject("project-1"), { projectExists: true, configuration: null });
|
||||||
|
assert.deepEqual(reader.getByProject("missing"), { projectExists: false, configuration: null });
|
||||||
|
|
||||||
|
new ExternalModelConfigurationProjectCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalModelConfigurationUpdateProjectCommand(null, snapshot()),
|
||||||
|
});
|
||||||
|
const transferRepository = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = transferRepository.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
const duplicate = transferRepository.importDuplicate(transfer);
|
||||||
|
const copied = context.db.select().from(externalModelConfigurations)
|
||||||
|
.where(eq(externalModelConfigurations.projectId, duplicate.projectId)).get();
|
||||||
|
assert.ok(copied);
|
||||||
|
assert.notEqual(copied.id, "config-1");
|
||||||
|
assert.notEqual(copied.configuration.deviceTypes[0]!.id, deviceTypeId);
|
||||||
|
assert.equal(
|
||||||
|
copied.configuration.familyTypeRules[0]!.deviceTypeId,
|
||||||
|
copied.configuration.deviceTypes[0]!.id
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
|
||||||
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
|
||||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
|
||||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
|
||||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
|
||||||
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
|
||||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
|
||||||
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
|
|
||||||
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
|
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
|
||||||
import { rooms } from "../src/db/schema/rooms.js";
|
|
||||||
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
|
|
||||||
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
|
|
||||||
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
|
||||||
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
|
||||||
import {
|
|
||||||
createExternalRoomKey,
|
|
||||||
indexExternalObjectsByIfcGuid,
|
|
||||||
projectInitialExternalObject,
|
|
||||||
} from "../src/external-model/domain/external-model-matching.js";
|
|
||||||
import {
|
|
||||||
externalCsvTestConfiguration,
|
|
||||||
firstIfcGuid,
|
|
||||||
quotedRevitCsv,
|
|
||||||
utf8Bytes,
|
|
||||||
} from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
function configuredCsv() {
|
|
||||||
const configuration = structuredClone(externalCsvTestConfiguration);
|
|
||||||
configuration.familyTypeRules.push({
|
|
||||||
exactFamilyAndType: "Steckdose: Doppelsteckdose",
|
|
||||||
internalDeviceType: "Steckdose",
|
|
||||||
connectionKind: "socket",
|
|
||||||
category: "single_phase",
|
|
||||||
quantityRule: { kind: "mapped-column" },
|
|
||||||
displayNameSuggestion: { kind: "selection-marker" },
|
|
||||||
});
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("external model matching", () => {
|
|
||||||
it("normalizes source rooms and rejects duplicate IFC identities", () => {
|
|
||||||
assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101");
|
|
||||||
assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD");
|
|
||||||
assert.equal(createExternalRoomKey(" ", " "), null);
|
|
||||||
assert.throws(
|
|
||||||
() => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]),
|
|
||||||
/Duplicate IfcGUID/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives initial planning values from an exact family rule", () => {
|
|
||||||
const configuration = configuredCsv();
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes: utf8Bytes(quotedRevitCsv, true),
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
const projected = projectInitialExternalObject(preview.objects[0], configuration);
|
|
||||||
|
|
||||||
assert.deepEqual(projected.issues, []);
|
|
||||||
assert.equal(projected.ifcGuid, firstIfcGuid);
|
|
||||||
assert.equal(projected.planningValues.displayName, "Arbeitsplatz");
|
|
||||||
assert.equal(projected.planningValues.category, "single_phase");
|
|
||||||
assert.equal(projected.planningValues.effectiveQuantity, 2);
|
|
||||||
assert.equal(projected.planningValues.powerPerUnitW, 120.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps unclassified or invalid values visible as issues", () => {
|
|
||||||
const projected = projectInitialExternalObject(
|
|
||||||
{
|
|
||||||
rowNumber: 4,
|
|
||||||
ifcGuid: firstIfcGuid,
|
|
||||||
roomNumber: "",
|
|
||||||
roomName: "",
|
|
||||||
familyAndType: "Unbekannt",
|
|
||||||
selectionMarker: "",
|
|
||||||
circuitIdentifier: "",
|
|
||||||
power: "12,3,4",
|
|
||||||
quantity: "0",
|
|
||||||
additionalSourceValues: {},
|
|
||||||
},
|
|
||||||
externalCsvTestConfiguration
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(projected.issues, [
|
|
||||||
"unknown-family-and-type",
|
|
||||||
"missing-room",
|
|
||||||
"invalid-power",
|
|
||||||
]);
|
|
||||||
assert.equal(projected.planningValues.powerPerUnitW, null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("external model state repository", () => {
|
|
||||||
it("reads a deterministic complete external state without changing project data", () => {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
try {
|
|
||||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
context.db.insert(externalModelSources).values({
|
|
||||||
id: "source-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Revit-Gesamtmodell",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
}).run();
|
|
||||||
const configuration = configuredCsv();
|
|
||||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
|
||||||
const document = parseExternalCsv(bytes, configuration);
|
|
||||||
context.db.insert(externalImportBatches).values({
|
|
||||||
id: "batch-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
importKind: "initial",
|
|
||||||
importedAtIso: "2026-08-02T15:30:00.000Z",
|
|
||||||
fileName: "revit.csv",
|
|
||||||
sha256: "hash",
|
|
||||||
appliedProjectRevision: 1,
|
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: configuration,
|
|
||||||
originalBytes: Buffer.from(bytes),
|
|
||||||
document,
|
|
||||||
}).run();
|
|
||||||
context.db.insert(externalRoomMappings).values({
|
|
||||||
id: "mapping-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
normalizedSourceRoomKey: "number:01/101",
|
|
||||||
sourceFloorName: null,
|
|
||||||
sourceRoomNumber: "01/101",
|
|
||||||
sourceRoomName: "Technik",
|
|
||||||
roomId: null,
|
|
||||||
defaultDistributionBoardId: null,
|
|
||||||
}).run();
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
const projected = projectInitialExternalObject(preview.objects[0], configuration);
|
|
||||||
context.db.insert(externalModelObjects).values({
|
|
||||||
id: "object-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
ifcGuid: projected.ifcGuid,
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
|
||||||
acceptedSourceValues: projected.sourceValues,
|
|
||||||
planningValues: projected.planningValues,
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId: "mapping-1",
|
|
||||||
distributionBoardId: null,
|
|
||||||
linkedProjectDeviceId: null,
|
|
||||||
circuitDeviceRowId: null,
|
|
||||||
presenceStatus: "present",
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
const result = new ExternalModelStateRepository(context.db).getByProject("project-1");
|
|
||||||
assert.equal(result.projectExists, true);
|
|
||||||
assert.equal(result.state.source?.id, "source-1");
|
|
||||||
assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64"));
|
|
||||||
assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101");
|
|
||||||
assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid);
|
|
||||||
assert.equal(result.state.objects[0].circuitDeviceRowId, null);
|
|
||||||
assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => context.db.insert(externalModelObjects).values({
|
|
||||||
...context.db.select().from(externalModelObjects).get()!,
|
|
||||||
id: "object-2",
|
|
||||||
}).run(),
|
|
||||||
/UNIQUE constraint failed/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("distinguishes an empty project from an unknown project", () => {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
try {
|
|
||||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
const repository = new ExternalModelStateRepository(context.db);
|
|
||||||
assert.deepEqual(repository.getByProject("project-1"), {
|
|
||||||
projectExists: true,
|
|
||||||
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
|
|
||||||
});
|
|
||||||
assert.equal(repository.getByProject("missing").projectExists, false);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("external model snapshot and transfer", () => {
|
|
||||||
it("captures, restores and undoes the complete external state", () => {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
try {
|
|
||||||
insertLinkedExternalFixture(context.db);
|
|
||||||
const populated = readProjectStateSnapshot(context.db, "project-1");
|
|
||||||
assert.ok(populated);
|
|
||||||
assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion);
|
|
||||||
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
|
|
||||||
|
|
||||||
context.db.delete(externalModelSources).run();
|
|
||||||
const empty = readProjectStateSnapshot(context.db, "project-1");
|
|
||||||
assert.ok(empty);
|
|
||||||
assert.equal(empty.state.externalModel.source, null);
|
|
||||||
|
|
||||||
const repository = new ProjectStateRestoreCommandRepository(context.db);
|
|
||||||
const restored = repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createProjectStateRestoreCommand(
|
|
||||||
empty.payloadSha256,
|
|
||||||
populated.state
|
|
||||||
),
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.objects[0].linkedProjectDeviceId,
|
|
||||||
"device-1"
|
|
||||||
);
|
|
||||||
|
|
||||||
repository.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 1,
|
|
||||||
source: "undo",
|
|
||||||
historyTargetChangeSetId: restored.revision.changeSetId,
|
|
||||||
command: restored.inverse,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
|
||||||
.state.source,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remaps every internal external-model link for a project duplicate", () => {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
try {
|
|
||||||
insertLinkedExternalFixture(context.db);
|
|
||||||
const transferRepository = new ProjectTransferRepository(context.db);
|
|
||||||
const transfer = transferRepository.exportProject("project-1");
|
|
||||||
assert.ok(transfer);
|
|
||||||
const duplicate = transferRepository.importDuplicate(transfer);
|
|
||||||
const copied = new ExternalModelStateRepository(context.db)
|
|
||||||
.getByProject(duplicate.projectId).state;
|
|
||||||
|
|
||||||
assert.notEqual(copied.source?.id, "source-1");
|
|
||||||
assert.notEqual(copied.importBatches[0].id, "batch-1");
|
|
||||||
assert.notEqual(copied.roomMappings[0].id, "mapping-1");
|
|
||||||
assert.notEqual(copied.objects[0].id, "object-1");
|
|
||||||
assert.equal(copied.objects[0].ifcGuid, firstIfcGuid);
|
|
||||||
assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id);
|
|
||||||
assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id);
|
|
||||||
assert.notEqual(copied.objects[0].distributionBoardId, "board-1");
|
|
||||||
assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1");
|
|
||||||
assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1");
|
|
||||||
assert.equal(
|
|
||||||
copied.importBatches[0].originalContentBase64,
|
|
||||||
Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64")
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseContext>["db"]) {
|
|
||||||
database.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
|
||||||
database.insert(floors).values({
|
|
||||||
id: "floor-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "EG",
|
|
||||||
sortOrder: 10,
|
|
||||||
}).run();
|
|
||||||
database.insert(rooms).values({
|
|
||||||
id: "room-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
floorId: "floor-1",
|
|
||||||
roomNumber: "01/101",
|
|
||||||
roomName: "Technik",
|
|
||||||
}).run();
|
|
||||||
database.insert(projectDevices).values({
|
|
||||||
id: "device-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "socket",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
phaseType: "single_phase",
|
|
||||||
connectionKind: "socket",
|
|
||||||
costGroup: null,
|
|
||||||
category: "single_phase",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.12,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: null,
|
|
||||||
remark: null,
|
|
||||||
voltageV: 230,
|
|
||||||
}).run();
|
|
||||||
database.insert(distributionBoards).values({
|
|
||||||
id: "board-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "UV 1",
|
|
||||||
floorId: "floor-1",
|
|
||||||
supplyType: "AV",
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
}).run();
|
|
||||||
database.insert(circuitLists).values({
|
|
||||||
id: "list-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
distributionBoardId: "board-1",
|
|
||||||
name: "UV 1 Stromkreisliste",
|
|
||||||
}).run();
|
|
||||||
database.insert(circuitSections).values({
|
|
||||||
id: "section-1",
|
|
||||||
circuitListId: "list-1",
|
|
||||||
key: "single_phase",
|
|
||||||
displayName: "1-phasig 1",
|
|
||||||
prefix: "-2F1.",
|
|
||||||
sortOrder: 10,
|
|
||||||
category: "single_phase",
|
|
||||||
groupNumber: 1,
|
|
||||||
}).run();
|
|
||||||
database.insert(circuits).values({
|
|
||||||
id: "circuit-1",
|
|
||||||
circuitListId: "list-1",
|
|
||||||
sectionId: "section-1",
|
|
||||||
equipmentIdentifier: "-2F1.1",
|
|
||||||
displayName: "Steckdosen",
|
|
||||||
sortOrder: 10,
|
|
||||||
voltage: 230,
|
|
||||||
isReserve: 0,
|
|
||||||
}).run();
|
|
||||||
database.insert(circuitDeviceRows).values({
|
|
||||||
id: "row-1",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
linkedProjectDeviceId: "device-1",
|
|
||||||
sortOrder: 10,
|
|
||||||
name: "socket",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
phaseType: "single_phase",
|
|
||||||
connectionKind: "socket",
|
|
||||||
costGroup: null,
|
|
||||||
category: "single_phase",
|
|
||||||
level: null,
|
|
||||||
roomId: "room-1",
|
|
||||||
roomNumberSnapshot: "01/101",
|
|
||||||
roomNameSnapshot: "Technik",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.12,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: null,
|
|
||||||
remark: null,
|
|
||||||
overriddenFields: null,
|
|
||||||
}).run();
|
|
||||||
|
|
||||||
database.insert(externalModelSources).values({
|
|
||||||
id: "source-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Revit-Gesamtmodell",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
}).run();
|
|
||||||
const configuration = configuredCsv();
|
|
||||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
|
||||||
database.insert(externalImportBatches).values({
|
|
||||||
id: "batch-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
importKind: "initial",
|
|
||||||
importedAtIso: "2026-08-02T15:30:00.000Z",
|
|
||||||
fileName: "revit.csv",
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
appliedProjectRevision: 0,
|
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: configuration,
|
|
||||||
originalBytes: Buffer.from(bytes),
|
|
||||||
document: parseExternalCsv(bytes, configuration),
|
|
||||||
}).run();
|
|
||||||
database.insert(externalRoomMappings).values({
|
|
||||||
id: "mapping-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
normalizedSourceRoomKey: "number:01/101",
|
|
||||||
sourceFloorName: "EG",
|
|
||||||
sourceRoomNumber: "01/101",
|
|
||||||
sourceRoomName: "Technik",
|
|
||||||
roomId: "room-1",
|
|
||||||
defaultDistributionBoardId: "board-1",
|
|
||||||
}).run();
|
|
||||||
const preview = createExternalCsvPreview({
|
|
||||||
fileName: "revit.csv",
|
|
||||||
bytes,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
const projected = projectInitialExternalObject(preview.objects[0], configuration);
|
|
||||||
database.insert(externalModelObjects).values({
|
|
||||||
id: "object-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
ifcGuid: projected.ifcGuid,
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
|
||||||
acceptedSourceValues: projected.sourceValues,
|
|
||||||
planningValues: projected.planningValues,
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId: "mapping-1",
|
|
||||||
distributionBoardId: "board-1",
|
|
||||||
linkedProjectDeviceId: "device-1",
|
|
||||||
circuitDeviceRowId: "row-1",
|
|
||||||
presenceStatus: "present",
|
|
||||||
}).run();
|
|
||||||
}
|
|
||||||
87
tests/external-model-xlsx-foundation.test.ts
Normal file
87
tests/external-model-xlsx-foundation.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
|
|
||||||
|
const lowerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff";
|
||||||
|
const upperId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-FFFFFFFF";
|
||||||
|
|
||||||
|
function fullBatch(id: string, importedAtIso: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId: "p",
|
||||||
|
sourceId: "s",
|
||||||
|
importKind: "follow_up" as const,
|
||||||
|
importedAtIso,
|
||||||
|
fileName: `${id}.xlsx`,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
objectCount: 1,
|
||||||
|
appliedProjectRevision: 3,
|
||||||
|
configurationSnapshot: createDefaultExternalModelConfiguration(),
|
||||||
|
originalContentBase64: "AA==",
|
||||||
|
document: {
|
||||||
|
sheetName: "Bauteilliste",
|
||||||
|
headerRowNumber: 1,
|
||||||
|
firstDataRowNumber: 3,
|
||||||
|
firstDataColumnNumber: 1,
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
issues: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Linear XLSX external model foundation", () => {
|
||||||
|
it("keeps only the newest batch complete", () => {
|
||||||
|
const newest = fullBatch("new", "2026-02-01T00:00:00.000Z");
|
||||||
|
const older = {
|
||||||
|
...fullBatch("old", "2026-01-01T00:00:00.000Z"),
|
||||||
|
appliedProjectRevision: null,
|
||||||
|
configurationSnapshot: null,
|
||||||
|
originalContentBase64: null,
|
||||||
|
document: null,
|
||||||
|
};
|
||||||
|
assert.doesNotThrow(() => parseExternalModelStateSnapshot({
|
||||||
|
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
|
||||||
|
importBatches: [older, newest],
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
}));
|
||||||
|
assert.throws(() => parseExternalModelStateSnapshot({
|
||||||
|
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
|
||||||
|
importBatches: [newest, fullBatch("newer", "2026-03-01T00:00:00.000Z")],
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
}), /Nur der letzte Importbatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares Worksharing-IDs exactly and allows duplicate IfcGUID values", () => {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
try {
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.sqlite.exec(`
|
||||||
|
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
|
||||||
|
INSERT INTO external_model_sources (id, project_id, name) VALUES ('s', 'p', 'Revit');
|
||||||
|
INSERT INTO external_import_batches
|
||||||
|
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, object_count)
|
||||||
|
VALUES ('b', 'p', 's', 'initial', '2026-01-01T00:00:00.000Z', 'model.xlsx', '${"a".repeat(64)}', 2);
|
||||||
|
`);
|
||||||
|
const insert = context.sqlite.prepare(`
|
||||||
|
INSERT INTO external_model_objects
|
||||||
|
(id, project_id, source_id, worksharing_id, ifc_guid, device_type_id,
|
||||||
|
last_seen_import_batch_id, last_accepted_import_batch_id,
|
||||||
|
accepted_source_values, planning_values, overridden_fields)
|
||||||
|
VALUES (?, 'p', 's', ?, 'same-IfcGUID', 'type', 'b', 'b', '{}', '{}', '[]')
|
||||||
|
`);
|
||||||
|
insert.run("o1", lowerId);
|
||||||
|
insert.run("o2", upperId);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2);
|
||||||
|
assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -31,7 +31,6 @@ import {
|
||||||
externalObjectAssignToNewCircuitCommandType,
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
||||||
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||||
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
|
|
||||||
function createFixture(initialObjectRowId: string | null = "row-1") {
|
function createFixture(initialObjectRowId: string | null = "row-1") {
|
||||||
const context = createDatabaseContext(":memory:");
|
const context = createDatabaseContext(":memory:");
|
||||||
|
|
@ -63,20 +62,16 @@ function createFixture(initialObjectRowId: string | null = "row-1") {
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run();
|
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run();
|
||||||
database.insert(externalImportBatches).values({
|
database.insert(externalImportBatches).values({
|
||||||
id: "batch-1",
|
id: "batch-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
importKind: "initial",
|
importKind: "initial",
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
fileName: "revit.csv",
|
fileName: "revit.xlsx",
|
||||||
sha256: "a".repeat(64),
|
sha256: "a".repeat(64),
|
||||||
appliedProjectRevision: 0,
|
objectCount: 1,
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: externalCsvTestConfiguration,
|
|
||||||
originalBytes: Buffer.from("test"),
|
|
||||||
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
|
||||||
}).run();
|
}).run();
|
||||||
database.insert(externalRoomMappings).values({
|
database.insert(externalRoomMappings).values({
|
||||||
id: "mapping-1",
|
id: "mapping-1",
|
||||||
|
|
@ -98,7 +93,9 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
|
||||||
id: "object-1",
|
id: "object-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
|
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
|
||||||
ifcGuid: "ifc-1",
|
ifcGuid: "ifc-1",
|
||||||
|
deviceTypeId: "device-type-1",
|
||||||
lastSeenImportBatchId: "batch-1",
|
lastSeenImportBatchId: "batch-1",
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
acceptedSourceValues: {
|
acceptedSourceValues: {
|
||||||
|
|
@ -107,14 +104,18 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
|
||||||
roomName: "Büro",
|
roomName: "Büro",
|
||||||
familyAndType: "Steckdose: Standard",
|
familyAndType: "Steckdose: Standard",
|
||||||
selectionMarker: "Steckdose",
|
selectionMarker: "Steckdose",
|
||||||
|
socketCount: "2",
|
||||||
|
sourceRemark: null,
|
||||||
|
circuitCombined: "-2F1.1",
|
||||||
|
boardName: "UV 1",
|
||||||
circuitIdentifier: "-2F1.1",
|
circuitIdentifier: "-2F1.1",
|
||||||
power: "120",
|
designation: null,
|
||||||
quantity: "2",
|
electricalPowerW: "120",
|
||||||
additionalSourceValues: {},
|
voltageV: 230,
|
||||||
|
currentA: null,
|
||||||
},
|
},
|
||||||
planningValues: {
|
planningValues: {
|
||||||
displayName: "Steckdose",
|
displayName: "Steckdose",
|
||||||
internalDeviceType: "socket",
|
|
||||||
category: "single_phase",
|
category: "single_phase",
|
||||||
connectionKind: "socket",
|
connectionKind: "socket",
|
||||||
effectiveQuantity: 2,
|
effectiveQuantity: 2,
|
||||||
|
|
|
||||||
115
tests/linear-xlsx-external-model-migration.test.ts
Normal file
115
tests/linear-xlsx-external-model-migration.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { it } from "node:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
|
||||||
|
it("replaces legacy CSV state and resets linked row quantities to their manual share", () => {
|
||||||
|
const source = path.resolve("src", "db", "migrations");
|
||||||
|
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-"));
|
||||||
|
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
|
||||||
|
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
|
||||||
|
mkdirSync(path.join(staging, "meta"));
|
||||||
|
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
try {
|
||||||
|
migrate(context.db, { migrationsFolder: staging });
|
||||||
|
context.sqlite.exec(`
|
||||||
|
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
|
||||||
|
INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV');
|
||||||
|
INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste');
|
||||||
|
INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.');
|
||||||
|
INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1');
|
||||||
|
INSERT INTO circuit_device_rows
|
||||||
|
(id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor)
|
||||||
|
VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1);
|
||||||
|
INSERT INTO external_csv_configurations
|
||||||
|
(id, project_id, configuration_version, configuration)
|
||||||
|
VALUES ('cfg', 'p', 3, '{}');
|
||||||
|
INSERT INTO external_model_sources (id, project_id, name, source_type)
|
||||||
|
VALUES ('src', 'p', 'Revit', 'revit_csv');
|
||||||
|
INSERT INTO external_import_batches
|
||||||
|
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256,
|
||||||
|
applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version)
|
||||||
|
VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv',
|
||||||
|
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3);
|
||||||
|
INSERT INTO external_model_objects
|
||||||
|
(id, project_id, source_id, ifc_guid, last_seen_import_batch_id,
|
||||||
|
last_accepted_import_batch_id, accepted_source_values, planning_values,
|
||||||
|
overridden_fields, circuit_device_row_id)
|
||||||
|
VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r');
|
||||||
|
`);
|
||||||
|
|
||||||
|
migrate(context.db, { migrationsFolder: source });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0);
|
||||||
|
assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/);
|
||||||
|
|
||||||
|
const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>;
|
||||||
|
assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1);
|
||||||
|
assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0);
|
||||||
|
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
||||||
|
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and verifies a backup before the Linear-XLSX migration", () => {
|
||||||
|
const source = path.resolve("src", "db", "migrations");
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-"));
|
||||||
|
const staging = path.join(root, "migrations");
|
||||||
|
const dataDirectory = path.join(root, "data");
|
||||||
|
mkdirSync(path.join(staging, "meta"), { recursive: true });
|
||||||
|
mkdirSync(dataDirectory, { recursive: true });
|
||||||
|
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
|
||||||
|
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
|
||||||
|
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const databasePath = path.join(dataDirectory, "leistungsbilanz.db");
|
||||||
|
const context = createDatabaseContext(databasePath);
|
||||||
|
try {
|
||||||
|
migrate(context.db, { migrationsFolder: staging });
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], {
|
||||||
|
cwd: path.resolve("."),
|
||||||
|
env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory },
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
assert.match(result.stdout, /Verified pre-migration backup created/);
|
||||||
|
const backups = readdirSync(path.join(dataDirectory, "backups"));
|
||||||
|
assert.equal(backups.length, 1);
|
||||||
|
|
||||||
|
const migrated = createDatabaseContext(databasePath);
|
||||||
|
try {
|
||||||
|
assert.ok(migrated.sqlite.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'"
|
||||||
|
).get());
|
||||||
|
} finally {
|
||||||
|
migrated.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
|
||||||
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
|
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
|
@ -44,7 +44,7 @@ import { projects } from "../src/db/schema/projects.js";
|
||||||
import { buildings } from "../src/db/schema/buildings.js";
|
import { buildings } from "../src/db/schema/buildings.js";
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { rooms } from "../src/db/schema/rooms.js";
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
||||||
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
|
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
|
||||||
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
||||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
|
|
@ -90,9 +90,8 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p
|
||||||
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
|
||||||
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
|
||||||
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
|
||||||
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
|
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
|
||||||
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||||
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
|
@ -166,7 +165,7 @@ function createService(context: DatabaseContext) {
|
||||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||||
new CircuitProtectionProjectCommandRepository(context.db),
|
new CircuitProtectionProjectCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db),
|
new ProjectHistoryRepository(context.db),
|
||||||
new ExternalCsvConfigurationProjectCommandRepository(context.db)
|
new ExternalModelConfigurationProjectCommandRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1127,31 +1126,62 @@ describe("project command service", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches external CSV configuration updates through persistent history", () => {
|
it("dispatches external XLSX configuration updates through persistent history", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
const service = createService(context);
|
const service = createService(context);
|
||||||
const target = {
|
const target = {
|
||||||
id: "external-config-1",
|
id: "external-config-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
configurationVersion: 1,
|
configuration: createDefaultExternalModelConfiguration(),
|
||||||
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
|
||||||
};
|
};
|
||||||
const updated = service.executeUser({
|
const updated = service.executeUser({
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
expectedRevision: 0,
|
expectedRevision: 0,
|
||||||
command: createExternalCsvConfigurationUpdateProjectCommand(null, target),
|
command: createExternalModelConfigurationUpdateProjectCommand(null, target),
|
||||||
});
|
});
|
||||||
assert.equal(updated.history.currentRevision, 1);
|
assert.equal(updated.history.currentRevision, 1);
|
||||||
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
|
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
||||||
|
|
||||||
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
|
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
|
||||||
assert.equal(undone.history.currentRevision, 2);
|
assert.equal(undone.history.currentRevision, 2);
|
||||||
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0);
|
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 0);
|
||||||
|
|
||||||
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
|
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
|
||||||
assert.equal(redone.history.currentRevision, 3);
|
assert.equal(redone.history.currentRevision, 3);
|
||||||
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
|
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects legacy CSV commands stored in project history with a clear error", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const service = createService(context);
|
||||||
|
service.executeUser({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
command: createExternalModelConfigurationUpdateProjectCommand(null, {
|
||||||
|
id: "external-config-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
configuration: createDefaultExternalModelConfiguration(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
context.db.update(projectChangeSets).set({
|
||||||
|
inversePayloadJson: JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "external-import.apply-initial",
|
||||||
|
payload: {},
|
||||||
|
}),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => service.undo({ projectId: "project-1", expectedRevision: 1 }),
|
||||||
|
/alten Revit-CSV-Anbindung/
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
|
||||||
|
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { externalModelSources } from "../src/db/schema/external-model-sources.js
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -178,7 +177,7 @@ function linkExternalObject(
|
||||||
id: "source-1",
|
id: "source-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
name: "Revit",
|
name: "Revit",
|
||||||
sourceType: "revit_csv",
|
sourceType: "revit_xlsx",
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
@ -189,13 +188,9 @@ function linkExternalObject(
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
importKind: "initial",
|
importKind: "initial",
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
fileName: "revit.csv",
|
fileName: "revit.xlsx",
|
||||||
sha256: "a".repeat(64),
|
sha256: "a".repeat(64),
|
||||||
appliedProjectRevision: 0,
|
objectCount: 1,
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: externalCsvTestConfiguration,
|
|
||||||
originalBytes: Buffer.from("test"),
|
|
||||||
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
@ -204,7 +199,9 @@ function linkExternalObject(
|
||||||
id: "object-1",
|
id: "object-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
|
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
|
||||||
ifcGuid: "ifc-1",
|
ifcGuid: "ifc-1",
|
||||||
|
deviceTypeId: "device-type-1",
|
||||||
lastSeenImportBatchId: "batch-1",
|
lastSeenImportBatchId: "batch-1",
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
acceptedSourceValues: {
|
acceptedSourceValues: {
|
||||||
|
|
@ -213,14 +210,18 @@ function linkExternalObject(
|
||||||
roomName: "Büro",
|
roomName: "Büro",
|
||||||
familyAndType: "Leuchte: Standard",
|
familyAndType: "Leuchte: Standard",
|
||||||
selectionMarker: "Leuchte",
|
selectionMarker: "Leuchte",
|
||||||
|
socketCount: String(effectiveQuantity),
|
||||||
|
sourceRemark: null,
|
||||||
|
circuitCombined: "-1F1",
|
||||||
|
boardName: "UV 1",
|
||||||
circuitIdentifier: "-1F1",
|
circuitIdentifier: "-1F1",
|
||||||
power: "30",
|
designation: null,
|
||||||
quantity: String(effectiveQuantity),
|
electricalPowerW: "30",
|
||||||
additionalSourceValues: {},
|
voltageV: 230,
|
||||||
|
currentA: null,
|
||||||
},
|
},
|
||||||
planningValues: {
|
planningValues: {
|
||||||
displayName: "Leuchte",
|
displayName: "Leuchte",
|
||||||
internalDeviceType: "luminaire",
|
|
||||||
category: "single_phase",
|
category: "single_phase",
|
||||||
connectionKind: "fixed",
|
connectionKind: "fixed",
|
||||||
effectiveQuantity,
|
effectiveQuantity,
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,8 @@ describe("project snapshot repository", () => {
|
||||||
rooms: Array<Record<string, unknown>>;
|
rooms: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
payload.schemaVersion = 5;
|
payload.schemaVersion = 5;
|
||||||
|
(payload as Record<string, unknown>).externalCsvConfiguration = null;
|
||||||
|
delete (payload as Record<string, unknown>).externalModelConfiguration;
|
||||||
delete payload.buildings;
|
delete payload.buildings;
|
||||||
for (const floor of payload.floors) {
|
for (const floor of payload.floors) {
|
||||||
delete floor.buildingId;
|
delete floor.buildingId;
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,16 @@ import {
|
||||||
} from "../src/domain/models/project-state-snapshot.model.js";
|
} from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
|
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
|
||||||
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
|
||||||
|
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
||||||
|
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
/** Removes the fields introduced by snapshot schema version 9. */
|
/** Removes the fields introduced by snapshot schema version 9. */
|
||||||
function withoutVersionNineFields<
|
function withoutVersionNineFields<
|
||||||
T extends { distributionBoards: object[] },
|
T extends { distributionBoards: object[] },
|
||||||
>(snapshot: T): T {
|
>(snapshot: T): T {
|
||||||
|
const record = snapshot as Record<string, unknown>;
|
||||||
|
record.externalCsvConfiguration = null;
|
||||||
|
delete record.externalModelConfiguration;
|
||||||
for (const board of snapshot.distributionBoards) {
|
for (const board of snapshot.distributionBoards) {
|
||||||
delete (board as Record<string, unknown>).feedingDistributionBoardId;
|
delete (board as Record<string, unknown>).feedingDistributionBoardId;
|
||||||
delete (board as Record<string, unknown>).feedingCircuitId;
|
delete (board as Record<string, unknown>).feedingCircuitId;
|
||||||
|
|
@ -77,7 +82,7 @@ function minimalSnapshot() {
|
||||||
projectEditor: null as string | null,
|
projectEditor: null as string | null,
|
||||||
},
|
},
|
||||||
distributionBoards: [],
|
distributionBoards: [],
|
||||||
externalCsvConfiguration: null,
|
externalModelConfiguration: null,
|
||||||
externalModel: {
|
externalModel: {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -315,6 +320,7 @@ describe("project state snapshot model", () => {
|
||||||
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
||||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||||
|
delete (baseline as Record<string, unknown>).externalModelConfiguration;
|
||||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||||
delete (baseline as Record<string, unknown>).externalModel;
|
delete (baseline as Record<string, unknown>).externalModel;
|
||||||
|
|
||||||
|
|
@ -322,7 +328,7 @@ describe("project state snapshot model", () => {
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.project.isPublicBuilding, false);
|
assert.equal(upgraded.project.isPublicBuilding, false);
|
||||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
assert.equal(upgraded.externalModelConfiguration, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
|
||||||
|
|
@ -334,7 +340,7 @@ describe("project state snapshot model", () => {
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
assert.equal(upgraded.externalModelConfiguration, null);
|
||||||
assert.deepEqual(upgraded.externalModel, {
|
assert.deepEqual(upgraded.externalModel, {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -351,7 +357,7 @@ describe("project state snapshot model", () => {
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
assert.equal(upgraded.externalModelConfiguration, null);
|
||||||
assert.deepEqual(upgraded.externalModel, {
|
assert.deepEqual(upgraded.externalModel, {
|
||||||
source: null,
|
source: null,
|
||||||
importBatches: [],
|
importBatches: [],
|
||||||
|
|
@ -360,6 +366,28 @@ describe("project state snapshot model", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects legacy CSV configuration and imported state with a German migration error", () => {
|
||||||
|
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
|
||||||
|
configured.schemaVersion = 9 as typeof configured.schemaVersion;
|
||||||
|
configured.externalCsvConfiguration = {
|
||||||
|
id: "legacy",
|
||||||
|
projectId: "project-1",
|
||||||
|
configurationVersion: 1,
|
||||||
|
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
||||||
|
};
|
||||||
|
assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/);
|
||||||
|
|
||||||
|
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
|
||||||
|
imported.schemaVersion = 9 as typeof imported.schemaVersion;
|
||||||
|
imported.externalModel.source = {
|
||||||
|
id: "legacy-source",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Legacy",
|
||||||
|
sourceType: "revit_csv",
|
||||||
|
};
|
||||||
|
assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/);
|
||||||
|
});
|
||||||
|
|
||||||
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
|
||||||
const previous = withoutVersionSixFields(protectedSnapshot());
|
const previous = withoutVersionSixFields(protectedSnapshot());
|
||||||
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
||||||
|
|
@ -567,7 +595,7 @@ describe("project state snapshot model", () => {
|
||||||
});
|
});
|
||||||
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
|
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
|
||||||
|
|
||||||
const previous = structuredClone(current);
|
const previous = withoutVersionNineFields(structuredClone(current));
|
||||||
previous.schemaVersion = 7 as typeof previous.schemaVersion;
|
previous.schemaVersion = 7 as typeof previous.schemaVersion;
|
||||||
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
|
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue