leistungsbilanz-ts/src/external-model/application/external-xlsx-follow-up-target.ts

138 lines
11 KiB
TypeScript

import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelConfiguration } from "../configuration/external-model-configuration.js";
import type { ExternalModelObjectSnapshot, ExternalModelObjectSourceValues, ExternalModelStateSnapshot } from "../domain/external-model-contracts.js";
import type { ExternalXlsxDocument } from "../xlsx/external-xlsx-contracts.js";
import type { ExternalFamilyProjectDeviceDecision, ExternalRoomDecision } from "./external-xlsx-initial-import-target.js";
import type { ExternalSourceDecisionField, ExternalXlsxFollowUpPlan } from "./external-xlsx-follow-up-import.js";
export interface ExternalSourceChangeDecision { objectId: string; accept: boolean; }
export interface ExternalRowQuantityTransition { expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot; }
export function buildExternalXlsxFollowUpTarget(input: {
projectId: string;
expectedRevision: number;
importedAtIso: string;
configuration: ExternalModelConfiguration;
originalContentBase64: string;
document: ExternalXlsxDocument;
plan: ExternalXlsxFollowUpPlan;
currentState: ExternalModelStateSnapshot;
sourceChangeDecisions: ExternalSourceChangeDecision[];
roomDecisions: ExternalRoomDecision[];
familyProjectDeviceDecisions: ExternalFamilyProjectDeviceDecision[];
objectsWithoutRoomDefaultDistributionBoardId: string | null;
rowsById: ReadonlyMap<string, CircuitDeviceRowSnapshot>;
createId: () => string;
}): { targetState: ExternalModelStateSnapshot; rowTransitions: ExternalRowQuantityTransition[] } {
const source = input.currentState.source;
if (!source || source.projectId !== input.projectId) throw new Error("Für den Folgeimport fehlt die Revit-Quelle.");
const decisions = exactDecisionMap(input.plan.changes.map((change) => change.objectId), input.sourceChangeDecisions, (decision) => decision.objectId, "Quelländerung");
const newRoomSet = new Set(input.plan.newSourceRoomKeys);
const roomDecisions = exactDecisionMap(input.plan.newSourceRoomKeys, input.roomDecisions, (decision) => decision.sourceRoomKey, "Quellraum");
const newFamilySet = new Set(input.plan.newFamilyAndTypes);
const familyDecisions = exactDecisionMap(input.plan.newFamilyAndTypes, input.familyProjectDeviceDecisions, (decision) => decision.familyAndType, "Familie und Typ");
const batchId = input.createId();
const oldMappings = input.currentState.roomMappings.map((mapping) => ({ ...mapping }));
const mappingByKey = new Map(oldMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping]));
const newObjectIds = new Set(input.plan.newObjectWorksharingIds);
const neededRoomKeys = new Set(input.plan.objects.filter((object) => newObjectIds.has(object.worksharingId)).map((object) => object.sourceRoomKey).filter((key): key is string => key !== null));
for (const change of input.plan.changes) {
if (!decisions.get(change.objectId)!.accept || !change.changedFields.some((field) => field === "roomNumber" || field === "roomName")) continue;
const key = input.plan.objects.find((object) => object.worksharingId === change.worksharingId)?.sourceRoomKey;
if (key) neededRoomKeys.add(key);
}
for (const room of input.plan.sourceRooms.filter((candidate) => newRoomSet.has(candidate.sourceRoomKey) && neededRoomKeys.has(candidate.sourceRoomKey))) {
const decision = roomDecisions.get(room.sourceRoomKey)!;
const mapping = { id: input.createId(), projectId: input.projectId, sourceId: source.id, normalizedSourceRoomKey: room.sourceRoomKey, sourceFloorName: null, sourceRoomNumber: room.roomNumber, sourceRoomName: room.roomName, roomId: decision.roomId, defaultDistributionBoardId: decision.defaultDistributionBoardId };
oldMappings.push(mapping); mappingByKey.set(mapping.normalizedSourceRoomKey, mapping);
}
const incomingById = new Map(input.plan.objects.map((object) => [object.worksharingId, object]));
const existingByWorksharingId = new Map(input.currentState.objects.map((object) => [object.worksharingId, object]));
const changedById = new Map(input.plan.changes.map((change) => [change.objectId, change]));
const quantityDeltaByRow = new Map<string, number>();
const objects: ExternalModelObjectSnapshot[] = [];
for (const existing of input.currentState.objects) {
const incoming = incomingById.get(existing.worksharingId);
if (!incoming) { objects.push({ ...existing, presenceStatus: "missing" }); continue; }
const change = changedById.get(existing.id);
const accept = change ? decisions.get(existing.id)!.accept : false;
if (accept && incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Eine akzeptierte Quelländerung besitzt keine gültige Familien- oder Mengenregel.");
const planningValues = { ...existing.planningValues };
let deviceTypeId = existing.deviceTypeId;
let externalRoomMappingId = existing.externalRoomMappingId;
let distributionBoardId = existing.distributionBoardId;
let ifcGuid = existing.ifcGuid;
if (accept && change) {
const fields = new Set<ExternalSourceDecisionField>(change.changedFields);
if (fields.has("ifcGuid")) ifcGuid = incoming.ifcGuid;
if (fields.has("familyAndType")) {
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
if (!rule) throw new Error("Eine geänderte Familie ist nicht klassifiziert.");
deviceTypeId = rule.deviceTypeId;
const type = input.configuration.deviceTypes.find((candidate) => candidate.id === rule.deviceTypeId)!;
if (!existing.overriddenFields.includes("category")) planningValues.category = type.category;
}
if (fields.has("selectionMarker") && !existing.overriddenFields.includes("displayName")) planningValues.displayName = scalarText(incoming.sourceValues.selectionMarker) || incoming.sourceValues.familyAndType;
if (fields.has("sourceRemark") && !existing.overriddenFields.includes("remark")) planningValues.remark = scalarText(incoming.sourceValues.sourceRemark) || null;
if (fields.has("socketCount") && !existing.overriddenFields.includes("effectiveQuantity")) planningValues.effectiveQuantity = incoming.suggestedPlanningValues.effectiveQuantity;
if (fields.has("roomNumber") || fields.has("roomName")) {
const oldMapping = existing.externalRoomMappingId ? oldMappings.find((mapping) => mapping.id === existing.externalRoomMappingId) : null;
const nextMapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
externalRoomMappingId = nextMapping?.id ?? null;
if (!existing.circuitDeviceRowId && (existing.distributionBoardId === null || existing.distributionBoardId === oldMapping?.defaultDistributionBoardId)) distributionBoardId = nextMapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId;
}
}
if (existing.circuitDeviceRowId && planningValues.effectiveQuantity !== existing.planningValues.effectiveQuantity) {
quantityDeltaByRow.set(existing.circuitDeviceRowId, (quantityDeltaByRow.get(existing.circuitDeviceRowId) ?? 0) + planningValues.effectiveQuantity - existing.planningValues.effectiveQuantity);
}
objects.push({
...existing,
ifcGuid, deviceTypeId, externalRoomMappingId, distributionBoardId,
lastSeenImportBatchId: batchId,
lastAcceptedImportBatchId: change && !accept ? existing.lastAcceptedImportBatchId : batchId,
acceptedSourceValues: mergeAcceptedSource(existing.acceptedSourceValues, incoming.sourceValues, accept),
planningValues,
presenceStatus: "present",
});
}
for (const incoming of input.plan.objects.filter((object) => !existingByWorksharingId.has(object.worksharingId))) {
if (incoming.issues.some((issue) => issue === "unknown-family-and-type" || issue === "invalid-quantity")) throw new Error("Ein neues Revit-Objekt besitzt keine gültige Familien- oder Mengenregel.");
const rule = input.configuration.familyTypeRules.find((candidate) => candidate.exactFamilyAndType === incoming.sourceValues.familyAndType);
if (!rule) throw new Error("Eine neue Familie ist nicht klassifiziert.");
const mapping = incoming.sourceRoomKey ? mappingByKey.get(incoming.sourceRoomKey) ?? null : null;
objects.push({
id: input.createId(), projectId: input.projectId, sourceId: source.id, worksharingId: incoming.worksharingId, ifcGuid: incoming.ifcGuid,
deviceTypeId: rule.deviceTypeId, lastSeenImportBatchId: batchId, lastAcceptedImportBatchId: batchId,
acceptedSourceValues: incoming.sourceValues, planningValues: incoming.suggestedPlanningValues, overriddenFields: [],
externalRoomMappingId: mapping?.id ?? null,
distributionBoardId: mapping?.defaultDistributionBoardId ?? input.objectsWithoutRoomDefaultDistributionBoardId,
linkedProjectDeviceId: newFamilySet.has(incoming.sourceValues.familyAndType) ? familyDecisions.get(incoming.sourceValues.familyAndType)!.projectDeviceId : null,
circuitDeviceRowId: null, presenceStatus: "present",
});
}
const rowTransitions = [...quantityDeltaByRow].map(([rowId, delta]) => {
const expected = input.rowsById.get(rowId);
if (!expected) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
return { expected, target: { ...expected, quantity: expected.quantity + delta } };
});
const previousBatches = input.currentState.importBatches.map((batch) => ({ ...batch, appliedProjectRevision: null, configurationSnapshot: null, originalContentBase64: null, document: null }));
return {
targetState: {
source: { ...source },
importBatches: [...previousBatches, { id: batchId, projectId: input.projectId, sourceId: source.id, importKind: "follow_up", importedAtIso: input.importedAtIso, fileName: input.plan.fileName, sha256: input.plan.sha256, objectCount: input.plan.objectCount, appliedProjectRevision: input.expectedRevision + 1, configurationSnapshot: input.configuration, originalContentBase64: input.originalContentBase64, document: input.document }],
roomMappings: oldMappings,
objects,
},
rowTransitions,
};
}
const returnFields = new Set<keyof ExternalModelObjectSourceValues>(["rowNumber", "circuitCombined", "boardName", "circuitIdentifier", "designation", "electricalPowerW", "voltageV", "currentA"]);
function mergeAcceptedSource(previous: ExternalModelObjectSourceValues, incoming: ExternalModelObjectSourceValues, acceptSourceChanges: boolean) {
if (acceptSourceChanges) return incoming;
return Object.fromEntries(Object.keys(previous).map((key) => [key, returnFields.has(key as keyof ExternalModelObjectSourceValues) ? incoming[key as keyof ExternalModelObjectSourceValues] : previous[key as keyof ExternalModelObjectSourceValues]])) as unknown as ExternalModelObjectSourceValues;
}
function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value).trim(); }
function exactDecisionMap<T>(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { const expected = new Set(expectedKeys); const result = new Map<string, T>(); for (const decision of decisions) { const key = keyOf(decision); if (!expected.has(key) || result.has(key)) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); result.set(key, decision); } if (result.size !== expected.size) throw new Error(`${label}-Entscheidungen sind unvollständig oder doppelt.`); return result; }