import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; import { createDefaultExternalXlsxTransportConfiguration, type ExternalXlsxColumnConfiguration, type ExternalXlsxTransportConfiguration, } from "../xlsx/external-xlsx-contracts.js"; // Version 2 adds `deviceTypes[].isSocket`. Version 1 stays readable wherever // it is stored (configuration row, import-batch copies, history commands, // snapshots); socket classification then derives from the family rules // (`resolveSocketDeviceTypeIds`). export const externalModelConfigurationSchemaVersion = 2 as const; export const legacyExternalModelConfigurationSchemaVersion = 1 as const; export const externalQuantityRules = ["one", "socketCount"] as const; export type ExternalQuantityRule = (typeof externalQuantityRules)[number]; export interface ExternalDeviceTypeDefaults { powerPerUnitW: number | null; simultaneityFactor: number | null; cosPhi: number | null; costGroup: string | null; } export interface ExternalDeviceTypeConfiguration { id: string; name: string; category: CircuitGroupCategory; defaults: ExternalDeviceTypeDefaults; // Required from schema version 2, absent in version 1. isSocket?: boolean; } export interface ExternalFamilyTypeRule { id: string; exactFamilyAndType: string; deviceTypeId: string; quantityRule: ExternalQuantityRule; } export interface ExternalModelConfiguration extends ExternalXlsxTransportConfiguration { schemaVersion: | typeof externalModelConfigurationSchemaVersion | typeof legacyExternalModelConfigurationSchemaVersion; deviceTypes: ExternalDeviceTypeConfiguration[]; familyTypeRules: ExternalFamilyTypeRule[]; } export function createDefaultExternalModelConfiguration(): ExternalModelConfiguration { return { schemaVersion: externalModelConfigurationSchemaVersion, ...createDefaultExternalXlsxTransportConfiguration(), deviceTypes: [], familyTypeRules: [], }; } export function assertExternalModelConfiguration( value: unknown ): asserts value is ExternalModelConfiguration { if ( !isRecord(value) || (value.schemaVersion !== externalModelConfigurationSchemaVersion && value.schemaVersion !== legacyExternalModelConfigurationSchemaVersion) ) { throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration."); } assertExactKeys(value, [ "schemaVersion", "layout", "circuitMode", "columns", "deviceTypes", "familyTypeRules", ], "Revit-XLSX-Konfiguration"); assertLayout(value.layout); if (value.circuitMode !== "combined" && value.circuitMode !== "separate") { throw new Error("Der Stromkreismodus der Revit-XLSX-Konfiguration ist ungültig."); } assertColumns(value.columns, value.circuitMode); if (!Array.isArray(value.deviceTypes) || !Array.isArray(value.familyTypeRules)) { throw new Error("Gerätearten und Familienregeln müssen Listen sein."); } const deviceTypeIds = new Set(); const deviceTypeNames = new Set(); for (const [index, deviceType] of value.deviceTypes.entries()) { assertDeviceType( deviceType, index, value.schemaVersion === externalModelConfigurationSchemaVersion ); assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID"); assertUnique(deviceTypeNames, deviceType.name, "Geräteartname"); } const ruleIds = new Set(); const familyNames = new Set(); for (const [index, rule] of value.familyTypeRules.entries()) { assertFamilyRule(rule, index); assertUnique(ruleIds, rule.id, "Familienregel-ID"); assertUnique(familyNames, rule.exactFamilyAndType, "Familie und Typ"); if (!deviceTypeIds.has(rule.deviceTypeId)) { throw new Error( `Familienregel ${index + 1} verweist auf eine unbekannte Geräteart.` ); } } } export function validateExternalModelConfiguration(value: unknown): | { success: true; data: ExternalModelConfiguration } | { success: false; error: string } { try { assertExternalModelConfiguration(value); return { success: true, data: value }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Ungültige Konfiguration.", }; } } function assertLayout(value: unknown) { if (!isRecord(value)) throw new Error("Das XLSX-Layout fehlt."); assertExactKeys(value, ["headerRow", "firstDataRow", "firstDataColumn"], "XLSX-Layout"); assertPositiveInteger(value.headerRow, "Kopfzeile"); assertPositiveInteger(value.firstDataRow, "erste Datenzeile"); assertPositiveInteger(value.firstDataColumn, "erste Datenspalte"); if ((value.firstDataRow as number) <= (value.headerRow as number)) { throw new Error("Die erste Datenzeile muss nach der Kopfzeile liegen."); } } function assertColumns( value: unknown, circuitMode: ExternalModelConfiguration["circuitMode"] ) { if (!isRecord(value)) throw new Error("Die XLSX-Spaltenzuordnung fehlt."); const keys = [ "worksharingId", "ifcGuid", "roomNumber", "roomName", "familyAndType", "selectionMarker", "socketCount", "sourceRemark", "circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA", ] satisfies (keyof ExternalXlsxColumnConfiguration)[]; assertExactKeys(value, keys, "XLSX-Spaltenzuordnung"); const mappedNames = new Set(); for (const key of keys) { const columnName = value[key]; const required = key === "worksharingId" || key === "familyAndType" || (circuitMode === "combined" && key === "circuitCombined") || (circuitMode === "separate" && (key === "boardName" || key === "circuitIdentifier")); if (columnName === null && !required) continue; assertNonEmptyString(columnName, `Spalte ${key}`); assertUnique(mappedNames, columnName, "Spaltenzuordnung"); } } function assertDeviceType( value: unknown, index: number, hasSocketFlag: boolean ): asserts value is ExternalDeviceTypeConfiguration { if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`); assertExactKeys( value, hasSocketFlag ? ["id", "name", "category", "defaults", "isSocket"] : ["id", "name", "category", "defaults"], `Geräteart ${index + 1}` ); if (hasSocketFlag && typeof value.isSocket !== "boolean") { throw new Error(`Geräteart ${index + 1}.isSocket muss ja oder nein sein.`); } assertUuid(value.id, `Geräteart ${index + 1}.id`); assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`); if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) { throw new Error(`Geräteart ${index + 1} besitzt eine ungültige Kategorie.`); } if (!isRecord(value.defaults)) throw new Error(`Geräteart ${index + 1}.defaults fehlt.`); assertExactKeys( value.defaults, ["powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup"], `Geräteart ${index + 1}.defaults` ); assertNullableNumber(value.defaults.powerPerUnitW, 0, Number.POSITIVE_INFINITY, "Leistung"); assertNullableNumber(value.defaults.simultaneityFactor, 0, 1, "GZF"); assertNullableNumber(value.defaults.cosPhi, Number.MIN_VALUE, 1, "cosPhi"); if (value.defaults.costGroup !== null) { assertNonEmptyString(value.defaults.costGroup, "Kostengruppe"); } } function assertFamilyRule(value: unknown, index: number): asserts value is ExternalFamilyTypeRule { if (!isRecord(value)) throw new Error(`Familienregel ${index + 1} ist ungültig.`); assertExactKeys( value, ["id", "exactFamilyAndType", "deviceTypeId", "quantityRule"], `Familienregel ${index + 1}` ); assertUuid(value.id, `Familienregel ${index + 1}.id`); assertNonEmptyString(value.exactFamilyAndType, `Familienregel ${index + 1}.exactFamilyAndType`); assertUuid(value.deviceTypeId, `Familienregel ${index + 1}.deviceTypeId`); if (!externalQuantityRules.includes(value.quantityRule as ExternalQuantityRule)) { throw new Error(`Familienregel ${index + 1} besitzt eine ungültige Mengenregel.`); } } function assertPositiveInteger(value: unknown, label: string) { if (!Number.isSafeInteger(value) || (value as number) < 1) { throw new Error(`${label} muss eine positive Ganzzahl sein.`); } } function assertNullableNumber( value: unknown, minimum: number, maximum: number, label: string ) { if (value === null) return; if ( typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum ) { throw new Error(`${label} ist ungültig.`); } } function assertNonEmptyString(value: unknown, label: string): asserts value is string { if (typeof value !== "string" || value.length === 0 || value !== value.trim()) { throw new Error(`${label} muss nichtleer und ohne äußere Leerzeichen sein.`); } } function assertUuid(value: unknown, label: string): asserts value is string { if ( typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ) { throw new Error(`${label} muss eine UUID sein.`); } } function assertUnique(values: Set, value: string, label: string) { if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`); values.add(value); } function assertExactKeys(value: Record, keys: readonly string[], label: string) { const actual = Object.keys(value).sort(); const expected = [...keys].sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { throw new Error(`${label} enthält unbekannte oder fehlende Felder.`); } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }