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; 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(); 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(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(["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(expectedKeys: string[], decisions: T[], keyOf: (decision: T) => string, label: string) { const expected = new Set(expectedKeys); const result = new Map(); 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; }