Include external models in project snapshots
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user