forked from jappel/leistungsbilanz-ts
138 lines
11 KiB
TypeScript
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; }
|