meta ?? []; $hasHeader = (bool) ($meta['has_header'] ?? true); $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; $columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : []; $targetToSource = $this->buildTargetLookup($import); if (! $targetToSource) { return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); } $fileResult = $this->readFileRows($import, $hasHeader, $delimiter, $columns, $limit); if (isset($fileResult['error'])) { return $this->errorPayload($fileResult['error']); } // Extract by reference modifications (columns adjusted if no header) $rows = $fileResult['rows']; $columns = $fileResult['columns']; // Discover mapped entity roots and then filter by supported list with safe fallbacks $detectedRoots = $this->detectEntityRoots($targetToSource); $supported = $this->loadSupportedEntityRoots(); $entityRoots = $this->filterEntityRoots($detectedRoots, $supported, $targetToSource); $summaries = $this->initSummaries($entityRoots); // Caches & running state $contractCache = []; $accountCache = []; $genericCaches = []; // per root generic caches: [root => [reference => model|null]] $runningBalances = []; // Duplicate detection state: existing payment references per account + seen in this simulation $existingPaymentRefs = []; // [account_id => [ref => true]] $seenPaymentRefs = []; // [account_id => [ref => true]] // Generic duplicate detection (by identity keys per root) $genericExistingIdentities = []; // [root => [identity => true]] $genericSeenIdentities = []; // [root => [identity => true]] $translatedActions = $this->actionTranslations(); $translatedStatuses = $this->statusTranslations(); $simRows = []; foreach ($rows as $idx => $rawValues) { $assoc = $this->associateRow($columns, $rawValues); $rowEntities = []; // Reactivation intent detection (row > import > template) $rowReactivate = false; if (array_key_exists('reactivate', $assoc)) { $rawReactivateVal = $assoc['reactivate']; if (! is_null($rawReactivateVal) && $rawReactivateVal !== '') { $rowReactivate = filter_var($rawReactivateVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; } } $importReactivate = (bool) ($import->reactivate ?? false); $templateReactivate = (bool) (optional($import->template)->reactivate ?? false); $reactivateMode = $rowReactivate || $importReactivate || $templateReactivate; // Helper closure to resolve mapping value (with normalization fallbacks) $val = function (string $tf) use ($assoc, $targetToSource) { // Direct hit if (isset($targetToSource[$tf])) { return $assoc[$targetToSource[$tf]] ?? null; } // Fallback: normalize root part (contracts.reference -> contract.reference) if (str_contains($tf, '.')) { [$root, $rest] = explode('.', $tf, 2); $norm = $this->normalizeRoot($root); if ($norm !== $root) { $alt = $norm.'.'.$rest; if (isset($targetToSource[$alt])) { return $assoc[$targetToSource[$alt]] ?? null; } } } return null; }; // Contract if (isset($entityRoots['contract'])) { [$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference')); // If reactivation requested and contract exists but is inactive / soft-deleted, mark action as reactivate for UI clarity if ($reactivateMode && ($contractEntity['action'] === 'update') && ( (isset($contractEntity['active']) && $contractEntity['active'] === 0) || (! empty($contractEntity['deleted_at'])) )) { $contractEntity['original_action'] = $contractEntity['action']; $contractEntity['action'] = 'reactivate'; $contractEntity['reactivation'] = true; } $rowEntities['contract'] = $contractEntity + [ 'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'], ]; } // Account (explicit mapping with fallback inheritance from contract.reference when missing) if (isset($entityRoots['account'])) { $rawAccountRef = $val('account.reference'); $inherited = false; if (($rawAccountRef === null || $rawAccountRef === '') && isset($entityRoots['contract'])) { $contractRef = $val('contract.reference'); if ($contractRef !== null && $contractRef !== '') { $rawAccountRef = $contractRef; $inherited = true; } } [$accountEntity, $summaries, $accountCache] = $this->simulateAccount($val, $summaries, $accountCache, $rawAccountRef); if ($inherited) { $accountEntity['inherited_reference'] = true; } $rowEntities['account'] = $accountEntity + [ 'action_label' => $translatedActions[$accountEntity['action']] ?? $accountEntity['action'], ]; } // Determine if we have an existing contract (update) to derive chain entities later $existingContract = isset($rowEntities['contract']['action']) && $rowEntities['contract']['action'] === 'update'; // Generic roots (person, address, email, phone, client_case, etc.) excluding already handled ones foreach (array_keys($entityRoots) as $rootKey) { if (in_array($rootKey, ['contract', 'account', 'payment'], true)) { continue; // already simulated explicitly } // If contract already exists, we skip simulating person / client_case generically. // ImportProcessor will not create new ones in that scenario; it reuses the chain. if ($existingContract && in_array($rootKey, ['person', 'client_case'], true)) { continue; } $reference = $val($rootKey.'.reference'); $identityCandidates = $this->genericIdentityCandidates($rootKey, $val); [$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] = $this->simulateGenericRoot( $rootKey, $val, $summaries, $genericCaches, $reference, $identityCandidates, $genericExistingIdentities, $genericSeenIdentities, $verbose, $targetToSource, ); $rowEntities[$rootKey] = $genericEntity + [ 'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'], ]; } // Attach chain entities (client_case, person) if contract already existed if ($existingContract && isset($rowEntities['contract']['reference'])) { $contractRef = $rowEntities['contract']['reference']; $contractModel = $contractRef && isset($contractCache[$contractRef]) ? $contractCache[$contractRef] : null; if ($contractModel) { // Load client_case if mapped root present if (isset($entityRoots['client_case']) && $contractModel->client_case_id) { $cc = ClientCase::query()->find($contractModel->client_case_id, ['id', 'client_ref', 'person_id']); if ($cc) { if (! isset($summaries['client_case'])) { $summaries['client_case'] = [ 'root' => 'client_case', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries['client_case']['total_rows']++; $summaries['client_case']['update']++; $rowEntities['client_case'] = [ 'id' => $cc->id, 'reference' => $cc->client_ref, 'exists' => true, 'action' => 'update', 'action_label' => $translatedActions['update'] ?? 'posodobi', 'existing_chain' => true, ]; // Person from chain if mapped if (isset($entityRoots['person']) && $cc->person_id) { $p = Person::query()->find($cc->person_id, ['id', 'nu', 'full_name', 'first_name', 'last_name', 'birthday', 'description']); if ($p) { if (! isset($summaries['person'])) { $summaries['person'] = [ 'root' => 'person', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries['person']['total_rows']++; $summaries['person']['update']++; $rowEntities['person'] = [ 'id' => $p->id, 'reference' => $p->nu ?? (string) $p->id, 'exists' => true, 'action' => 'update', 'action_label' => $translatedActions['update'] ?? 'posodobi', 'existing_chain' => true, 'full_name' => $p->full_name, 'first_name' => $p->first_name, 'last_name' => $p->last_name, 'birthday' => $p->birthday, 'description' => $p->description, ]; // Attach email/phone/address if their roots are mapped and we skipped generic simulation if ($p->id) { // Email if (isset($entityRoots['email']) && ! isset($rowEntities['email'])) { $em = Email::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'value']); if ($em) { if (! isset($summaries['email'])) { $summaries['email'] = [ 'root' => 'email', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries['email']['total_rows']++; $summaries['email']['update']++; $rowEntities['email'] = [ 'id' => $em->id, 'reference' => $em->value, 'value' => $em->value, 'exists' => true, 'action' => 'update', 'action_label' => $translatedActions['update'] ?? 'posodobi', 'existing_chain' => true, ]; } } // Phone if (isset($entityRoots['phone']) && ! isset($rowEntities['phone'])) { $ph = PersonPhone::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'nu']); if ($ph) { if (! isset($summaries['phone'])) { $summaries['phone'] = [ 'root' => 'phone', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries['phone']['total_rows']++; $summaries['phone']['update']++; $rowEntities['phone'] = [ 'id' => $ph->id, 'reference' => $ph->nu, 'nu' => $ph->nu, 'exists' => true, 'action' => 'update', 'action_label' => $translatedActions['update'] ?? 'posodobi', 'existing_chain' => true, ]; } } // Address if (isset($entityRoots['address']) && ! isset($rowEntities['address'])) { $ad = PersonAddress::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'address', 'country']); if ($ad) { if (! isset($summaries['address'])) { $summaries['address'] = [ 'root' => 'address', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries['address']['total_rows']++; $summaries['address']['update']++; $rowEntities['address'] = [ 'id' => $ad->id, 'reference' => $ad->address, 'address' => $ad->address, // postal_code removed (not in schema) 'country' => $ad->country, 'exists' => true, 'action' => 'update', 'action_label' => $translatedActions['update'] ?? 'posodobi', 'existing_chain' => true, ]; } } } } } } } } } // If existing contract: upgrade generic email/phone/address entities (already simulated) to mark as chain if corresponding person attached if ($existingContract && isset($rowEntities['person']['id'])) { foreach (['email', 'phone', 'address'] as $gRoot) { if (isset($rowEntities[$gRoot]) && ! ($rowEntities[$gRoot]['existing_chain'] ?? false)) { $rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle } } } // Payment (affects account balance; may create implicit account) if (isset($entityRoots['payment'])) { // Inject inferred account if none mapped explicitly if (! isset($entityRoots['account']) && isset($rowEntities['contract']['id'])) { [$implicitAccount, $summaries, $accountCache] = $this->simulateImplicitAccount($rowEntities['contract']['id'], $summaries, $accountCache); if ($implicitAccount) { $rowEntities['account'] = $implicitAccount + [ 'action_label' => $translatedActions[$implicitAccount['action']] ?? $implicitAccount['action'], ]; } } [$paymentEntity, $rowEntities, $summaries, $runningBalances, $existingPaymentRefs, $seenPaymentRefs] = $this->simulatePayment( $val, $rowEntities, $summaries, $runningBalances, $targetToSource, $verbose, $existingPaymentRefs, $seenPaymentRefs ); $paymentEntity['status_label'] = $translatedStatuses[$paymentEntity['status']] ?? $paymentEntity['status']; $rowEntities['payment'] = $paymentEntity; } // If verbose, attach source metadata for non-payment entities (reference fields) to aid debugging if ($verbose) { foreach ($rowEntities as $eroot => &$ent) { $tf = $eroot.'.reference'; if (isset($targetToSource[$tf])) { $ent['sources'] = $ent['sources'] ?? []; if (! isset($ent['sources'][$tf])) { $ent['sources'][$tf] = [ 'source_column' => $targetToSource[$tf], 'value' => $val($tf), ]; } } } unset($ent); } // Compute delta for account if present (frontend may filter on this) if (isset($rowEntities['account']['balance_before'], $rowEntities['account']['balance_after'])) { $rowEntities['account']['delta'] = $rowEntities['account']['balance_after'] - $rowEntities['account']['balance_before']; } $rowStatus = 'ok'; if (isset($rowEntities['payment']['status']) && $rowEntities['payment']['status'] !== 'ok') { $rowStatus = $rowEntities['payment']['status']; } $simRows[] = [ 'index' => $idx + 1, 'entities' => $rowEntities, 'status' => $rowStatus, ]; } // Prune roots that are entirely empty (all rows action=skip and no identity or preview data) $nonEmptyRoots = []; // Map whether root has any mapping keys (after normalization) to avoid hiding legitimately mapped-but-empty columns early $rootHasMapping = []; foreach (array_keys($targetToSource) as $tfKey) { if (str_contains($tfKey, '.')) { [$r] = explode('.', $tfKey, 2); $rootHasMapping[$r] = true; } } foreach ($simRows as $row) { if (! isset($row['entities'])) { continue; } foreach ($row['entities'] as $root => $ent) { if (! isset($entityRoots[$root])) { continue; } // Determine if entity has meaningful data $hasData = false; foreach (['reference', 'identity_used', 'identity_candidates', 'full_name', 'first_name', 'last_name', 'address', 'country', 'nu', 'value'] as $k) { if (isset($ent[$k]) && $ent[$k]) { $hasData = true; break; } } // Some entities (e.g. payment) do not have 'action'; treat them as non-empty if they have data or status if (! isset($ent['action']) || $ent['action'] !== 'skip' || $hasData || isset($ent['status'])) { $nonEmptyRoots[$root] = true; } } } // Filter entityRoots and rows $neverPrune = ['person', 'address', 'client_case']; foreach (array_keys($entityRoots) as $root) { if (! isset($nonEmptyRoots[$root]) && ! in_array($root, ['contract', 'account', 'payment'], true) && ! in_array($root, $neverPrune, true) && empty($rootHasMapping[$root]) ) { unset($entityRoots[$root]); unset($summaries[$root]); // Remove from each row foreach ($simRows as &$row) { if (isset($row['entities'][$root])) { unset($row['entities'][$root]); } } unset($row); } } // Add Slovenian summary mirror (does not replace original machine keys) $localizedSummaries = $this->localizeSummaries($summaries); return [ 'rows' => $simRows, 'entities' => array_keys($entityRoots), 'summaries' => $summaries, 'povzetki' => $localizedSummaries, // Slovenian friendly summaries 'lokalizacija' => [ 'dejanja' => $translatedActions, 'statusi' => $translatedStatuses, ], ]; } /* ---------------------------- Helper: structure ---------------------------- */ private function buildTargetLookup(Import $import): array { $mappings = \DB::table('import_mappings') ->where('import_id', $import->id) ->orderBy('position') ->get(['source_column', 'target_field']); $lookup = []; foreach ($mappings as $m) { $target = trim((string) $m->target_field); $source = trim((string) $m->source_column); if ($target === '' || $source === '') { continue; } if (! isset($lookup[$target])) { $lookup[$target] = $source; } // If mapping uses *.client_ref, also register *.reference alias for simulation reference purposes if (str_ends_with($target, '.client_ref')) { $alias = substr($target, 0, -strlen('.client_ref')).'.reference'; if (! isset($lookup[$alias])) { $lookup[$alias] = $source; } } if (str_contains($target, '.')) { [$root, $rest] = explode('.', $target, 2); $norm = $this->normalizeRoot($root); if ($norm !== $root) { $alt = $norm.'.'.$rest; if (! isset($lookup[$alt])) { $lookup[$alt] = $source; } } if (str_ends_with($root, 's')) { $sing = substr($root, 0, -1); if ($sing && $sing !== $root) { $alt2 = $sing.'.'.$rest; if (! isset($lookup[$alt2])) { $lookup[$alt2] = $source; } } } } } return $lookup; } private function readFileRows(Import $import, bool $hasHeader, string $delimiter, array $columns, int $limit): array { $path = Storage::disk($import->disk)->path($import->path); if (! is_readable($path)) { return ['error' => 'Datoteka ni berljiva']; } $fh = @fopen($path, 'r'); if (! $fh) { return ['error' => 'Datoteke ni mogoče odpreti']; } if ($hasHeader) { $header = fgetcsv($fh, 0, $delimiter) ?: []; $columns = array_map(static fn ($h) => is_string($h) ? trim($h) : (string) $h, $header); } $rows = []; $widest = count($columns); while (($data = fgetcsv($fh, 0, $delimiter)) !== false && count($rows) < $limit) { if (! $hasHeader) { $widest = max($widest, count($data)); } $rows[] = $data; } fclose($fh); if (! $hasHeader && $widest > count($columns)) { $columns = array_map(static fn ($i) => 'col_'.($i + 1), range(0, $widest - 1)); } return compact('rows', 'columns'); } private function detectEntityRoots(array $targetToSource): array { $roots = []; foreach (array_keys($targetToSource) as $tf) { if (str_contains($tf, '.')) { [$root] = explode('.', $tf, 2); $roots[$this->normalizeRoot($root)] = true; } } return $roots; // associative for faster isset checks } /** * Normalize mapping root keys (plural or table-like) to canonical simulation roots. */ private function normalizeRoot(string $root): string { static $map = [ 'contracts' => 'contract', 'contract' => 'contract', 'accounts' => 'account', 'account' => 'account', 'payments' => 'payment', 'payment' => 'payment', 'emails' => 'email', 'email' => 'email', 'person_addresses' => 'address', 'person_address' => 'address', 'person_addresse' => 'address', 'addresses' => 'address', 'address' => 'address', 'person_phones' => 'phone', 'person_phone' => 'phone', 'phones' => 'phone', 'phone' => 'phone', 'client_cases' => 'client_case', 'client_case' => 'client_case', 'people' => 'person', 'persons' => 'person', 'person' => 'person', ]; return $map[$root] ?? $root; } /** * Filter detected entity roots against supported list coming from import_entities table. * Guarantees that core roots (payment, account, contract) are retained if they are mapped. * If supported list is empty (e.g. table empty / query failure), falls back to all detected. * Additionally, if filtering would yield an empty set while we still have mappings, it will * keep the original detected set to avoid hiding entities (fail-open strategy for UX). */ private function filterEntityRoots(array $detected, array $supported, array $targetToSource): array { // Fail-open if no supported list gathered if (empty($supported)) { return $detected; } $supportedFlip = array_flip($supported); $filtered = []; foreach ($detected as $root => $flag) { if (isset($supportedFlip[$root])) { $filtered[$root] = $flag; } } // Always retain core roots if they were mapped, even if not in supported list foreach (['payment', 'account', 'contract', 'address'] as $core) { if (isset($detected[$core])) { $filtered[$core] = true; } } // If after filtering nothing remains but mappings exist, revert (avoid confusing empty output) if (empty($filtered) && ! empty($detected)) { return $detected; } return $filtered; } private function initSummaries(array $entityRoots): array { $summaries = []; foreach (array_keys($entityRoots) as $root) { $summaries[$root] = [ 'root' => $root, 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } return $summaries; } private function associateRow(array $columns, array $values): array { $assoc = []; foreach ($columns as $i => $col) { $assoc[$col] = $values[$i] ?? null; } return $assoc; } /* -------------------------- Entity simulation parts -------------------------- */ private function simulateContract(callable $val, array $summaries, array $cache, ?string $reference): array { $contract = null; if ($reference) { if (array_key_exists($reference, $cache)) { $contract = $cache[$reference]; } else { $contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id', 'active', 'deleted_at']); $cache[$reference] = $contract; // may be null } } $entity = [ 'reference' => $reference, 'id' => $contract?->id, 'exists' => (bool) $contract, 'client_case_id' => $contract?->client_case_id, 'active' => $contract?->active, 'deleted_at' => $contract?->deleted_at, 'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'), ]; $summaries['contract']['total_rows']++; if (! $reference) { $summaries['contract']['missing_ref']++; } elseif ($contract) { $summaries['contract']['update']++; } else { $summaries['contract']['create']++; } return [$entity, $summaries, $cache]; } private function simulateAccount(callable $val, array $summaries, array $cache, ?string $reference): array { $account = null; if ($reference) { if (array_key_exists($reference, $cache)) { $account = $cache[$reference]; } else { $account = Account::query() ->where('reference', $reference) ->where('active', 1) ->first(['id', 'reference', 'balance_amount']); $cache[$reference] = $account; } } $entity = [ 'reference' => $reference, 'id' => $account?->id, 'exists' => (bool) $account, 'balance_before' => $account?->balance_amount, 'balance_after' => $account?->balance_amount, 'action' => $account ? 'update' : ($reference ? 'create' : 'skip'), ]; // Direct balance override support. // Some mappings may have stored the plural root ("accounts.balance_amount") instead of the singular // that the value resolver expects. Also allow a simpler fallback key (account.balance). $rawIncoming = $val('account.balance_amount') ?? $val('accounts.balance_amount') ?? $val('account.balance'); if ($rawIncoming !== null && $rawIncoming !== '') { $rawStr = (string) $rawIncoming; // Remove currency symbols and non numeric punctuation except , . - $clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? ''; if ($clean !== '') { // If both comma and dot exist, assume dot is decimal separator => strip commas (thousands) if (str_contains($clean, ',') && str_contains($clean, '.')) { $normalized = str_replace(',', '', $clean); } else { // Only one of them present -> treat comma as decimal separator $normalized = str_replace(',', '.', $clean); } // Collapse multiple dots keeping last as decimal (edge case). If multiple appear, remove all but last. if (substr_count($normalized, '.') > 1) { $parts = explode('.', $normalized); $last = array_pop($parts); $normalized = preg_replace('/\.+/', '', implode('', $parts)).'.'.$last; // join integer part } if (is_numeric($normalized)) { $incoming = (float) $normalized; $entity['balance_after'] = $incoming; $entity['direct_balance_override'] = true; } } } $summaries['account']['total_rows']++; if (! $reference) { $summaries['account']['missing_ref']++; } elseif ($account) { $summaries['account']['update']++; } else { $summaries['account']['create']++; } return [$entity, $summaries, $cache]; } private function simulateImplicitAccount(int $contractId, array $summaries, array $cache): array { $acct = Account::query()->where('contract_id', $contractId)->orderBy('id')->first(['id', 'reference', 'balance_amount']); if (! $acct) { return [null, $summaries, $cache]; } $entity = [ 'reference' => $acct->reference, 'id' => $acct->id, 'exists' => true, 'balance_before' => $acct->balance_amount, 'balance_after' => $acct->balance_amount, 'action' => 'implicit', 'inferred' => true, ]; if (! isset($summaries['account'])) { $summaries['account'] = [ 'root' => 'account', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, ]; } $summaries['account']['total_rows']++; $summaries['account']['update']++; $cache[$acct->reference] = $acct; return [$entity, $summaries, $cache]; } private function simulatePayment( callable $val, array $rowEntities, array $summaries, array $runningBalances, array $targetToSource, bool $verbose, array $existingPaymentRefs, array $seenPaymentRefs, ): array { $rawAmount = $val('payment.amount'); $amount = null; if ($rawAmount !== null && $rawAmount !== '') { $norm = str_replace([' ', ','], ['', '.'], (string) $rawAmount); if (is_numeric($norm)) { $amount = (float) $norm; } } $date = $val('payment.payment_date'); $reference = $val('payment.reference'); // Adjust account running balance if (isset($rowEntities['account']['id']) && empty($rowEntities['account']['direct_balance_override'])) { $accId = $rowEntities['account']['id']; $initial = $runningBalances[$accId] ?? (float) $rowEntities['account']['balance_before']; $before = $initial; $after = $initial; if ($amount !== null) { $after = $initial - $amount; // payment reduces balance $runningBalances[$accId] = $after; $rowEntities['account']['balance_before'] = $before; $rowEntities['account']['balance_after'] = $after; } } $entity = [ 'amount' => $amount, 'payment_date' => $date, 'reference' => $reference, 'status' => $amount === null ? 'invalid_amount' : 'ok', ]; if ($verbose) { // Only include verbose structures when requested $effectiveSources = []; foreach (['payment.amount', 'payment.payment_date', 'payment.reference', 'contract.reference', 'account.reference'] as $tf) { if (isset($targetToSource[$tf])) { $effectiveSources[$tf] = [ 'source_column' => $targetToSource[$tf], 'value' => $val($tf), ]; if ($tf === 'payment.amount') { $effectiveSources[$tf]['normalized'] = $amount; } } } $entity['sources'] = $effectiveSources; $entity['raw_amount'] = $rawAmount; } // Duplicate detection (only if have reference and an account id and status ok so far) if ($entity['status'] === 'ok' && $reference !== null && $reference !== '' && isset($rowEntities['account']['id'])) { $accId = $rowEntities['account']['id']; // Load existing refs lazily if (! isset($existingPaymentRefs[$accId])) { $existingPaymentRefs[$accId] = []; // Only query if account exists in DB (id assumed existing if action update/implicit) if (! empty($accId)) { foreach (Payment::query()->where('account_id', $accId)->pluck('reference') as $ref) { if ($ref !== null && $ref !== '') { $existingPaymentRefs[$accId][$ref] = true; } } } } if (isset($existingPaymentRefs[$accId][$reference])) { $entity['status'] = 'duplicate_db'; } else { if (! isset($seenPaymentRefs[$accId])) { $seenPaymentRefs[$accId] = []; } if (isset($seenPaymentRefs[$accId][$reference])) { $entity['status'] = 'duplicate'; } else { $seenPaymentRefs[$accId][$reference] = true; } } } $summaries['payment']['total_rows'] = ($summaries['payment']['total_rows'] ?? 0) + 1; if ($amount === null) { $summaries['payment']['invalid'] = ($summaries['payment']['invalid'] ?? 0) + 1; } if (isset($entity['status']) && $entity['status'] === 'duplicate') { $summaries['payment']['duplicate'] = ($summaries['payment']['duplicate'] ?? 0) + 1; } if (isset($entity['status']) && $entity['status'] === 'duplicate_db') { $summaries['payment']['duplicate_db'] = ($summaries['payment']['duplicate_db'] ?? 0) + 1; } return [$entity, $rowEntities, $summaries, $runningBalances, $existingPaymentRefs, $seenPaymentRefs]; } private function simulateGenericRoot( string $root, callable $val, array $summaries, array $genericCaches, ?string $reference, array $identityCandidates, array $genericExistingIdentities, array $genericSeenIdentities, bool $verbose = false, array $targetToSource = [], ): array { // Ensure summary bucket exists if (! isset($summaries[$root])) { $summaries[$root] = [ 'root' => $root, 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, ]; } $summaries[$root]['total_rows']++; $modelClass = $this->modelClassForGeneric($root); $record = null; if ($reference) { if (! isset($genericCaches[$root])) { $genericCaches[$root] = []; } if (array_key_exists($reference, $genericCaches[$root])) { $record = $genericCaches[$root][$reference]; } elseif ($modelClass && class_exists($modelClass)) { // Try/catch to avoid issues if column doesn't exist try { if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) { $record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']); } } catch (\Throwable) { $record = null; } $genericCaches[$root][$reference] = $record; // may be null } } // Fallback reference derivation for specific roots when explicit reference missing if (! $reference) { if ($root === 'client_case') { $reference = $val('client_case.client_ref'); } elseif ($root === 'person') { // Derive pseudo-reference from first_name (or full_name) if nothing else present so UI shows something $reference = $val('person.first_name') ?: $val('person.full_name'); } elseif ($root === 'address') { $reference = $val('address.address'); } elseif ($root === 'phone') { $reference = $val('phone.nu'); } elseif ($root === 'email') { $reference = $val('email.value'); } } $entity = [ 'reference' => $reference, 'id' => $record?->id, 'exists' => (bool) $record, 'action' => $reference ? ($record ? 'update' : 'create') : 'skip', // collect identity candidates for UI (raw list) and chosen identity marker 'identity_candidates' => $identityCandidates, ]; // Lightweight attribute previews (non-persistent, for UI clarity only) switch ($root) { case 'person': $entity['full_name'] = $val('person.full_name') ?? null; $entity['first_name'] = $val('person.first_name') ?? null; $entity['last_name'] = $val('person.last_name') ?? null; $entity['description'] = $val('person.description') ?? null; $entity['birthday'] = $val('person.birthday') ?? null; break; case 'address': $entity['address'] = $val('address.address') ?? null; // postal_code not present in schema $entity['country'] = $val('address.country') ?? null; break; case 'phone': $entity['nu'] = $val('phone.nu') ?? null; break; case 'email': $entity['value'] = $val('email.value') ?? null; break; case 'client_case': $entity['title'] = $val('client_case.title') ?? null; $entity['status'] = $val('client_case.status') ?? null; break; } if ($verbose) { $srcs = []; foreach ($targetToSource as $tf => $col) { if (str_starts_with($tf, $root.'.')) { $srcs[$tf] = [ 'source_column' => $col, 'value' => $val($tf), ]; } } if ($srcs) { $entity['sources'] = $entity['sources'] ?? []; $entity['sources'] += $srcs; } } if (! $reference) { $summaries[$root]['missing_ref']++; } elseif ($record) { $summaries[$root]['update']++; } else { $summaries[$root]['create']++; } // Duplicate detection based on identity candidates (first successful identity used) foreach ($identityCandidates as $identity) { if ($identity === null || $identity === '') { continue; } // Load existing identities once per root if (! isset($genericExistingIdentities[$root])) { $genericExistingIdentities[$root] = $this->loadExistingGenericIdentities($root); } if (isset($genericExistingIdentities[$root][$identity])) { $entity['duplicate_db'] = true; $entity['identity_used'] = $identity; $summaries[$root]['duplicate_db']++; break; } if (! isset($genericSeenIdentities[$root])) { $genericSeenIdentities[$root] = []; } if (isset($genericSeenIdentities[$root][$identity])) { $entity['duplicate'] = true; $entity['identity_used'] = $identity; $summaries[$root]['duplicate']++; break; } // Mark seen and continue to next identity candidate (only first unique tracked) $genericSeenIdentities[$root][$identity] = true; $entity['identity_used'] = $identity; break; } return [$entity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]; } private function genericIdentityCandidates(string $root, callable $val): array { switch ($root) { case 'email': $v = $val('email.value'); return $v ? ['value:'.mb_strtolower(trim((string) $v))] : []; case 'phone': $nu = $val('phone.nu'); if ($nu) { $norm = preg_replace('/\D+/', '', (string) $nu) ?? ''; return $norm ? ['nu:'.$norm] : []; } return []; case 'person': $ids = []; $tax = $val('person.tax_number'); if ($tax) { $ids[] = 'tax:'.mb_strtolower(trim((string) $tax)); } $ssn = $val('person.social_security_number'); if ($ssn) { $ids[] = 'ssn:'.mb_strtolower(trim((string) $ssn)); } $full = $val('person.full_name'); if ($full) { $ids[] = 'full:'.mb_strtolower(trim((string) $full)); } return $ids; case 'address': $addr = $val('address.address'); $pc = null; // postal code not stored $country = $val('address.country'); if ($addr || $pc || $country) { $key = mb_strtolower(trim((string) ($addr ?? ''))).'|'.mb_strtolower(trim((string) ($pc ?? ''))).'|'.mb_strtolower(trim((string) ($country ?? ''))); return ['addr:'.$key]; } return []; default: return []; } } private function loadExistingGenericIdentities(string $root): array { $set = []; try { switch ($root) { case 'email': foreach (\App\Models\Email::query()->pluck('value') as $v) { if ($v) { $set['value:'.mb_strtolower(trim((string) $v))] = true; } } break; case 'phone': foreach (\App\Models\Person\PersonPhone::query()->pluck('nu') as $p) { if ($p) { $set['nu:'.preg_replace('/\D+/', '', (string) $p)] = true; } } break; case 'person': foreach (\App\Models\Person\Person::query()->get(['tax_number', 'social_security_number', 'full_name']) as $rec) { if ($rec->tax_number) { $set['tax:'.mb_strtolower(trim((string) $rec->tax_number))] = true; } if ($rec->social_security_number) { $set['ssn:'.mb_strtolower(trim((string) $rec->social_security_number))] = true; } if ($rec->full_name) { $set['full:'.mb_strtolower(trim((string) $rec->full_name))] = true; } } break; case 'address': foreach (\App\Models\Person\PersonAddress::query()->get(['address', 'country']) as $rec) { $key = mb_strtolower(trim((string) ($rec->address ?? ''))).'|'.mb_strtolower(trim((string) ($rec->country ?? ''))); if (trim($key, '|') !== '') { $set['addr:'.$key] = true; } } break; } } catch (\Throwable) { // swallow and return what we have } return $set; } private function modelClassForGeneric(string $root): ?string { // Explicit mapping for known roots; extend as needed return [ 'person' => \App\Models\Person\Person::class, 'address' => \App\Models\Person\PersonAddress::class, 'phone' => \App\Models\Person\PersonPhone::class, 'email' => \App\Models\Email::class, 'booking' => \App\Models\Booking::class, 'activity' => \App\Models\Activity::class, 'client' => \App\Models\Client::class, 'client_case' => \App\Models\ClientCase::class, ][$root] ?? null; } private function loadSupportedEntityRoots(): array { // Pull keys + canonical_root from import_entities table to determine allowed roots try { $rows = \App\Models\ImportEntity::query()->get(['key', 'canonical_root']); $roots = []; foreach ($rows as $r) { if ($r->canonical_root) { $roots[] = $r->canonical_root; } if ($r->key) { // keys sometimes plural; we only want canonical forms for simulation root detection // keep both to be safe $roots[] = $r->key; } } // Normalize underscores plural forms to canonical ones (contracts -> contract) where possible $roots = array_unique(array_map(function ($v) { if (str_ends_with($v, 's')) { $sing = substr($v, 0, -1); return $sing ?: $v; } return $v; }, $roots)); return $roots; } catch (\Throwable) { // Fallback: allow existing known roots if table unavailable return ['contract', 'account', 'payment', 'person', 'address', 'phone', 'email', 'booking', 'activity', 'client', 'client_case']; } } /* ------------------------------- Localization ------------------------------- */ private function actionTranslations(): array { return [ 'create' => 'ustvari', 'update' => 'posodobi', 'skip' => 'preskoči', 'implicit' => 'posredno', 'reactivate' => 'reaktiviraj', ]; } private function statusTranslations(): array { return [ 'ok' => 'v_redu', 'invalid_amount' => 'neveljaven_znesek', 'duplicate' => 'podvojen', 'duplicate_db' => 'podvojen_v_bazi', ]; } private function localizeSummaries(array $summaries): array { $map = []; foreach ($summaries as $root => $s) { $map[$root] = [ 'koren' => $root, 'vrstice_skupaj' => $s['total_rows'], 'za_ustvariti' => $s['create'], 'za_posodobiti' => $s['update'], 'manjkajoca_referenca' => $s['missing_ref'], 'neveljavno' => $s['invalid'], 'podvojeni' => $s['duplicate'] ?? 0, 'podvojeni_v_bazi' => $s['duplicate_db'] ?? 0, ]; } return $map; } private function errorPayload(string $message): array { return [ 'rows' => [], 'entities' => [], 'summaries' => [], 'error' => $message, ]; } }