Replace Revit CSV backend with Linear XLSX

This commit is contained in:
Julian Appel 2026-09-30 08:46:07 +02:00
parent 52d3b1a270
commit 1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions

View 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);
}