forked from jappel/leistungsbilanz-ts
- Project setting maxSocketsPerCircuit (migration 0018, project.update-settings schema 4, snapshot schema 11); older command and snapshot versions keep or upgrade the value to null. - Revit configuration schema 2 marks device types as socket; version 1 stays readable and derives the flag from socketCount family rules. - Settings dialog field and device-type card on the Revit page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
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<string>();
|
|
const deviceTypeNames = new Set<string>();
|
|
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<string>();
|
|
const familyNames = new Set<string>();
|
|
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<string>();
|
|
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<string>, value: string, label: string) {
|
|
if (values.has(value)) throw new Error(`${label} ist nicht eindeutig: ${value}`);
|
|
values.add(value);
|
|
}
|
|
|
|
function assertExactKeys(value: Record<string, unknown>, 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<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|