loadEntityConfigurations(); // Only CSV/TXT supported if (! in_array($import->source_type, ['csv', 'txt'])) { return $this->errorPayload('Podprti so samo CSV/TXT formati.'); } $filePath = $import->path; if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) { return $this->errorPayload("Datoteka ni najdena: {$filePath}"); } $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); $fh = fopen($fullPath, 'r'); if (! $fh) { return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}"); } $meta = $import->meta ?? []; $hasHeader = (bool) ($meta['has_header'] ?? true); $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; $mappings = $this->loadMappings($import); if (empty($mappings)) { fclose($fh); return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); } $header = null; $rowNum = 0; // Read header if present if ($hasHeader) { $header = fgetcsv($fh, 0, $delimiter); $rowNum++; } $simRows = []; $summaries = $this->initSummaries(); $rowCount = 0; while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) { $rowNum++; $rowCount++; try { $rawAssoc = $this->buildRowAssoc($row, $header); // Skip empty rows if ($this->rowIsEffectivelyEmpty($rawAssoc)) { continue; } $mapped = $this->applyMappings($rawAssoc, $mappings); // Group mapped data by entity (from "entity.field" to nested structure) $groupedMapped = $this->groupMappedDataByEntity($mapped); \Log::info('ImportSimulation: Grouped entities', [ 'row' => $rowNum, 'entity_keys' => array_keys($groupedMapped), 'config_roots' => array_keys($this->entityConfigs), ]); // Simulate processing for this row // Context must include 'import' for EntityResolutionService to work $context = [ 'import' => $import, 'simulation' => true, ]; $rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose); // Update summaries - handle both single and array results foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) { // Extract entity root from key (e.g., 'person', 'contract', etc.) $root = explode('.', $entityKey)[0]; // Handle array of results (grouped entities) if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) { foreach ($entityDataOrArray as $entityData) { $action = $entityData['action'] ?? 'skip'; if (!isset($summaries[$root])) { $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; } $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; } } else { // Single result $action = $entityDataOrArray['action'] ?? 'skip'; if (!isset($summaries[$root])) { $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; } $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; } } $simRows[] = [ 'row_number' => $rowNum, 'raw_data' => $verbose ? $rawAssoc : null, 'entities' => $rowResult['entities'], 'warnings' => $rowResult['warnings'] ?? [], 'errors' => $rowResult['errors'] ?? [], ]; } catch (\Throwable $e) { $simRows[] = [ 'row_number' => $rowNum, 'raw_data' => $verbose ? ($rawAssoc ?? null) : null, 'entities' => [], 'warnings' => [], 'errors' => [$e->getMessage()], ]; } } fclose($fh); return [ 'success' => true, 'total_simulated' => $rowCount, 'limit' => $limit, 'summaries' => $summaries, 'rows' => $simRows, 'meta' => [ 'has_header' => $hasHeader, 'delimiter' => $delimiter, 'mappings_count' => count($mappings), ], ]; } catch (\Throwable $e) { return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage()); } } /** * Simulate processing a single row without database writes. * * Updated to match ImportServiceV2 logic: * - Process entities in priority order from entity configs * - Accumulate entity results in context for chain resolution * - Pass proper context to handlers for EntityResolutionService */ protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array { $entities = []; $warnings = []; $errors = []; $entityResults = []; // Process entities in configured priority order (like ImportServiceV2) foreach ($this->entityConfigs as $root => $config) { // Check if this entity exists in mapped data $mappedKey = $this->findMappedKey($mapped, $root, $config); if (!$mappedKey || !isset($mapped[$mappedKey])) { continue; } $handler = $this->handlers[$root] ?? null; if (!$handler) { continue; } try { // Check if this is an array of entities (grouped) $entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0]) ? $mapped[$mappedKey] : [$mapped[$mappedKey]]; $results = []; foreach ($entityDataArray as $entityData) { // Validate $validation = $handler->validate($entityData); if (!$validation['valid']) { $results[] = [ 'action' => 'invalid', 'data' => $entityData, 'errors' => $validation['errors'], ]; continue; } // Skip empty/invalid data that handlers would skip during real import // Phone: skip if nu is 0, empty, or #N/A if ($root === 'phone') { $nu = $entityData['nu'] ?? null; if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') { continue; // Skip this phone entirely } } // Address: skip if address is empty or #N/A if ($root === 'address') { $address = $entityData['address'] ?? null; if (empty($address) || $address === '#N/A' || trim((string)$address) === '') { continue; // Skip this address entirely } } // Email: skip if value is 0, empty, or #N/A if ($root === 'email') { $email = $entityData['value'] ?? null; if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') { continue; // Skip this email entirely } } // DEBUG: Log context for grouped entities if (in_array($root, ['phone', 'address'])) { Log::info("ImportSimulation: Resolving grouped entity", [ 'entity' => $root, 'data' => $entityData, 'has_person_in_context' => isset($entityResults['person']), 'person_id' => $entityResults['person']['entity']->id ?? null, 'context_keys' => array_keys(array_merge($context, $entityResults)), ]); } // Resolve existing entity (uses EntityResolutionService internally) // Pass accumulated entityResults as context for chain resolution try { $existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults)); } catch (\Throwable $resolutionError) { // In simulation mode, resolution may fail due to simulated entities // Just treat as new entity \Log::debug("ImportSimulation: Resolution failed (treating as new)", [ 'entity' => $root, 'error' => $resolutionError->getMessage(), ]); $existingEntity = null; } if ($existingEntity) { // Would update existing $results[] = [ 'action' => 'update', 'reference' => $this->getEntityReference($existingEntity, $root), 'existing_id' => $existingEntity->id ?? null, 'data' => $entityData, 'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null, 'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null, ]; // Add to entityResults for subsequent handlers $entityResults[$root] = [ 'entity' => $existingEntity, 'action' => 'updated', ]; } else { // Would create new $results[] = [ 'action' => 'create', 'data' => $entityData, ]; // Simulate entity creation for context (no actual ID) // Mark as simulated so resolution service knows not to use model methods $simulatedEntity = (object) $entityData; $simulatedEntity->_simulated = true; $entityResults[$root] = [ 'entity' => $simulatedEntity, 'action' => 'inserted', '_simulated' => true, ]; } } // Store results (single or array) $entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results; } catch (\Throwable $e) { $entities[$mappedKey] = [ 'action' => 'error', 'errors' => [$e->getMessage()], ]; $errors[] = "{$root}: {$e->getMessage()}"; } } return compact('entities', 'warnings', 'errors'); } /** * Find the mapped key for an entity (supports aliases and common variations). */ protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string { // Check canonical root exactly if (isset($mapped[$canonicalRoot])) { return $canonicalRoot; } // Build comprehensive list of variations $variations = [$canonicalRoot]; // Generate plural variations (handle -y endings correctly) if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { // activity -> activities $variations[] = substr($canonicalRoot, 0, -1) . 'ies'; } else { // address -> addresses $variations[] = $canonicalRoot . 's'; } // Add singular form (remove trailing s or ies) if (str_ends_with($canonicalRoot, 'ies')) { $variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity } else { $variations[] = rtrim($canonicalRoot, 's'); // addresses -> address } // Add person_ prefixed versions $variations[] = 'person_' . $canonicalRoot; // person_activity -> person_activities if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { $variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies'; } else { $variations[] = 'person_' . $canonicalRoot . 's'; } // Special handling: if canonical is 'address', also check 'person_addresses' if ($canonicalRoot === 'address') { $variations[] = 'person_addresses'; } // Special handling: if canonical is 'phone', also check 'person_phones' if ($canonicalRoot === 'phone') { $variations[] = 'person_phones'; } // Reverse: if canonical has 'person_', also check without it if (str_starts_with($canonicalRoot, 'person_')) { $withoutPerson = str_replace('person_', '', $canonicalRoot); $variations[] = $withoutPerson; // Handle plural variations if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) { $variations[] = substr($withoutPerson, 0, -1) . 'ies'; } else { $variations[] = rtrim($withoutPerson, 's'); $variations[] = $withoutPerson . 's'; } } $variations = array_unique($variations); foreach ($variations as $variation) { if (isset($mapped[$variation])) { \Log::debug("ImportSimulation: Matched entity", [ 'canonical_root' => $canonicalRoot, 'matched_key' => $variation, ]); return $variation; } } // Check aliases if configured if (isset($config->options['aliases'])) { $aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : []; foreach ($aliases as $alias) { if (isset($mapped[$alias])) { return $alias; } } } \Log::debug("ImportSimulation: No match found for entity", [ 'canonical_root' => $canonicalRoot, 'tried_variations' => array_slice($variations, 0, 5), 'available_keys' => array_keys($mapped), ]); return null; } /** * Group mapped data by entity from "entity.field" format to nested structure. * Handles both single values and arrays (for grouped entities like multiple addresses). * * Special handling: * - activity.note arrays are kept together (single activity with multiple notes) * - Other array values create separate entity instances (e.g., multiple addresses) * * Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']] * Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]] */ protected function groupMappedDataByEntity(array $mapped): array { $grouped = []; foreach ($mapped as $key => $value) { if (!str_contains($key, '.')) { continue; } [$entity, $field] = explode('.', $key, 2); // Handle array values if (is_array($value)) { // Special case: activity.note should be kept as array in single instance if ($entity === 'activity' || $entity === 'activities') { if (!isset($grouped[$entity])) { $grouped[$entity] = []; } $grouped[$entity][$field] = $value; // Keep as array } else { // For other entities, only create multiple instances if: // 1. Entity doesn't exist yet, OR // 2. Entity has no other fields yet (is empty array) if (!isset($grouped[$entity])) { $grouped[$entity] = []; } // If entity already has string-keyed fields, just set the array as field value // Otherwise, create separate instances $hasStringKeys = !empty($grouped[$entity]) && isset(array_keys($grouped[$entity])[0]) && is_string(array_keys($grouped[$entity])[0]); if ($hasStringKeys) { // Entity has fields already - don't split, keep array as-is $grouped[$entity][$field] = $value; } else { // Create separate entity instances for each array value foreach ($value as $idx => $val) { if (!isset($grouped[$entity][$idx])) { $grouped[$entity][$idx] = []; } $grouped[$entity][$idx][$field] = $val; } } } } else { // Single value if (!isset($grouped[$entity])) { $grouped[$entity] = []; } // Check if entity is already an array of instances (from previous grouped field) if (!empty($grouped[$entity]) && is_int(array_key_first($grouped[$entity]))) { // Entity has multiple instances - add field to all instances foreach ($grouped[$entity] as &$instance) { if (is_array($instance)) { $instance[$field] = $value; } } unset($instance); } else { // Simple associative array - add field $grouped[$entity][$field] = $value; } } } return $grouped; } /** * Determine if entity data is grouped (array of instances). */ protected function isGroupedEntity($data): bool { if (!is_array($data)) { return false; } // Check if numeric array (multiple instances) $keys = array_keys($data); return isset($keys[0]) && is_int($keys[0]); } /** * Extract existing entity data as array. */ protected function extractExistingData($entity): array { if (method_exists($entity, 'toArray')) { return $entity->toArray(); } return (array) $entity; } /** * Detect changes between existing entity and new data. */ protected function detectChanges($existingEntity, array $newData): array { $changes = []; foreach ($newData as $key => $newValue) { $oldValue = $existingEntity->{$key} ?? null; // Convert to comparable formats if ($oldValue instanceof \Carbon\Carbon) { $oldValue = $oldValue->format('Y-m-d'); } if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) { $changes[$key] = [ 'old' => $oldValue, 'new' => $newValue, ]; } } return $changes; } /** * Get a reference string for an entity. */ protected function getEntityReference($entity, string $root): string { if (isset($entity->reference)) { return (string) $entity->reference; } if (isset($entity->value)) { return (string) $entity->value; } if (isset($entity->title)) { return (string) $entity->title; } if (isset($entity->id)) { return "{$root}#{$entity->id}"; } return 'N/A'; } /** * Load entity configurations from database. */ protected function loadEntityConfigurations(): void { $entities = ImportEntity::where('is_active', true) ->orderBy('priority', 'desc') ->get(); foreach ($entities as $entity) { $this->entityConfigs[$entity->canonical_root] = $entity; // Instantiate handler if configured if ($entity->handler_class && class_exists($entity->handler_class)) { $this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]); } } } /** * Get handler for entity root. */ protected function getHandler(string $root) { return $this->handlers[$root] ?? null; } /** * Load mappings from import_mappings table. * Uses target_field in "entity.field" format. * Supports multiple sources mapping to same target (for groups). */ protected function loadMappings(Import $import): array { $rows = \DB::table('import_mappings') ->where('import_id', $import->id) ->orderBy('position') ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); $mappings = []; foreach ($rows as $row) { $source = trim((string) $row->source_column); $target = trim((string) $row->target_field); if ($source === '' || $target === '') { continue; } // Use unique key combining source and target to avoid overwriting $key = $source . '→' . $target; // target_field is in "entity.field" format $mappings[$key] = [ 'source' => $source, 'target' => $target, 'transform' => $row->transform ?? null, 'apply_mode' => $row->apply_mode ?? 'both', 'options' => $row->options ? json_decode($row->options, true) : [], ]; } return $mappings; } /** * Build associative array from row data. */ protected function buildRowAssoc(array $row, ?array $header): array { if ($header) { return array_combine($header, array_pad($row, count($header), null)); } // Use numeric indices if no header return array_combine( array_map(fn ($i) => "col_{$i}", array_keys($row)), $row ); } /** * Check if row is effectively empty. */ protected function rowIsEffectivelyEmpty(array $assoc): bool { foreach ($assoc as $value) { if ($value !== null && $value !== '') { return false; } } return true; } /** * Apply mappings to raw row data. * Returns array keyed by "entity.field". * * Updated to match ImportServiceV2: * - Supports group option for concatenating multiple sources * - Returns flat array with "entity.field" keys (no nesting) */ protected function applyMappings(array $raw, array $mappings): array { $mapped = []; // Group mappings by target field to handle concatenation (same as ImportServiceV2) $groupedMappings = []; foreach ($mappings as $mapping) { $target = $mapping['target']; if (!isset($groupedMappings[$target])) { $groupedMappings[$target] = []; } $groupedMappings[$target][] = $mapping; } foreach ($groupedMappings as $targetField => $fieldMappings) { // Group by group number from options $valuesByGroup = []; foreach ($fieldMappings as $mapping) { $source = $mapping['source']; if (!isset($raw[$source])) { continue; } $value = $raw[$source]; // Apply transform if specified if (!empty($mapping['transform'])) { $value = $this->applyTransform($value, $mapping['transform']); } // Get group from options $options = $mapping['options'] ?? []; $group = $options['group'] ?? null; // Group values by their group number (same logic as ImportServiceV2) if ($group !== null) { // Same group = concatenate if (!isset($valuesByGroup[$group])) { $valuesByGroup[$group] = []; } $valuesByGroup[$group][] = $value; } else { // No group = each gets its own group $valuesByGroup[] = [$value]; } } // Now set the values - KEEP FLAT, DON'T NEST foreach ($valuesByGroup as $values) { if (count($values) === 1) { // Single value - add to array if key exists, otherwise set directly if (isset($mapped[$targetField])) { // Convert to array and append if (!is_array($mapped[$targetField])) { $mapped[$targetField] = [$mapped[$targetField]]; } $mapped[$targetField][] = $values[0]; } else { $mapped[$targetField] = $values[0]; } } else { // Multiple values in same group - concatenate with newline $concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== '')); if (!empty($concatenated)) { if (isset($mapped[$targetField])) { // Convert to array and append if (!is_array($mapped[$targetField])) { $mapped[$targetField] = [$mapped[$targetField]]; } $mapped[$targetField][] = $concatenated; } else { $mapped[$targetField] = $concatenated; } } } } } return $mapped; } /** * Set nested value in array using dot notation. * If the key already exists, convert to array and append the new value. * * Same logic as ImportServiceV2. */ protected function setNestedValue(array &$array, string $key, mixed $value): void { $keys = explode('.', $key); $current = &$array; foreach ($keys as $i => $k) { if ($i === count($keys) - 1) { // If key already exists, convert to array and append if (isset($current[$k])) { // Convert existing single value to array if needed if (!is_array($current[$k])) { $current[$k] = [$current[$k]]; } // Append new value $current[$k][] = $value; } else { // Set as single value $current[$k] = $value; } } else { if (!isset($current[$k]) || !is_array($current[$k])) { $current[$k] = []; } $current = &$current[$k]; } } } /** * Apply transform to a value. */ protected function applyTransform(mixed $value, string $transform): mixed { return match ($transform) { 'trim' => trim((string) $value), 'upper' => strtoupper((string) $value), 'lower' => strtolower((string) $value), 'decimal' => (float) str_replace(',', '.', (string) $value), default => $value, }; } /** * Initialize summary counters. */ protected function initSummaries(): array { $summaries = []; foreach (array_keys($this->entityConfigs) as $root) { $summaries[$root] = [ 'create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0, ]; } return $summaries; } /** * Create error payload. */ protected function errorPayload(string $message): array { return [ 'success' => false, 'error' => $message, 'total_simulated' => 0, 'summaries' => [], 'rows' => [], ]; } }