Teren-app/app/Services/ImportProcessor.php

2631 lines
123 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Account;
use App\Models\AccountType;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\ContractType;
use App\Models\Decision;
use App\Models\Email;
use App\Models\Import;
use App\Models\ImportEntity;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Models\Payment;
use App\Models\Person\AddressType;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonPhone;
use App\Models\Person\PersonType;
use App\Models\Person\PhoneType;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ImportProcessor
{
/**
* Process an import and apply basic dedup checks.
* Returns summary counts.
*/
public function process(Import $import, ?Authenticatable $user = null): array
{
$started = now();
$total = 0;
$skipped = 0;
$imported = 0;
$invalid = 0;
$fh = null;
// Only CSV/TSV supported in this pass
if (! in_array($import->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']);
// Load dynamic entity config
[$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple] = $this->loadImportEntityConfig();
// Normalize aliases (plural/legacy roots, field names) before validation
$mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap);
// Validate mapping roots early to avoid silent failures due to typos
$this->validateMappingRoots($mappings, $validRoots);
$header = $import->meta['columns'] ?? null;
// Template meta flags
$tplMeta = optional($import->template)->meta ?? [];
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
// Prefer explicitly chosen delimiter, then template meta, else detected
$delimiter = $import->meta['forced_delimiter']
?? optional($import->template)->meta['delimiter']
?? $import->meta['detected_delimiter']
?? ',';
$hasHeader = (bool) ($import->meta['has_header'] ?? true);
$path = Storage::disk($import->disk)->path($import->path);
// Note: Do not auto-detect or infer mappings/fields beyond what the template mapping provides
// 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.',
]);
// Preflight recommendations for payments-import
if ($paymentsImport) {
$hasContractRef = $this->mappingIncludes($mappings, 'contract.reference');
$hasPaymentAmount = $this->mappingIncludes($mappings, 'payment.amount');
$hasPaymentDate = $this->mappingIncludes($mappings, 'payment.payment_date') || $this->mappingIncludes($mappings, 'payment.paid_at');
$hasPaymentRef = $this->mappingIncludes($mappings, 'payment.reference');
$hasPaymentNu = $this->mappingIncludes($mappings, 'payment.payment_nu');
$missing = [];
if (! $hasContractRef) {
$missing[] = 'contract.reference';
}
if (! $hasPaymentAmount) {
$missing[] = 'payment.amount';
}
if (! $hasPaymentDate) {
$missing[] = 'payment.payment_date';
}
if (! empty($missing)) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'payments_mapping_recommendation',
'level' => 'warning',
'message' => 'Payments import: recommended mappings missing.',
'context' => ['missing' => $missing],
]);
}
if (! $hasPaymentRef && ! $hasPaymentNu) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'payments_idempotency_recommendation',
'level' => 'warning',
'message' => 'For idempotency, map payment.reference (preferred) or payment.payment_nu.',
]);
}
}
$rowNum = 0;
if ($hasHeader) {
$first = fgetcsv($fh, 0, $delimiter);
$rowNum++;
// Always use the actual header from the file for parsing
$header = array_map(fn ($v) => $this->sanitizeHeaderName((string) $v), $first ?: []);
// Heuristic: if header parsed as a single column but contains common delimiters, warn about mismatch
if (count($header) === 1) {
$rawHeader = $first[0] ?? '';
if (is_string($rawHeader) && (str_contains($rawHeader, ';') || str_contains($rawHeader, "\t"))) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'delimiter_mismatch_suspected',
'level' => 'warning',
'message' => 'Header parsed as a single column. Suspected delimiter mismatch. Set a forced delimiter in the template or import settings.',
'context' => [
'current_delimiter' => $delimiter,
'raw_header' => $rawHeader,
],
]);
}
}
// Preflight: warn if any mapped source columns are not present in the header (exact match)
$headerSet = [];
foreach ($header as $h) {
$headerSet[$h] = true;
}
$missingSources = [];
foreach ($mappings as $map) {
$src = (string) ($map->source_column ?? '');
if ($src !== '' && ! array_key_exists($src, $headerSet)) {
$missingSources[] = $src;
}
}
if (! empty($missingSources)) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'source_columns_missing_in_header',
'level' => 'warning',
'message' => 'Some mapped source columns are not present in the file header (exact match required).',
'context' => [
'missing' => $missingSources,
'header' => $header,
],
]);
}
}
// If mapping contains contract.reference, we require each row to successfully resolve/create a contract
$requireContract = $this->mappingIncludes($mappings, 'contract.reference');
$isPg = DB::connection()->getDriverName() === 'pgsql';
$failedRows = [];
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
$rowNum++;
$total++;
if ($isPg) {
// Establish a savepoint so a failing row does not poison the whole transaction
DB::statement('SAVEPOINT import_row_'.$rowNum);
}
// Scope variables per row so they aren't reused after exception
$importRow = null;
try {
$rawAssoc = $this->buildRowAssoc($row, $header);
// Skip entirely empty rows (all raw values blank/null after trimming) without creating an ImportRow
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
$skipped++;
if ($isPg) {
// No DB changes were made for this row; nothing to roll back explicitly.
}
continue; // proceed to next CSV row
}
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings, $supportsMultiple);
// Determine row-level reactivation intent: precedence row > import > template
$rowReactivate = false;
$rawReactivateVal = $rawAssoc['reactivate'] ?? null; // direct column named 'reactivate'
if (! is_null($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;
// Do not auto-derive or fallback values; only use explicitly mapped fields
$rawSha1 = sha1(json_encode($rawAssoc));
$importRow = ImportRow::create([
'import_id' => $import->id,
'row_number' => $rowNum,
'record_type' => $recordType,
'raw_data' => $rawAssoc,
'mapped_data' => $mapped,
'status' => 'valid',
'raw_sha1' => $rawSha1,
]);
// Contracts
$contractResult = null;
$reactivatedThisRow = false;
if (isset($mapped['contract'])) {
// In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only
if ($paymentsImport && $contractKeyMode === 'reference') {
$ref = $mapped['contract']['reference'] ?? null;
if (is_string($ref)) {
$ref = preg_replace('/\s+/', '', trim($ref));
}
if ($ref) {
$q = Contract::query()
->when($import->client_id, function ($q2, $clientId) {
$q2->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId);
})
->where('contracts.reference', $ref)
->select('contracts.*');
$found = $q->first();
if ($found) {
$contractResult = ['action' => 'resolved', 'contract' => $found];
// Reactivation branch for resolved existing contract
if ($reactivateMode && ($found->active == 0 || $found->deleted_at)) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import.',
'context' => ['contract_id' => $found->id],
]);
// Do NOT continue; allow postContractActions + account processing below.
}
}
} else {
$contractResult = null; // let requireContract logic flag invalid later
}
} else {
$contractResult = null;
}
} else {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
// If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow.
if ($reactivateMode && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$found = $contractResult['contract'];
if ($found->active == 0 || $found->deleted_at) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import (post-upsert).',
'context' => ['contract_id' => $found->id],
]);
// Do not continue; allow post actions + account handling.
}
}
}
}
if ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta
// like attaching a segment or creating an activity. Do that if we have the contract.
if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
try {
$this->postContractActions($import, $contractResult['contract']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_actions_applied',
'level' => 'info',
'message' => 'Applied template post-actions on existing contract.',
'context' => ['contract_id' => $contractResult['contract']->id],
]);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
}
}
$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,
]);
$contractFieldsStr = '';
if (! empty($contractResult['applied_fields'] ?? [])) {
$contractFieldsStr = $this->formatAppliedFieldMessage('contract', $contractResult['applied_fields']);
}
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'.($contractFieldsStr ? ' '.$contractFieldsStr : ''),
'context' => ['id' => $contractResult['contract']->id, 'fields' => $contractResult['applied_fields'] ?? []],
]);
// Post-contract actions from template/import meta
if (! $reactivateMode || $reactivatedThisRow) { // run post actions also for reactivated contracts
try {
$this->postContractActions($import, $contractResult['contract']);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
}
}
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]);
}
}
// Enforce hard requirement: if template mapped contract.reference but we didn't resolve/create a contract, mark row invalid and continue
if ($requireContract) {
$contractEnsured = false;
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$contractEnsured = true;
}
if (! $contractEnsured) {
$srcCol = $this->findSourceColumnFor($mappings, 'contract.reference');
$rawVal = $srcCol !== null ? ($rawAssoc[$srcCol] ?? null) : null;
$extra = $srcCol !== null ? ' Source column: "'.$srcCol.'" value: '.(is_null($rawVal) || $rawVal === '' ? '(empty)' : (is_scalar($rawVal) ? (string) $rawVal : json_encode($rawVal))) : '';
$msg = 'Row '.$rowNum.': Contract was required (contract.reference mapped) but not created/resolved. '.($contractResult['message'] ?? '').$extra;
// Avoid double-counting invalid if already set by contract processing
if ($importRow->status !== 'invalid') {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$msg]]);
}
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_invalid',
'level' => 'error',
'message' => $msg,
]);
// Skip further processing for this row
continue;
}
}
// Accounts
$accountResult = null;
if (isset($mapped['account'])) {
// If a contract was just created or resolved above, pass its id to account mapping for this row
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$mapped['account']['contract_id'] = $contractResult['contract']->id;
}
$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).',
'context' => $accountResult['context'] ?? null,
]);
} elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') {
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Account::class,
'entity_id' => $accountResult['account']->id,
]);
$accountFieldsStr = '';
if (! empty($accountResult['applied_fields'] ?? [])) {
$accountFieldsStr = $this->formatAppliedFieldMessage('account', $accountResult['applied_fields']);
}
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'.($accountFieldsStr ? ' '.$accountFieldsStr : ''),
'context' => ['id' => $accountResult['account']->id, 'fields' => $accountResult['applied_fields'] ?? []],
]);
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]);
}
}
// Contacts: resolve person via Contract/Account chain, client_case.client_ref, 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');
}
// Payments: when present, require account resolution and create payment
if (isset($mapped['payment'])) {
// If no account yet, try to resolve via contract + first account
$accountIdForPayment = $accountResult['account']->id ?? null;
// If payments-import mode with contract_key_mode=reference and we have contract.reference mapped, resolve by reference only
$tplMeta = optional($import->template)->meta ?? [];
$paymentsImport = (bool) ($tplMeta['payments_import'] ?? false);
$contractKeyMode = $tplMeta['contract_key_mode'] ?? null;
if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') {
$contractRef = $mapped['contract']['reference'] ?? null;
if (! $contractId) {
$contract = \App\Models\Contract::query()
->when($import->client_id, function ($q, $clientId) {
$q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId);
})
->where('contracts.reference', $contractRef)
->select('contracts.id')
->first();
} elseif ($hasContractRoot) {
// If mapping for contract.reference is keyref, do NOT create contract lookup only
$refMode = $this->mappingMode($mappings, 'contract.reference');
if ($refMode === 'keyref') {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => null,
'event' => 'row_skipped',
'level' => 'warning',
'message' => 'Contract reference '.$contractRef.' does not exist (keyref); row skipped.',
]);
return [
'action' => 'skipped',
'message' => 'contract.reference keyref lookup failed: not found',
];
}
/* Lines 1242-1269 omitted */
$contractId = $createdContract->id;
}
}
if (! $accountIdForPayment) {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => ['Payment requires an account. Could not resolve account for payment.']]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_invalid',
'level' => 'error',
'message' => 'Payment requires an account (not resolved).',
]);
continue;
}
// Build payment payload
$p = $mapped['payment'];
// Normalize reference and payment number (if provided)
$refVal = isset($p['reference']) ? (is_string($p['reference']) ? trim($p['reference']) : $p['reference']) : null;
$nuVal = isset($p['payment_nu']) ? (is_string($p['payment_nu']) ? trim($p['payment_nu']) : $p['payment_nu']) : null;
$payload = [
'account_id' => $accountIdForPayment,
'reference' => $p['reference'] ?? null,
'paid_at' => $p['payment_date'] ?? ($p['paid_at'] ?? null),
'currency' => $p['currency'] ?? 'EUR',
'created_by' => $user?->getAuthIdentifier(),
];
// Attach payment_nu into meta for idempotency if provided
$meta = [];
if (is_array($p['meta'] ?? null)) {
$meta = $p['meta'];
}
if (! empty($nuVal)) {
$meta['payment_nu'] = $nuVal;
}
if (! empty($meta)) {
$payload['meta'] = $meta;
}
// Amount: accept either amount (preferred) or legacy amount_cents; convert cents -> decimal
if (array_key_exists('amount', $p)) {
$payload['amount'] = is_string($p['amount']) ? (float) $this->normalizeDecimal($p['amount']) : (float) $p['amount'];
} elseif (array_key_exists('amount_cents', $p)) {
$payload['amount'] = ((int) $p['amount_cents']) / 100.0;
}
// Idempotency: skip creating if a payment with same (account_id, reference) already exists
if (! empty($refVal)) {
$exists = Payment::query()
->where('account_id', $accountIdForPayment)
->where('reference', $refVal)
->exists();
if ($exists) {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'payment_duplicate_skipped',
'level' => 'info',
'message' => 'Skipped duplicate payment (by reference).',
'context' => [
'account_id' => $accountIdForPayment,
'reference' => $refVal,
],
]);
continue;
}
}
$payment = new Payment;
$payment->fill($payload);
// Save the account balance before applying this payment
$accForBal = Account::find($accountIdForPayment);
if ($accForBal) {
$payment->balance_before = (float) ($accForBal->balance_amount ?? 0);
}
// If amount not in payload yet but provided, set it directly
if (! array_key_exists('amount', $payload) && isset($p['amount'])) {
$payment->amount = (float) $this->normalizeDecimal($p['amount']);
}
try {
$payment->save();
} catch (\Throwable $e) {
// Gracefully skip if unique index on (account_id, reference) is violated due to race conditions
if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) {
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'payment_duplicate_skipped_db',
'level' => 'info',
'message' => 'Skipped duplicate payment due to database unique constraint (account_id, reference).',
'context' => [
'account_id' => $accountIdForPayment,
'reference' => $refVal,
],
]);
continue;
}
throw $e;
}
// Option A: create a credit booking so account balance updates via booking events
try {
if (isset($payment->amount)) {
\App\Models\Booking::query()->create([
'account_id' => $accountIdForPayment,
'payment_id' => $payment->id,
'amount_cents' => (int) round(((float) $payment->amount) * 100),
'type' => 'credit',
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
'booked_at' => $payment->paid_at ?? now(),
]);
}
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'booking_create_failed',
'level' => 'warning',
'message' => 'Failed to create booking for payment: '.$e->getMessage(),
]);
}
// Optionally create an activity entry for this payment
try {
$settings = \App\Models\PaymentSetting::query()->first();
if ($settings && ($settings->create_activity_on_payment ?? false)) {
$amountCents = (int) round(((float) $payment->amount) * 100);
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
$note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], $note);
// Append balance context (before/after) and mark cause as payment
// At this point, booking has been created so the account balance should reflect the new amount
$accountAfter = Account::find($accountIdForPayment);
$beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR');
$afterStr = number_format((float) ($accountAfter?->balance_amount ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR');
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
// Resolve client_case_id via account->contract
$accountForActivity = $accForBal ?: Account::find($accountIdForPayment);
$accountForActivity?->loadMissing('contract');
$contractId = $accountForActivity?->contract_id;
$clientCaseId = $accountForActivity?->contract?->client_case_id;
if ($clientCaseId) {
$activity = \App\Models\Activity::query()->create([
'due_date' => null,
'amount' => $amountCents / 100,
'note' => $note,
'action_id' => $settings->default_action_id,
'decision_id' => $settings->default_decision_id,
'client_case_id' => $clientCaseId,
'contract_id' => $contractId,
]);
$payment->update(['activity_id' => $activity->id]);
} else {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'payment_activity_skipped',
'level' => 'info',
'message' => 'Skipped creating activity for payment due to missing client_case_id on contract.',
]);
}
}
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'payment_activity_failed',
'level' => 'warning',
'message' => 'Failed to create activity for payment: '.$e->getMessage(),
]);
}
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Payment::class,
'entity_id' => $payment->id,
]);
$paymentFields = $this->collectPaymentAppliedFields($payload, $payment);
$paymentFieldsStr = $this->formatAppliedFieldMessage('payment', $paymentFields);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_imported',
'level' => 'info',
'message' => 'Inserted payment'.($paymentFieldsStr ? ' '.$paymentFieldsStr : ''),
'context' => ['id' => $payment->id, 'fields' => $paymentFields],
]);
}
// 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 client_case.client_ref for this client (prefer reusing existing person)
if (! $personIdForRow && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$cc = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->first();
if ($cc) {
$personIdForRow = $cc->person_id ?: null;
}
}
// Resolve by contact values next
if (! $personIdForRow) {
// consider first values from multi groups if present
$emailVal = trim((string) ($this->firstFromMulti($mapped, 'email', 'value') ?? ''));
$phoneNu = trim((string) ($this->firstFromMulti($mapped, 'phone', 'nu') ?? ''));
$addrLine = trim((string) ($this->firstFromMulti($mapped, 'address', 'address') ?? ''));
// Try to resolve by existing contacts first
if ($emailVal !== '') {
$personIdForRow = Email::where('value', $emailVal)->value('person_id');
}
if (! $personIdForRow && $phoneNu !== '') {
$personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id');
}
if (! $personIdForRow && $addrLine !== '') {
$personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id');
}
// If still no person but we have any contact value, auto-create a minimal person
// BUT if we can map to an existing client_case by client_ref, reuse that case and set person there (avoid separate person rows)
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
if ($import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$cc = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->first();
if ($cc) {
$pid = $cc->person_id ?: $this->createMinimalPersonId();
if (! $cc->person_id) {
$cc->person_id = $pid;
$cc->save();
}
$personIdForRow = $pid;
}
}
}
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
$personIdForRow = $this->createMinimalPersonId();
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id ?? null,
'event' => 'person_autocreated_for_contacts',
'level' => 'info',
'message' => 'Created minimal person to attach contact data (email/phone/address).',
'context' => [
'email' => $emailVal ?: null,
'phone' => $phoneNu ?: null,
'address' => $addrLine ?: null,
'person_id' => $personIdForRow,
],
]);
}
}
// 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']);
}
// At this point, personIdForRow is either resolved or remains null (no contacts/person data)
$contactChanged = false;
if ($personIdForRow) {
// Fan-out for multi-supported roots; backward compatible for single hashes
foreach (['email' => 'upsertEmail', 'address' => 'upsertAddress', 'phone' => 'upsertPhone'] as $root => $method) {
if (isset($mapped[$root]) && is_array($mapped[$root])) {
// If it's a grouped map (supports multiple), iterate groups; else treat as single data hash
$data = $mapped[$root];
$isGrouped = $this->isGroupedMulti($data);
if ($isGrouped) {
// De-duplicate grouped items within the same row by their unique key per root
$keyField = $root === 'email' ? 'value' : ($root === 'phone' ? 'nu' : 'address');
$normalizer = function ($v) use ($root) {
if ($v === null) {
return null;
}
$s = trim((string) $v);
if ($s === '') {
return '';
}
if ($root === 'email') {
return mb_strtolower($s);
}
if ($root === 'phone') {
// Keep leading + and digits only for comparison
$s = preg_replace('/[^0-9+]/', '', $s) ?? $s;
// Collapse multiple + to single leading
$s = ltrim($s, '+');
return '+'.$s;
}
// address: normalize whitespace and lowercase for comparison
$s = preg_replace('/\s+/', ' ', $s) ?? $s;
return mb_strtolower(trim($s));
};
$data = $this->dedupeGroupedItems($data, $keyField, $normalizer);
foreach ($data as $grp => $payload) {
if (empty(array_filter($payload, fn ($v) => ! is_null($v) && trim((string) $v) !== ''))) {
continue; // skip empty group
}
$r = $this->{$method}($personIdForRow, $payload, $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
$contactChanged = true;
}
}
} else {
if (! empty($data)) {
$r = $this->{$method}($personIdForRow, $data, $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']);
}
}
} catch (\Throwable $e) {
if ($isPg) {
// Roll back only this row's work
try {
DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum);
} catch (\Throwable $ignored) { /* noop */
}
}
// Ensure importRow exists for logging if failure happened before its creation
if (! $importRow) {
try {
$msg = $this->safeErrorMessage($e->getMessage());
$rawPreviewSha1 = isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null;
$importRow = ImportRow::create([
'import_id' => $import->id,
'row_number' => $rowNum,
'record_type' => null,
'raw_data' => isset($rawAssoc) ? $rawAssoc : [],
'mapped_data' => [],
'status' => 'invalid',
'errors' => [$msg],
'raw_sha1' => $rawPreviewSha1,
]);
} catch (\Throwable $inner) {
// Last resort: cannot persist row; log only event
}
} else {
// Mark existing row as invalid (avoid double increment if already invalid)
if ($importRow->status !== 'invalid') {
$importRow->update(['status' => 'invalid', 'errors' => [$this->safeErrorMessage($e->getMessage())]]);
}
}
$failedRows[] = $rowNum;
$invalid++;
try {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow?->id, // may be null if creation failed
'event' => 'row_exception',
'level' => 'error',
'message' => $this->safeErrorMessage($e->getMessage()),
'context' => [
'classification' => $this->classifyRowException($e),
'driver' => DB::connection()->getDriverName(),
'row_number' => $rowNum,
'raw_sha1' => isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null,
'raw_data_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [],
],
]);
} catch (\Throwable $evtErr) {
// Swallow secondary failure to ensure loop continues
}
// Skip to next row without aborting whole import
continue;
}
}
fclose($fh);
if (! empty($failedRows)) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'row_exceptions_summary',
'level' => 'warning',
'message' => 'Rows failed: '.(count($failedRows) > 30 ? (implode(',', array_slice($failedRows, 0, 30)).' (+'.(count($failedRows) - 30).' more)') : implode(',', $failedRows)),
'context' => [
'failed_count' => count($failedRows),
'rows' => count($failedRows) > 200 ? array_slice($failedRows, 0, 200) : $failedRows,
],
]);
}
$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;
}
protected function applyMappings(array $raw, $mappings, array $supportsMultiple): array
{
$recordType = null;
$mapped = [];
foreach ($mappings as $map) {
$src = $map->source_column;
$target = $map->target_field;
if (! $target) {
continue;
}
$value = $raw[$src] ?? null;
// Transform chain support: e.g. "trim|decimal" or "upper|alnum"
$transform = (string) ($map->transform ?? '');
if ($transform !== '') {
$parts = explode('|', $transform);
foreach ($parts as $t) {
$t = trim($t);
if ($t === 'trim') {
$value = is_string($value) ? trim($value) : $value;
} elseif ($t === 'upper') {
$value = is_string($value) ? strtoupper($value) : $value;
} elseif ($t === 'lower') {
$value = is_string($value) ? strtolower($value) : $value;
} elseif ($t === 'digits' || $t === 'numeric') {
$value = is_string($value) ? preg_replace('/[^0-9]/', '', $value) : $value;
} elseif ($t === 'decimal') {
$value = is_string($value) ? $this->normalizeDecimal($value) : $value;
} elseif ($t === 'alnum') {
$value = is_string($value) ? preg_replace('/[^A-Za-z0-9]/', '', $value) : $value;
} elseif ($t === 'ref') {
// Reference safe: keep letters+digits only, uppercase
$value = is_string($value) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $value)) : $value;
}
}
}
// detect record type from first segment, e.g., "account.balance_amount"
$parts = explode('.', $target);
$rootWithBracket = $parts[0] ?? '';
// Support bracket grouping like address[1].city
$group = null;
$root = $rootWithBracket;
if (preg_match('/^(?P<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $rootWithBracket, $m)) {
$root = $m['base'];
$group = $m['grp'];
}
if (! $recordType && isset($root)) {
$recordType = $root;
}
// If this root supports multiple, determine group id: prefer mapping options.group, else bracket, else '1'
$supportsMulti = $supportsMultiple[$root] ?? false;
// Special handling for meta mappings: contract.meta.key (supports options.key and options.type)
if ($root === 'contract' && isset($parts[1]) && str_starts_with($parts[1], 'meta')) {
// Path could be meta.key or meta[key]
$metaKey = null;
$metaType = null;
// support dot path contract.meta.someKey
if (isset($parts[2])) {
$metaKey = $parts[2];
} else {
// support contract.meta[someKey]
if (preg_match('/^meta\[(?P<k>[^\]]+)\]$/', $parts[1], $mm)) {
$metaKey = $mm['k'];
}
}
if ($metaKey === null || $metaKey === '') {
// fallback: read key from mapping options.key if present
$opts = $map->options ?? null;
if (is_string($opts)) {
$opts = json_decode($opts, true) ?: [];
}
if (is_array($opts) && ! empty($opts['key'])) {
$metaKey = (string) $opts['key'];
}
if (is_array($opts) && ! empty($opts['type'])) {
$metaType = is_string($opts['type']) ? strtolower($opts['type']) : null;
}
} else {
// we still may have options.type
$opts = $map->options ?? null;
if (is_string($opts)) {
$opts = json_decode($opts, true) ?: [];
}
if (is_array($opts) && ! empty($opts['type'])) {
$metaType = is_string($opts['type']) ? strtolower($opts['type']) : null;
}
}
if ($metaKey !== null && $metaKey !== '') {
// group-aware bucket for meta entries
$groupOpt = $this->mappingOptionGroup($map);
$grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1');
if (! isset($mapped['contract'])) {
$mapped['contract'] = [];
}
if (! isset($mapped['contract']['meta']) || ! is_array($mapped['contract']['meta'])) {
$mapped['contract']['meta'] = [];
}
if (! isset($mapped['contract']['meta'][$grp])) {
$mapped['contract']['meta'][$grp] = [];
}
// Optionally coerce the value based on provided type
$coerced = $value;
$metaType = in_array($metaType, ['string', 'number', 'date', 'boolean'], true) ? $metaType : null;
if ($metaType === 'number') {
if (is_string($coerced)) {
$norm = $this->normalizeDecimal($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 for consistency
if (is_scalar($coerced)) {
$coerced = (string) $coerced;
}
}
// Store as structure with title, value and optional type
$entry = [
'title' => is_string($src) ? $src : (string) $src,
'value' => $coerced,
];
if ($metaType !== null) {
$entry['type'] = $metaType;
}
$mapped['contract']['meta'][$grp][$metaKey] = $entry;
continue;
}
}
if ($supportsMulti) {
$groupOpt = $this->mappingOptionGroup($map);
$grp = ($groupOpt !== null && $groupOpt !== '') ? (string) $groupOpt : ($group ?? '1');
// rebuild target path to exclude bracket part
$field = $parts[1] ?? null;
if ($field !== null) {
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
$mapped[$root] = [];
}
if (! isset($mapped[$root][$grp]) || ! is_array($mapped[$root][$grp])) {
$mapped[$root][$grp] = [];
}
$mapped[$root][$grp][$field] = $value;
}
} else {
// single item root: assign field or root as appropriate
$field = $parts[1] ?? null;
if ($field !== null) {
if (! isset($mapped[$root]) || ! is_array($mapped[$root])) {
$mapped[$root] = [];
}
$mapped[$root][$field] = $value;
} else {
$mapped[$root] = $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;
// Determine if the template includes any contract mappings; if not, do not create contracts here
$hasContractRoot = $this->mappingsContainRoot($mappings, 'contract');
// Normalize references (remove spaces) for consistent matching
if (! is_null($reference)) {
$reference = preg_replace('/\s+/', '', trim((string) $reference));
$acc['reference'] = $reference;
}
if (! empty($acc['contract_reference'] ?? null)) {
$acc['contract_reference'] = preg_replace('/\s+/', '', trim((string) $acc['contract_reference']));
}
// 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;
} elseif ($hasContractRoot) {
// Only create a new contract if the template explicitly includes contract mappings
// Resolve debtor via identifiers or provided person
$personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []);
if (! $personId) {
$personId = $this->findOrCreatePersonId($mapped['person'] ?? []);
}
if (! $personId) {
$personId = $this->createMinimalPersonId();
}
$clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId, $mapped['client_case']['client_ref'] ?? null);
$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])) {
$val = $contractFields[$k];
if (in_array($k, ['start_date', 'end_date'], true)) {
$val = $this->normalizeDate(is_scalar($val) ? (string) $val : null);
}
$newContractData[$k] = $val;
}
}
$newContractData['start_date'] = $newContractData['start_date'] ?? now()->toDateString();
$newContractData['type_id'] = $newContractData['type_id'] ?? $this->getDefaultContractTypeId();
$createdContract = Contract::create($newContractData);
$contractId = $createdContract->id;
} else {
// Do not create contracts implicitly when not mapped in the template
$contractId = null;
}
if ($contractId) {
$acc['contract_id'] = $contractId;
$mapped['account'] = $acc;
}
}
}
// Fallback: if account.reference is empty but contract.reference is present, use it
if ((is_null($reference) || $reference === '') && ! empty($mapped['contract']['reference'] ?? null)) {
$reference = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference']));
if ($reference !== '') {
$acc['reference'] = $reference;
$mapped['account'] = $acc;
}
}
// Do not default or infer account.reference from other fields; rely solely on mapped values
if (! $contractId || ! $reference) {
$issues = [];
if (! $contractId) {
$issues[] = 'contract_id unresolved';
}
if (! $reference) {
$issues[] = 'account.reference empty';
}
$candidateContractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
return [
'action' => 'skipped',
'message' => 'Prerequisite missing: '.implode(' & ', $issues),
'context' => [
'has_contract_root_mapped' => $hasContractRoot,
'candidate_contract_reference' => $candidateContractRef,
'account_reference_provided' => $reference,
'account_fields_present' => array_keys(array_filter($acc, fn ($v) => ! is_null($v) && $v !== '')),
],
];
}
$existing = Account::query()
->where('contract_id', $contractId)
->where('reference', $reference)
->where('active', 1)
->first();
// Build applyable data based on apply_mode
$applyInsert = [];
$applyUpdate = [];
$applyModeByField = [];
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;
if (in_array($field, ['balance_amount', 'initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($value);
}
$mode = $map->apply_mode ?? 'both';
if ($mode === 'keyref') {
// treat as insert-only field (lookup + create), never update
$applyInsert[$field] = $value;
continue;
}
$applyModeByField[$field] = $mode;
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $value;
}
if (in_array($mode, ['update', 'both'])) {
// Do not allow updating initial_amount when mapping is update-only
if ($field === 'initial_amount' && $mode === 'update') {
// skip: initial_amount can change only on insert, or when mapping mode is 'both'
} else {
$applyUpdate[$field] = $value;
}
}
}
if ($existing) {
// Build non-null changes for account fields
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// Track balance change
$oldBalance = (float) ($existing->balance_amount ?? 0);
// Note: meta merging for contracts is handled in upsertContractChain, not here
if (! empty($changes)) {
$existing->fill($changes);
$existing->save();
}
// If balance_amount changed and this wasn't caused by a payment (we are in account upsert), log an activity with before/after
if (array_key_exists('balance_amount', $changes)) {
$newBalance = (float) ($existing->balance_amount ?? 0);
if ($newBalance !== $oldBalance) {
try {
$contractId = $existing->contract_id;
$clientCaseId = Contract::where('id', $contractId)->value('client_case_id');
$currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR';
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
if ($clientCaseId) {
// Use action_id from import meta if available to satisfy NOT NULL constraint on activities.action_id
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
if ($metaActionId > 0) {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $metaActionId,
'decision_id' => $import->meta['decision_id'] ?? null,
'client_case_id' => $clientCaseId,
'contract_id' => $contractId,
]);
} else {
// If no action id is provided, skip creating the activity to avoid NOT NULL violation
}
}
} catch (\Throwable $e) {
// Non-fatal: ignore activity creation failures
}
}
}
// also include contract hints for downstream contact resolution
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId, 'applied_fields' => $changes];
} else {
// On insert: if initial_amount is not provided but balance_amount is, allow defaulting
// Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null).
$initMode = $applyModeByField['initial_amount'] ?? null;
if ((! array_key_exists('initial_amount', $applyInsert) || is_null($applyInsert['initial_amount'] ?? null))
&& array_key_exists('balance_amount', $applyInsert)
&& ($applyInsert['balance_amount'] !== null && $applyInsert['balance_amount'] !== '')
&& ($initMode === null || in_array($initMode, ['insert', 'both'], true))) {
$applyInsert['initial_amount'] = $applyInsert['balance_amount'];
}
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, 'applied_fields' => $data];
}
}
private function mappingsContainRoot($mappings, string $root): bool
{
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if ($target !== '' && str_starts_with($target, $root.'.')) {
return true;
}
}
return false;
}
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 (! is_null($reference)) {
$reference = preg_replace('/\s+/', '', trim((string) $reference));
$contractData['reference'] = $reference;
}
if (! $reference) {
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
}
// Determine mapping mode for contract.reference (e.g., keyref)
$refMode = $this->mappingMode($mappings, '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 contract.reference is keyref and contract not found, do not create any entities
if (! $existing && $refMode === 'keyref') {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => null,
'event' => 'row_skipped',
'level' => 'warning',
'message' => 'Contract reference '.$reference.' does not exist (keyref); row skipped.',
]);
return ['action' => 'skipped', 'message' => 'contract.reference keyref lookup failed: not found'];
}
// If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary
if (! $existing && ! $clientCaseId) {
$clientRef = $mapped['client_case']['client_ref'] ?? null;
// First, if we have a client and client_ref, try to reuse existing case to avoid creating extra persons
if ($clientId && $clientRef) {
$cc = ClientCase::where('client_id', $clientId)->where('client_ref', $clientRef)->first();
if ($cc) {
// Reuse this case
$clientCaseId = $cc->id;
// If case has no person yet and we have mapped person identifiers/data, set it once
if (! $cc->person_id) {
$pid = null;
if (! empty($mapped['person'] ?? [])) {
$pid = $this->findPersonIdByIdentifiers($mapped['person']);
if (! $pid) {
$pid = $this->findOrCreatePersonId($mapped['person']);
}
}
if (! $pid) {
$pid = $this->createMinimalPersonId();
}
$cc->person_id = $pid;
$cc->save();
}
}
}
if (! $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, $clientRef);
} 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] ?? null) !== 'contract') {
continue;
}
$field = $parts[1] ?? null;
if (! $field) {
continue;
}
$value = $contractData[$field] ?? null;
if ($field === 'reference' && ! is_null($value)) {
$value = preg_replace('/\s+/', '', trim((string) $value));
}
$mode = $map->apply_mode ?? 'both';
// keyref: used as lookup and applied on insert, but not on update
if ($mode === 'keyref') {
$applyInsert[$field] = $value;
continue;
}
if (in_array($mode, ['insert', 'both'], true)) {
$applyInsert[$field] = $value;
}
if (in_array($mode, ['update', 'both'], true)) {
$applyUpdate[$field] = $value;
}
}
if ($existing) {
// 1) Prepare contract field changes (non-null)
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
// 2) Prepare meta changes if provided via mapping
$metaUpdated = false;
$metaAppliedKeys = [];
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
// Flatten incoming grouped meta to key => {title, value}
$incomingMeta = [];
foreach ($contractData['meta'] as $grp => $entries) {
if (! is_array($entries)) {
continue;
}
foreach ($entries as $k => $v) {
// v is expected as [title, value]
$incomingMeta[$k] = $v;
}
}
if (! empty($incomingMeta)) {
$currentMeta = is_array($existing->meta ?? null) ? $existing->meta : (json_decode((string) $existing->meta, true) ?: []);
foreach ($incomingMeta as $k => $entry) {
$newVal = is_array($entry) && array_key_exists('value', $entry) ? $entry['value'] : $entry;
$newTitle = is_array($entry) && array_key_exists('title', $entry) ? $entry['title'] : null;
$newType = is_array($entry) && array_key_exists('type', $entry) ? $entry['type'] : null;
$curEntry = $currentMeta[$k] ?? null;
$curVal = is_array($curEntry) && array_key_exists('value', $curEntry) ? $curEntry['value'] : $curEntry;
$curTitle = is_array($curEntry) && array_key_exists('title', $curEntry) ? $curEntry['title'] : null;
$curType = is_array($curEntry) && array_key_exists('type', $curEntry) ? $curEntry['type'] : null;
// Update when value differs, or title differs, or type differs
$shouldUpdate = ($newVal !== $curVal) || ($newTitle !== null && $newTitle !== $curTitle) || ($newType !== null && $newType !== $curType);
if ($shouldUpdate) {
if (is_array($entry)) {
$currentMeta[$k] = $entry;
} else {
$currentMeta[$k] = ['title' => (string) $k, 'value' => $newVal];
}
$metaUpdated = true;
$metaAppliedKeys[] = $k;
}
}
if ($metaUpdated) {
$existing->meta = $currentMeta;
}
}
}
if (empty($changes) && ! $metaUpdated) {
// Nothing to change
return ['action' => 'skipped', 'message' => 'No contract fields or meta changes', 'contract' => $existing];
}
if (! empty($changes)) {
$existing->fill($changes);
}
$existing->save();
// Build applied fields info, include meta keys if any
$applied = $changes;
if ($metaUpdated && ! empty($metaAppliedKeys)) {
foreach ($metaAppliedKeys as $k) {
$applied['meta:'.$k] = 'updated';
}
}
return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $applied];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
if (array_key_exists('start_date', $data)) {
$norm = $this->normalizeDate(is_scalar($data['start_date']) ? (string) $data['start_date'] : null);
if ($norm === null || $norm === '') {
unset($data['start_date']); // let default fill below
} else {
$data['start_date'] = $norm;
}
}
if (array_key_exists('end_date', $data)) {
$normEnd = $this->normalizeDate(is_scalar($data['end_date']) ? (string) $data['end_date'] : null);
if ($normEnd === null || $normEnd === '') {
unset($data['end_date']); // treat blank as null (omit)
} else {
$data['end_date'] = $normEnd;
}
}
$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();
// Merge meta for create if provided
if (! empty($contractData['meta'] ?? null) && is_array($contractData['meta'])) {
$incomingMeta = [];
foreach ($contractData['meta'] as $grp => $entries) {
if (! is_array($entries)) {
continue;
}
foreach ($entries as $k => $v) {
$incomingMeta[$k] = $v;
}
}
if (! empty($incomingMeta)) {
$data['meta'] = $incomingMeta;
}
}
$created = Contract::create($data);
return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data];
}
}
private function sanitizeHeaderName(string $v): string
{
// Strip UTF-8 BOM and trim whitespace/control characters
$v = preg_replace('/^\xEF\xBB\xBF/', '', $v) ?? $v;
return trim($v);
}
/**
* Normalize a raw date string coming from import sources to Y-m-d or null.
* Accepts common European formats like d.m.Y / d.m.y / d/m/Y / d/m/y and ISO.
* Falls back to strtotime parsing; returns null on failure instead of throwing.
*/
private function normalizeDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
$candidates = ['d.m.Y', 'd.m.y', 'd/m/Y', 'd/m/y', 'Y-m-d'];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
// Reject invalid (createFromFormat returns false on mismatch; partial matches handled by checking errors)
$errors = \DateTime::getLastErrors();
if (($errors['warning_count'] ?? 0) === 0 && ($errors['error_count'] ?? 0) === 0) {
return $dt->format('Y-m-d');
}
}
}
// Fallback: strtotime (very permissive); if fails return null
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
private function findSourceColumnFor($mappings, string $targetField): ?string
{
foreach ($mappings as $map) {
if ((string) ($map->target_field ?? '') === $targetField) {
$src = (string) ($map->source_column ?? '');
return $src !== '' ? $src : null;
}
}
return null;
}
// Removed auto-detection helpers by request: no pattern scanning or fallback derivation
private function normalizeDecimal(string $raw): string
{
// Keep digits, comma, dot, and minus to detect separators
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
$s = trim($s);
if ($s === '') {
return $s;
}
$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 (the other one) and unify decimal to '.'
if ($decimalSep === ',') {
// remove all dots
$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
$s = str_replace(',', '', $s);
// dot already decimal
} else {
// no decimal separator: remove commas/dots entirely
$s = str_replace([',', '.'], '', $s);
}
// Collapse multiple minus signs, keep leading only
$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;
}
/**
* Classify a row-level exception into a coarse category for diagnostics.
* duplicate|constraint|integrity|validation|db|unknown
*/
private function classifyRowException(\Throwable $e): string
{
$msg = strtolower($e->getMessage());
if (str_contains($msg, 'duplicate') || str_contains($msg, 'unique') || str_contains($msg, 'already exists')) {
return 'duplicate';
}
if (str_contains($msg, 'foreign key') || str_contains($msg, 'not-null') || str_contains($msg, 'violates') || str_contains($msg, 'constraint')) {
return 'constraint';
}
if (str_contains($msg, 'integrity')) {
return 'integrity';
}
if (str_contains($msg, 'missing') || str_contains($msg, 'required')) {
return 'validation';
}
if (str_contains($msg, 'sqlstate') || str_contains($msg, 'syntax error') || str_contains($msg, 'invalid input')) {
return 'db';
}
return 'unknown';
}
/**
* Ensure error message is valid UTF-8 and safely truncated.
*/
private function safeErrorMessage(string $msg): string
{
// Convert to UTF-8, dropping invalid sequences
if (! mb_detect_encoding($msg, 'UTF-8', true)) {
$msg = mb_convert_encoding($msg, 'UTF-8', 'UTF-8');
}
// Fallback strip invalid bytes
$msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg;
if (strlen($msg) > 500) {
$msg = substr($msg, 0, 497).'...';
}
return $msg;
}
/**
* Build a trimmed raw data preview (first 8 columns, truncated values) for logging.
*/
private function buildRawDataPreview(array $raw): array
{
$out = [];
$i = 0;
foreach ($raw as $k => $v) {
if ($i >= 8) {
break;
}
$val = is_scalar($v) || is_null($v) ? (string) $v : json_encode($v);
if (mb_strlen($val) > 80) {
$val = mb_substr($val, 0, 77).'...';
}
$out[$k] = $val;
$i++;
}
return $out;
}
/**
* Build a concise human-readable field=value list for logging.
* Example: [account] reference=ACC123 balance_amount=100.00
*/
private function formatAppliedFieldMessage(string $root, array $fields): string
{
if (empty($fields)) {
return '';
}
$parts = [];
foreach ($fields as $k => $v) {
if (is_scalar($v) || is_null($v)) {
$disp = is_null($v) ? 'NULL' : (string) $v;
} elseif (is_array($v)) {
$disp = json_encode($v);
} else {
$disp = method_exists($v, '__toString') ? (string) $v : gettype($v);
}
// Truncate very long values for log safety
if (strlen($disp) > 60) {
$disp = substr($disp, 0, 57).'...';
}
$parts[] = $k.'='.$disp;
}
return '['.$root.'] '.implode(' ', $parts);
}
/**
* Collect persisted payment fields (sanitized) for event logging.
*/
private function collectPaymentAppliedFields(array $payload, \App\Models\Payment $payment): array
{
$fields = [];
foreach (['account_id', 'reference', 'amount', 'paid_at', 'currency'] as $f) {
if (array_key_exists($f, $payload)) {
$fields[$f] = $payload[$f];
} elseif (isset($payment->$f)) {
$fields[$f] = $payment->$f;
}
}
if (isset($payload['meta'])) {
$fields['meta'] = $payload['meta'];
}
return $fields;
}
/**
* Determine if a raw CSV row is "effectively" empty: all scalar values are null or blank after trimming.
* Non-scalar values (arrays/objects) will cause the row to be treated as non-empty.
*/
private function rowIsEffectivelyEmpty(array $rawAssoc): bool
{
if (empty($rawAssoc)) {
return true; // no columns at all
}
foreach ($rawAssoc as $v) {
if (is_array($v) || is_object($v)) {
return false; // treat structured data as content
}
if (! is_null($v)) {
$s = trim((string) $v);
if ($s !== '') {
return false;
}
}
}
return true;
}
/**
* Ensure mapping roots are recognized; fail fast if unknown roots found.
*/
private function validateMappingRoots($mappings, array $validRoots): void
{
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if ($target === '') {
continue;
}
$root = explode('.', $target)[0];
if (! in_array($root, $validRoots, true)) {
// Common typos guidance
$hint = '';
if (str_starts_with($root, 'contract')) {
$hint = ' Did you mean "contract"?';
}
throw new \InvalidArgumentException('Unknown mapping root "'.$root.'" in target_field "'.$target.'".'.$hint);
}
}
}
private function mappingIncludes($mappings, string $targetField): bool
{
foreach ($mappings as $map) {
if ((string) ($map->target_field ?? '') === $targetField) {
return true;
}
}
return false;
}
/**
* Normalize mapping target_field to canonical forms.
* Examples:
* - contracts.reference => contract.reference
* - accounts.balance_amount => account.balance_amount
* - person_phones.nu => phone.nu
* - person_addresses.address => address.address
* - emails.email|emails.value => email.value
*/
private function normalizeMappings($mappings, array $rootAliasMap, array $fieldAliasMap)
{
$normalized = [];
foreach ($mappings as $map) {
$clone = clone $map;
$clone->target_field = $this->normalizeTargetField((string) ($map->target_field ?? ''), $rootAliasMap, $fieldAliasMap);
$normalized[] = $clone;
}
return collect($normalized);
}
private function normalizeTargetField(string $target, array $rootAliasMap, array $fieldAliasMap): string
{
if ($target === '') {
return $target;
}
$parts = explode('.', $target);
$rootWithBracket = $parts[0] ?? '';
// Extract optional bracket group from root (e.g., address[1]) but preserve it after aliasing
$bracket = null;
if (preg_match('/^(?P<base>[a-zA-Z_][a-zA-Z0-9_]*)(\[(?P<grp>[^\]]+)\])$/', $rootWithBracket, $m)) {
$root = $m['base'];
$bracket = $m['grp'];
} else {
$root = $rootWithBracket;
}
$field = $parts[1] ?? null;
// Root aliases (plural to canonical) from DB
$root = $rootAliasMap[$root] ?? $root;
// Field aliases per root from DB
$aliases = $fieldAliasMap[$root] ?? [];
if ($field === null && isset($aliases['__default'])) {
$field = $aliases['__default'];
} elseif (isset($aliases[$field])) {
$field = $aliases[$field];
}
// Rebuild
if ($field !== null) {
$rootOut = $bracket !== null ? ($root.'['.$bracket.']') : $root;
return $rootOut.'.'.$field;
}
return $bracket !== null ? ($root.'['.$bracket.']') : $root;
}
/**
* Get apply mode for a specific mapping target field, normalized via normalizeMappings beforehand.
* Returns lowercased mode string like 'insert', 'update', 'both', 'keyref', or null if not present.
*/
private function mappingMode($mappings, string $targetField): ?string
{
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if ($target === $targetField) {
$mode = $map->apply_mode ?? null;
return is_string($mode) ? strtolower($mode) : null;
}
}
return null;
}
protected function loadImportEntityConfig(): array
{
$entities = ImportEntity::all();
$rootAliasMap = [];
$fieldAliasMap = [];
$validRoots = [];
$supportsMultiple = [];
foreach ($entities as $ent) {
$canonical = $ent->canonical_root;
$validRoots[] = $canonical;
foreach ((array) ($ent->aliases ?? []) as $alias) {
$rootAliasMap[$alias] = $canonical;
}
// Also ensure canonical maps to itself
$rootAliasMap[$canonical] = $canonical;
$aliases = (array) ($ent->field_aliases ?? []);
// Allow default field per entity via '__default'
if (is_array($ent->fields) && count($ent->fields)) {
$aliases['__default'] = $aliases['__default'] ?? null;
}
$fieldAliasMap[$canonical] = $aliases;
$supportsMultiple[$canonical] = (bool) ($ent->supports_multiple ?? false);
}
// sensible defaults when DB empty
if (empty($validRoots)) {
$validRoots = ['person', 'contract', 'account', 'address', 'phone', 'email', 'client_case'];
$supportsMultiple = [
'address' => true,
'phone' => true,
'email' => true,
];
}
return [$rootAliasMap, $fieldAliasMap, $validRoots, $supportsMultiple];
}
/**
* Get mapping options.group if provided.
*/
private function mappingOptionGroup(object $map): ?string
{
$raw = $map->options ?? null;
if (is_string($raw)) {
try {
$json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
return isset($json['group']) ? (string) $json['group'] : null;
} catch (\Throwable $e) {
return null;
}
}
if (is_array($raw)) {
return isset($raw['group']) ? (string) $raw['group'] : null;
}
return null;
}
/**
* Determine if a mapped root is a grouped multi structure.
*/
private function isGroupedMulti(mixed $data): bool
{
if (! is_array($data)) {
return false;
}
// Consider grouped if first element is itself an array
foreach ($data as $k => $v) {
return is_array($v);
}
return false;
}
/**
* Read first value from a multi-group or single map for a given root/field.
*/
private function firstFromMulti(array $mapped, string $root, string $field): mixed
{
if (! isset($mapped[$root])) {
return null;
}
$data = $mapped[$root];
if ($this->isGroupedMulti($data)) {
foreach ($data as $grp => $payload) {
if (isset($payload[$field]) && $payload[$field] !== null && trim((string) $payload[$field]) !== '') {
return $payload[$field];
}
}
return null;
}
return $data[$field] ?? null;
}
/**
* Remove duplicates from grouped items by comparing a key field across groups after normalization.
* Keeps the first occurrence and drops later duplicates. Empty/blank keys are kept only once.
*
* @param array<string,array> $grouped e.g. ['1' => ['value' => 'a'], '2' => ['value' => 'a']]
* @param string $keyField e.g. 'value' for email, 'nu' for phone, 'address' for address
* @param callable|null $normalizer function(string|null): string|null normalizes comparison key
* @return array<string,array>
*/
protected function dedupeGroupedItems(array $grouped, string $keyField, ?callable $normalizer = null): array
{
$seen = [];
$out = [];
foreach ($grouped as $grp => $payload) {
$raw = $payload[$keyField] ?? null;
$key = $normalizer ? $normalizer($raw) : (is_null($raw) ? null : trim((string) $raw));
$key = $key === '' ? '' : $key; // ensure empty string stays empty
$finger = is_null($key) ? '__NULL__' : (string) $key;
if (array_key_exists($finger, $seen)) {
// duplicate => skip
continue;
}
$seen[$finger] = true;
$out[$grp] = $payload;
}
return $out;
}
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, ?string $clientRef = null): int
{
// Prefer existing by client_ref if provided
if ($clientRef) {
$cc = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($cc) {
// Ensure person_id is set (if missing) when matching by client_ref
if (! $cc->person_id) {
$cc->person_id = $personId;
$cc->save();
}
return $cc->id;
}
}
// Fallback: by (client_id, person_id)
$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, 'client_ref' => $clientRef])->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;
}
// Allow alias 'postal_code' in CSV mappings but persist as 'post_code' in DB
$targetField = $field === 'postal_code' ? 'post_code' : $field;
$val = $addrData[$field] ?? $addrData[$targetField] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$targetField] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$targetField] = $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];
}
}
/**
* After a contract is inserted/updated, attach default segment and create an activity
* using decision_id from import/template meta. Activity note includes template name.
*/
private function postContractActions(Import $import, Contract $contract): 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) {
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 : '')),
]);
}
}
/**
* Attempt to reactivate a single archived contract via the latest enabled reactivate ArchiveSetting.
* Returns array{reactivated: bool}.
*/
protected function attemptContractReactivation(Contract $contract, ?Authenticatable $user = null): array
{
try {
// Skip if already active
if ($contract->active && ! $contract->deleted_at) {
return ['reactivated' => false];
}
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->orderByDesc('id')
->first();
if (! $setting) {
return ['reactivated' => false];
}
$context = [
'contract_id' => $contract->id,
'client_case_id' => $contract->client_case_id,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$executor->executeSetting($setting, $context, $user?->getAuthIdentifier());
// Ensure contract flagged active (safety)
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
// Activity from archive setting (if action/decision present) handled inside executor path or we can optionally create here
if ($setting->action_id || $setting->decision_id) {
try {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Ponovna aktivacija pogodba '.$contract->reference,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'user_id' => $user?->getAuthIdentifier(),
]);
} catch (\Throwable $e) {
// Non-fatal
}
}
return ['reactivated' => true];
} catch (\Throwable $e) {
return ['reactivated' => false];
}
}
}