diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index ba6aed4..0c8f1fc 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -311,11 +311,14 @@ public function storeActivity(ClientCase $clientCase, Request $request) 'action_id' => 'exists:\App\Models\Action,id', 'decision_id' => 'exists:\App\Models\Decision,id', 'contract_uuid' => 'nullable|uuid', + 'phone_view' => 'nullable|boolean', 'send_auto_mail' => 'sometimes|boolean', 'attachment_document_ids' => 'sometimes|array', 'attachment_document_ids.*' => 'integer', ]); + $isPhoneView = $attributes['phone_view'] ?? false; + // Map contract_uuid to contract_id within the same client case, if provided $contractId = null; if (! empty($attributes['contract_uuid'])) { @@ -338,10 +341,23 @@ public function storeActivity(ClientCase $clientCase, Request $request) 'decision_id' => $attributes['decision_id'], 'contract_id' => $contractId, ]); - /*foreach ($activity->decision->events as $e) { - $class = '\\App\\Events\\' . $e->name; - event(new $class($clientCase)); - }*/ + + if ($isPhoneView && $contractId) { + $fieldJob = $contract->fieldJobs() + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->where('assigned_user_id', \Auth::id()) + ->orderByDesc('id') + ->first(); + + if ($fieldJob) { + $fieldJob->update([ + 'added_activity' => true, + 'last_activity' => $row->created_at, + ]); + + } + } logger()->info('Activity successfully inserted', $attributes); @@ -356,8 +372,8 @@ public function storeActivity(ClientCase $clientCase, Request $request) ->values(); $validAttachmentIds = collect(); if ($attachmentIds->isNotEmpty() && $contractId) { - $validAttachmentIds = \App\Models\Document::query() - ->where('documentable_type', \App\Models\Contract::class) + $validAttachmentIds = Document::query() + ->where('documentable_type', Contract::class) ->where('documentable_id', $contractId) ->whereIn('id', $attachmentIds) ->pluck('id'); @@ -902,178 +918,115 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r { $contract = Contract::query()->where('uuid', $uuid)->firstOrFail(); if ($contract->client_case_id !== $clientCase->id) { + \Log::warning('Contract not found uuid: {uuid}', ['uuid' => $uuid]); abort(404); } - $reactivateRequested = (bool) $request->boolean('reactivate'); - // Determine applicable settings based on intent (archive vs reactivate) - if ($reactivateRequested) { - $latestReactivate = \App\Models\ArchiveSetting::query() - ->where('enabled', true) - ->where('reactivate', true) - ->whereIn('strategy', ['immediate', 'manual']) - ->orderByDesc('id') - ->first(); - if (! $latestReactivate) { - return back()->with('warning', __('contracts.reactivate_not_allowed')); - } - $settings = collect([$latestReactivate]); - $hasReactivateRule = true; - } else { - $settings = \App\Models\ArchiveSetting::query() - ->where('enabled', true) - ->whereIn('strategy', ['immediate', 'manual']) - ->where(function ($q) { // exclude reactivate-only rules from archive run - $q->whereNull('reactivate')->orWhere('reactivate', false); - }) - ->get(); - if ($settings->isEmpty()) { - return back()->with('warning', __('contracts.no_archive_settings')); - } - $hasReactivateRule = false; + + $attr = $request->validate([ + 'reactivate' => 'boolean', + ]); + + $reactivate = $attr['reactivate'] ?? false; + + $setting = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->whereIn('strategy', ['immediate', 'manual']) + ->where('reactivate', $reactivate) + ->orderByDesc('id') + ->first(); + + if (! $setting->exists()) { + \Log::warning('No archive settings found!'); + + return back()->with('warning', 'No settings found'); } + // Service archive executor $executor = app(\App\Services\Archiving\ArchiveExecutor::class); + $result = null; + $context = [ 'contract_id' => $contract->id, 'client_case_id' => $clientCase->id, + 'account_id' => $contract->account->id ?? null, ]; - if ($contract->account) { - $context['account_id'] = $contract->account->id; + + try { + $result = $executor->executeSetting($setting, $context, \Auth::id()); + } catch (Exception $e) { + \Log::error('There was an error executing ArchiveExecutor::executeSetting {msg}', ['msg' => $e->getMessage()]); + + return back()->with('warning', 'Something went wrong!'); } - $overall = []; - $hadAnyEffect = false; - foreach ($settings as $setting) { - - $res = $executor->executeSetting($setting, $context, optional($request->user())->id); - foreach ($res as $table => $count) { - $overall[$table] = ($overall[$table] ?? 0) + $count; - if ($count > 0) { - $hadAnyEffect = true; - } - } - } - - if ($reactivateRequested && $hasReactivateRule) { - // Reactivation path: ensure contract becomes active and soft-delete cleared. - if ($contract->active == 0 || $contract->deleted_at) { - $contract->forceFill(['active' => 1, 'deleted_at' => null])->save(); - $overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1; - $hadAnyEffect = true; - } - } else { - // Ensure the contract itself is archived even if rule conditions would have excluded it - if (! empty($contract->getAttributes()) && $contract->active) { - if (! array_key_exists('contracts', $overall)) { - $contract->update(['active' => 0]); - $overall['contracts'] = ($overall['contracts'] ?? 0) + 1; - } else { - $contract->refresh(); - } - $hadAnyEffect = true; - } - } - - // Create an Activity record logging this archive if an action or decision is tied to any setting - if ($hadAnyEffect) { - $activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id)); - if ($activitySetting) { - try { - if ($reactivateRequested) { - $note = 'Ponovna aktivacija pogodba '.$contract->reference; - } else { - $noteKey = 'contracts.archived_activity_note'; - $note = __($noteKey, ['reference' => $contract->reference]); - if ($note === $noteKey) { - $note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl'); - } - } + try { + \DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) { + // Create an Activity record logging this archive if an action or decision is tied to any setting + if ($setting->action_id && $setting->decision_id) { $activityData = [ 'client_case_id' => $clientCase->id, - 'action_id' => $activitySetting->action_id, - 'decision_id' => $activitySetting->decision_id, - 'note' => $note, - 'active' => 1, - 'user_id' => optional($request->user())->id, + 'action_id' => $setting->action_id, + 'decision_id' => $setting->decision_id, + 'note' => ($reactivate) + ? "Ponovno aktivirana pogodba $contract->reference" + : "Arhivirana pogodba $contract->reference", ]; - if ($reactivateRequested) { - // Attach the contract_id when reactivated as per requirement - $activityData['contract_id'] = $contract->id; + + try { + \App\Models\Activity::create($activityData); + } catch (Exception $e) { + \Log::warning('Activity could not be created!'); } - \App\Models\Activity::create($activityData); - } catch (\Throwable $e) { - logger()->warning('Failed to create archive/reactivate activity', [ - 'error' => $e->getMessage(), - 'contract_id' => $contract->id, - 'setting_id' => optional($activitySetting)->id, - 'reactivate' => $reactivateRequested, - ]); + } - } - } - // If any archive setting specifies a segment_id, move the contract to that segment (archive bucket) - $segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified - if ($segmentSetting && $segmentSetting->segment_id) { - try { - $segmentId = $segmentSetting->segment_id; - \DB::transaction(function () use ($contract, $segmentId, $clientCase) { - // Ensure the segment is attached to the client case (activate if previously inactive) - $casePivot = \DB::table('client_case_segment') - ->where('client_case_id', $clientCase->id) - ->where('segment_id', $segmentId) - ->first(); - if (! $casePivot) { - \DB::table('client_case_segment')->insert([ - 'client_case_id' => $clientCase->id, - 'segment_id' => $segmentId, + + // If any archive setting specifies a segment_id, move the contract to that segment (archive bucket) + if ($setting->segment_id) { + $segmentId = $setting->segment_id; + + $contract->segments() + ->allRelatedIds() + ->map(fn (int $val, int|string $key) => $contract->segments()->updateExistingPivot($val, [ + 'active' => false, + 'updated_at' => now(), + ]) + ); + + if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) { + $contract->attachedSegments()->updateExistingPivot($segmentId, [ 'active' => true, - 'created_at' => now(), 'updated_at' => now(), ]); - } elseif (! $casePivot->active) { - \DB::table('client_case_segment') - ->where('id', $casePivot->id) - ->update(['active' => true, 'updated_at' => now()]); - } - - // Deactivate all current active contract segments - \DB::table('contract_segment') - ->where('contract_id', $contract->id) - ->where('active', true) - ->update(['active' => false, 'updated_at' => now()]); - - // Attach or activate the archive segment for this contract - $existing = \DB::table('contract_segment') - ->where('contract_id', $contract->id) - ->where('segment_id', $segmentId) - ->first(); - if ($existing) { - \DB::table('contract_segment') - ->where('id', $existing->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(), - ]); + $contract->segments()->attach( + $segmentId, + [ + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); } - }); - } catch (\Throwable $e) { - logger()->warning('Failed to move contract to archive segment', [ - 'error' => $e->getMessage(), - 'contract_id' => $contract->id, - 'segment_id' => $segmentSetting->segment_id, - 'setting_id' => $segmentSetting->id, - ]); - } + } + + $contract->fieldJobs() + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->update([ + 'cancelled_at' => date('Y-m-d'), + 'updated_at' => now(), + ]); + }); + } catch (Exception $e) { + \Log::warning('Something went wrong with inserting / updating archive setting partials!'); + + return back()->with('warning', 'Something went wrong!'); } - $message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived'); - - return back()->with('success', $message)->with('flash_method', 'PATCH'); + return back()->with('success', $reactivate + ? __('contracts.reactivated') + : __('contracts.archived') + ); } /** diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php index 26f2b7f..320d63c 100644 --- a/app/Http/Controllers/PhoneViewController.php +++ b/app/Http/Controllers/PhoneViewController.php @@ -78,169 +78,81 @@ public function completedToday(Request $request) public function showCase(\App\Models\ClientCase $clientCase, Request $request) { $userId = $request->user()->id; - $completedMode = (bool) $request->boolean('completed'); + $completedMode = $request->boolean('completed'); - // Eager load client case with person details - $case = \App\Models\ClientCase::query() - ->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])]) - ->findOrFail($clientCase->id); + // Eager load case with person details + $case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'); - // Determine contracts of this case relevant to the current user - // - Normal mode: contracts assigned to me and still active (not completed/cancelled) - // - Completed mode (?completed=1): contracts where my field job was completed today - if ($completedMode) { - $start = now()->startOfDay(); - $end = now()->endOfDay(); - $contractIds = FieldJob::query() - ->where('assigned_user_id', $userId) - ->whereNull('cancelled_at') - ->whereBetween('completed_at', [$start, $end]) - ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id)) - ->pluck('contract_id') - ->unique() - ->values(); - } else { - $contractIds = FieldJob::query() - ->where('assigned_user_id', $userId) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id)) - ->pluck('contract_id') - ->unique() - ->values(); - } + // Query contracts based on field jobs + $contractsQuery = FieldJob::query() + ->where('assigned_user_id', $userId) + ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id)) + ->when($completedMode, + fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]), + fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at') + ); + // Get contracts with relationships $contracts = \App\Models\Contract::query() ->where('client_case_id', $case->id) - ->whereIn('id', $contractIds) - ->with(['type:id,name', 'account']) + ->whereIn('id', $contractsQuery->pluck('contract_id')->unique()) + ->with(['type:id,name', 'account', 'latestObject']) ->orderByDesc('created_at') ->get(); - // Attach latest object (if any) to each contract as last_object for display - if ($contracts->isNotEmpty()) { - $byId = $contracts->keyBy('id'); - $latestObjects = \App\Models\CaseObject::query() - ->whereIn('contract_id', $byId->keys()) - ->whereNull('deleted_at') - ->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at') - ->orderByDesc('created_at') - ->get() - ->groupBy('contract_id') - ->map(function ($group) { - return $group->first(); - }); - - foreach ($latestObjects as $cid => $obj) { - if (isset($byId[$cid])) { - $byId[$cid]->setAttribute('last_object', $obj); - } - } - } - - // Build merged documents: case documents + documents of assigned contracts - $contractRefMap = []; - foreach ($contracts as $c) { - $contractRefMap[$c->id] = $c->reference; - } - - $contractDocs = \App\Models\Document::query() - ->where('documentable_type', \App\Models\Contract::class) - ->whereIn('documentable_id', $contractIds) + // Build merged documents + $documents = $case->documents() ->orderByDesc('created_at') ->get() - ->map(function ($d) use ($contractRefMap) { - $arr = $d->toArray(); - $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; - $arr['documentable_type'] = \App\Models\Contract::class; - $arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid; - - return $arr; - }); - - $caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) { - $arr = $d->toArray(); - $arr['documentable_type'] = \App\Models\ClientCase::class; - $arr['client_case_uuid'] = $case->uuid; - - return $arr; - }); - - $documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values(); - - // Provide minimal types for PersonInfoGrid - $types = [ - 'address_types' => $this->referenceCache->getAddressTypes(), - 'phone_types' => $this->referenceCache->getPhoneTypes(), - ]; - - // Case activities (compact for phone): latest 20 with relations - $activities = $case->activities() - ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) - ->orderByDesc('created_at') - ->limit(20) - ->get() - ->map(function ($a) { - $a->setAttribute('user_name', optional($a->user)->name); - - return $a; - }); - - // Determine segment filters from FieldJobSettings for this case/user context - $settingIds = FieldJob::query() - ->where('assigned_user_id', $userId) - ->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id)) - ->when( - $completedMode, - function ($q) { - $q->whereNull('cancelled_at') - ->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]); - }, - function ($q) { - $q->whereNull('completed_at')->whereNull('cancelled_at'); - } + ->map(fn ($d) => array_merge($d->toArray(), [ + 'documentable_type' => \App\Models\ClientCase::class, + 'client_case_uuid' => $case->uuid, + ])) + ->concat( + \App\Models\Document::query() + ->where('documentable_type', \App\Models\Contract::class) + ->whereIn('documentable_id', $contracts->pluck('id')) + ->with('documentable:id,uuid,reference') + ->orderByDesc('created_at') + ->get() + ->map(fn ($d) => array_merge($d->toArray(), [ + 'contract_reference' => $d->documentable?->reference, + 'contract_uuid' => $d->documentable?->uuid, + ])) ) - ->pluck('field_job_setting_id') - ->filter() - ->unique() + ->sortByDesc('created_at') ->values(); - $segmentIds = collect(); - if ($settingIds->isNotEmpty()) { - $segmentIds = \App\Models\FieldJobSetting::query() - ->whereIn('id', $settingIds) - ->pluck('segment_id') - ->filter() - ->unique() - ->values(); - } - - // Filter actions and their decisions by the derived segment ids (decisions.segment_id) - $actions = \App\Models\Action::query() - ->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { - // Filter actions by their segment_id matching the FieldJobSetting segment(s) - $q->whereIn('segment_id', $segmentIds); - }) - ->with([ - 'decisions' => function ($q) { - $q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id'); - }, - 'decisions.emailTemplate' => function ($q) { - $q->select('id', 'name', 'entity_types', 'allow_attachments'); - }, - ]) - ->get(['id', 'name', 'color_tag', 'segment_id']); + // Get segment IDs for filtering actions + $segmentIds = \App\Models\FieldJobSetting::query() + ->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique()) + ->pluck('segment_id') + ->filter() + ->unique(); return Inertia::render('Phone/Case/Index', [ - 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(), + 'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'), 'client_case' => $case, 'contracts' => $contracts, 'documents' => $documents, - 'types' => $types, - 'account_types' => $this->referenceCache->getAccountTypes(), - // Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments) - 'actions' => $actions, - 'activities' => $activities, + 'types' => [ + 'address_types' => \App\Models\Person\AddressType::all(), + 'phone_types' => \App\Models\Person\PhoneType::all(), + ], + 'account_types' => \App\Models\AccountType::all(), + 'actions' => \App\Models\Action::query() + ->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds)) + ->with([ + 'decisions:id,name,color_tag,auto_mail,email_template_id', + 'decisions.emailTemplate:id,name,entity_types,allow_attachments', + ]) + ->get(['id', 'name', 'color_tag', 'segment_id']), + 'activities' => $case->activities() + ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) + ->orderByDesc('created_at') + ->limit(20) + ->get() + ->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)), 'completed_mode' => $completedMode, ]); } diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 3605e64..c419346 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -112,6 +112,11 @@ public function segments(): BelongsToMany ->wherePivot('active', true); } + public function attachedSegments(): BelongsToMany + { + return $this->belongsToMany(\App\Models\Segment::class); + } + public function account(): HasOne { // Use latestOfMany to always surface newest account snapshot if multiple exist. @@ -130,6 +135,18 @@ public function documents(): MorphMany return $this->morphMany(\App\Models\Document::class, 'documentable'); } + public function fieldJobs(): HasMany + { + return $this->hasMany(\App\Models\FieldJob::class); + } + + public function latestObject(): HasOne + { + return $this->hasOne(\App\Models\CaseObject::class) + ->whereNull('deleted_at') + ->latest(); + } + protected static function booted(): void { static::created(function (Contract $contract): void { diff --git a/app/Models/FieldJob.php b/app/Models/FieldJob.php index 179d44a..11dfa10 100644 --- a/app/Models/FieldJob.php +++ b/app/Models/FieldJob.php @@ -24,6 +24,8 @@ class FieldJob extends Model 'priority', 'notes', 'address_snapshot ', + 'last_activity', + 'added_activity' ]; protected $casts = [ @@ -31,6 +33,8 @@ class FieldJob extends Model 'completed_at' => 'datetime', 'cancelled_at' => 'datetime', 'priority' => 'boolean', + 'last_activity' => 'datetime', + 'added_activity' => 'boolean', 'address_snapshot ' => 'array', ]; @@ -90,7 +94,8 @@ public function user(): BelongsTo public function contract(): BelongsTo { - return $this->belongsTo(Contract::class, 'contract_id'); + return $this->belongsTo(Contract::class, 'contract_id') + ->where('active', true); } /** diff --git a/app/Services/Archiving/ArchiveExecutor.php b/app/Services/Archiving/ArchiveExecutor.php index 63de252..b3e1fd4 100644 --- a/app/Services/Archiving/ArchiveExecutor.php +++ b/app/Services/Archiving/ArchiveExecutor.php @@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null, $entities = $flat; } + // dd($entities); + foreach ($entities as $entityDef) { $rawTable = $entityDef['table'] ?? null; if (! $rawTable) { @@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null, // Process in batches to avoid locking large tables while (true) { $query = DB::table($table)->whereNull('deleted_at'); - if (Schema::hasColumn($table, 'active')) { + if (Schema::hasColumn($table, 'active') && ! $reactivate) { $query->where('active', 1); } // Apply context filters or chain derived filters diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 6c6bc24..f322245 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -25,14 +25,17 @@ use App\Models\Person\PersonType; use App\Models\Person\PhoneType; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\QueryException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; class ImportProcessor { /** * Track contracts that already existed and were matched during history imports. + * * @var array */ private array $historyFoundContractIds = []; @@ -180,10 +183,7 @@ public function process(Import $import, ?Authenticatable $user = null): array } // Preflight: warn if any mapped source columns are not present in the header (exact match) $headerSet = []; - foreach ($header as $h) { - $headerSet[$h] = true; - } - $missingSources = []; + // Regex validation removed per request; rely on basic length/placeholder checks only foreach ($mappings as $map) { $src = (string) ($map->source_column ?? ''); if ($src !== '' && ! array_key_exists($src, $headerSet)) { @@ -216,7 +216,28 @@ public function process(Import $import, ?Authenticatable $user = null): array if ($isPg) { // Establish a savepoint so a failing row does not poison the whole transaction - DB::statement('SAVEPOINT import_row_'.$rowNum); + try { + DB::statement('SAVEPOINT import_row_'.$rowNum); + } catch (\Throwable $se) { + Log::error('Import savepoint_failed', [ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($se), + ]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'savepoint_failed', + 'level' => 'error', + 'message' => 'Failed to create savepoint; transaction already aborted.', + 'context' => [ + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($se), + ], + ]); + + throw $se; // abort import so root cause surfaces + } } // Scope variables per row so they aren't reused after exception @@ -1067,13 +1088,38 @@ public function process(Import $import, ?Authenticatable $user = null): array } } } catch (\Throwable $e) { + $rollbackFailed = false; + $rollbackError = null; if ($isPg) { // Roll back only this row's work try { DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum); - } catch (\Throwable $ignored) { /* noop */ + } catch (\Throwable $ignored) { + $rollbackFailed = true; + $rollbackError = $ignored; } } + if ($rollbackFailed) { + Log::error('Import row_rollback_failed', [ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($rollbackError ?? $e), + ]); + // Abort the whole import if we cannot rollback to the row savepoint (transaction is poisoned) + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_rollback_failed', + 'level' => 'error', + 'message' => 'Rollback to savepoint failed; aborting import.', + 'context' => [ + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($rollbackError ?? $e), + ], + ]); + + throw $rollbackError ?? $e; + } // Ensure importRow exists for logging if failure happened before its creation if (! $importRow) { try { @@ -1100,6 +1146,12 @@ public function process(Import $import, ?Authenticatable $user = null): array } $failedRows[] = $rowNum; $invalid++; + Log::error('Import row_exception', [ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($e), + 'raw_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [], + ]); try { ImportEvent::create([ 'import_id' => $import->id, @@ -1117,6 +1169,12 @@ public function process(Import $import, ?Authenticatable $user = null): array ], ]); } catch (\Throwable $evtErr) { + Log::error('Import row_exception_event_failed', [ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'exception' => $this->exceptionContext($evtErr), + 'original_exception' => $this->exceptionContext($e), + ]); // Swallow secondary failure to ensure loop continues } @@ -1189,12 +1247,17 @@ public function process(Import $import, ?Authenticatable $user = null): array // Mark failed and log after rollback (so no partial writes persist) $import->refresh(); $import->update(['status' => 'failed', 'failed_at' => now()]); + Log::error('Import processing_failed', [ + 'import_id' => $import->id, + 'exception' => $this->exceptionContext($e), + ]); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'event' => 'processing_failed', 'level' => 'error', - 'message' => $e->getMessage(), + 'message' => $this->safeErrorMessage($e->getMessage()), + 'context' => $this->exceptionContext($e), ]); return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()]; @@ -1920,6 +1983,8 @@ private function upsertActivity(Import $import, array $mapped, $mappings, ?array } elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) { $normalized = is_null($value) ? null : (int) $value; } elseif (is_string($normalized)) { + // Clean invalid UTF-8 sequences from string fields + $normalized = mb_convert_encoding($normalized, 'UTF-8', 'UTF-8'); $normalized = trim($normalized); } if (in_array($applyMode, ['both', 'insert'], true)) { @@ -2009,7 +2074,7 @@ private function upsertActivity(Import $import, array $mapped, $mappings, ?array } $data = array_filter($applyInsert, fn ($v) => ! is_null($v)); - $activityModel = new Activity(); + $activityModel = new Activity; $activityModel->forceFill($data); if (array_key_exists('created_at', $data)) { // Preserve provided timestamps by disabling automatic timestamps for this save @@ -2233,6 +2298,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings, b if ($existing) { if ($historyImport) { $this->historyFoundContractIds[$existing->id] = true; + return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)']; } // 1) Prepare contract field changes (non-null) @@ -2475,13 +2541,57 @@ private function safeErrorMessage(string $msg): string } // Fallback strip invalid bytes $msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg; - if (strlen($msg) > 500) { - $msg = substr($msg, 0, 497).'...'; + // Use mb_strlen and mb_substr for UTF-8 safety + if (mb_strlen($msg) > 500) { + $msg = mb_substr($msg, 0, 497).'...'; } return $msg; } + /** + * Extract structured exception details for logging. + */ + private function exceptionContext(\Throwable $e): array + { + $ctx = [ + 'exception' => get_class($e), + 'message' => $this->safeErrorMessage($e->getMessage()), + 'code' => $e->getCode(), + 'file' => $e->getFile().':'.$e->getLine(), + ]; + + if (method_exists($e, 'getPrevious') && $e->getPrevious()) { + $prev = $e->getPrevious(); + $ctx['previous'] = [ + 'exception' => get_class($prev), + 'message' => $this->safeErrorMessage($prev->getMessage()), + 'code' => $prev->getCode(), + 'file' => $prev->getFile().':'.$prev->getLine(), + ]; + } + + if ($e instanceof QueryException) { + $ctx['sql'] = $e->getSql(); + $ctx['bindings'] = $e->getBindings(); + $info = $e->errorInfo ?? null; + if (is_array($info)) { + $ctx['sqlstate'] = $info[0] ?? null; + $ctx['driver_error_code'] = $info[1] ?? null; + $ctx['driver_error_message'] = $info[2] ?? null; + } + } elseif (property_exists($e, 'errorInfo')) { + $info = $e->errorInfo; + if (is_array($info)) { + $ctx['sqlstate'] = $info[0] ?? null; + $ctx['driver_error_code'] = $info[1] ?? null; + $ctx['driver_error_message'] = $info[2] ?? null; + } + } + + return $ctx; + } + /** * Build a trimmed raw data preview (first 8 columns, truncated values) for logging. */ @@ -2522,9 +2632,9 @@ private function formatAppliedFieldMessage(string $root, array $fields): string } 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).'...'; + // Truncate very long values for log safety (use mb_substr for UTF-8 safety) + if (mb_strlen($disp) > 60) { + $disp = mb_substr($disp, 0, 57).'...'; } $parts[] = $k.'='.$disp; } @@ -3000,8 +3110,10 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array private function upsertAddress(int $personId, array $addrData, $mappings): array { $addressLine = trim((string) ($addrData['address'] ?? '')); - // Normalize whitespace + // Normalize whitespace: collapse multiples and tighten around separators $addressLine = preg_replace('/\s+/', ' ', $addressLine); + $addressLine = preg_replace('/\s*([,;\/])\s*/', '$1 ', $addressLine); + $addressLine = trim($addressLine); // Skip common placeholders or missing values if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) { return ['action' => 'skipped', 'message' => 'No address value']; @@ -3009,15 +3121,21 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array if (mb_strlen($addressLine) < 3) { return ['action' => 'skipped', 'message' => 'Invalid address value']; } - // Allow only basic address characters to avoid noisy special chars - if (! preg_match('/^[A-Za-z0-9\\s\\.,\\-\\/\\#\\\'"\\(\\)&]+$/', $addressLine)) { - return ['action' => 'skipped', 'message' => 'Invalid address value']; - } + // If identical address already exists anywhere, skip to avoid constraint violation + /*$existingAny = PersonAddress::where('address', $addressLine)->first(); + if ($existingAny) { + return ['action' => 'skipped', 'message' => 'Address already exists in database']; + }*/ // 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(); + // Compare addresses with all spaces removed to handle whitespace variations + $addressLineNoSpaces = preg_replace('/\s+/', '', $addressLine); + $existing = PersonAddress::where('person_id', $personId) + ->whereRaw("REPLACE(address, ' ', '') = ?", [$addressLineNoSpaces]) + ->first(); + $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { @@ -3060,9 +3178,23 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array $data['person_id'] = $personId; $data['country'] = $data['country'] ?? 'SLO'; $data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); - $created = PersonAddress::create($data); + try { + $created = PersonAddress::create($data); - return ['action' => 'inserted', 'address' => $created]; + return ['action' => 'inserted', 'address' => $created]; + } catch (QueryException $e) { + // If unique constraint violation, skip instead of aborting + Log::warning('Address constraint violation during import', [ + 'person_id' => $personId, + 'address' => $addressLine, + 'error' => $e->getMessage(), + ]); + if ($e->getCode() === '23505' || str_contains($e->getMessage(), 'unique') || str_contains($e->getMessage(), 'duplicate')) { + return ['action' => 'skipped', 'message' => 'Address already exists (constraint violation)']; + } + + throw $e; + } } } diff --git a/composer.lock b/composer.lock index e2658a8..2238565 100644 --- a/composer.lock +++ b/composer.lock @@ -11289,6 +11289,6 @@ "platform": { "php": "^8.2" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2025_12_20_171903_alter_field_jobs_table_add_column_last_activity.php b/database/migrations/2025_12_20_171903_alter_field_jobs_table_add_column_last_activity.php new file mode 100644 index 0000000..e4543f4 --- /dev/null +++ b/database/migrations/2025_12_20_171903_alter_field_jobs_table_add_column_last_activity.php @@ -0,0 +1,30 @@ +boolean('added_activity')->default(false); + $table->timestamp('last_activity')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('field_jobs', function (Blueprint $table) { + $table->dropColumn('last_activity'); + $table->dropColumn('added_activity'); + }); + } +}; diff --git a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue index 4ca3bc1..2ebf9c3 100644 --- a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue +++ b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue @@ -122,6 +122,7 @@ const store = async () => { form .transform((data) => ({ ...data, + phone_view: props.phoneMode, due_date: formatDateForSubmit(data.due_date), attachment_document_ids: templateAllowsAttachments.value && data.attach_documents diff --git a/resources/js/Pages/Phone/Index.vue b/resources/js/Pages/Phone/Index.vue index c2f9de1..8440a76 100644 --- a/resources/js/Pages/Phone/Index.vue +++ b/resources/js/Pages/Phone/Index.vue @@ -1,5 +1,6 @@