Replace Revit CSV backend with Linear XLSX

This commit is contained in:
Julian Appel 2026-09-30 08:46:07 +02:00
parent 52d3b1a270
commit 1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions

View file

@ -295,7 +295,10 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 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;
versions 1 to 8 are upgraded with both references set to `null`. Version 8
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`,
`projectLead` and `projectEditor`; all older supported versions are upgraded
with `null` for these fields.
Stored named and automatic snapshots of every supported version (1 to 9)
remain restorable; they are upgraded while parsing. Version 5 stores
Stored named and automatic snapshots of versions 1 to 9 remain restorable when
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
`manualQuantity = quantity`; they already contain the complete confirmed
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
remap component ids and protection-owner references together with the existing
project graph.
The current runtime still contains the legacy Revit-CSV/IfcGUID foundation:
project configuration, preview, confirmed initial import, external objects and
the three row-assignment commands. It remains the audited starting point, not
the Phase-14 target. Do not extend its CSV transport or IFCGUID identity model.
The approved cutover is Linear XLSX with exact case-sensitive Worksharing-ID
identity. See `docs/spec/revit-excel-integration-requirements.md` and
R3 replaced the runtime configuration and persistence foundation with Linear
XLSX and exact case-sensitive Worksharing-ID identity. Migration `0014` drops
the old development-only external CSV data after resetting linked row totals to
their manual share. Snapshot schema 10, restore and project transfer use the
new state; non-empty legacy CSV snapshots and legacy CSV history entries are
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`.
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`
invalidates stale plans, and each import batch stores a configuration copy.
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
selected combined/separate mode block export.
- Missing GZF defaults visibly to 1 without separate confirmation.
- R2 must generate a changed return workbook from the local sample. The user
tests it in Linear before R3. If Linear rejects it, propose direct OOXML
modification of the original workbook before starting R3.
- The corrected R2 return workbook was successfully imported into Linear on
2026-09-30; R3 is complete and R4 is the next package.
- All confirmed writes use typed project commands with `expectedRevision`,
complete inverse data and the shared project-command transaction boundary.
- Do not add automatic circuit planning, Revit circuit adoption, multi-source

View file

@ -9,12 +9,11 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes
Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind
vorhanden. Die bisherige Revit-CSV-/IfcGUID-Grundlage mit Vorschau und
bestätigtem Erstimport ist noch im Code vorhanden. Der freigegebene
Phase-14-Plan ersetzt sie schrittweise durch den XLSX-Austausch des
Linear-Plugins mit Worksharing-ID als externem Schlüssel. R0 ist abgeschlossen;
R1 aktualisiert die Dokumentation. Editorzuordnung, Folgeimport und Rückexport
sind noch nicht als neuer Excel-Pfad ausgeliefert.
vorhanden. Die Backend- und Persistenzgrundlage der bisherigen
Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit
Worksharing-ID als externem Schlüssel ersetzt. R0 bis R3 sind abgeschlossen;
die eigene Revit-Seite, Erstimport, Editorzuordnung, Rückgabe und Folgeimport
folgen in R4 bis R10.
## 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
detaillierten Bilanz vergleichen und einzelne Zeilen als Projektgerät
übernehmen
- den vorhandenen Legacy-Revit-CSV-Erstimport verwenden; der Zielpfad über
Linear-XLSX, eigene Revit-Seite, Editorzuordnung, Rückgabe und Folgeimport
wird gemäß Phase-14-Plan umgesetzt
- Linear-XLSX-Dateien verlustarm lesen und schreiben; Revit-Seite,
Erstimport, Editorzuordnung, Rückgabe und Folgeimport werden gemäß
Phase-14-Plan in den nächsten Paketen angebunden
## Technik

View file

@ -510,6 +510,9 @@ Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten
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
mit `null` hochgestuft. Restore und Projektduplizierung erhalten beziehungsweise
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`
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
enthielt bereits den damaligen externen CSV-Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthielt außerdem die
damalige projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
@ -640,7 +643,34 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-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
Legacy-Laufzeitpfad. Er verwendet CSV und IfcGUID und ist die geprüfte

View file

@ -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
erfolgreichem Linear-Rückimport beginnen.
Die korrigierte R2-Rückgabedatei wurde am 30.09.2026 erfolgreich in Linear
eingelesen. Damit ist diese Abnahme erfüllt und R3 freigegeben.
Scheitert der Linear-Import, wird vor R3 eine Alternative zur Freigabe
vorgelegt: Die Original-XLSX wird direkt auf OOXML-Ebene kopiert und nur die
erforderlichen Zellwerte/-typen sowie Zeilenbereiche angepasst. Die

View file

@ -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. |
| 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. |
| R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. |
| R2 | abgeschlossen und freigegeben | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt; der korrigierte Rückimport wurde am 30.09.2026 erfolgreich in Linear getestet. |
| R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. |
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. |
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. |

View file

@ -24,7 +24,7 @@
"test": "node scripts/run-tests.mjs",
"test:watch": "node scripts/run-tests.mjs --watch",
"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:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"

View file

@ -4,13 +4,65 @@ const Database = require('better-sqlite3');
const fs = require('node:fs');
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 });
const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
const db = drizzle(sqlite);
const databasePath = path.join(dataDir, 'leistungsbilanz.db');
migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
async function run() {
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}`);
}
migrate(drizzle(sqlite), {
migrationsFolder: path.resolve('src/db/migrations'),
});
} finally {
sqlite.close();
}
console.log('Migrations applied');
}
function requiresLinearXlsxBackup(sqlite) {
const tables = new Set(
sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((row) => row.name)
);
return tables.has('external_csv_configurations') &&
!tables.has('external_model_configurations');
}
function verifyBackup(backupPath) {
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
try {
const integrity = backup.pragma('integrity_check', { simple: true });
const foreignKeyViolations = backup.pragma('foreign_key_check');
if (integrity !== 'ok' || foreignKeyViolations.length > 0) {
throw new Error(
`Pre-migration backup verification failed: integrity=${integrity}, ` +
`foreign-key violations=${foreignKeyViolations.length}`
);
}
} finally {
backup.close();
}
}
run().catch((error) => {
console.error('Migration failed:', error instanceof Error ? error.message : error);
process.exit(1);
});

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,9 @@ export const externalModelObjects = sqliteTable(
sourceId: text("source_id")
.notNull()
.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")
.notNull()
.references(() => externalImportBatches.id, { onDelete: "restrict" }),
@ -62,17 +64,27 @@ export const externalModelObjects = sqliteTable(
.default("present"),
},
(table) => [
uniqueIndex("external_model_objects_source_ifc_guid_unique").on(
uniqueIndex("external_model_objects_source_worksharing_id_unique").on(
table.sourceId,
table.ifcGuid
table.worksharingId
),
index("external_model_objects_project_presence_idx").on(
table.projectId,
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(
table.distributionBoardId
),
index("external_model_objects_linked_project_device_idx").on(
table.linkedProjectDeviceId
),
index("external_model_objects_circuit_device_row_idx").on(
table.circuitDeviceRowId
),

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,10 @@ import {
} from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.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 {
externalImportKinds,
externalModelSourceTypes,
@ -35,7 +39,8 @@ import {
type ExternalModelStateSnapshot,
} 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 buildingProjectStateSnapshotSchemaVersion = 7 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,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
sourceType: z.literal("revit_csv"),
}).strict();
const externalImportBatchSchema = z.object({
const legacyExternalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
@ -461,7 +466,7 @@ const externalRoomMappingSchema = z.object({
defaultDistributionBoardId: idSchema.nullable(),
}).strict();
const externalPlanningFieldNames = [
const legacyExternalPlanningFieldNames = [
"displayName",
"internalDeviceType",
"category",
@ -474,7 +479,7 @@ const externalPlanningFieldNames = [
"remark",
] as const;
const externalModelObjectSchema = z.object({
const legacyExternalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
@ -504,6 +509,170 @@ const externalModelObjectSchema = z.object({
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).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(
(values) => new Set(values).size === values.length
),
@ -519,7 +688,21 @@ const externalModelStateSchema = z.object({
importBatches: z.array(externalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
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(
value: unknown
@ -565,19 +748,30 @@ export const projectStateSnapshotSchema = z
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.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
.object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
@ -588,7 +782,7 @@ const buildingProjectStateSnapshotSchema = z
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...buildingProjectStateSnapshotContents,
})
.strict();
@ -599,7 +793,7 @@ const coverProjectStateSnapshotSchema = z
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -610,7 +804,7 @@ const boardInfoProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -621,7 +815,7 @@ const previousProjectStateSnapshotSchema = z
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
externalModel: legacyExternalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
@ -684,6 +878,39 @@ export function parseProjectStateSnapshot(
}
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);
if (
roomArea !== null &&
@ -695,7 +922,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: legacyCsvProjectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardFeeding(
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(
snapshot: ProjectStateSnapshot
): ProjectStateSnapshot {
@ -891,10 +1132,10 @@ function assertProjectStateSnapshotRelations(
) {
const projectId = snapshot.project.id;
if (
snapshot.externalCsvConfiguration !== null &&
snapshot.externalCsvConfiguration.projectId !== projectId
snapshot.externalModelConfiguration !== null &&
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(
snapshot.distributionBoards,
@ -1235,6 +1476,12 @@ function assertExternalModelRelations(
}
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");
const batchIds = uniqueIds(importBatches, "external import batch");
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) {
assertProjectOwnership(object.projectId, references.projectId, "external model object");
if (object.sourceId !== source.id) {
throw new Error("Snapshot external model object belongs to a different source.");
}
assertUniqueKey(
ifcGuids,
object.ifcGuid,
"Snapshot contains duplicate external IFCGUIDs."
worksharingIds,
object.worksharingId,
"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.lastAcceptedImportBatchId, "external object accepted batch");
if (object.externalRoomMappingId !== null) {

View file

@ -146,16 +146,35 @@ export function remapProjectState(
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({
...source,
project: { ...source.project, id: targetProjectId, name },
externalCsvConfiguration:
source.externalCsvConfiguration === null
externalModelConfiguration:
source.externalModelConfiguration === null
? null
: {
...source.externalCsvConfiguration,
...source.externalModelConfiguration,
id: createId(),
projectId: targetProjectId,
configuration: remapExternalModelConfiguration(
source.externalModelConfiguration.configuration,
deviceTypeIds,
familyRuleIds
),
},
externalModel: {
source: source.externalModel.source === null
@ -170,6 +189,14 @@ export function remapProjectState(
id: requiredId(externalBatchIds, batch.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
configurationSnapshot:
batch.configurationSnapshot === null
? null
: remapExternalModelConfiguration(
batch.configurationSnapshot,
deviceTypeIds,
familyRuleIds
),
})),
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
...mapping,
@ -190,6 +217,7 @@ export function remapProjectState(
id: requiredId(externalObjectIds, object.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
deviceTypeId: requiredId(deviceTypeIds, object.deviceTypeId),
lastSeenImportBatchId: requiredId(
externalBatchIds,
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(
entries: ReadonlyArray<{ id: string }>,
createId: () => string

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,6 @@ import type {
ExternalCsvConfiguration,
ExternalCsvDialect,
} from "../external-model/csv/external-csv-contracts";
import type { createExternalInitialImportPlan } from "../external-model/application/external-initial-import-plan";
export interface ExternalCsvConfigurationSnapshotDto {
id: string;
@ -52,9 +51,80 @@ export interface ExternalCsvPreviewDto {
suspectRowNumbers: number[];
}
export type ExternalInitialImportPlanDto = ReturnType<
typeof createExternalInitialImportPlan
>;
export interface ExternalInitialImportPlanDto {
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 {
expectedRevision: number;

View file

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

View file

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

View file

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

View file

@ -53,14 +53,6 @@ import {
importProjectTransfer,
importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js";
import {
getExternalCsvConfiguration,
applyExternalInitialImport,
previewExternalCsv,
planExternalInitialImport,
updateExternalCsvConfiguration,
getExternalCircuitListObjects,
} from "../controllers/external-csv.controller.js";
export const projectRouter = Router();
@ -70,21 +62,6 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/export", exportProjectTransfer);
projectRouter.post("/:projectId/import", importProjectTransfer);
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
projectRouter.post(
"/:projectId/external-csv/initial-import/plan",
planExternalInitialImport
);
projectRouter.get(
"/:projectId/circuit-lists/:circuitListId/external-objects",
getExternalCircuitListObjects
);
projectRouter.post(
"/:projectId/external-csv/initial-import/apply",
applyExternalInitialImport
);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-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 { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.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 { floors } from "../src/db/schema/floors.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 { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.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 { 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 { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@ -166,7 +165,7 @@ function createService(context: DatabaseContext) {
new CircuitGroupSubtreeProjectCommandRepository(context.db),
new CircuitProtectionProjectCommandRepository(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();
try {
const service = createService(context);
const target = {
id: "external-config-1",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
configuration: createDefaultExternalModelConfiguration(),
};
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createExternalCsvConfigurationUpdateProjectCommand(null, target),
command: createExternalModelConfigurationUpdateProjectCommand(null, target),
});
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 });
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 });
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 {
context.close();
}

View file

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

View file

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

View file

@ -8,11 +8,16 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.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. */
function withoutVersionNineFields<
T extends { distributionBoards: object[] },
>(snapshot: T): T {
const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null;
delete record.externalModelConfiguration;
for (const board of snapshot.distributionBoards) {
delete (board as Record<string, unknown>).feedingDistributionBoardId;
delete (board as Record<string, unknown>).feedingCircuitId;
@ -77,7 +82,7 @@ function minimalSnapshot() {
projectEditor: null as string | null,
},
distributionBoards: [],
externalCsvConfiguration: null,
externalModelConfiguration: null,
externalModel: {
source: null,
importBatches: [],
@ -315,6 +320,7 @@ describe("project state snapshot model", () => {
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
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>).externalModel;
@ -322,7 +328,7 @@ describe("project state snapshot model", () => {
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
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", () => {
@ -334,7 +340,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
@ -351,7 +357,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
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", () => {
const previous = withoutVersionSixFields(protectedSnapshot());
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);
const previous = structuredClone(current);
const previous = withoutVersionNineFields(structuredClone(current));
previous.schemaVersion = 7 as typeof previous.schemaVersion;
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
const upgraded = parseProjectStateSnapshot(previous);