Include external models in project snapshots
This commit is contained in:
@@ -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
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 3 additionally stores the optional project-wide
|
Current snapshot schema version 4 additionally stores the complete confirmed
|
||||||
Revit CSV configuration. Version 2 snapshots are upgraded with an empty external
|
external-model source, import batches, room mappings and objects. Version 3
|
||||||
configuration. Supported baseline version 1 snapshots are additionally upgraded
|
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
|
with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All
|
||||||
supported versions contain circuit-group identity, distribution-board components and the
|
supported versions contain circuit-group identity, distribution-board components and the
|
||||||
separate circuit/component protection records. Portable duplicate imports
|
separate circuit/component protection records. Portable duplicate imports
|
||||||
@@ -261,7 +263,7 @@ project graph.
|
|||||||
Project-wide Revit CSV configuration uses
|
Project-wide Revit CSV configuration uses
|
||||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||||
persisted through the shared project-command transaction, so create, update,
|
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
|
project duplication preserve the configuration; duplication remaps its internal
|
||||||
id and project link.
|
id and project link.
|
||||||
The project page exposes the current Phase 14.1 Revit CSV modal. It edits the
|
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
|
normalized source-room mappings and external objects unique by
|
||||||
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
||||||
The current repository is read-only; no runtime import command writes these
|
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
|
tables yet. Snapshot/restore and portable project transfer schema version 4
|
||||||
package adds version 4 atomically.
|
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
|
The central revision boundary creates an automatic logical snapshot after each
|
||||||
25 new revisions and retains only the newest 12 automatic snapshots per
|
25 new revisions and retains only the newest 12 automatic snapshots per
|
||||||
project. Named snapshots are never removed by this retention policy.
|
project. Named snapshots are never removed by this retention policy.
|
||||||
|
|||||||
@@ -416,13 +416,15 @@ wieder her.
|
|||||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||||
Snapshot-Schema 3 enthält zusätzlich die projektweite Revit-CSV-Konfiguration,
|
Snapshot-Schema 4 enthält zusätzlich den vollständigen externen Modellzustand
|
||||||
die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
|
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
|
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
|
||||||
1:1-Schutzgerätedaten. Capture,
|
1:1-Schutzgerätedaten. Capture,
|
||||||
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
|
||||||
JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte
|
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
|
Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor
|
||||||
dieser Baseline werden nicht eingelesen.
|
dieser Baseline werden nicht eingelesen.
|
||||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
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,
|
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
|
||||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
|
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
|
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
|
||||||
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
||||||
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
|
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
|
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
||||||
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
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
|
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
||||||
werden Konfigurations-UUID und Projektlink remapped.
|
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
|
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
|
||||||
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
|
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
|
||||||
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
|
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
|
||||||
Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch
|
Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links,
|
||||||
nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen.
|
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
|
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
||||||
ändern die Konfiguration; der PUT plant Identität und nächsten
|
ändern die Konfiguration; der PUT plant Identität und nächsten
|
||||||
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ einen Commit aufgenommen.
|
|||||||
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
|
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
|
||||||
weiterhin keinen Import-Write außerhalb eines Commands.
|
weiterhin keinen Import-Write außerhalb eines Commands.
|
||||||
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
|
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.
|
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
|
||||||
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
|
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
|
||||||
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow.
|
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow.
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import { projectChangeSets } from "../schema/project-change-sets.js";
|
|||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { 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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
@@ -163,6 +167,12 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
|||||||
floors: byId(state.floors),
|
floors: byId(state.floors),
|
||||||
rooms: byId(state.rooms),
|
rooms: byId(state.rooms),
|
||||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
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,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
) {
|
) {
|
||||||
|
database
|
||||||
|
.delete(externalModelSources)
|
||||||
|
.where(eq(externalModelSources.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
database
|
database
|
||||||
.delete(externalCsvConfigurations)
|
.delete(externalCsvConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
.where(eq(externalCsvConfigurations.projectId, state.project.id))
|
||||||
@@ -263,6 +277,21 @@ function replaceProjectState(
|
|||||||
circuitDeviceRows,
|
circuitDeviceRows,
|
||||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||||
);
|
);
|
||||||
|
if (state.externalModel.source !== null) {
|
||||||
|
database.insert(externalModelSources).values(state.externalModel.source).run();
|
||||||
|
}
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
externalImportBatches,
|
||||||
|
state.externalModel.importBatches.map(
|
||||||
|
({ originalContentBase64, ...batch }) => ({
|
||||||
|
...batch,
|
||||||
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
||||||
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { projectDevices } from "../schema/project-devices.js";
|
|||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
export interface PersistedProjectStateSnapshot {
|
export interface PersistedProjectStateSnapshot {
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
@@ -65,6 +66,8 @@ export function readProjectStateSnapshot(
|
|||||||
.from(externalCsvConfigurations)
|
.from(externalCsvConfigurations)
|
||||||
.where(eq(externalCsvConfigurations.projectId, projectId))
|
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||||
.get() ?? null;
|
.get() ?? null;
|
||||||
|
const externalModel = new ExternalModelStateRepository(database)
|
||||||
|
.getByProject(projectId).state;
|
||||||
const listRows = database
|
const listRows = database
|
||||||
.select()
|
.select()
|
||||||
.from(circuitLists)
|
.from(circuitLists)
|
||||||
@@ -248,6 +251,7 @@ export function readProjectStateSnapshot(
|
|||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
externalCsvConfiguration,
|
externalCsvConfiguration,
|
||||||
|
externalModel,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
circuitSections: sectionRows,
|
circuitSections: sectionRows,
|
||||||
distributionBoardComponents: componentRows,
|
distributionBoardComponents: componentRows,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { projectDevices } from "../schema/project-devices.js";
|
|||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
import { 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 {
|
import {
|
||||||
hashProjectStatePayload,
|
hashProjectStatePayload,
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
@@ -157,6 +161,21 @@ function insertProjectState(
|
|||||||
circuitDeviceRows,
|
circuitDeviceRows,
|
||||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||||
);
|
);
|
||||||
|
if (state.externalModel.source !== null) {
|
||||||
|
database.insert(externalModelSources).values(state.externalModel.source).run();
|
||||||
|
}
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
externalImportBatches,
|
||||||
|
state.externalModel.importBatches.map(
|
||||||
|
({ originalContentBase64, ...batch }) => ({
|
||||||
|
...batch,
|
||||||
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
||||||
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
|||||||
@@ -20,12 +20,22 @@ import {
|
|||||||
} from "../services/project-voltage.service.js";
|
} from "../services/project-voltage.service.js";
|
||||||
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
|
||||||
|
import {
|
||||||
|
externalImportKinds,
|
||||||
|
externalModelSourceTypes,
|
||||||
|
externalObjectPresenceStatuses,
|
||||||
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 3 as const;
|
export const projectStateSnapshotSchemaVersion = 4 as const;
|
||||||
const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
const previousProjectStateSnapshotSchemaVersion = 3 as const;
|
||||||
|
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
|
||||||
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
|
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
|
||||||
|
|
||||||
const idSchema = z.string().trim().min(1);
|
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 nullableStringSchema = z.string().nullable();
|
||||||
const finiteNumberSchema = z.number().finite();
|
const finiteNumberSchema = z.number().finite();
|
||||||
|
|
||||||
@@ -263,6 +273,147 @@ const externalCsvConfigurationSnapshotSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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<ExternalCsvConfiguration>(
|
||||||
|
(value) => validateExternalCsvConfiguration(value).success
|
||||||
|
),
|
||||||
|
originalContentBase64: z.string().min(1).regex(
|
||||||
|
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||||
|
),
|
||||||
|
document: externalCsvDocumentSchema,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalRoomMappingSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
sourceId: idSchema,
|
||||||
|
normalizedSourceRoomKey: z.string().trim().min(1),
|
||||||
|
sourceFloorName: nullableStringSchema,
|
||||||
|
sourceRoomNumber: z.string(),
|
||||||
|
sourceRoomName: z.string(),
|
||||||
|
roomId: idSchema.nullable(),
|
||||||
|
defaultDistributionBoardId: idSchema.nullable(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalPlanningFieldNames = [
|
||||||
|
"displayName",
|
||||||
|
"internalDeviceType",
|
||||||
|
"category",
|
||||||
|
"connectionKind",
|
||||||
|
"effectiveQuantity",
|
||||||
|
"powerPerUnitW",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"costGroup",
|
||||||
|
"remark",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const externalModelObjectSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
sourceId: idSchema,
|
||||||
|
ifcGuid: externalIfcGuidSchema,
|
||||||
|
lastSeenImportBatchId: idSchema,
|
||||||
|
lastAcceptedImportBatchId: idSchema,
|
||||||
|
acceptedSourceValues: z.object({
|
||||||
|
rowNumber: z.number().int().positive(),
|
||||||
|
roomNumber: z.string(),
|
||||||
|
roomName: z.string(),
|
||||||
|
familyAndType: z.string(),
|
||||||
|
selectionMarker: z.string(),
|
||||||
|
circuitIdentifier: z.string(),
|
||||||
|
power: z.string(),
|
||||||
|
quantity: z.string().nullable(),
|
||||||
|
additionalSourceValues: z.record(z.string(), z.string()),
|
||||||
|
}).strict(),
|
||||||
|
planningValues: z.object({
|
||||||
|
displayName: nullableStringSchema,
|
||||||
|
internalDeviceType: nullableStringSchema,
|
||||||
|
category: z.enum(circuitGroupCategories).nullable(),
|
||||||
|
connectionKind: nullableStringSchema,
|
||||||
|
effectiveQuantity: z.number().int().positive(),
|
||||||
|
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
|
||||||
|
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||||
|
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
|
||||||
|
costGroup: nullableStringSchema,
|
||||||
|
remark: nullableStringSchema,
|
||||||
|
}).strict(),
|
||||||
|
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
|
||||||
|
(values) => new Set(values).size === values.length
|
||||||
|
),
|
||||||
|
externalRoomMappingId: idSchema.nullable(),
|
||||||
|
distributionBoardId: idSchema.nullable(),
|
||||||
|
linkedProjectDeviceId: idSchema.nullable(),
|
||||||
|
circuitDeviceRowId: idSchema.nullable(),
|
||||||
|
presenceStatus: z.enum(externalObjectPresenceStatuses),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const externalModelStateSchema = z.object({
|
||||||
|
source: externalModelSourceSchema.nullable(),
|
||||||
|
importBatches: z.array(externalImportBatchSchema),
|
||||||
|
roomMappings: z.array(externalRoomMappingSchema),
|
||||||
|
objects: z.array(externalModelObjectSchema),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
const projectStateSnapshotContents = {
|
const projectStateSnapshotContents = {
|
||||||
circuitLists: z.array(circuitListSchema),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
@@ -283,6 +434,7 @@ export const projectStateSnapshotSchema = z
|
|||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
|
externalModel: externalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -292,6 +444,16 @@ const previousProjectStateSnapshotSchema = z
|
|||||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
|
...projectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const legacyProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -335,7 +497,16 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
externalModel: emptyExternalModelState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) {
|
||||||
|
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
||||||
|
return {
|
||||||
|
...legacy,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
|
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
|
||||||
@@ -345,11 +516,21 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...baseline.project, isPublicBuilding: false },
|
project: { ...baseline.project, isPublicBuilding: false },
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyExternalModelState() {
|
||||||
|
return {
|
||||||
|
source: null,
|
||||||
|
importBatches: [],
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSnapshotPhaseTypes(
|
function normalizeSnapshotPhaseTypes(
|
||||||
snapshot: ProjectStateSnapshot
|
snapshot: ProjectStateSnapshot
|
||||||
): ProjectStateSnapshot {
|
): ProjectStateSnapshot {
|
||||||
@@ -666,6 +847,94 @@ function assertProjectStateSnapshotRelations(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertExternalModelRelations(snapshot, {
|
||||||
|
boardIds,
|
||||||
|
deviceRowIds,
|
||||||
|
projectDeviceIds,
|
||||||
|
projectId,
|
||||||
|
roomIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExternalModelRelations(
|
||||||
|
snapshot: ProjectStateSnapshot,
|
||||||
|
references: {
|
||||||
|
boardIds: ReadonlySet<string>;
|
||||||
|
deviceRowIds: ReadonlySet<string>;
|
||||||
|
projectDeviceIds: ReadonlySet<string>;
|
||||||
|
projectId: string;
|
||||||
|
roomIds: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { source, importBatches, roomMappings, objects } = snapshot.externalModel;
|
||||||
|
if (source === null) {
|
||||||
|
if (importBatches.length || roomMappings.length || objects.length) {
|
||||||
|
throw new Error("Snapshot external model entries require a source.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertProjectOwnership(source.projectId, references.projectId, "external model source");
|
||||||
|
const batchIds = uniqueIds(importBatches, "external import batch");
|
||||||
|
const mappingIds = uniqueIds(roomMappings, "external room mapping");
|
||||||
|
uniqueIds(objects, "external model object");
|
||||||
|
|
||||||
|
for (const batch of importBatches) {
|
||||||
|
assertProjectOwnership(batch.projectId, references.projectId, "external import batch");
|
||||||
|
if (batch.sourceId !== source.id) {
|
||||||
|
throw new Error("Snapshot external import batch belongs to a different source.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomKeys = new Set<string>();
|
||||||
|
for (const mapping of roomMappings) {
|
||||||
|
assertProjectOwnership(mapping.projectId, references.projectId, "external room mapping");
|
||||||
|
if (mapping.sourceId !== source.id) {
|
||||||
|
throw new Error("Snapshot external room mapping belongs to a different source.");
|
||||||
|
}
|
||||||
|
assertUniqueKey(
|
||||||
|
roomKeys,
|
||||||
|
mapping.normalizedSourceRoomKey,
|
||||||
|
"Snapshot contains duplicate external room keys."
|
||||||
|
);
|
||||||
|
if (mapping.roomId !== null) {
|
||||||
|
assertReference(references.roomIds, mapping.roomId, "external room mapping room");
|
||||||
|
}
|
||||||
|
if (mapping.defaultDistributionBoardId !== null) {
|
||||||
|
assertReference(
|
||||||
|
references.boardIds,
|
||||||
|
mapping.defaultDistributionBoardId,
|
||||||
|
"external room mapping distribution board"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ifcGuids = new Set<string>();
|
||||||
|
for (const object of objects) {
|
||||||
|
assertProjectOwnership(object.projectId, references.projectId, "external model object");
|
||||||
|
if (object.sourceId !== source.id) {
|
||||||
|
throw new Error("Snapshot external model object belongs to a different source.");
|
||||||
|
}
|
||||||
|
assertUniqueKey(
|
||||||
|
ifcGuids,
|
||||||
|
object.ifcGuid,
|
||||||
|
"Snapshot contains duplicate external IFCGUIDs."
|
||||||
|
);
|
||||||
|
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
|
||||||
|
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
|
||||||
|
if (object.externalRoomMappingId !== null) {
|
||||||
|
assertReference(mappingIds, object.externalRoomMappingId, "external object room mapping");
|
||||||
|
}
|
||||||
|
if (object.distributionBoardId !== null) {
|
||||||
|
assertReference(references.boardIds, object.distributionBoardId, "external object distribution board");
|
||||||
|
}
|
||||||
|
if (object.linkedProjectDeviceId !== null) {
|
||||||
|
assertReference(references.projectDeviceIds, object.linkedProjectDeviceId, "external object project device");
|
||||||
|
}
|
||||||
|
if (object.circuitDeviceRowId !== null) {
|
||||||
|
assertReference(references.deviceRowIds, object.circuitDeviceRowId, "external object device row");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertComponentPlacement(
|
function assertComponentPlacement(
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export function remapProjectState(
|
|||||||
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
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({
|
return parseProjectStateSnapshot({
|
||||||
...source,
|
...source,
|
||||||
project: { ...source.project, id: targetProjectId, name },
|
project: { ...source.project, id: targetProjectId, name },
|
||||||
@@ -102,6 +109,68 @@ export function remapProjectState(
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
projectId: targetProjectId,
|
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) => ({
|
distributionBoards: source.distributionBoards.map((board) => ({
|
||||||
...board,
|
...board,
|
||||||
id: requiredId(boardIds, board.id),
|
id: requiredId(boardIds, board.id),
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ import { describe, it } from "node:test";
|
|||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.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 { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
|
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.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 { 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 { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
||||||
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
||||||
import {
|
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<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,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ function minimalSnapshot() {
|
|||||||
},
|
},
|
||||||
distributionBoards: [],
|
distributionBoards: [],
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
|
externalModel: {
|
||||||
|
source: null,
|
||||||
|
importBatches: [],
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [],
|
||||||
|
},
|
||||||
circuitLists: [],
|
circuitLists: [],
|
||||||
circuitSections: [],
|
circuitSections: [],
|
||||||
distributionBoardComponents: [],
|
distributionBoardComponents: [],
|
||||||
@@ -149,6 +155,7 @@ describe("project state snapshot model", () => {
|
|||||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||||
|
delete (baseline as Record<string, unknown>).externalModel;
|
||||||
|
|
||||||
const upgraded = parseProjectStateSnapshot(baseline);
|
const upgraded = parseProjectStateSnapshot(baseline);
|
||||||
|
|
||||||
@@ -161,11 +168,35 @@ describe("project state snapshot model", () => {
|
|||||||
const previous = structuredClone(minimalSnapshot());
|
const previous = structuredClone(minimalSnapshot());
|
||||||
previous.schemaVersion = 2 as typeof previous.schemaVersion;
|
previous.schemaVersion = 2 as typeof previous.schemaVersion;
|
||||||
delete (previous as Record<string, unknown>).externalCsvConfiguration;
|
delete (previous as Record<string, unknown>).externalCsvConfiguration;
|
||||||
|
delete (previous as Record<string, unknown>).externalModel;
|
||||||
|
|
||||||
const upgraded = parseProjectStateSnapshot(previous);
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
|
||||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
assert.equal(upgraded.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<string, unknown>).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", () => {
|
it("rejects malformed baseline and unknown snapshot shapes", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user