diff --git a/AGENTS.md b/AGENTS.md index dd0a5d7..a08ca7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,9 +250,11 @@ 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 3 additionally stores the optional project-wide -Revit CSV configuration. Version 2 snapshots are upgraded with an empty external -configuration. Supported baseline version 1 snapshots are additionally upgraded +Current snapshot schema version 4 additionally stores the complete confirmed +external-model source, import batches, room mappings and objects. Version 3 +snapshots are upgraded with an empty external-model state; version 2 snapshots +are additionally upgraded with an empty external CSV configuration. Supported +baseline version 1 snapshots are additionally upgraded with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All supported versions contain circuit-group identity, distribution-board components and the separate circuit/component protection records. Portable duplicate imports @@ -261,7 +263,7 @@ project graph. Project-wide Revit CSV configuration uses `external-csv-configuration.update`. Its complete expected/target snapshot is persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 3, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 4, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the current Phase 14.1 Revit CSV modal. It edits the @@ -274,8 +276,9 @@ immutable import batches with original bytes plus the classified cell matrix, normalized source-room mappings and external objects unique by `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. The current repository is read-only; no runtime import command writes these -tables yet, and snapshot/transfer schema remains version 3 until the next work -package adds version 4 atomically. +tables yet. Snapshot/restore and portable project transfer schema version 4 +capture the complete external state and remap every internal UUID and link while +preserving IFCGUIDs, source values, original bytes and the classified matrix. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 832b354..243503d 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -416,13 +416,15 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 3 enthält zusätzlich die projektweite Revit-CSV-Konfiguration, -die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, +Snapshot-Schema 4 enthält zusätzlich den vollständigen externen Modellzustand +aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die +projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte -Version 2 wird mit leerer externer Konfiguration hochgestuft. Die unterstützte +Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit +leerer externer Konfiguration hochgestuft. Die unterstützte Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor dieser Baseline werden nicht eingelesen. Persistente Insert-, Update- und Delete-Commands versionieren Anlage, @@ -475,7 +477,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Ein triggergeführtes Register erzwingt bereits eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell - Snapshot-Schema 3. Persistente Insert/Delete/Update-Commands für + Snapshot-Schema 4. Persistente Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und @@ -498,7 +500,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl `external_csv_configurations` enthält höchstens eine versionierte Konfiguration je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder -Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, +Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 4, Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren werden Konfigurations-UUID und Projektlink remapped. @@ -512,8 +514,11 @@ und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den vollständigen Zustand deterministisch und unterscheidet unbekannte von noch nicht importierten Projekten. Der letzte bestätigte Batch wird aus der Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. -Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch -nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen. +Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links, +stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim +Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben +unverändert. Import-Command und bestätigte Importübernahme sind noch nicht +implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen. `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder ändern die Konfiguration; der PUT plant Identität und nächsten Konfigurationsstand serverseitig und verwendet den typisierten Command. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index 3d22b12..4570a5a 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -273,6 +273,10 @@ einen Commit aufgenommen. exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt weiterhin keinen Import-Write außerhalb eines Commands. 2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen. + **Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft; + Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes, + Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs, + lassen IFCGUID und Quelltransport jedoch unverändert. 3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 34422b3..dcae184 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -27,6 +27,10 @@ import { projectChangeSets } from "../schema/project-change-sets.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { readProjectStateSnapshot, @@ -163,6 +167,12 @@ function canonicalProjectState(state: ProjectStateSnapshot) { floors: byId(state.floors), rooms: byId(state.rooms), externalCsvConfiguration: state.externalCsvConfiguration, + externalModel: { + source: state.externalModel.source, + importBatches: byId(state.externalModel.importBatches), + roomMappings: byId(state.externalModel.roomMappings), + objects: byId(state.externalModel.objects), + }, }); } @@ -185,6 +195,10 @@ function replaceProjectState( database: AppDatabase, state: ProjectStateSnapshot ) { + database + .delete(externalModelSources) + .where(eq(externalModelSources.projectId, state.project.id)) + .run(); database .delete(externalCsvConfigurations) .where(eq(externalCsvConfigurations.projectId, state.project.id)) @@ -263,6 +277,21 @@ function replaceProjectState( circuitDeviceRows, state.circuits.flatMap((circuit) => circuit.deviceRows) ); + if (state.externalModel.source !== null) { + database.insert(externalModelSources).values(state.externalModel.source).run(); + } + insertMany( + database, + externalImportBatches, + state.externalModel.importBatches.map( + ({ originalContentBase64, ...batch }) => ({ + ...batch, + originalBytes: Buffer.from(originalContentBase64, "base64"), + }) + ) + ); + insertMany(database, externalRoomMappings, state.externalModel.roomMappings); + insertMany(database, externalModelObjects, state.externalModel.objects); } function insertMany[0]>( diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index 03f35ab..1fb96da 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -20,6 +20,7 @@ import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { ExternalModelStateRepository } from "./external-model-state.repository.js"; export interface PersistedProjectStateSnapshot { currentRevision: number; @@ -65,6 +66,8 @@ export function readProjectStateSnapshot( .from(externalCsvConfigurations) .where(eq(externalCsvConfigurations.projectId, projectId)) .get() ?? null; + const externalModel = new ExternalModelStateRepository(database) + .getByProject(projectId).state; const listRows = database .select() .from(circuitLists) @@ -248,6 +251,7 @@ export function readProjectStateSnapshot( }, distributionBoards: boardRows, externalCsvConfiguration, + externalModel, circuitLists: listRows, circuitSections: sectionRows, distributionBoardComponents: componentRows, diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 8bb7274..07a5067 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -21,6 +21,10 @@ import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; +import { externalImportBatches } from "../schema/external-import-batches.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalModelSources } from "../schema/external-model-sources.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { hashProjectStatePayload, readProjectStateSnapshot, @@ -157,6 +161,21 @@ function insertProjectState( circuitDeviceRows, state.circuits.flatMap((circuit) => circuit.deviceRows) ); + if (state.externalModel.source !== null) { + database.insert(externalModelSources).values(state.externalModel.source).run(); + } + insertMany( + database, + externalImportBatches, + state.externalModel.importBatches.map( + ({ originalContentBase64, ...batch }) => ({ + ...batch, + originalBytes: Buffer.from(originalContentBase64, "base64"), + }) + ) + ); + insertMany(database, externalRoomMappings, state.externalModel.roomMappings); + insertMany(database, externalModelObjects, state.externalModel.objects); } function insertMany[0]>( diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 8dc2e7e..e0ee77f 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -20,12 +20,22 @@ 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 { + externalImportKinds, + externalModelSourceTypes, + externalObjectPresenceStatuses, +} from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 3 as const; -const previousProjectStateSnapshotSchemaVersion = 2 as const; +export const projectStateSnapshotSchemaVersion = 4 as const; +const previousProjectStateSnapshotSchemaVersion = 3 as const; +const legacyProjectStateSnapshotSchemaVersion = 2 as const; const baselineProjectStateSnapshotSchemaVersion = 1 as const; const idSchema = z.string().trim().min(1); +const externalIfcGuidSchema = z.string().min(1).refine( + (value) => value === value.trim(), + "Snapshot IFCGUID must not contain outer whitespace." +); const nullableStringSchema = z.string().nullable(); const finiteNumberSchema = z.number().finite(); @@ -263,6 +273,147 @@ const externalCsvConfigurationSnapshotSchema = z }) .strict(); +const externalCsvDialectSchema = z.object({ + encoding: z.literal("utf-8"), + hasBom: z.boolean(), + delimiter: z.enum([";", ",", "\t"]), + lineEnding: z.enum(["\r\n", "\n", "\r"]), + quoteCharacter: z.literal('"'), + quoteAllFields: z.boolean(), + hasTrailingLineEnding: z.boolean(), +}).strict(); + +const externalCsvDocumentSchema = z.object({ + dialect: externalCsvDialectSchema, + headerRowIndex: z.number().int().nonnegative(), + rows: z.array(z.object({ + index: z.number().int().nonnegative(), + cells: z.array(z.object({ + value: z.string(), + wasQuoted: z.boolean(), + }).strict()), + classification: z.enum([ + "metadata", + "header", + "passthrough", + "object", + "suspect-object", + ]), + }).strict()).min(1), +}).strict().superRefine((document, context) => { + if (document.rows[document.headerRowIndex]?.classification !== "header") { + context.addIssue({ + code: "custom", + message: "Snapshot external CSV document header is invalid.", + }); + } + document.rows.forEach((row, index) => { + if (row.index !== index) { + context.addIssue({ + code: "custom", + message: "Snapshot external CSV row indexes must be contiguous.", + }); + } + }); +}); + +const externalModelSourceSchema = z.object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + sourceType: z.enum(externalModelSourceTypes), +}).strict(); + +const externalImportBatchSchema = z.object({ + id: idSchema, + projectId: idSchema, + sourceId: idSchema, + importKind: z.enum(externalImportKinds), + importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))), + fileName: z.string().trim().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + appliedProjectRevision: z.number().int().nonnegative(), + configurationSnapshot: z.custom( + (value) => validateExternalCsvConfiguration(value).success + ), + originalContentBase64: z.string().min(1).regex( + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + ), + document: externalCsvDocumentSchema, +}).strict(); + +const externalRoomMappingSchema = z.object({ + id: idSchema, + projectId: idSchema, + sourceId: idSchema, + normalizedSourceRoomKey: z.string().trim().min(1), + sourceFloorName: nullableStringSchema, + sourceRoomNumber: z.string(), + sourceRoomName: z.string(), + roomId: idSchema.nullable(), + defaultDistributionBoardId: idSchema.nullable(), +}).strict(); + +const externalPlanningFieldNames = [ + "displayName", + "internalDeviceType", + "category", + "connectionKind", + "effectiveQuantity", + "powerPerUnitW", + "simultaneityFactor", + "cosPhi", + "costGroup", + "remark", +] as const; + +const externalModelObjectSchema = z.object({ + id: idSchema, + projectId: idSchema, + sourceId: idSchema, + ifcGuid: externalIfcGuidSchema, + lastSeenImportBatchId: idSchema, + lastAcceptedImportBatchId: idSchema, + acceptedSourceValues: z.object({ + rowNumber: z.number().int().positive(), + roomNumber: z.string(), + roomName: z.string(), + familyAndType: z.string(), + selectionMarker: z.string(), + circuitIdentifier: z.string(), + power: z.string(), + quantity: z.string().nullable(), + additionalSourceValues: z.record(z.string(), z.string()), + }).strict(), + planningValues: z.object({ + displayName: nullableStringSchema, + internalDeviceType: nullableStringSchema, + category: z.enum(circuitGroupCategories).nullable(), + connectionKind: nullableStringSchema, + effectiveQuantity: z.number().int().positive(), + powerPerUnitW: finiteNumberSchema.nonnegative().nullable(), + simultaneityFactor: finiteNumberSchema.min(0).max(1), + cosPhi: finiteNumberSchema.positive().max(1).nullable(), + costGroup: nullableStringSchema, + remark: nullableStringSchema, + }).strict(), + overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine( + (values) => new Set(values).size === values.length + ), + externalRoomMappingId: idSchema.nullable(), + distributionBoardId: idSchema.nullable(), + linkedProjectDeviceId: idSchema.nullable(), + circuitDeviceRowId: idSchema.nullable(), + presenceStatus: z.enum(externalObjectPresenceStatuses), +}).strict(); + +const externalModelStateSchema = z.object({ + source: externalModelSourceSchema.nullable(), + importBatches: z.array(externalImportBatchSchema), + roomMappings: z.array(externalRoomMappingSchema), + objects: z.array(externalModelObjectSchema), +}).strict(); + const projectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), @@ -283,6 +434,7 @@ export const projectStateSnapshotSchema = z project: projectSchema, distributionBoards: z.array(distributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: externalModelStateSchema, ...projectStateSnapshotContents, }) .strict(); @@ -292,6 +444,16 @@ const previousProjectStateSnapshotSchema = z schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + ...projectStateSnapshotContents, + }) + .strict(); + +const legacyProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), ...projectStateSnapshotContents, }) .strict(); @@ -335,7 +497,16 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { return { ...previous, schemaVersion: projectStateSnapshotSchemaVersion, + externalModel: emptyExternalModelState(), + }; + } + if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) { + const legacy = legacyProjectStateSnapshotSchema.parse(value); + return { + ...legacy, + schemaVersion: projectStateSnapshotSchemaVersion, externalCsvConfiguration: null, + externalModel: emptyExternalModelState(), }; } if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) { @@ -345,11 +516,21 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { schemaVersion: projectStateSnapshotSchemaVersion, project: { ...baseline.project, isPublicBuilding: false }, externalCsvConfiguration: null, + externalModel: emptyExternalModelState(), }; } return value; } +function emptyExternalModelState() { + return { + source: null, + importBatches: [], + roomMappings: [], + objects: [], + }; +} + function normalizeSnapshotPhaseTypes( snapshot: ProjectStateSnapshot ): ProjectStateSnapshot { @@ -666,6 +847,94 @@ function assertProjectStateSnapshotRelations( ); } } + + assertExternalModelRelations(snapshot, { + boardIds, + deviceRowIds, + projectDeviceIds, + projectId, + roomIds, + }); +} + +function assertExternalModelRelations( + snapshot: ProjectStateSnapshot, + references: { + boardIds: ReadonlySet; + deviceRowIds: ReadonlySet; + projectDeviceIds: ReadonlySet; + projectId: string; + roomIds: ReadonlySet; + } +) { + const { source, importBatches, roomMappings, objects } = snapshot.externalModel; + if (source === null) { + if (importBatches.length || roomMappings.length || objects.length) { + throw new Error("Snapshot external model entries require a source."); + } + return; + } + assertProjectOwnership(source.projectId, references.projectId, "external model source"); + const batchIds = uniqueIds(importBatches, "external import batch"); + const mappingIds = uniqueIds(roomMappings, "external room mapping"); + uniqueIds(objects, "external model object"); + + for (const batch of importBatches) { + assertProjectOwnership(batch.projectId, references.projectId, "external import batch"); + if (batch.sourceId !== source.id) { + throw new Error("Snapshot external import batch belongs to a different source."); + } + } + + const roomKeys = new Set(); + for (const mapping of roomMappings) { + assertProjectOwnership(mapping.projectId, references.projectId, "external room mapping"); + if (mapping.sourceId !== source.id) { + throw new Error("Snapshot external room mapping belongs to a different source."); + } + assertUniqueKey( + roomKeys, + mapping.normalizedSourceRoomKey, + "Snapshot contains duplicate external room keys." + ); + if (mapping.roomId !== null) { + assertReference(references.roomIds, mapping.roomId, "external room mapping room"); + } + if (mapping.defaultDistributionBoardId !== null) { + assertReference( + references.boardIds, + mapping.defaultDistributionBoardId, + "external room mapping distribution board" + ); + } + } + + const ifcGuids = new Set(); + for (const object of objects) { + assertProjectOwnership(object.projectId, references.projectId, "external model object"); + if (object.sourceId !== source.id) { + throw new Error("Snapshot external model object belongs to a different source."); + } + assertUniqueKey( + ifcGuids, + object.ifcGuid, + "Snapshot contains duplicate external IFCGUIDs." + ); + assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch"); + assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch"); + if (object.externalRoomMappingId !== null) { + assertReference(mappingIds, object.externalRoomMappingId, "external object room mapping"); + } + if (object.distributionBoardId !== null) { + assertReference(references.boardIds, object.distributionBoardId, "external object distribution board"); + } + if (object.linkedProjectDeviceId !== null) { + assertReference(references.projectDeviceIds, object.linkedProjectDeviceId, "external object project device"); + } + if (object.circuitDeviceRowId !== null) { + assertReference(references.deviceRowIds, object.circuitDeviceRowId, "external object device row"); + } + } } function assertComponentPlacement( diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index 1282ef6..3f28f51 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -91,6 +91,13 @@ export function remapProjectState( circuit.deviceRows.map((row) => [row.id, createId()] as const) ) ); + const externalSourceId = source.externalModel.source ? createId() : null; + const externalBatchIds = idMap(source.externalModel.importBatches, createId); + const externalRoomMappingIds = idMap( + source.externalModel.roomMappings, + createId + ); + const externalObjectIds = idMap(source.externalModel.objects, createId); return parseProjectStateSnapshot({ ...source, project: { ...source.project, id: targetProjectId, name }, @@ -102,6 +109,68 @@ export function remapProjectState( id: createId(), projectId: targetProjectId, }, + externalModel: { + source: source.externalModel.source === null + ? null + : { + ...source.externalModel.source, + id: externalSourceId!, + projectId: targetProjectId, + }, + importBatches: source.externalModel.importBatches.map((batch) => ({ + ...batch, + id: requiredId(externalBatchIds, batch.id), + projectId: targetProjectId, + sourceId: externalSourceId!, + })), + roomMappings: source.externalModel.roomMappings.map((mapping) => ({ + ...mapping, + id: requiredId(externalRoomMappingIds, mapping.id), + projectId: targetProjectId, + sourceId: externalSourceId!, + roomId: + mapping.roomId === null + ? null + : requiredId(roomIds, mapping.roomId), + defaultDistributionBoardId: + mapping.defaultDistributionBoardId === null + ? null + : requiredId(boardIds, mapping.defaultDistributionBoardId), + })), + objects: source.externalModel.objects.map((object) => ({ + ...object, + id: requiredId(externalObjectIds, object.id), + projectId: targetProjectId, + sourceId: externalSourceId!, + lastSeenImportBatchId: requiredId( + externalBatchIds, + object.lastSeenImportBatchId + ), + lastAcceptedImportBatchId: requiredId( + externalBatchIds, + object.lastAcceptedImportBatchId + ), + externalRoomMappingId: + object.externalRoomMappingId === null + ? null + : requiredId( + externalRoomMappingIds, + object.externalRoomMappingId + ), + distributionBoardId: + object.distributionBoardId === null + ? null + : requiredId(boardIds, object.distributionBoardId), + linkedProjectDeviceId: + object.linkedProjectDeviceId === null + ? null + : requiredId(deviceIds, object.linkedProjectDeviceId), + circuitDeviceRowId: + object.circuitDeviceRowId === null + ? null + : requiredId(rowIds, object.circuitDeviceRowId), + })), + }, distributionBoards: source.distributionBoards.map((board) => ({ ...board, id: requiredId(boardIds, board.id), diff --git a/tests/external-model-foundation.test.ts b/tests/external-model-foundation.test.ts index aa67c0a..4bc6708 100644 --- a/tests/external-model-foundation.test.ts +++ b/tests/external-model-foundation.test.ts @@ -4,11 +4,23 @@ 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 { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; import { @@ -189,3 +201,228 @@ describe("external model state repository", () => { } }); }); + +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, 4); + 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["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, + 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(); +} diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 61dc8bd..a786814 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -32,6 +32,12 @@ function minimalSnapshot() { }, distributionBoards: [], externalCsvConfiguration: null, + externalModel: { + source: null, + importBatches: [], + roomMappings: [], + objects: [], + }, circuitLists: [], circuitSections: [], distributionBoardComponents: [], @@ -149,6 +155,7 @@ describe("project state snapshot model", () => { baseline.schemaVersion = 1 as typeof baseline.schemaVersion; delete (baseline.project as Record).isPublicBuilding; delete (baseline as Record).externalCsvConfiguration; + delete (baseline as Record).externalModel; const upgraded = parseProjectStateSnapshot(baseline); @@ -161,11 +168,35 @@ describe("project state snapshot model", () => { const previous = structuredClone(minimalSnapshot()); previous.schemaVersion = 2 as typeof previous.schemaVersion; delete (previous as Record).externalCsvConfiguration; + delete (previous as Record).externalModel; const upgraded = parseProjectStateSnapshot(previous); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.externalCsvConfiguration, null); + assert.deepEqual(upgraded.externalModel, { + source: null, + importBatches: [], + roomMappings: [], + objects: [], + }); + }); + + it("upgrades snapshot version 3 with an empty external model", () => { + const previous = structuredClone(minimalSnapshot()); + previous.schemaVersion = 3 as typeof previous.schemaVersion; + delete (previous as Record).externalModel; + + const upgraded = parseProjectStateSnapshot(previous); + + assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); + assert.equal(upgraded.externalCsvConfiguration, null); + assert.deepEqual(upgraded.externalModel, { + source: null, + importBatches: [], + roomMappings: [], + objects: [], + }); }); it("rejects malformed baseline and unknown snapshot shapes", () => {