From 36b63a180d735ba8ee6d2747f50bbc99509b56a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sun, 28 Dec 2025 13:55:09 +0100 Subject: [PATCH] fixed import --- app/Services/Import/DecimalNormalizer.php | 83 ++++++ .../Import/EntityResolutionService.php | 7 +- .../Import/Handlers/AccountHandler.php | 64 ++++- .../Import/Handlers/ContractHandler.php | 16 ++ app/Services/Import/Handlers/EmailHandler.php | 17 +- .../Import/Handlers/PersonHandler.php | 13 + app/Services/Import/ImportServiceV2.php | 272 +++++++++++++++++- .../Import/ImportSimulationServiceV2.php | 92 ++++-- mark-import-failed.php | 18 ++ 9 files changed, 548 insertions(+), 34 deletions(-) create mode 100644 app/Services/Import/DecimalNormalizer.php create mode 100644 mark-import-failed.php diff --git a/app/Services/Import/DecimalNormalizer.php b/app/Services/Import/DecimalNormalizer.php new file mode 100644 index 0000000..adcdc0f --- /dev/null +++ b/app/Services/Import/DecimalNormalizer.php @@ -0,0 +1,83 @@ + "958.31" + * - "1.234,56" => "1234.56" + * - "1,234.56" => "1234.56" + * - "1234" => "1234" + * + * Based on ImportProcessor::normalizeDecimal() + */ + public static function normalize(?string $raw): ?string + { + if ($raw === null) { + return null; + } + + // Keep digits, comma, dot, and minus to detect separators + $s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? ''; + $s = trim($s); + + if ($s === '') { + return null; + } + + $lastComma = strrpos($s, ','); + $lastDot = strrpos($s, '.'); + + // Determine decimal separator by last occurrence + $decimalSep = null; + if ($lastComma !== false || $lastDot !== false) { + if ($lastComma === false) { + $decimalSep = '.'; + } elseif ($lastDot === false) { + $decimalSep = ','; + } else { + $decimalSep = $lastComma > $lastDot ? ',' : '.'; + } + } + + // Remove all thousand separators and unify decimal to '.' + if ($decimalSep === ',') { + // Remove all dots (thousand separators) + $s = str_replace('.', '', $s); + // Replace last comma with dot + $pos = strrpos($s, ','); + if ($pos !== false) { + $s[$pos] = '.'; + } + // Remove any remaining commas (unlikely) + $s = str_replace(',', '', $s); + } elseif ($decimalSep === '.') { + // Remove all commas (thousand separators) + $s = str_replace(',', '', $s); + // Dot is already decimal separator + } else { + // No decimal separator: remove commas/dots entirely + $s = str_replace([',', '.'], '', $s); + } + + // Handle negative numbers + $s = ltrim($s, '+'); + $neg = false; + if (str_starts_with($s, '-')) { + $neg = true; + $s = ltrim($s, '-'); + } + // Remove any stray minus signs + $s = str_replace('-', '', $s); + if ($neg) { + $s = '-' . $s; + } + + return $s; + } +} diff --git a/app/Services/Import/EntityResolutionService.php b/app/Services/Import/EntityResolutionService.php index cbc8afc..463401b 100644 --- a/app/Services/Import/EntityResolutionService.php +++ b/app/Services/Import/EntityResolutionService.php @@ -370,9 +370,14 @@ public function resolveOrCreateClientCaseForContract(Import $import, array $mapp if (!$personId) { // Create minimal Person as last resort - $personId = Person::create(['type_id' => 1])->id; + $defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1); + $personId = Person::create([ + 'type_id' => 1, + 'group_id' => $defaultGroupId, + ])->id; Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [ 'person_id' => $personId, + 'group_id' => $defaultGroupId, ]); } } diff --git a/app/Services/Import/Handlers/AccountHandler.php b/app/Services/Import/Handlers/AccountHandler.php index d76bc69..9cab68b 100644 --- a/app/Services/Import/Handlers/AccountHandler.php +++ b/app/Services/Import/Handlers/AccountHandler.php @@ -5,6 +5,7 @@ use App\Models\Account; use App\Models\Import; use App\Services\Import\BaseEntityHandler; +use App\Services\Import\DecimalNormalizer; class AccountHandler extends BaseEntityHandler { @@ -13,10 +14,38 @@ public function getEntityClass(): string return Account::class; } + /** + * Override validate to handle contract_id and reference from context. + * Both contract_id and reference are populated in process() (reference defaults to contract reference). + */ + public function validate(array $mapped): array + { + // Remove contract_id and reference from validation - both will be populated in process() + // Reference defaults to contract.reference if not set (matching v1 behavior) + $rules = $this->entityConfig?->validation_rules ?? []; + + unset($rules['contract_id'], $rules['reference']); + + if (empty($rules)) { + return ['valid' => true, 'errors' => []]; + } + + $validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules); + + if ($validator->fails()) { + return [ + 'valid' => false, + 'errors' => $validator->errors()->all(), + ]; + } + + return ['valid' => true, 'errors' => []]; + } + public function resolve(array $mapped, array $context = []): mixed { $reference = $mapped['reference'] ?? null; - $contractId = $mapped['contract_id'] ?? $context['contract']?->entity?->id ?? null; + $contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null; if (! $reference || ! $contractId) { return null; @@ -37,7 +66,15 @@ public function process(Import $import, array $mapped, array $raw, array $contex ]; } - $contractId = $context['contract']->entity->id; + // Fallback: if account.reference is empty, use contract.reference (matching v1 behavior) + if (empty($mapped['reference'])) { + $contractReference = $context['contract']['entity']->reference ?? null; + if ($contractReference) { + $mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference)); + } + } + + $contractId = $context['contract']['entity']->id; $mapped['contract_id'] = $contractId; $existing = $this->resolve($mapped, $context); @@ -75,6 +112,12 @@ public function process(Import $import, array $mapped, array $raw, array $contex // Create new account $account = new Account; $payload = $this->buildPayload($mapped, $account); + + // Ensure required defaults for new accounts + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultAccountTypeId(); + } + $account->fill($payload); $account->save(); @@ -100,7 +143,14 @@ protected function buildPayload(array $mapped, $model): array foreach ($fieldMap as $source => $target) { if (array_key_exists($source, $mapped)) { - $payload[$target] = $mapped[$source]; + $value = $mapped[$source]; + + // Normalize decimal fields (convert comma to period) + if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) { + $value = DecimalNormalizer::normalize($value); + } + + $payload[$target] = $value; } } @@ -155,4 +205,12 @@ protected function createBalanceChangeActivity(Account $account, float $oldBalan ]); } } + + /** + * Get default account type ID. + */ + protected function getDefaultAccountTypeId(): int + { + return (int) (\App\Models\AccountType::min('id') ?? 1); + } } \ No newline at end of file diff --git a/app/Services/Import/Handlers/ContractHandler.php b/app/Services/Import/Handlers/ContractHandler.php index 44f5fb7..caa7e69 100644 --- a/app/Services/Import/Handlers/ContractHandler.php +++ b/app/Services/Import/Handlers/ContractHandler.php @@ -217,6 +217,22 @@ protected function buildPayload(array $mapped, $model): array } } + // Handle meta field - merge grouped meta into flat structure + if (!empty($mapped['meta']) && is_array($mapped['meta'])) { + $metaData = []; + foreach ($mapped['meta'] as $grp => $entries) { + if (!is_array($entries)) { + continue; + } + foreach ($entries as $k => $v) { + $metaData[$k] = $v; + } + } + if (!empty($metaData)) { + $payload['meta'] = $metaData; + } + } + return $payload; } diff --git a/app/Services/Import/Handlers/EmailHandler.php b/app/Services/Import/Handlers/EmailHandler.php index 89c9934..3489acb 100644 --- a/app/Services/Import/Handlers/EmailHandler.php +++ b/app/Services/Import/Handlers/EmailHandler.php @@ -14,7 +14,8 @@ public function getEntityClass(): string } /** - * Override validate to skip validation if email is empty. + * Override validate to skip validation if email is empty or invalid. + * Invalid emails should be skipped, not cause transaction rollback. */ public function validate(array $mapped): array { @@ -23,6 +24,12 @@ public function validate(array $mapped): array return ['valid' => true, 'errors' => []]; } + // Validate email format - if invalid, mark as valid to skip instead of failing + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + // Return valid=true but we'll skip it in process() + return ['valid' => true, 'errors' => []]; + } + return parent::validate($mapped); } @@ -48,6 +55,14 @@ public function process(Import $import, array $mapped, array $raw, array $contex ]; } + // Skip if email format is invalid + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return [ + 'action' => 'skipped', + 'message' => 'Invalid email format', + ]; + } + // Resolve person_id from context $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; diff --git a/app/Services/Import/Handlers/PersonHandler.php b/app/Services/Import/Handlers/PersonHandler.php index e4ba4fe..53dc4a0 100644 --- a/app/Services/Import/Handlers/PersonHandler.php +++ b/app/Services/Import/Handlers/PersonHandler.php @@ -128,7 +128,20 @@ public function process(Import $import, array $mapped, array $raw, array $contex $payload['type_id'] = $this->getDefaultPersonTypeId(); } + Log::debug('PersonHandler: Payload before fill', [ + 'payload' => $payload, + 'has_group_id' => isset($payload['group_id']), + 'group_id_value' => $payload['group_id'] ?? null, + ]); + $person->fill($payload); + + Log::debug('PersonHandler: Person attributes after fill', [ + 'attributes' => $person->getAttributes(), + 'has_group_id' => isset($person->group_id), + 'group_id_value' => $person->group_id ?? null, + ]); + $person->save(); Log::info('PersonHandler: Created new Person', [ diff --git a/app/Services/Import/ImportServiceV2.php b/app/Services/Import/ImportServiceV2.php index 6b448a4..7e1910c 100644 --- a/app/Services/Import/ImportServiceV2.php +++ b/app/Services/Import/ImportServiceV2.php @@ -8,6 +8,7 @@ use App\Models\ImportRow; use App\Services\Import\Contracts\EntityHandlerInterface; use App\Services\Import\DateNormalizer; +use App\Services\Import\DecimalNormalizer; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -75,6 +76,9 @@ public function process(Import $import, ?Authenticatable $user = null): array throw new \RuntimeException("File not found: {$filePath}"); } + // Check if this is a retry (import_rows already exist) + $isRetry = ImportRow::where('import_id', $import->id)->exists(); + $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); $fh = fopen($fullPath, 'r'); @@ -98,7 +102,82 @@ public function process(Import $import, ?Authenticatable $user = null): array $isPg = DB::connection()->getDriverName() === 'pgsql'; - while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { + // If retry mode, only process failed/invalid rows + if ($isRetry) { + $failedRows = ImportRow::where('import_id', $import->id) + ->whereIn('status', ['invalid', 'failed']) + ->orderBy('row_number') + ->get(); + + foreach ($failedRows as $importRow) { + $total++; + + try { + $rawAssoc = $importRow->raw_data; + $mapped = $importRow->mapped_data; + + // Process entities in priority order within a transaction + $context = ['import' => $import, 'user' => $user, 'import_row' => $importRow]; + + DB::beginTransaction(); + try { + $results = $this->processRow($import, $mapped, $rawAssoc, $context); + + // If processing succeeded, commit the transaction + if ($results['status'] === 'imported' || $results['status'] === 'skipped') { + DB::commit(); + } else { + DB::rollBack(); + } + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Collect entity details from results + $entityData = $this->collectEntityDetails($results); + $entityDetails = $entityData['details']; + $hasErrors = $entityData['hasErrors']; + $hasWarnings = $entityData['hasWarnings']; + + // Handle different result statuses + if ($results['status'] === 'imported') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => $results['entity_type'] ?? null, + 'entity_id' => $results['entity_id'] ?? null, + ]); + $this->createRowProcessedEvent($import, $user, $importRow->row_number, $entityDetails, $hasWarnings, $rawAssoc); + } elseif ($results['status'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + $this->createRowSkippedEvent($import, $user, $importRow->row_number, $entityDetails, $rawAssoc); + } else { + $invalid++; + $importRow->update([ + 'status' => 'invalid', + 'errors' => $results['errors'] ?? ['Processing failed'], + ]); + $this->createRowFailedEvent( + $import, + $user, + $importRow->row_number, + $results['errors'] ?? ['Processing failed'], + $entityDetails, + $rawAssoc + ); + } + } catch (\Throwable $e) { + $invalid++; + $this->handleRowException($import, $user, $importRow->row_number, $e); + } + } + + fclose($fh); + } else { + // Normal mode: process all rows from CSV + while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { $rowNum++; $total++; @@ -180,9 +259,10 @@ public function process(Import $import, ?Authenticatable $user = null): array $invalid++; $this->handleRowException($import, $user, $rowNum, $e); } - } + } - fclose($fh); + fclose($fh); + } $this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid); } catch (\Throwable $e) { @@ -272,6 +352,12 @@ protected function applyMappings(array $raw, $mappings): array } foreach ($groupedMappings as $targetField => $fieldMappings) { + // Special handling for meta fields: contracts.meta or other_entity.meta + if (str_ends_with($targetField, '.meta')) { + $this->applyMetaMappings($mapped, $targetField, $fieldMappings, $raw); + continue; + } + // Group by group number from options $valuesByGroup = []; @@ -323,6 +409,89 @@ protected function applyMappings(array $raw, $mappings): array return $mapped; } + + /** + * Apply meta mappings with special structure: entity.meta[group][key] = {title, value, type} + */ + protected function applyMetaMappings(array &$mapped, string $targetField, array $fieldMappings, array $raw): void + { + // Extract entity from target field: contracts.meta -> contracts + $entity = str_replace('.meta', '', $targetField); + + foreach ($fieldMappings as $mapping) { + $sourceCol = $mapping->source_column; + + if (!isset($raw[$sourceCol])) { + continue; + } + + $value = $raw[$sourceCol]; + + // Apply transform + if ($mapping->transform) { + $value = $this->applyTransform($value, $mapping->transform); + } + + // Get options + $options = $mapping->options ? json_decode($mapping->options, true) : []; + $metaKey = $options['key'] ?? null; + $metaType = $options['type'] ?? 'string'; + $group = $options['group'] ?? '1'; + + if (!$metaKey) { + continue; + } + + // Coerce value based on type + $coerced = $value; + if ($metaType === 'number') { + if (is_string($coerced)) { + $norm = DecimalNormalizer::normalize($coerced); + $coerced = is_numeric($norm) ? (float) $norm : $coerced; + } + } elseif ($metaType === 'boolean') { + if (is_string($coerced)) { + $lc = strtolower(trim($coerced)); + if (in_array($lc, ['1', 'true', 'yes', 'y'], true)) { + $coerced = true; + } elseif (in_array($lc, ['0', 'false', 'no', 'n'], true)) { + $coerced = false; + } + } else { + $coerced = (bool) $coerced; + } + } elseif ($metaType === 'date') { + $coerced = is_scalar($coerced) ? $this->normalizeDate((string) $coerced) : null; + } else { + // string or unspecified: cast scalars to string + if (is_scalar($coerced)) { + $coerced = (string) $coerced; + } + } + + // Initialize structure if needed + if (!isset($mapped[$entity])) { + $mapped[$entity] = []; + } + if (!isset($mapped[$entity]['meta']) || !is_array($mapped[$entity]['meta'])) { + $mapped[$entity]['meta'] = []; + } + if (!isset($mapped[$entity]['meta'][$group])) { + $mapped[$entity]['meta'][$group] = []; + } + + // Store as structure with title, value and type + $entry = [ + 'title' => $sourceCol, + 'value' => $coerced, + ]; + if ($metaType) { + $entry['type'] = $metaType; + } + + $mapped[$entity]['meta'][$group][$metaKey] = $entry; + } + } /** * Apply transform to value. @@ -457,6 +626,19 @@ protected function processRow(Import $import, array $mapped, array $raw, array $ $lastEntityType = $handler->getEntityClass(); $lastEntityId = $result['entity']?->id ?? null; } + + // Post-contract actions (segment attachment, activity creation) + if ($root === 'contract' && in_array($result['action'] ?? null, ['inserted', 'updated', 'reactivated'])) { + try { + $this->postContractActions($import, $result['entity'], $context); + } catch (\Throwable $e) { + Log::warning('Post-contract action failed', [ + 'import_id' => $import->id, + 'contract_id' => $result['entity']->id ?? null, + 'error' => $e->getMessage(), + ]); + } + } } catch (\Throwable $e) { $hasErrors = true; @@ -714,8 +896,13 @@ protected function finalizeImport( int $skipped, int $invalid ): void { + // If there are any invalid rows, mark import as failed, not completed + $status = $invalid > 0 ? 'failed' : 'completed'; + $eventLevel = $invalid > 0 ? 'error' : 'info'; + $eventName = $invalid > 0 ? 'processing_failed' : 'processing_completed'; + $import->update([ - 'status' => 'completed', + 'status' => $status, 'finished_at' => now(), 'total_rows' => $total, 'imported_rows' => $imported, @@ -726,8 +913,8 @@ protected function finalizeImport( ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), - 'event' => 'processing_completed', - 'level' => 'info', + 'event' => $eventName, + 'level' => $eventLevel, 'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid", ]); } @@ -756,4 +943,77 @@ protected function handleFatalException( 'message' => $e->getMessage(), ]); } + + /** + * Post-contract actions: attach segment, create activity with decision. + * Matches ImportProcessor::postContractActions() behavior. + */ + protected function postContractActions(Import $import, $contract, array $context = []): void + { + $meta = $import->meta ?? []; + $segmentId = (int) ($meta['segment_id'] ?? 0); + $decisionId = (int) ($meta['decision_id'] ?? 0); + $templateName = (string) ($meta['template_name'] ?? optional($import->template)->name ?? ''); + $actionId = (int) ($meta['action_id'] ?? 0); + + // Attach segment to contract as the main (active) segment if provided + if ($segmentId > 0) { + // Ensure the segment exists on the client case and is active + $ccSeg = \DB::table('client_case_segment') + ->where('client_case_id', $contract->client_case_id) + ->where('segment_id', $segmentId) + ->first(); + if (! $ccSeg) { + \DB::table('client_case_segment')->insert([ + 'client_case_id' => $contract->client_case_id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif (! $ccSeg->active) { + \DB::table('client_case_segment') + ->where('id', $ccSeg->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Deactivate all other segments for this contract to make this the main one + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', '!=', $segmentId) + ->update(['active' => false, 'updated_at' => now()]); + + // Upsert the selected segment as active for this contract + $pivot = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $segmentId) + ->first(); + if ($pivot) { + if (! $pivot->active) { + \DB::table('contract_segment') + ->where('id', $pivot->id) + ->update(['active' => true, 'updated_at' => now()]); + } + } else { + \DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + // Create activity if decision provided + if ($decisionId > 0) { + \App\Models\Activity::create([ + 'decision_id' => $decisionId, + 'action_id' => $actionId > 0 ? $actionId : null, + 'contract_id' => $contract->id, + 'client_case_id' => $contract->client_case_id, + 'note' => trim('Imported via template'.($templateName ? ': '.$templateName : '')), + ]); + } + } } diff --git a/app/Services/Import/ImportSimulationServiceV2.php b/app/Services/Import/ImportSimulationServiceV2.php index 00930d5..7c86c78 100644 --- a/app/Services/Import/ImportSimulationServiceV2.php +++ b/app/Services/Import/ImportSimulationServiceV2.php @@ -258,7 +258,17 @@ protected function simulateRow(Import $import, array $mapped, array $raw, array // Resolve existing entity (uses EntityResolutionService internally) // Pass accumulated entityResults as context for chain resolution - $existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults)); + 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 @@ -284,9 +294,14 @@ protected function simulateRow(Import $import, array $mapped, array $raw, array ]; // 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' => (object) $entityData, + 'entity' => $simulatedEntity, 'action' => 'inserted', + '_simulated' => true, ]; } } @@ -420,23 +435,35 @@ protected function groupMappedDataByEntity(array $mapped): array // Handle array values if (is_array($value)) { - if (!isset($grouped[$entity])) { - $grouped[$entity] = []; - } - // Special case: activity.note should be kept as array in single instance if ($entity === 'activity' || $entity === 'activities') { - if (!isset($grouped[$entity][0])) { - $grouped[$entity][0] = []; + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; } - $grouped[$entity][0][$field] = $value; // Keep as array + $grouped[$entity][$field] = $value; // Keep as array } else { - // Create separate entity instances for each array value - foreach ($value as $idx => $val) { - if (!isset($grouped[$entity][$idx])) { - $grouped[$entity][$idx] = []; + // 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; } - $grouped[$entity][$idx][$field] = $val; } } } else { @@ -445,14 +472,17 @@ protected function groupMappedDataByEntity(array $mapped): array $grouped[$entity] = []; } - // If entity is already an array (from previous grouped field), add to all instances - if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) { + // 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) { - $instance[$field] = $value; + if (is_array($instance)) { + $instance[$field] = $value; + } } unset($instance); } else { - // Simple associative array + // Simple associative array - add field $grouped[$entity][$field] = $value; } } @@ -634,7 +664,7 @@ protected function rowIsEffectivelyEmpty(array $assoc): bool * * Updated to match ImportServiceV2: * - Supports group option for concatenating multiple sources - * - Uses setNestedValue for proper array handling + * - Returns flat array with "entity.field" keys (no nesting) */ protected function applyMappings(array $raw, array $mappings): array { @@ -685,16 +715,32 @@ protected function applyMappings(array $raw, array $mappings): array } } - // Now set the values (same logic as ImportServiceV2) + // Now set the values - KEEP FLAT, DON'T NEST foreach ($valuesByGroup as $values) { if (count($values) === 1) { - // Single value - set directly - $this->setNestedValue($mapped, $targetField, $values[0]); + // 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)) { - $this->setNestedValue($mapped, $targetField, $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; + } } } } diff --git a/mark-import-failed.php b/mark-import-failed.php new file mode 100644 index 0000000..fbd13bb --- /dev/null +++ b/mark-import-failed.php @@ -0,0 +1,18 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$uuid = '68c7b8f8-fdf0-4575-9cbc-3ab2b3544d8f'; +$import = \App\Models\Import::where('uuid', $uuid)->first(); + +if ($import) { + $import->status = 'failed'; + $import->save(); + echo "Import {$uuid} marked as failed\n"; + echo "Current status: {$import->status}\n"; +} else { + echo "Import not found\n"; +}