Include external models in project snapshots

This commit is contained in:
2026-08-02 17:25:25 +02:00
parent 76493b573e
commit e87a3289fa
10 changed files with 685 additions and 15 deletions
@@ -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<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 = {
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<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(