source_type, ['csv','txt'])) { ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'event' => 'processing_skipped', 'level' => 'warning', 'message' => 'Only CSV/TXT supported in this pass.', ]); $import->update(['status' => 'completed', 'finished_at' => now()]); return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid') ]; } // Get mappings for this import (with apply_mode) $mappings = DB::table('import_mappings') ->where('import_id', $import->id) ->get(['source_column','target_field','transform','apply_mode','options']); $header = $import->meta['columns'] ?? null; $delimiter = $import->meta['detected_delimiter'] ?? ','; $hasHeader = (bool) ($import->meta['has_header'] ?? true); $path = Storage::disk($import->disk)->path($import->path); // Parse file and create import_rows with mapped_data $fh = @fopen($path, 'r'); if (!$fh) { ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'event' => 'processing_failed', 'level' => 'error', 'message' => 'Unable to open file for reading.', ]); $import->update(['status' => 'failed', 'failed_at' => now()]); return [ 'ok' => false, 'status' => $import->status ]; } try { DB::beginTransaction(); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'event' => 'processing_started', 'level' => 'info', 'message' => 'Processing started.', ]); $rowNum = 0; if ($hasHeader) { $first = fgetcsv($fh, 0, $delimiter); $rowNum++; // use actual detected header if not already stored if (!$header) { $header = array_map(fn($v) => trim((string) $v), $first ?: []); } } while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { $rowNum++; $total++; $rawAssoc = $this->buildRowAssoc($row, $header); [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); $importRow = ImportRow::create([ 'import_id' => $import->id, 'row_number' => $rowNum, 'record_type' => $recordType, 'raw_data' => $rawAssoc, 'mapped_data' => $mapped, 'status' => 'valid', ]); // Contracts $contractResult = null; if (isset($mapped['contract'])) { $contractResult = $this->upsertContractChain($import, $mapped, $mappings); if ($contractResult['action'] === 'skipped') { $skipped++; $importRow->update(['status' => 'skipped']); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_skipped', 'level' => 'info', 'message' => $contractResult['message'] ?? 'Skipped contract (no changes).', ]); } elseif (in_array($contractResult['action'], ['inserted','updated'])) { $imported++; $importRow->update([ 'status' => 'imported', 'entity_type' => Contract::class, 'entity_id' => $contractResult['contract']->id, ]); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_imported', 'level' => 'info', 'message' => ucfirst($contractResult['action']).' contract', 'context' => [ 'id' => $contractResult['contract']->id ], ]); } else { $invalid++; $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); } } // Accounts $accountResult = null; if (isset($mapped['account'])) { $accountResult = $this->upsertAccount($import, $mapped, $mappings); if ($accountResult['action'] === 'skipped') { $skipped++; $importRow->update(['status' => 'skipped']); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_skipped', 'level' => 'info', 'message' => $accountResult['message'] ?? 'Skipped (no changes).', ]); } elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') { $imported++; $importRow->update([ 'status' => 'imported', 'entity_type' => Account::class, 'entity_id' => $accountResult['account']->id, ]); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_imported', 'level' => 'info', 'message' => ucfirst($accountResult['action']).' account', 'context' => [ 'id' => $accountResult['account']->id ], ]); } else { $invalid++; $importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]); } } // Contacts: resolve person strictly via Contract -> ClientCase -> Person, contacts, or identifiers $personIdForRow = null; // Prefer person from contract created/updated above if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { $ccId = $contractResult['contract']->client_case_id; $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); } // If we have a contract reference, resolve existing contract for this client and derive person if (!$personIdForRow && $import->client_id && !empty($mapped['contract']['reference'] ?? null)) { $existingContract = Contract::query() ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->where('client_cases.client_id', $import->client_id) ->where('contracts.reference', $mapped['contract']['reference']) ->select('contracts.client_case_id') ->first(); if ($existingContract) { $personIdForRow = ClientCase::where('id', $existingContract->client_case_id)->value('person_id'); } } // If account processing created/resolved a contract, derive person via its client_case if (!$personIdForRow && $accountResult) { if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) { $ccId = $accountResult['contract']->client_case_id; $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); } elseif (isset($accountResult['contract_id'])) { $ccId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id'); if ($ccId) { $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); } } } // Resolve by contact values next if (!$personIdForRow) { $emailVal = trim((string)($mapped['email']['value'] ?? '')); if ($emailVal !== '') { $personIdForRow = Email::where('value', $emailVal)->value('person_id'); } } if (!$personIdForRow) { $phoneNu = trim((string)($mapped['phone']['nu'] ?? '')); if ($phoneNu !== '') { $personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id'); } } if (!$personIdForRow) { $addrLine = trim((string)($mapped['address']['address'] ?? '')); if ($addrLine !== '') { $personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id'); } } // Try identifiers from mapped person (no creation yet) if (!$personIdForRow && !empty($mapped['person'] ?? [])) { $personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']); } // Finally, if still unknown and person fields provided, create if (!$personIdForRow && !empty($mapped['person'] ?? [])) { $personIdForRow = $this->findOrCreatePersonId($mapped['person']); } $contactChanged = false; if ($personIdForRow) { if (!empty($mapped['email'] ?? [])) { $r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings); if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } } if (!empty($mapped['address'] ?? [])) { $r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings); if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } } if (!empty($mapped['phone'] ?? [])) { $r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings); if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } } } if (!isset($mapped['contract']) && !isset($mapped['account'])) { if ($contactChanged) { $imported++; $importRow->update([ 'status' => 'imported', 'entity_type' => Person::class, 'entity_id' => $personIdForRow, ]); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_imported', 'level' => 'info', 'message' => 'Contacts upserted', 'context' => [ 'person_id' => $personIdForRow ], ]); } else { $skipped++; $importRow->update(['status' => 'skipped']); } } } fclose($fh); $import->update([ 'status' => 'completed', 'finished_at' => now(), 'total_rows' => $total, 'imported_rows' => $imported, 'invalid_rows' => $invalid, 'valid_rows' => $total - $invalid, ]); DB::commit(); return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid'), ]; } catch (\Throwable $e) { if (is_resource($fh)) { @fclose($fh); } DB::rollBack(); // Mark failed and log after rollback (so no partial writes persist) $import->refresh(); $import->update(['status' => 'failed', 'failed_at' => now()]); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'event' => 'processing_failed', 'level' => 'error', 'message' => $e->getMessage(), ]); return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ]; } } private function buildRowAssoc(array $row, ?array $header): array { if (!$header) { // positional mapping: 0..N-1 $assoc = []; foreach ($row as $i => $v) { $assoc[(string)$i] = $v; } return $assoc; } $assoc = []; foreach ($header as $i => $name) { $assoc[$name] = $row[$i] ?? null; } return $assoc; } private function applyMappings(array $raw, $mappings): array { $recordType = null; $mapped = []; foreach ($mappings as $map) { $src = $map->source_column; $target = $map->target_field; if (!$target) continue; $value = $raw[$src] ?? null; // very basic transforms if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; } if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; } // detect record type from first segment, e.g., "account.balance_amount" $parts = explode('.', $target); if (!$recordType && isset($parts[0])) { $recordType = $parts[0]; } // build nested array by dot notation $this->arraySetDot($mapped, $target, $value); } return [$recordType, $mapped]; } private function arraySetDot(array &$arr, string $path, $value): void { $keys = explode('.', $path); $ref =& $arr; foreach ($keys as $k) { if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; } $ref =& $ref[$k]; } $ref = $value; } private function upsertAccount(Import $import, array $mapped, $mappings): array { $clientId = $import->client_id; // may be null, used for contract lookup/creation $acc = $mapped['account'] ?? []; $contractId = $acc['contract_id'] ?? null; $reference = $acc['reference'] ?? null; // If contract_id not provided, attempt to resolve by contract reference for the selected client if (!$contractId) { $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); if ($clientId && $contractRef) { // 1) Search existing contract by reference for that client (across its client cases) $existingContract = Contract::query() ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->where('client_cases.client_id', $clientId) ->where('contracts.reference', $contractRef) ->select('contracts.*') ->first(); if ($existingContract) { $contractId = $existingContract->id; } else { // 2) Not found: attempt to resolve debtor via identifiers or provided person, then create case+contract // Try strong identifiers first $personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []); // Create from provided person data if unresolved if (!$personId) { $personId = $this->findOrCreatePersonId($mapped['person'] ?? []); } // Last resort, create minimal if (!$personId) { $personId = $this->createMinimalPersonId(); } // Use the selected client for this import to tie the case/contract if (!$clientId) { return ['action' => 'skipped', 'message' => 'Client required to create contract']; } $resolvedClientId = $clientId; $clientCaseId = $this->findOrCreateClientCaseId($resolvedClientId, $personId); // Build minimal/new contract $contractFields = $mapped['contract'] ?? []; $newContractData = [ 'client_case_id' => $clientCaseId, 'reference' => $contractRef, ]; foreach (['start_date','end_date','description','type_id'] as $k) { if (array_key_exists($k, $contractFields) && !is_null($contractFields[$k])) { $newContractData[$k] = $contractFields[$k]; } } // ensure required fields on contracts $newContractData['start_date'] = $newContractData['start_date'] ?? now()->toDateString(); $newContractData['type_id'] = $newContractData['type_id'] ?? $this->getDefaultContractTypeId(); $createdContract = Contract::create($newContractData); $contractId = $createdContract->id; } if ($contractId) { $acc['contract_id'] = $contractId; $mapped['account'] = $acc; } } } // Default account.reference to contract reference if missing if (!$reference) { $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); if ($contractRef) { $reference = $contractRef; $acc['reference'] = $reference; $mapped['account'] = $acc; } } if (!$contractId || !$reference) { return ['action' => 'skipped', 'message' => 'Missing contract_id/reference']; } $existing = Account::query() ->where('contract_id', $contractId) ->where('reference', $reference) ->first(); // Build applyable data based on apply_mode $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { if (!$map->target_field) continue; $parts = explode('.', $map->target_field); if ($parts[0] !== 'account') continue; $field = $parts[1] ?? null; if (!$field) continue; $value = $acc[$field] ?? null; $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } } if ($existing) { if (empty($applyUpdate)) { return ['action' => 'skipped', 'message' => 'No fields marked for update']; } // Only update fields that are set; skip nulls to avoid wiping unintentionally $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); if (empty($changes)) { return ['action' => 'skipped', 'message' => 'No non-null changes']; } $existing->fill($changes); $existing->save(); // also include contract hints for downstream contact resolution return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No fields marked for insert']; } $data = array_filter($applyInsert, fn($v) => !is_null($v)); $data['contract_id'] = $contractId; $data['reference'] = $reference; // ensure required defaults $data['type_id'] = $data['type_id'] ?? $this->getDefaultAccountTypeId(); if (!array_key_exists('active', $data)) { $data['active'] = 1; } $created = Account::create($data); return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId]; } } private function findPersonIdByIdentifiers(array $p): ?int { $tax = $p['tax_number'] ?? null; if ($tax) { $found = Person::where('tax_number', $tax)->first(); if ($found) return $found->id; } $ssn = $p['social_security_number'] ?? null; if ($ssn) { $found = Person::where('social_security_number', $ssn)->first(); if ($found) return $found->id; } return null; } private function upsertContractChain(Import $import, array $mapped, $mappings): array { $contractData = $mapped['contract'] ?? []; $reference = $contractData['reference'] ?? null; if (!$reference) { return ['action' => 'invalid', 'message' => 'Missing contract.reference']; } // Determine client_case_id: prefer provided, else derive via person/client $clientCaseId = $contractData['client_case_id'] ?? null; $clientId = $import->client_id; // may be null // Try to find existing contract EARLY by (client_id, reference) across all cases to prevent duplicates $existing = null; if ($clientId) { $existing = Contract::query() ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->where('client_cases.client_id', $clientId) ->where('contracts.reference', $reference) ->select('contracts.*') ->first(); } // If not found by client+reference and a specific client_case_id is provided, try that too if (!$existing && $clientCaseId) { $existing = Contract::query() ->where('client_case_id', $clientCaseId) ->where('reference', $reference) ->first(); } // If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary if (!$existing && !$clientCaseId) { // Resolve by identifiers or provided person; do not use Client->person $personId = null; if (!empty($mapped['person'] ?? [])) { $personId = $this->findPersonIdByIdentifiers($mapped['person']); if (!$personId) { $personId = $this->findOrCreatePersonId($mapped['person']); } } // As a last resort, create a minimal person for this client if ($clientId && !$personId) { $personId = $this->createMinimalPersonId(); } if ($clientId && $personId) { $clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId); } elseif ($personId) { // require an import client to attach case/contract return ['action' => 'invalid', 'message' => 'Import must be linked to a client to create a case']; } else { return ['action' => 'invalid', 'message' => 'Unable to resolve client_case (need import client)']; } } // Build applyable data based on apply_mode $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { if (!$map->target_field) continue; $parts = explode('.', $map->target_field); if ($parts[0] !== 'contract') continue; $field = $parts[1] ?? null; if (!$field) continue; $value = $contractData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } } if ($existing) { if (empty($applyUpdate)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for update']; } $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); if (empty($changes)) { return ['action' => 'skipped', 'message' => 'No non-null contract changes']; } $existing->fill($changes); $existing->save(); return ['action' => 'updated', 'contract' => $existing]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; } $data = array_filter($applyInsert, fn($v) => !is_null($v)); $data['client_case_id'] = $clientCaseId; $data['reference'] = $reference; // ensure required defaults $data['start_date'] = $data['start_date'] ?? now()->toDateString(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId(); $created = Contract::create($data); return ['action' => 'inserted', 'contract' => $created]; } } private function findOrCreatePersonId(array $p): ?int { // Basic dedup: by tax_number, ssn, else full_name $query = Person::query(); if (!empty($p['tax_number'] ?? null)) { $found = $query->where('tax_number', $p['tax_number'])->first(); if ($found) return $found->id; } if (!empty($p['social_security_number'] ?? null)) { $found = Person::where('social_security_number', $p['social_security_number'])->first(); if ($found) return $found->id; } // Do NOT use full_name as an identifier // Create person if any fields present; ensure required foreign keys if (!empty($p)) { $data = []; foreach (['first_name','last_name','full_name','tax_number','social_security_number','birthday','gender','description','group_id','type_id'] as $k) { if (array_key_exists($k, $p)) $data[$k] = $p[$k]; } // derive full_name if missing if (empty($data['full_name'])) { $fn = trim((string)($data['first_name'] ?? '')); $ln = trim((string)($data['last_name'] ?? '')); if ($fn || $ln) $data['full_name'] = trim($fn.' '.$ln); } // ensure required group/type ids $data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId(); $created = Person::create($data); return $created->id; } return null; } private function createMinimalPersonId(): int { return Person::create([ 'group_id' => $this->getDefaultPersonGroupId(), 'type_id' => $this->getDefaultPersonTypeId(), // names and identifiers can be null; 'nu' will be auto-generated (unique 6-char) ])->id; } private function getDefaultPersonGroupId(): int { return (int) (PersonGroup::min('id') ?? 1); } private function getDefaultPersonTypeId(): int { return (int) (PersonType::min('id') ?? 1); } private function getDefaultContractTypeId(): int { return (int) (ContractType::min('id') ?? 1); } private function getDefaultAccountTypeId(): int { return (int) (AccountType::min('id') ?? 1); } private function getDefaultAddressTypeId(): int { return (int) (AddressType::min('id') ?? 1); } private function getDefaultPhoneTypeId(): int { return (int) (PhoneType::min('id') ?? 1); } // Removed getExistingPersonIdForClient: resolution should come from Contract -> ClientCase -> Person or identifiers private function findOrCreateClientId(int $personId): int { $client = Client::where('person_id', $personId)->first(); if ($client) return $client->id; return Client::create(['person_id' => $personId])->id; } private function findOrCreateClientCaseId(int $clientId, int $personId): int { $cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first(); if ($cc) return $cc->id; return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id; } private function upsertEmail(int $personId, array $emailData, $mappings): array { $value = trim((string)($emailData['value'] ?? '')); if ($value === '') return ['action' => 'skipped', 'message' => 'No email value']; $existing = Email::where('person_id', $personId)->where('value', $value)->first(); $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { if (!$map->target_field) continue; $parts = explode('.', $map->target_field); if ($parts[0] !== 'email') continue; $field = $parts[1] ?? null; if (!$field) continue; $val = $emailData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } } if ($existing) { $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); if (empty($changes)) return ['action' => 'skipped', 'message' => 'No email updates']; $existing->fill($changes); $existing->save(); return ['action' => 'updated', 'email' => $existing]; } else { if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No email fields for insert']; $data = array_filter($applyInsert, fn($v) => !is_null($v)); $data['person_id'] = $personId; if (!array_key_exists('is_active', $data)) $data['is_active'] = true; $created = Email::create($data); return ['action' => 'inserted', 'email' => $created]; } } private function upsertAddress(int $personId, array $addrData, $mappings): array { $addressLine = trim((string)($addrData['address'] ?? '')); if ($addressLine === '') return ['action' => 'skipped', 'message' => 'No address value']; // Default country SLO if not provided if (!isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { $addrData['country'] = 'SLO'; } $existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first(); $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { if (!$map->target_field) continue; $parts = explode('.', $map->target_field); if ($parts[0] !== 'address') continue; $field = $parts[1] ?? null; if (!$field) continue; $val = $addrData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } } if ($existing) { $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates']; $existing->fill($changes); $existing->save(); return ['action' => 'updated', 'address' => $existing]; } else { if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No address fields for insert']; $data = array_filter($applyInsert, fn($v) => !is_null($v)); $data['person_id'] = $personId; $data['country'] = $data['country'] ?? 'SLO'; $data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); $created = PersonAddress::create($data); return ['action' => 'inserted', 'address' => $created]; } } private function upsertPhone(int $personId, array $phoneData, $mappings): array { $nu = trim((string)($phoneData['nu'] ?? '')); if ($nu === '') return ['action' => 'skipped', 'message' => 'No phone value']; $existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first(); $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { if (!$map->target_field) continue; $parts = explode('.', $map->target_field); if ($parts[0] !== 'phone') continue; $field = $parts[1] ?? null; if (!$field) continue; $val = $phoneData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } } if ($existing) { $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); if (empty($changes)) return ['action' => 'skipped', 'message' => 'No phone updates']; $existing->fill($changes); $existing->save(); return ['action' => 'updated', 'phone' => $existing]; } else { if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No phone fields for insert']; $data = array_filter($applyInsert, fn($v) => !is_null($v)); $data['person_id'] = $personId; $data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId(); $created = PersonPhone::create($data); return ['action' => 'inserted', 'phone' => $created]; } } }