forked from jappel/leistungsbilanz-ts
Replace Revit CSV backend with Linear XLSX
This commit is contained in:
parent
52d3b1a270
commit
1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions
246
src/external-model/configuration/external-model-configuration.ts
Normal file
246
src/external-model/configuration/external-model-configuration.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import {
|
||||
createDefaultExternalXlsxTransportConfiguration,
|
||||
type ExternalXlsxColumnConfiguration,
|
||||
type ExternalXlsxTransportConfiguration,
|
||||
} from "../xlsx/external-xlsx-contracts.js";
|
||||
|
||||
export const externalModelConfigurationSchemaVersion = 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;
|
||||
}
|
||||
|
||||
export interface ExternalFamilyTypeRule {
|
||||
id: string;
|
||||
exactFamilyAndType: string;
|
||||
deviceTypeId: string;
|
||||
quantityRule: ExternalQuantityRule;
|
||||
}
|
||||
|
||||
export interface ExternalModelConfiguration
|
||||
extends ExternalXlsxTransportConfiguration {
|
||||
schemaVersion: typeof externalModelConfigurationSchemaVersion;
|
||||
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) {
|
||||
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);
|
||||
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): asserts value is ExternalDeviceTypeConfiguration {
|
||||
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
|
||||
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`);
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue