import { z } from "zod"; import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js"; import { circuitGroupCategories, } from "../../shared/constants/circuit-group.js"; import { distributionBoardComponentPlacements, distributionBoardComponentRoles, } from "../../shared/constants/distribution-board-component.js"; import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; import { breakerTripCharacteristics, fuseUtilizationCategories, protectionDeviceTypes, rcdTypes, } from "../../shared/constants/protection-device.js"; import { resolveCircuitPhaseType, resolveProjectVoltage, } 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, type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; export const projectStateSnapshotSchemaVersion = 5 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const; const externalModelProjectStateSnapshotSchemaVersion = 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(); const previousProjectSchema = z .object({ id: idSchema, name: z.string().trim().min(1), internalProjectNumber: z.string().trim().min(1).max(100).nullable(), externalProjectNumber: z.string().trim().min(1).max(100).nullable(), buildingOwner: z.string().trim().min(1).max(200).nullable(), description: z.string().trim().min(1).max(2000).nullable(), singlePhaseVoltageV: finiteNumberSchema.positive(), threePhaseVoltageV: finiteNumberSchema.positive(), enabledDistributionBoardSupplyTypes: z .array(z.enum(distributionBoardSupplyTypes)) .min(1) .refine((values) => new Set(values).size === values.length), }) .strict(); const projectSchema = previousProjectSchema .extend({ isPublicBuilding: z.boolean() }) .strict(); const distributionBoardSchema = z .object({ id: idSchema, projectId: idSchema, name: z.string().trim().min(1), floorId: idSchema.nullable(), supplyType: z.enum(distributionBoardSupplyTypes).nullable(), simultaneityFactor: finiteNumberSchema.min(0).max(1), }) .strict(); const circuitListSchema = z .object({ id: idSchema, projectId: idSchema, distributionBoardId: idSchema, name: z.string().trim().min(1), }) .strict(); const circuitSectionSchema = z .object({ id: idSchema, circuitListId: idSchema, key: z.string().trim().min(1), displayName: z.string().trim().min(1), prefix: z.string().trim().min(1), sortOrder: finiteNumberSchema, category: z.enum(circuitGroupCategories).nullable(), groupNumber: z.number().int().positive().nullable(), }) .strict() .superRefine((section, context) => { if ((section.category === null) !== (section.groupNumber === null)) { context.addIssue({ code: "custom", message: "Circuit-section category and group number must both be set or both be null.", }); } }); const circuitDeviceRowSchema = z.preprocess( (value) => { if (value === null || typeof value !== "object" || Array.isArray(value)) return value; const row = value as Record; return Object.prototype.hasOwnProperty.call(row, "manualQuantity") ? row : { ...row, manualQuantity: row.quantity }; }, z.object({ id: idSchema, circuitId: idSchema, linkedProjectDeviceId: idSchema.nullable(), sortOrder: finiteNumberSchema, name: z.string().trim().min(1), displayName: z.string().trim().min(1), phaseType: z.enum(["single_phase", "three_phase"]).nullable(), connectionKind: nullableStringSchema, costGroup: nullableStringSchema, category: nullableStringSchema, level: nullableStringSchema, roomId: idSchema.nullable(), roomNumberSnapshot: nullableStringSchema, roomNameSnapshot: nullableStringSchema, quantity: finiteNumberSchema.nonnegative(), manualQuantity: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(), simultaneityFactor: finiteNumberSchema.nonnegative(), cosPhi: finiteNumberSchema.positive().nullable(), remark: nullableStringSchema, overriddenFields: nullableStringSchema, }) .strict() ); const circuitSchema = z .object({ id: idSchema, circuitListId: idSchema, sectionId: idSchema, equipmentIdentifier: z.string().trim().min(1), displayName: nullableStringSchema, sortOrder: finiteNumberSchema, cableType: nullableStringSchema, cableCrossSection: nullableStringSchema, cableLength: finiteNumberSchema.nonnegative().nullable(), rcdAssignment: nullableStringSchema, terminalDesignation: nullableStringSchema, voltage: finiteNumberSchema.positive().nullable(), controlRequirement: nullableStringSchema, status: nullableStringSchema, isReserve: z.boolean(), remark: nullableStringSchema, deviceRows: z.array(circuitDeviceRowSchema), }) .strict(); const projectDeviceSchema = z .object({ id: idSchema, projectId: idSchema, name: z.string().trim().min(1), displayName: z.string().trim().min(1), phaseType: z.enum(["single_phase", "three_phase"]), connectionKind: nullableStringSchema, costGroup: nullableStringSchema, category: nullableStringSchema, quantity: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(), simultaneityFactor: finiteNumberSchema.min(0).max(1), cosPhi: finiteNumberSchema.min(0).max(1).nullable(), remark: nullableStringSchema, voltageV: finiteNumberSchema.positive().nullable(), }) .strict(); const floorSchema = z .object({ id: idSchema, projectId: idSchema, name: z.string().trim().min(1), sortOrder: finiteNumberSchema, }) .strict(); const roomSchema = z .object({ id: idSchema, projectId: idSchema, floorId: idSchema.nullable(), roomNumber: z.string().trim().min(1), roomName: z.string().trim().min(1), }) .strict(); const distributionBoardComponentSchema = z .object({ id: idSchema, circuitListId: idSchema, sectionId: idSchema.nullable(), equipmentIdentifier: z.string().trim().min(1), name: z.string().trim().min(1), role: z.enum(distributionBoardComponentRoles), placement: z.enum(distributionBoardComponentPlacements), sortOrder: finiteNumberSchema, }) .strict(); const persistedProtectionDeviceFields = { type: z.enum(protectionDeviceTypes), ratedCurrentA: finiteNumberSchema.positive(), fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(), tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(), rcdType: z.enum(rcdTypes).nullable(), ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(), }; function validatePersistedProtectionDevice( value: { type: (typeof protectionDeviceTypes)[number]; ratedCurrentA: number; fuseUtilizationCategory: | (typeof fuseUtilizationCategories)[number] | null; tripCharacteristic: | (typeof breakerTripCharacteristics)[number] | null; rcdType: (typeof rcdTypes)[number] | null; ratedResidualCurrentMa: number | null; }, context: z.RefinementCtx ) { const result = protectionDeviceConfigurationSchema.safeParse({ type: value.type, ratedCurrentA: value.ratedCurrentA, ...(value.fuseUtilizationCategory === null ? {} : { fuseUtilizationCategory: value.fuseUtilizationCategory }), ...(value.tripCharacteristic === null ? {} : { tripCharacteristic: value.tripCharacteristic }), ...(value.rcdType === null ? {} : { rcdType: value.rcdType }), ...(value.ratedResidualCurrentMa === null ? {} : { ratedResidualCurrentMa: value.ratedResidualCurrentMa }), }); if (!result.success) { context.addIssue({ code: "custom", message: "Snapshot protection-device configuration is invalid.", }); } } const circuitProtectionDeviceSchema = z .object({ circuitId: idSchema, ...persistedProtectionDeviceFields, }) .strict() .superRefine(validatePersistedProtectionDevice); const componentProtectionDeviceSchema = z .object({ componentId: idSchema, ...persistedProtectionDeviceFields, }) .strict() .superRefine(validatePersistedProtectionDevice); const externalCsvConfigurationSnapshotSchema = z .object({ id: idSchema, projectId: idSchema, configurationVersion: z.number().int().positive(), configuration: z.custom( (value) => validateExternalCsvConfiguration(value).success ), }) .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(), configurationVersion: z.number().int().positive(), 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(); export function parseExternalModelStateSnapshot( value: unknown ): ExternalModelStateSnapshot { return externalModelStateSchema.parse(value); } const projectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), circuits: z.array(circuitSchema), projectDevices: z.array(projectDeviceSchema), floors: z.array(floorSchema), rooms: z.array(roomSchema), distributionBoardComponents: z.array(distributionBoardComponentSchema), circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), distributionBoardComponentProtectionDevices: z.array( componentProtectionDeviceSchema ), }; export const projectStateSnapshotSchema = z .object({ schemaVersion: z.literal(projectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, ...projectStateSnapshotContents, }) .strict(); const previousProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, ...projectStateSnapshotContents, }) .strict(); const externalModelProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion), 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(); const baselineProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), project: previousProjectSchema, distributionBoards: z.array(distributionBoardSchema), ...projectStateSnapshotContents, }) .strict(); export type ProjectStateSnapshot = z.infer< typeof projectStateSnapshotSchema >; export function parseProjectStateSnapshot( value: unknown ): ProjectStateSnapshot { const parsedSnapshot = projectStateSnapshotSchema.parse( upgradePreviousProjectStateSnapshot(value) ); const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot); assertProjectStateSnapshotRelations(snapshot); return snapshot; } function upgradePreviousProjectStateSnapshot(value: unknown): unknown { if ( value === null || typeof value !== "object" || Array.isArray(value) || typeof (value as { schemaVersion?: unknown }).schemaVersion !== "number" ) { return value; } const schemaVersion = (value as { schemaVersion: number }).schemaVersion; if (schemaVersion === previousProjectStateSnapshotSchemaVersion) { const previous = previousProjectStateSnapshotSchema.parse(value); return { ...previous, schemaVersion: projectStateSnapshotSchemaVersion, }; } if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) { const previous = externalModelProjectStateSnapshotSchema.parse(value); 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) { const baseline = baselineProjectStateSnapshotSchema.parse(value); return { ...baseline, 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 { const sectionById = new Map( snapshot.circuitSections.map((section) => [section.id, section]) ); const projectDeviceById = new Map( snapshot.projectDevices.map((device) => [device.id, device]) ); return { ...snapshot, circuits: snapshot.circuits.map((circuit) => { const section = sectionById.get(circuit.sectionId); const deviceRows = circuit.deviceRows.map((row) => { const linkedPhaseType = row.linkedProjectDeviceId ? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType : undefined; return { ...row, phaseType: row.phaseType ?? linkedPhaseType ?? (section?.key === "three_phase" ? "three_phase" : "single_phase"), }; }); return { ...circuit, voltage: section ? resolveProjectVoltage( resolveCircuitPhaseType( section.key, deviceRows.map((row) => row.phaseType) ), snapshot.project ) : circuit.voltage, deviceRows, }; }), }; } export function serializeProjectStateSnapshot( snapshot: ProjectStateSnapshot ): string { return JSON.stringify(parseProjectStateSnapshot(snapshot)); } export function deserializeProjectStateSnapshot( serialized: string ): ProjectStateSnapshot { return parseProjectStateSnapshot(JSON.parse(serialized) as unknown); } function assertProjectStateSnapshotRelations( snapshot: ProjectStateSnapshot ) { const projectId = snapshot.project.id; if ( snapshot.externalCsvConfiguration !== null && snapshot.externalCsvConfiguration.projectId !== projectId ) { throw new Error("Snapshot external CSV configuration belongs to a different project."); } const boardIds = uniqueIds( snapshot.distributionBoards, "distribution board" ); const circuitListIds = uniqueIds( snapshot.circuitLists, "circuit list" ); const sectionIds = uniqueIds( snapshot.circuitSections, "circuit section" ); const projectDeviceIds = uniqueIds( snapshot.projectDevices, "project device" ); const floorIds = uniqueIds(snapshot.floors, "floor"); const roomIds = uniqueIds(snapshot.rooms, "room"); const circuitIds = uniqueIds(snapshot.circuits, "circuit"); const componentIds = uniqueIds( snapshot.distributionBoardComponents, "distribution board component" ); const sectionById = new Map( snapshot.circuitSections.map((entry) => [entry.id, entry]) ); const componentById = new Map( snapshot.distributionBoardComponents.map((entry) => [ entry.id, entry, ]) ); const groupKeys = new Set(); const equipmentIdentifiersByList = new Map>(); for (const board of snapshot.distributionBoards) { assertProjectOwnership(board.projectId, projectId, "distribution board"); if (board.floorId !== null) { assertReference( floorIds, board.floorId, "distribution board floor" ); } if ( board.supplyType !== null && !snapshot.project.enabledDistributionBoardSupplyTypes.includes( board.supplyType ) ) { throw new Error( "Snapshot distribution board uses a disabled supply type." ); } } for (const list of snapshot.circuitLists) { assertProjectOwnership(list.projectId, projectId, "circuit list"); assertReference( boardIds, list.distributionBoardId, "circuit list distribution board" ); } for (const section of snapshot.circuitSections) { assertReference( circuitListIds, section.circuitListId, "circuit section list" ); if (section.category !== null && section.groupNumber !== null) { const groupKey = [ section.circuitListId, section.category, section.groupNumber, ].join(":"); if (groupKeys.has(groupKey)) { throw new Error( "Snapshot contains a duplicate circuit group number." ); } groupKeys.add(groupKey); } } for (const device of snapshot.projectDevices) { assertProjectOwnership(device.projectId, projectId, "project device"); if ( device.voltageV !== resolveProjectVoltage(device.phaseType, snapshot.project) ) { throw new Error( "Snapshot project device voltage must match project settings." ); } } for (const floor of snapshot.floors) { assertProjectOwnership(floor.projectId, projectId, "floor"); } for (const room of snapshot.rooms) { assertProjectOwnership(room.projectId, projectId, "room"); if (room.floorId !== null) { assertReference(floorIds, room.floorId, "room floor"); } } const deviceRowIds = new Set(); for (const circuit of snapshot.circuits) { assertReference( circuitListIds, circuit.circuitListId, "circuit list" ); assertReference(sectionIds, circuit.sectionId, "circuit section"); const section = sectionById.get(circuit.sectionId)!; if (section.circuitListId !== circuit.circuitListId) { throw new Error( "Snapshot circuit section belongs to a different circuit list." ); } if (circuit.isReserve !== (circuit.deviceRows.length === 0)) { throw new Error( "Snapshot circuit reserve state must match its device rows." ); } const expectedVoltage = resolveProjectVoltage( resolveCircuitPhaseType( section.key, circuit.deviceRows.map((row) => row.phaseType) ), snapshot.project ); if (circuit.voltage !== expectedVoltage) { throw new Error( "Snapshot circuit voltage must match project settings." ); } registerEquipmentIdentifier( equipmentIdentifiersByList, circuit.circuitListId, circuit.equipmentIdentifier ); for (const row of circuit.deviceRows) { if (row.circuitId !== circuit.id) { throw new Error( "Snapshot device row belongs to a different circuit." ); } if (deviceRowIds.has(row.id)) { throw new Error("Snapshot contains duplicate device row ids."); } deviceRowIds.add(row.id); if (row.linkedProjectDeviceId !== null) { assertReference( projectDeviceIds, row.linkedProjectDeviceId, "device row project device" ); } if (row.roomId !== null) { assertReference(roomIds, row.roomId, "device row room"); } if (row.manualQuantity > row.quantity) { throw new Error( "Snapshot device row manual quantity must not exceed total quantity." ); } } } const fixedComponentRolesByList = new Set(); const groupComponentRolesBySection = new Set(); for (const component of snapshot.distributionBoardComponents) { assertReference( circuitListIds, component.circuitListId, "distribution board component circuit list" ); if (component.sectionId !== null) { assertReference( sectionIds, component.sectionId, "distribution board component section" ); if ( sectionById.get(component.sectionId)?.circuitListId !== component.circuitListId ) { throw new Error( "Snapshot distribution board component section belongs to a different circuit list." ); } } assertComponentPlacement(component); registerEquipmentIdentifier( equipmentIdentifiersByList, component.circuitListId, component.equipmentIdentifier ); if ( component.role === "main_switch" || component.role === "surge_protective_device" ) { assertUniqueKey( fixedComponentRolesByList, `${component.circuitListId}:${component.role}`, "Snapshot contains duplicate fixed distribution board components." ); } if ( component.role === "group_upstream_protection" || component.role === "group_residual_current_protection" ) { assertUniqueKey( groupComponentRolesBySection, `${component.sectionId}:${component.role}`, "Snapshot contains duplicate group protection components." ); } } const protectedCircuitIds = new Set(); for (const protection of snapshot.circuitProtectionDevices) { assertReference( circuitIds, protection.circuitId, "circuit protection device circuit" ); assertUniqueKey( protectedCircuitIds, protection.circuitId, "Snapshot contains duplicate circuit protection devices." ); } const protectedComponentIds = new Set(); for (const protection of snapshot.distributionBoardComponentProtectionDevices) { assertReference( componentIds, protection.componentId, "component protection device component" ); assertUniqueKey( protectedComponentIds, protection.componentId, "Snapshot contains duplicate component protection devices." ); const component = componentById.get(protection.componentId)!; if ( component.role !== "group_upstream_protection" && component.role !== "group_residual_current_protection" ) { throw new Error( "Snapshot protection data belongs to a non-protection component." ); } } 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( component: ProjectStateSnapshot["distributionBoardComponents"][number] ) { const isFixedHeader = component.role === "main_switch" || component.role === "surge_protective_device"; const isGroupProtection = component.role === "group_upstream_protection" || component.role === "group_residual_current_protection"; if ( (isFixedHeader && (component.placement !== "header" || component.sectionId !== null)) || (isGroupProtection && (component.placement !== "group" || component.sectionId === null)) || (component.role === "auxiliary" && (component.placement !== "footer" || component.sectionId !== null)) ) { throw new Error( "Snapshot distribution board component placement is invalid." ); } } function registerEquipmentIdentifier( identifiersByList: Map>, circuitListId: string, equipmentIdentifier: string ) { const identifiers = identifiersByList.get(circuitListId) ?? new Set(); const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de"); if (identifiers.has(normalized)) { throw new Error( "Snapshot contains duplicate equipment identifiers in one circuit list." ); } identifiers.add(normalized); identifiersByList.set(circuitListId, identifiers); } function assertUniqueKey( keys: Set, key: string, message: string ) { if (keys.has(key)) { throw new Error(message); } keys.add(key); } function uniqueIds( entries: ReadonlyArray<{ id: string }>, label: string ) { const ids = new Set(); for (const entry of entries) { if (ids.has(entry.id)) { throw new Error(`Snapshot contains duplicate ${label} ids.`); } ids.add(entry.id); } return ids; } function assertProjectOwnership( actualProjectId: string, expectedProjectId: string, label: string ) { if (actualProjectId !== expectedProjectId) { throw new Error(`Snapshot ${label} belongs to a different project.`); } } function assertReference( ids: ReadonlySet, id: string, label: string ) { if (!ids.has(id)) { throw new Error(`Snapshot ${label} reference is invalid.`); } }