Add external model persistence foundation
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
||||
import type {
|
||||
ExternalModelObjectPlanningValues,
|
||||
ExternalModelObjectSourceValues,
|
||||
} from "./external-model-contracts.js";
|
||||
|
||||
export type InitialExternalObjectIssue =
|
||||
| "unknown-family-and-type"
|
||||
| "invalid-power"
|
||||
| "invalid-quantity"
|
||||
| "missing-room";
|
||||
|
||||
export interface InitialExternalObjectProjection {
|
||||
ifcGuid: string;
|
||||
sourceValues: ExternalModelObjectSourceValues;
|
||||
planningValues: ExternalModelObjectPlanningValues;
|
||||
issues: InitialExternalObjectIssue[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectImportCandidate {
|
||||
rowNumber: number;
|
||||
ifcGuid: string;
|
||||
roomNumber: string;
|
||||
roomName: string;
|
||||
familyAndType: string;
|
||||
selectionMarker: string;
|
||||
circuitIdentifier: string;
|
||||
power: string;
|
||||
quantity: string | null;
|
||||
additionalSourceValues: Record<string, string>;
|
||||
}
|
||||
|
||||
export function normalizeExternalRoomPart(value: string): string {
|
||||
return value.trim().normalize("NFKC").toLocaleUpperCase("de-DE");
|
||||
}
|
||||
|
||||
export function createExternalRoomKey(
|
||||
roomNumber: string,
|
||||
roomName: string
|
||||
): string | null {
|
||||
const normalizedNumber = normalizeExternalRoomPart(roomNumber);
|
||||
if (normalizedNumber) return `number:${normalizedNumber}`;
|
||||
const normalizedName = normalizeExternalRoomPart(roomName);
|
||||
return normalizedName ? `name:${normalizedName}` : null;
|
||||
}
|
||||
|
||||
export function indexExternalObjectsByIfcGuid<T extends { ifcGuid: string }>(
|
||||
objects: readonly T[]
|
||||
): Map<string, T> {
|
||||
const index = new Map<string, T>();
|
||||
for (const object of objects) {
|
||||
if (!object.ifcGuid || object.ifcGuid !== object.ifcGuid.trim()) {
|
||||
throw new Error("IfcGUID must be non-empty and must not contain outer whitespace.");
|
||||
}
|
||||
if (index.has(object.ifcGuid)) {
|
||||
throw new Error(`Duplicate IfcGUID: ${object.ifcGuid}`);
|
||||
}
|
||||
index.set(object.ifcGuid, object);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function projectInitialExternalObject(
|
||||
object: ExternalObjectImportCandidate,
|
||||
configuration: ExternalCsvConfiguration
|
||||
): InitialExternalObjectProjection {
|
||||
const rule = configuration.familyTypeRules.find(
|
||||
(candidate) => candidate.exactFamilyAndType === object.familyAndType
|
||||
);
|
||||
const issues: InitialExternalObjectIssue[] = [];
|
||||
if (!rule) issues.push("unknown-family-and-type");
|
||||
if (!createExternalRoomKey(object.roomNumber, object.roomName)) {
|
||||
issues.push("missing-room");
|
||||
}
|
||||
|
||||
const parsedPower = parseConfiguredNumber(object.power, configuration);
|
||||
if (object.power.trim() && parsedPower === null) issues.push("invalid-power");
|
||||
|
||||
let effectiveQuantity = 1;
|
||||
if (rule?.quantityRule.kind === "fixed") {
|
||||
effectiveQuantity = rule.quantityRule.quantity;
|
||||
} else if (rule?.quantityRule.kind === "mapped-column") {
|
||||
const parsedQuantity = parseConfiguredNumber(object.quantity ?? "", configuration);
|
||||
if (parsedQuantity === null || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) {
|
||||
issues.push("invalid-quantity");
|
||||
} else {
|
||||
effectiveQuantity = parsedQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ifcGuid: object.ifcGuid,
|
||||
sourceValues: {
|
||||
rowNumber: object.rowNumber,
|
||||
roomNumber: object.roomNumber,
|
||||
roomName: object.roomName,
|
||||
familyAndType: object.familyAndType,
|
||||
selectionMarker: object.selectionMarker,
|
||||
circuitIdentifier: object.circuitIdentifier,
|
||||
power: object.power,
|
||||
quantity: object.quantity,
|
||||
additionalSourceValues: { ...object.additionalSourceValues },
|
||||
},
|
||||
planningValues: {
|
||||
displayName: rule
|
||||
? resolveDisplayName(rule.displayNameSuggestion, object)
|
||||
: null,
|
||||
internalDeviceType: rule?.internalDeviceType ?? null,
|
||||
category: rule?.category ?? null,
|
||||
connectionKind: rule?.connectionKind ?? null,
|
||||
effectiveQuantity,
|
||||
powerPerUnitW:
|
||||
parsedPower === null ? null : parsedPower * configuration.wattsPerSourceUnit,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: null,
|
||||
costGroup: null,
|
||||
remark: null,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfiguredNumber(
|
||||
value: string,
|
||||
configuration: ExternalCsvConfiguration
|
||||
): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const normalized = configuration.decimalSeparator === ","
|
||||
? trimmed.replace(",", ".")
|
||||
: trimmed;
|
||||
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
suggestion: ExternalCsvConfiguration["familyTypeRules"][number]["displayNameSuggestion"],
|
||||
object: ExternalObjectImportCandidate
|
||||
): string | null {
|
||||
if (suggestion === null) return null;
|
||||
if (suggestion.kind === "fixed") return suggestion.value;
|
||||
if (suggestion.kind === "selection-marker") {
|
||||
return object.selectionMarker.trim() || null;
|
||||
}
|
||||
return object.familyAndType.trim() || null;
|
||||
}
|
||||
Reference in New Issue
Block a user