From 7e8e0a479b215d2982760ab0902c08144fb46e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sun, 28 Sep 2025 22:36:47 +0200 Subject: [PATCH] changes 0328092025 --- .env.testing | 14 + .env.testing.example | 19 + app/Console/Commands/DebugDocumentView.php | 73 ++ .../Commands/GenerateMissingPreviews.php | 98 +++ app/Http/Controllers/ClientCaseContoller.php | 697 ++++++++++++++++-- app/Http/Controllers/ClientController.php | 51 +- app/Http/Controllers/FieldJobController.php | 229 ++++++ .../Controllers/FieldJobSettingController.php | 29 +- app/Http/Controllers/PhoneViewController.php | 141 ++++ app/Http/Requests/StoreContractRequest.php | 1 + .../Requests/StoreFieldJobSettingRequest.php | 51 +- app/Http/Requests/UpdateContractRequest.php | 1 + .../Requests/UpdateFieldJobSettingRequest.php | 72 ++ app/Jobs/GenerateDocumentPreview.php | 148 ++-- app/Models/Activity.php | 28 +- app/Models/ClientCase.php | 18 +- app/Models/Contract.php | 28 +- app/Models/Document.php | 13 +- app/Models/FieldJob.php | 91 ++- app/Models/FieldJobSetting.php | 24 +- app/Services/ImportProcessor.php | 422 +++++++---- composer.lock | 2 +- database/factories/ClientCaseFactory.php | 5 +- database/factories/ClientFactory.php | 4 +- database/factories/ContractFactory.php | 9 +- database/factories/ContractTypeFactory.php | 19 + database/factories/DecisionFactory.php | 3 +- database/factories/Person/PersonFactory.php | 9 +- .../factories/Person/PersonGroupFactory.php | 9 +- .../factories/Person/PersonTypeFactory.php | 3 +- database/factories/SegmentFactory.php | 4 +- ...00_alter_segments_description_nullable.php | 65 +- ...ncel_decision_id_to_field_job_settings.php | 27 + ...eturn_segment_id_to_field_job_settings.php | 26 + ...2025_09_28_161000_rename_asign_columns.php | 20 + ...queue_segment_id_to_field_job_settings.php | 26 + database/seeders/DatabaseSeeder.php | 18 +- database/seeders/TestUserSeeder.php | 37 + resources/examples/sample_import.csv | 2 +- resources/js/Components/BasicTable.vue | 42 +- .../js/Components/DocumentUploadDialog.vue | 13 +- resources/js/Components/DocumentsTable.vue | 24 +- resources/js/Components/PersonDetailPhone.vue | 143 ++++ resources/js/Layouts/AppLayout.vue | 17 +- resources/js/Layouts/AppPhoneLayout.vue | 233 ++++++ .../js/Pages/Cases/Partials/ActivityTable.vue | 7 +- .../Cases/Partials/CaseObjectCreateDialog.vue | 7 +- .../Cases/Partials/CaseObjectsDialog.vue | 2 +- .../Pages/Cases/Partials/ContractDrawer.vue | 15 + .../js/Pages/Cases/Partials/ContractTable.vue | 671 +++++++++++------ resources/js/Pages/Cases/Show.vue | 24 +- resources/js/Pages/FieldJob/Index.vue | 202 +++++ resources/js/Pages/Phone/Case/Index.vue | 339 +++++++++ resources/js/Pages/Phone/Index.vue | 109 +++ .../js/Pages/Settings/FieldJob/Index.vue | 229 +++++- routes/web.php | 84 ++- tests/CreatesApplication.php | 17 + tests/Feature/FieldJobIndexFilterTest.php | 161 ++++ tests/Feature/ImportEmailTest.php | 75 ++ tests/Pest.php | 5 + tests/TestCase.php | 5 +- 61 files changed, 4306 insertions(+), 654 deletions(-) create mode 100644 .env.testing create mode 100644 .env.testing.example create mode 100644 app/Console/Commands/DebugDocumentView.php create mode 100644 app/Console/Commands/GenerateMissingPreviews.php create mode 100644 app/Http/Controllers/FieldJobController.php create mode 100644 app/Http/Controllers/PhoneViewController.php create mode 100644 app/Http/Requests/UpdateFieldJobSettingRequest.php create mode 100644 database/factories/ContractTypeFactory.php create mode 100644 database/migrations/2025_09_28_111600_add_cancel_decision_id_to_field_job_settings.php create mode 100644 database/migrations/2025_09_28_160000_add_return_segment_id_to_field_job_settings.php create mode 100644 database/migrations/2025_09_28_161000_rename_asign_columns.php create mode 100644 database/migrations/2025_09_28_170500_add_queue_segment_id_to_field_job_settings.php create mode 100644 database/seeders/TestUserSeeder.php create mode 100644 resources/js/Components/PersonDetailPhone.vue create mode 100644 resources/js/Layouts/AppPhoneLayout.vue create mode 100644 resources/js/Pages/FieldJob/Index.vue create mode 100644 resources/js/Pages/Phone/Case/Index.vue create mode 100644 resources/js/Pages/Phone/Index.vue create mode 100644 tests/CreatesApplication.php create mode 100644 tests/Feature/FieldJobIndexFilterTest.php create mode 100644 tests/Feature/ImportEmailTest.php create mode 100644 tests/Pest.php diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..b5e502c --- /dev/null +++ b/.env.testing @@ -0,0 +1,14 @@ +APP_ENV=testing +APP_DEBUG=true +APP_KEY=base64:rcfpwrgQQdcse2wrc/Sc1LR8hYxkIXBOg2BX957WcnI= +APP_URL=http://localhost +CACHE_STORE=array +MAIL_MAILER=array +PULSE_ENABLED=false +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +TELESCOPE_ENABLED=false + +# Use an isolated SQLite database for tests to avoid wiping your dev DB +DB_CONNECTION=sqlite +DB_DATABASE=database/database.sqlite diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..74fd0c4 --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,19 @@ +# Copy this file to .env.testing and update values for your local Postgres test database. +# Running `php artisan test` will then use this separate database instead of your dev DB. + +APP_ENV=testing +APP_DEBUG=true +CACHE_STORE=array +MAIL_MAILER=array +PULSE_ENABLED=false +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +TELESCOPE_ENABLED=false + +# Use a dedicated test database to avoid clearing your dev DB during tests +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=teren_app_test +DB_USERNAME=postgres +DB_PASSWORD=secret diff --git a/app/Console/Commands/DebugDocumentView.php b/app/Console/Commands/DebugDocumentView.php new file mode 100644 index 0000000..b07dafd --- /dev/null +++ b/app/Console/Commands/DebugDocumentView.php @@ -0,0 +1,73 @@ +argument('doc_uuid'); + $caseUuid = (string) $this->argument('case_uuid'); + + $case = ClientCase::where('uuid', $caseUuid)->first(); + if (! $case) { + $this->error('ClientCase not found by uuid: '.$caseUuid); + + return self::FAILURE; + } + $this->info('ClientCase found: id='.$case->id.' uuid='.$case->uuid); + + $doc = Document::withTrashed()->where('uuid', $docUuid)->first(); + if (! $doc) { + $this->error('Document not found by uuid (including trashed): '.$docUuid); + + return self::FAILURE; + } + $this->info('Document found: id='.$doc->id.' uuid='.$doc->uuid.' trashed='.(int) ($doc->deleted_at !== null)); + $this->line(' documentable_type='.$doc->documentable_type.' documentable_id='.$doc->documentable_id); + $this->line(' disk='.$doc->disk.' path='.$doc->path); + $this->line(' preview_path='.(string) $doc->preview_path.' preview_mime='.(string) $doc->preview_mime); + + // Ownership check like in controller + $belongsToCase = $doc->documentable_type === ClientCase::class && $doc->documentable_id === $case->id; + $belongsToContractOfCase = false; + if ($doc->documentable_type === Contract::class) { + $belongsToContractOfCase = Contract::withTrashed() + ->where('id', $doc->documentable_id) + ->where('client_case_id', $case->id) + ->exists(); + } + $this->line('Ownership: belongsToCase='.(int) $belongsToCase.' belongsToContractOfCase='.(int) $belongsToContractOfCase); + + // File existence checks + $disk = $doc->disk ?: 'public'; + $relPath = ltrim($doc->path ?? '', '/\\'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } + $existsOnDisk = Storage::disk($disk)->exists($relPath); + $this->line('Source exists on disk='.$existsOnDisk.' (disk='.$disk.' relPath='.$relPath.')'); + if (! $existsOnDisk) { + $publicFull = public_path($relPath); + $this->line('Public candidate='.$publicFull.' exists='.(int) is_file($publicFull)); + } + + $previewDisk = config('files.preview_disk', 'public'); + $previewExists = $doc->preview_path ? Storage::disk($previewDisk)->exists($doc->preview_path) : false; + $this->line('Preview exists on previewDisk='.$previewExists.' (disk='.$previewDisk.' path='.(string) $doc->preview_path.')'); + + $this->info('Done. Compare with controller logic to pin the 404 branch.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GenerateMissingPreviews.php b/app/Console/Commands/GenerateMissingPreviews.php new file mode 100644 index 0000000..2fc050e --- /dev/null +++ b/app/Console/Commands/GenerateMissingPreviews.php @@ -0,0 +1,98 @@ +option('limit'); + $now = (bool) $this->option('now'); + + $docs = Document::query() + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereRaw("lower(extension) in ('doc','docx')"); + }) + ->orderByDesc('updated_at') + ->limit($limit * 5) + ->get(); + + if ($docs->isEmpty()) { + $this->info('No documents requiring preview generation.'); + + return self::SUCCESS; + } + + $this->info('Scanning '.$docs->count().' candidate document(s) for (re)generation...'); + $dispatched = 0; + foreach ($docs as $doc) { + // Verify source file exists on disk or under public before dispatching + $disk = $doc->disk ?: 'public'; + $relPath = ltrim($doc->path ?? '', '/\\'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } + $has = Storage::disk($disk)->exists($relPath); + if (! $has) { + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; + $has = $realN && $rootN && str_starts_with($realN, $rootN) && is_file($real); + } + if (! $has) { + $this->warn('Skipping doc '.$doc->id.' (source file missing): '.$doc->path); + + continue; + } + + // Determine if (re)generation is required + $needs = false; + $previewDisk = config('files.preview_disk', 'public'); + if (empty($doc->preview_path)) { + $needs = true; + } else { + $existsPreview = Storage::disk($previewDisk)->exists($doc->preview_path); + if (! $existsPreview) { + $needs = true; + } elseif ($doc->preview_generated_at && $doc->updated_at && $doc->updated_at->gt($doc->preview_generated_at)) { + $needs = true; + } + } + if (! $needs) { + continue; + } + + if ($now) { + GenerateDocumentPreview::dispatchSync($doc->id); + } else { + GenerateDocumentPreview::dispatch($doc->id); + } + $dispatched++; + } + + $this->info(($now ? 'Ran' : 'Queued').' preview generation for '.$dispatched.' document(s).'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index c4c58b2..14261f9 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -2,16 +2,15 @@ namespace App\Http\Controllers; +use App\Http\Requests\StoreContractRequest; +use App\Http\Requests\UpdateContractRequest; use App\Models\ClientCase; use App\Models\Contract; use App\Models\Document; -use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Storage; use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\Request; -use App\Http\Requests\StoreContractRequest; -use App\Http\Requests\UpdateContractRequest; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class ClientCaseContoller extends Controller @@ -23,17 +22,16 @@ public function index(ClientCase $clientCase, Request $request) { return Inertia::render('Cases/Index', [ 'client_cases' => $clientCase::with(['person']) - ->when($request->input('search'), fn($que, $search) => - $que->whereHas( - 'person', - fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%') - ) + ->when($request->input('search'), fn ($que, $search) => $que->whereHas( + 'person', + fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') + ) ) ->where('active', 1) ->orderByDesc('created_at') ->paginate(15, ['*'], 'client-cases-page') ->withQueryString(), - 'filters' => $request->only(['search']) + 'filters' => $request->only(['search']), ]); } @@ -55,13 +53,13 @@ public function store(Request $request) $client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail(); - if( isset($client->id) ){ + if (isset($client->id)) { - \DB::transaction(function() use ($request, $client){ + \DB::transaction(function () use ($request, $client) { $pq = $request->input('person'); $person = $client->person()->create([ - 'nu' => rand(100000,200000), + 'nu' => rand(100000, 200000), 'first_name' => $pq['first_name'], 'last_name' => $pq['last_name'], 'full_name' => $pq['full_name'], @@ -71,23 +69,23 @@ public function store(Request $request) 'social_security_number' => $pq['social_security_number'], 'description' => $pq['description'], 'group_id' => 2, - 'type_id' => 1 + 'type_id' => 1, ]); $person->addresses()->create([ 'address' => $pq['address']['address'], 'country' => $pq['address']['country'], - 'type_id' => $pq['address']['type_id'] + 'type_id' => $pq['address']['type_id'], ]); $person->phones()->create([ 'nu' => $pq['phone']['nu'], 'country_code' => $pq['phone']['country_code'], - 'type_id' => $pq['phone']['type_id'] + 'type_id' => $pq['phone']['type_id'], ]); $person->clientCase()->create([ - 'client_id' => $client->id + 'client_id' => $client->id, ]); }); } @@ -97,8 +95,8 @@ public function store(Request $request) public function storeContract(ClientCase $clientCase, StoreContractRequest $request) { - - \DB::transaction(function() use ($request, $clientCase){ + + \DB::transaction(function () use ($request, $clientCase) { // Create contract $contract = $clientCase->contracts()->create([ @@ -110,11 +108,13 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ // Note: Contract config auto-application is handled in Contract model created hook. - // Optionally create/update related account amounts + // Optionally create related account with amounts and/or type $initial = $request->input('initial_amount'); $balance = $request->input('balance_amount'); - if (!is_null($initial) || !is_null($balance)) { + $accountTypeId = $request->input('account_type_id'); + if (! is_null($initial) || ! is_null($balance) || ! is_null($accountTypeId)) { $contract->account()->create([ + 'type_id' => $accountTypeId, 'initial_amount' => $initial ?? 0, 'balance_amount' => $balance ?? 0, ]); @@ -125,11 +125,11 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ return to_route('clientCase.show', $clientCase); } - public function updateContract(ClientCase $clientCase, String $uuid, UpdateContractRequest $request) + public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) { $contract = Contract::where('uuid', $uuid)->firstOrFail(); - \DB::transaction(function() use ($request, $contract){ + \DB::transaction(function () use ($request, $contract) { $contract->update([ 'reference' => $request->input('reference'), 'type_id' => $request->input('type_id'), @@ -139,14 +139,24 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr $initial = $request->input('initial_amount'); $balance = $request->input('balance_amount'); - if (!is_null($initial) || !is_null($balance)) { - $accountData = [ - 'initial_amount' => $initial ?? 0, - 'balance_amount' => $balance ?? 0, - ]; + $shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id'); + if ($shouldUpsertAccount) { + $accountData = []; + if (! is_null($initial)) { + $accountData['initial_amount'] = $initial; + } + if (! is_null($balance)) { + $accountData['balance_amount'] = $balance; + } + if ($request->has('account_type_id')) { + $accountData['type_id'] = $request->input('account_type_id'); + } + if ($contract->account) { $contract->account->update($accountData); } else { + // For create, ensure defaults exist if not provided + $accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData); $contract->account()->create($accountData); } } @@ -156,7 +166,8 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr return to_route('clientCase.show', $clientCase); } - public function storeActivity(ClientCase $clientCase, Request $request) { + public function storeActivity(ClientCase $clientCase, Request $request) + { try { $attributes = $request->validate([ 'due_date' => 'nullable|date', @@ -166,16 +177,16 @@ public function storeActivity(ClientCase $clientCase, Request $request) { 'decision_id' => 'exists:\App\Models\Decision,id', 'contract_uuid' => 'nullable|uuid', ]); - + // Map contract_uuid to contract_id within the same client case, if provided $contractId = null; - if (!empty($attributes['contract_uuid'])) { + if (! empty($attributes['contract_uuid'])) { $contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id'); if ($contract) { $contractId = $contract->id; } } - + // Create activity $row = $clientCase->activities()->create([ 'due_date' => $attributes['due_date'] ?? null, @@ -189,24 +200,30 @@ public function storeActivity(ClientCase $clientCase, Request $request) { $class = '\\App\\Events\\' . $e->name; event(new $class($clientCase)); }*/ - + logger()->info('Activity successfully inserted', $attributes); - return to_route('clientCase.show', $clientCase)->with('success', 'Successful created!'); + + // Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route. + // Use 303 to align with Inertia's recommended POST/Redirect/GET behavior. + return back(303)->with('success', 'Successful created!'); } catch (QueryException $e) { logger()->error('Database error occurred:', ['error' => $e->getMessage()]); - return back()->with('error', 'Failed to insert activity. ' . $e->getMessage()); + + return back()->with('error', 'Failed to insert activity. '.$e->getMessage()); } catch (Exception $e) { logger()->error('An unexpected error occurred:', ['error' => $e->getMessage()]); + // Return a generic error response return back()->with('error', 'An unexpected error occurred. Please try again later.'); } - + } - public function deleteContract(ClientCase $clientCase, String $uuid, Request $request) { + public function deleteContract(ClientCase $clientCase, string $uuid, Request $request) + { $contract = Contract::where('uuid', $uuid)->firstOrFail(); - \DB::transaction(function() use ($request, $contract){ + \DB::transaction(function () use ($contract) { $contract->delete(); }); @@ -254,22 +271,22 @@ public function attachSegment(ClientCase $clientCase, Request $request) 'make_active_for_contract' => ['sometimes', 'boolean'], ]); - \DB::transaction(function () use ($clientCase, $validated) { + \DB::transaction(function () use ($clientCase, $validated) { // Attach segment to client case if not already attached $attached = \DB::table('client_case_segment') ->where('client_case_id', $clientCase->id) ->where('segment_id', $validated['segment_id']) ->first(); - if (!$attached) { + if (! $attached) { $clientCase->segments()->attach($validated['segment_id'], ['active' => true]); - } else if (!$attached->active) { + } elseif (! $attached->active) { \DB::table('client_case_segment') ->where('id', $attached->id) ->update(['active' => true, 'updated_at' => now()]); } // Optionally make it active for a specific contract - if (!empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { + if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail(); \DB::table('contract_segment') ->where('contract_id', $contract->id) @@ -300,11 +317,18 @@ public function storeDocument(ClientCase $clientCase, Request $request) 'name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_public' => 'sometimes|boolean', + 'contract_uuid' => 'nullable|uuid', ]); $file = $validated['file']; $disk = 'public'; - $directory = 'cases/' . $clientCase->uuid . '/documents'; + $contract = null; + if (! empty($validated['contract_uuid'])) { + $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); + } + $directory = $contract + ? ('contracts/'.$contract->uuid.'/documents') + : ('cases/'.$clientCase->uuid.'/documents'); $path = $file->store($directory, $disk); $doc = new Document([ @@ -319,14 +343,18 @@ public function storeDocument(ClientCase $clientCase, Request $request) 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'checksum' => null, - 'is_public' => (bool)($validated['is_public'] ?? false), + 'is_public' => (bool) ($validated['is_public'] ?? false), ]); - $clientCase->documents()->save($doc); + if ($contract) { + $contract->documents()->save($doc); + } else { + $clientCase->documents()->save($doc); + } // Generate preview immediately for Office docs to avoid first-view delay $ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION)); - if (in_array($ext, ['doc','docx'])) { + if (in_array($ext, ['doc', 'docx'])) { \App\Jobs\GenerateDocumentPreview::dispatch($doc->id); } @@ -335,22 +363,52 @@ public function storeDocument(ClientCase $clientCase, Request $request) public function viewDocument(ClientCase $clientCase, Document $document, Request $request) { - // Ensure the document belongs to this client case - if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) { + // Ensure the document belongs to this client case or its contracts + $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; + $belongsToContractOfCase = false; + if ($document->documentable_type === Contract::class) { + // Include soft-deleted contracts when verifying ownership to this case + $belongsToContractOfCase = Contract::withTrashed() + ->where('id', $document->documentable_id) + ->where('client_case_id', $clientCase->id) + ->exists(); + + } + if (! ($belongsToCase || $belongsToContractOfCase)) { + logger()->warning('Document view 404: document does not belong to case or its contracts', [ + 'document_id' => $document->id, + 'document_uuid' => $document->uuid, + 'documentable_type' => $document->documentable_type, + 'documentable_id' => $document->documentable_id, + 'client_case_id' => $clientCase->id, + 'client_case_uuid' => $clientCase->uuid, + ]); abort(404); } // Optional: add authz checks here (e.g., policies) $disk = $document->disk ?: 'public'; + // Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows) + $relPath = $document->path ?? ''; + $relPath = str_replace('\\', '/', $relPath); // unify slashes + $relPath = ltrim($relPath, '/'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } // If a preview exists (e.g., PDF generated for doc/docx), stream that $previewDisk = config('files.preview_disk', 'public'); if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { $stream = Storage::disk($previewDisk)->readStream($document->preview_path); - if ($stream === false) abort(404); - return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ + if ($stream === false) { + abort(404); + } + + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, [ 'Content-Type' => $document->preview_mime ?: 'application/pdf', - 'Content-Disposition' => 'inline; filename="' . addslashes(($document->original_name ?: $document->file_name) . '.pdf') . '"', + 'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); @@ -358,17 +416,147 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request // If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); - if (in_array($ext, ['doc','docx'])) { + if (in_array($ext, ['doc', 'docx'])) { \App\Jobs\GenerateDocumentPreview::dispatch($document->id); + return response('Preview is being generated. Please try again shortly.', 202); } - if (!Storage::disk($disk)->exists($document->path)) { + // Try multiple path candidates to account for legacy prefixes + $candidates = []; + $candidates[] = $relPath; + // also try raw original (normalized slashes, trimmed) + $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; + if ($raw && $raw !== $relPath) { + $candidates[] = $raw; + } + // if path accidentally contains 'storage/' prefix (public symlink), strip it + if (str_starts_with($relPath, 'storage/')) { + $candidates[] = substr($relPath, 8); + } + if ($raw && str_starts_with($raw, 'storage/')) { + $candidates[] = substr($raw, 8); + } + + $existsOnDisk = false; + foreach ($candidates as $cand) { + if (Storage::disk($disk)->exists($cand)) { + $existsOnDisk = true; + $relPath = $cand; + break; + } + } + + if (! $existsOnDisk) { + // Fallback: some legacy files may live directly under public/, attempt to stream from there + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + logger()->info('Document view fallback: serving from public path', [ + 'document_id' => $document->id, + 'path' => $realN, + ]); + $fp = @fopen($real, 'rb'); + if ($fp === false) { + abort(404); + } + + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + + logger()->warning('Document view 404: file missing on disk and public fallback failed', [ + 'document_id' => $document->id, + 'document_uuid' => $document->uuid, + 'disk' => $disk, + 'path' => $document->path, + 'normalizedCandidates' => $candidates, + 'public_candidate' => $publicFull, + ]); abort(404); } - $stream = Storage::disk($disk)->readStream($document->path); + $stream = Storage::disk($disk)->readStream($relPath); if ($stream === false) { + logger()->warning('Document view: readStream failed, attempting fallbacks', [ + 'document_id' => $document->id, + 'disk' => $disk, + 'relPath' => $relPath, + ]); + + $headers = [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]; + + // Fallback 1: get() the bytes directly + try { + $bytes = Storage::disk($disk)->get($relPath); + } catch (\Throwable $e) { + $bytes = null; + } + if (! is_null($bytes) && $bytes !== false) { + return response($bytes, 200, $headers); + } + + // Fallback 2: open via absolute path (local driver) + $abs = null; + try { + if (method_exists(Storage::disk($disk), 'path')) { + $abs = Storage::disk($disk)->path($relPath); + } + } catch (\Throwable $e) { + $abs = null; + } + if ($abs && is_file($abs)) { + $fp = @fopen($abs, 'rb'); + if ($fp !== false) { + logger()->info('Document view fallback: serving from absolute storage path', [ + 'document_id' => $document->id, + 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), + ]); + + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + // Fallback 3: serve from public path if available + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + logger()->info('Document view fallback: serving from public path (post-readStream failure)', [ + 'document_id' => $document->id, + 'path' => $realN, + ]); + $fp = @fopen($real, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + logger()->warning('Document view 404: all fallbacks failed after readStream failure', [ + 'document_id' => $document->id, + 'disk' => $disk, + 'relPath' => $relPath, + ]); abort(404); } @@ -376,7 +564,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request fpassthru($stream); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'inline; filename="' . addslashes($document->original_name ?: $document->file_name) . '"', + 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); @@ -384,57 +572,418 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request public function downloadDocument(ClientCase $clientCase, Document $document, Request $request) { - if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) { + $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; + $belongsToContractOfCase = false; + if ($document->documentable_type === Contract::class) { + $belongsToContractOfCase = Contract::withTrashed() + ->where('id', $document->documentable_id) + ->where('client_case_id', $clientCase->id) + ->exists(); + } + if (! ($belongsToCase || $belongsToContractOfCase)) { + logger()->warning('Document download 404: document does not belong to case or its contracts', [ + 'document_id' => $document->id, + 'document_uuid' => $document->uuid, + 'documentable_type' => $document->documentable_type, + 'documentable_id' => $document->documentable_id, + 'client_case_id' => $clientCase->id, + 'client_case_uuid' => $clientCase->uuid, + ]); abort(404); } $disk = $document->disk ?: 'public'; - if (!Storage::disk($disk)->exists($document->path)) { + // Normalize relative path for Windows and legacy prefixes + $relPath = $document->path ?? ''; + $relPath = str_replace('\\', '/', $relPath); + $relPath = ltrim($relPath, '/'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } + + $candidates = []; + $candidates[] = $relPath; + $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; + if ($raw && $raw !== $relPath) { + $candidates[] = $raw; + } + if (str_starts_with($relPath, 'storage/')) { + $candidates[] = substr($relPath, 8); + } + if ($raw && str_starts_with($raw, 'storage/')) { + $candidates[] = substr($raw, 8); + } + + $existsOnDisk = false; + foreach ($candidates as $cand) { + if (Storage::disk($disk)->exists($cand)) { + $existsOnDisk = true; + $relPath = $cand; + break; + } + } + + if (! $existsOnDisk) { + // Fallback to public/ direct path if present + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + logger()->info('Document download fallback: serving from public path', [ + 'document_id' => $document->id, + 'path' => $realN, + ]); + $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; + $fp = @fopen($real, 'rb'); + if ($fp === false) { + abort(404); + } + + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + + logger()->warning('Document download 404: file missing on disk and public fallback failed', [ + 'document_id' => $document->id, + 'document_uuid' => $document->uuid, + 'disk' => $disk, + 'path' => $document->path, + 'normalizedCandidates' => $candidates, + 'public_candidate' => $publicFull, + ]); abort(404); } - $name = $document->original_name ?: $document->file_name; - $stream = Storage::disk($disk)->readStream($document->path); + $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; + $stream = Storage::disk($disk)->readStream($relPath); if ($stream === false) { + logger()->warning('Document download: readStream failed, attempting fallbacks', [ + 'document_id' => $document->id, + 'disk' => $disk, + 'relPath' => $relPath, + ]); + + $headers = [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]; + + // Fallback 1: get() the bytes directly + try { + $bytes = Storage::disk($disk)->get($relPath); + } catch (\Throwable $e) { + $bytes = null; + } + if (! is_null($bytes) && $bytes !== false) { + return response($bytes, 200, $headers); + } + + // Fallback 2: open via absolute storage path + $abs = null; + try { + if (method_exists(Storage::disk($disk), 'path')) { + $abs = Storage::disk($disk)->path($relPath); + } + } catch (\Throwable $e) { + $abs = null; + } + if ($abs && is_file($abs)) { + $fp = @fopen($abs, 'rb'); + if ($fp !== false) { + logger()->info('Document download fallback: serving from absolute storage path', [ + 'document_id' => $document->id, + 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), + ]); + + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + // Fallback 3: serve from public path if available + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + logger()->info('Document download fallback: serving from public path (post-readStream failure)', [ + 'document_id' => $document->id, + 'path' => $realN, + ]); + $fp = @fopen($real, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + logger()->warning('Document download 404: all fallbacks failed after readStream failure', [ + 'document_id' => $document->id, + 'disk' => $disk, + 'relPath' => $relPath, + ]); abort(404); } + return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', - 'Content-Disposition' => 'attachment; filename="' . addslashes($name) . '"', + 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } + /** + * View a contract document using contract route binding. + */ + public function viewContractDocument(Contract $contract, Document $document, Request $request) + { + // Ensure the document belongs to this contract (including trashed docs) + $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; + if (! $belongs) { + abort(404); + } + + // Reuse the existing logic by delegating to a small helper + return $this->streamDocumentForDisk($document, inline: true); + } + + /** + * Download a contract document using contract route binding. + */ + public function downloadContractDocument(Contract $contract, Document $document, Request $request) + { + $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; + if (! $belongs) { + abort(404); + } + + return $this->streamDocumentForDisk($document, inline: false); + } + + /** + * Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks. + */ + protected function streamDocumentForDisk(Document $document, bool $inline = true) + { + $disk = $document->disk ?: 'public'; + $relPath = $document->path ?? ''; + $relPath = str_replace('\\', '/', $relPath); + $relPath = ltrim($relPath, '/'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } + + // Previews for DOC/DOCX + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + $previewDisk = config('files.preview_disk', 'public'); + if ($inline && in_array($ext, ['doc', 'docx'])) { + if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { + $stream = Storage::disk($previewDisk)->readStream($document->preview_path); + if ($stream !== false) { + $previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); + + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, [ + 'Content-Type' => $document->preview_mime ?: 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + } + \App\Jobs\GenerateDocumentPreview::dispatch($document->id); + + return response('Preview is being generated. Please try again shortly.', 202); + } + + // Try storage candidates + $candidates = [$relPath]; + $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; + if ($raw && $raw !== $relPath) { + $candidates[] = $raw; + } + if (str_starts_with($relPath, 'storage/')) { + $candidates[] = substr($relPath, 8); + } + if ($raw && str_starts_with($raw, 'storage/')) { + $candidates[] = substr($raw, 8); + } + + $found = null; + foreach ($candidates as $cand) { + if (Storage::disk($disk)->exists($cand)) { + $found = $cand; + break; + } + } + + $headers = [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]; + + if (! $found) { + // public/ fallback + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + $fp = @fopen($real, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + abort(404); + } + + $stream = Storage::disk($disk)->readStream($found); + if ($stream !== false) { + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, $headers); + } + + // Fallbacks on readStream failure + try { + $bytes = Storage::disk($disk)->get($found); + if (! is_null($bytes) && $bytes !== false) { + return response($bytes, 200, $headers); + } + } catch (\Throwable $e) { + } + + $abs = null; + try { + if (method_exists(Storage::disk($disk), 'path')) { + $abs = Storage::disk($disk)->path($found); + } + } catch (\Throwable $e) { + $abs = null; + } + if ($abs && is_file($abs)) { + $fp = @fopen($abs, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + // public/ again as last try + $publicFull = public_path($found); + $real = @realpath($publicFull); + if ($real && is_file($real)) { + $fp = @fopen($real, 'rb'); + if ($fp !== false) { + return response()->stream(function () use ($fp) { + fpassthru($fp); + }, 200, $headers); + } + } + + abort(404); + } + /** * Display the specified resource. */ public function show(ClientCase $clientCase) { $case = $clientCase::with([ - 'person' => fn($que) => $que->with(['addresses', 'phones']) - ])->where('active', 1)->findOrFail($clientCase->id); + 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']), + ])->where('active', 1)->findOrFail($clientCase->id); $types = [ 'address_types' => \App\Models\Person\AddressType::all(), - 'phone_types' => \App\Models\Person\PhoneType::all() + 'phone_types' => \App\Models\Person\PhoneType::all(), ]; - return Inertia::render('Cases/Show', [ - 'client' => $case->client()->with('person', fn($q) => $q->with(['addresses', 'phones']))->firstOrFail(), + // Prepare contracts and a reference map + $contracts = $case->contracts() + ->with(['type', 'account', 'objects', 'segments:id,name']) + ->orderByDesc('created_at') + ->get(); + $contractRefMap = []; + foreach ($contracts as $c) { + $contractRefMap[$c->id] = $c->reference; + } + + // Merge client case and contract documents into a single array and include contract reference when applicable + $contractIds = $contracts->pluck('id'); + $contractDocs = Document::query() + ->where('documentable_type', Contract::class) + ->whereIn('documentable_id', $contractIds) + ->orderByDesc('created_at') + ->get() + ->map(function ($d) use ($contractRefMap) { + $arr = $d->toArray(); + $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; + $arr['documentable_type'] = Contract::class; + $arr['contract_uuid'] = optional(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'] = ClientCase::class; + $arr['client_case_uuid'] = $case->uuid; + + return $arr; + }); + $mergedDocs = $caseDocs + ->concat($contractDocs) + ->sortByDesc('created_at') + ->values(); + + return Inertia::render('Cases/Show', [ + 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(), 'client_case' => $case, - 'contracts' => $case->contracts() - ->with(['type', 'account', 'objects', 'segments:id,name']) - ->orderByDesc('created_at')->get(), - 'activities' => $case->activities()->with(['action', 'decision', 'contract:id,uuid,reference']) - ->orderByDesc('created_at') - ->paginate(20, ['*'], 'activities'), - 'documents' => $case->documents()->orderByDesc('created_at')->get(), + 'contracts' => $contracts, + 'activities' => tap( + $case->activities() + ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) + ->orderByDesc('created_at') + ->paginate(20, ['*'], 'activities'), + function ($p) { + $p->getCollection()->transform(function ($a) { + $a->setAttribute('user_name', optional($a->user)->name); + return $a; + }); + } + ), + 'documents' => $mergedDocs, 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), + 'account_types' => \App\Models\AccountType::all(), 'actions' => \App\Models\Action::with('decisions')->get(), 'types' => $types, - 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id','segments.name']), - 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id','name']) + 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), + 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), ]); } diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 90ee927..79c2cde 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -9,62 +9,62 @@ class ClientController extends Controller { - public function index(Client $client, Request $request){ - return Inertia::render('Client/Index',[ + public function index(Client $client, Request $request) + { + return Inertia::render('Client/Index', [ 'clients' => $client::query() ->with('person') - ->when($request->input('search'), fn($que, $search) => - $que->whereHas( - 'person', - fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%') - ) + ->when($request->input('search'), fn ($que, $search) => $que->whereHas( + 'person', + fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') + ) ) ->where('active', 1) ->orderByDesc('created_at') ->paginate(15) ->withQueryString(), - 'filters' => $request->only(['search']) + 'filters' => $request->only(['search']), ]); } - public function show(Client $client, Request $request) { + public function show(Client $client, Request $request) + { $data = $client::query() - ->with(['person' => fn($que) => $que->with(['addresses','phones'])]) + ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts'])]) ->findOrFail($client->id); $types = [ 'address_types' => \App\Models\Person\AddressType::all(), - 'phone_types' => \App\Models\Person\PhoneType::all() + 'phone_types' => \App\Models\Person\PhoneType::all(), ]; return Inertia::render('Client/Show', [ 'client' => $data, 'client_cases' => $data->clientCases() ->with('person') - ->when($request->input('search'), fn($que, $search) => - $que->whereHas( - 'person', - fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%') - ) + ->when($request->input('search'), fn ($que, $search) => $que->whereHas( + 'person', + fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%') + ) ) ->where('active', 1) ->orderByDesc('created_at') ->paginate(15) ->withQueryString(), 'types' => $types, - 'filters' => $request->only(['search']) + 'filters' => $request->only(['search']), ]); } public function store(Request $request) { - DB::transaction(function() use ($request){ + DB::transaction(function () use ($request) { $address = $request->input('address'); $phone = $request->input('phone'); $person = \App\Models\Person\Person::create([ - 'nu' => rand(100000,200000), + 'nu' => rand(100000, 200000), 'first_name' => $request->input('first_name'), 'last_name' => $request->input('last_name'), 'full_name' => $request->input('full_name'), @@ -74,31 +74,32 @@ public function store(Request $request) 'social_security_number' => $request->input('social_security_number'), 'description' => $request->input('description'), 'group_id' => 1, - 'type_id' => 2 + 'type_id' => 2, ]); $person->addresses()->create([ 'address' => $address['address'], 'country' => $address['country'], - 'type_id' => $address['type_id'] + 'type_id' => $address['type_id'], ]); $person->phones()->create([ 'nu' => $phone['nu'], 'country_code' => $phone['country_code'], - 'type_id' => $phone['type_id'] + 'type_id' => $phone['type_id'], ]); $person->client()->create(); }); - //\App\Models\Person\PersonAddress::create($address); + // \App\Models\Person\PersonAddress::create($address); return to_route('client'); - + } - public function update(Client $client, Request $request) { + public function update(Client $client, Request $request) + { return to_route('client.show', $client); } diff --git a/app/Http/Controllers/FieldJobController.php b/app/Http/Controllers/FieldJobController.php new file mode 100644 index 0000000..76bf5e3 --- /dev/null +++ b/app/Http/Controllers/FieldJobController.php @@ -0,0 +1,229 @@ +latest('id')->first(); + // Only fetch contracts that are currently in either the primary segment + // or the optional queue segment defined on the latest FieldJobSetting. + $segmentIds = collect([ + optional($setting)->queue_segment_id, + optional($setting)->segment_id, + ])->filter()->unique()->values(); + + $contracts = Contract::query() + ->with(['clientCase.person', 'type', 'account']) + ->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { + $q->whereHas('segments', function ($sq) use ($segmentIds) { + // Relation already filters on active pivots + $sq->whereIn('segments.id', $segmentIds); + }); + }, function ($q) { + // No segments configured on FieldJobSetting -> return none + $q->whereRaw('1 = 0'); + }) + ->latest('id') + ->limit(50) + ->get(); + + // Build active assignment map keyed by contract uuid for quicker UI checks + $assignments = collect(); + if ($contracts->isNotEmpty()) { + $activeJobs = FieldJob::query() + ->whereIn('contract_id', $contracts->pluck('id')) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid']) + ->get(); + + $assignments = $activeJobs->mapWithKeys(function (FieldJob $job) { + return [ + optional($job->contract)->uuid => [ + 'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null, + 'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null, + 'assigned_at' => $job->assigned_at, + ], + ]; + })->filter(); + } + + $users = User::query()->orderBy('name')->get(['id', 'name']); + + return Inertia::render('FieldJob/Index', [ + 'setting' => $setting, + 'contracts' => $contracts, + 'users' => $users, + 'assignments' => $assignments, + ]); + } + + public function assign(Request $request) + { + $data = $request->validate([ + 'contract_uuid' => 'required|string|exists:contracts,uuid', + 'assigned_user_id' => 'required|integer|exists:users,id', + ]); + + $setting = FieldJobSetting::query()->latest('id')->first(); + if (! $setting) { + return back()->withErrors(['setting' => 'No Field Job Setting found. Create one in Settings → Field Job Settings.']); + } + + $contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail(); + + $job = FieldJob::create([ + 'field_job_setting_id' => $setting->id, + 'assigned_user_id' => $data['assigned_user_id'], + 'contract_id' => $contract->id, + 'assigned_at' => now(), + ]); + + // Create an activity for the assignment + // Find the first action linked to the assign decision via pivot; also prefer actions within the same segment as the setting + $decisionId = $setting->assign_decision_id; + $actionId = null; + if ($decisionId) { + // Strictly use the action_decision pivot: take the first action mapped to this decision + $actionId = DB::table('action_decision') + ->where('decision_id', $decisionId) + ->orderBy('id') + ->value('action_id'); + } + + if ($actionId) { + $assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name'); + // Localized note: "Terensko opravilo dodeljeno" + assignee when present + $note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : ''); + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => $actionId, + 'decision_id' => $decisionId, + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + ]); + } + + return back()->with('success', 'Field job assigned.'); + } + + public function cancel(Request $request) + { + $data = $request->validate([ + 'contract_uuid' => 'required|string|exists:contracts,uuid', + ]); + + $contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail(); + + $job = FieldJob::query() + ->where('contract_id', $contract->id) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->latest('id') + ->first(); + + if ($job) { + $job->cancelled_at = now(); + $job->save(); + + // Create an activity for the cancellation, mirroring the assign flow + // Prefer the job's setting for a consistent decision + $job->loadMissing('setting'); + $decisionId = optional($job->setting)->cancel_decision_id; + if ($decisionId) { + $actionId = DB::table('action_decision') + ->where('decision_id', $decisionId) + ->orderBy('id') + ->value('action_id'); + + if ($actionId) { + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => 'Terensko opravilo preklicano', + 'action_id' => $actionId, + 'decision_id' => $decisionId, + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + ]); + } + } + } + + return back()->with('success', 'Field job cancelled.'); + } + + public function complete(Request $request, \App\Models\ClientCase $clientCase) + { + // Complete all active field jobs for contracts of this case assigned to current user + $userId = optional($request->user())->id; + $setting = FieldJobSetting::query()->latest('id')->first(); + if (! $setting) { + return back()->withErrors(['setting' => 'No Field Job Setting found.']); + } + + $decisionId = $setting->complete_decision_id; + $actionId = null; + if ($decisionId) { + $actionId = DB::table('action_decision') + ->where('decision_id', $decisionId) + ->orderBy('id') + ->value('action_id'); + } + + // Find all active jobs for this case for the current user + $jobs = FieldJob::query() + ->whereHas('contract', function ($q) use ($clientCase) { + $q->where('client_case_id', $clientCase->id); + }) + ->where(function ($q) use ($userId) { + if ($userId) { + $q->where('assigned_user_id', $userId); + } + }) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->with(['contract:id,client_case_id', 'setting']) + ->get(); + + DB::transaction(function () use ($jobs, $decisionId, $actionId) { + foreach ($jobs as $job) { + // Mark job complete + $job->completed_at = now(); + $job->save(); + + // Log completion activity on the contract/case + if ($actionId && $decisionId) { + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => 'Terensko opravilo zaključeno', + 'action_id' => $actionId, + 'decision_id' => $decisionId, + 'client_case_id' => $job->contract->client_case_id, + 'contract_id' => $job->contract_id, + ]); + } + + // Move contract to configured return segment + $job->returnContractToConfiguredSegment(); + } + }); + + // Redirect back to phone index + return to_route('phone.index'); + } +} diff --git a/app/Http/Controllers/FieldJobSettingController.php b/app/Http/Controllers/FieldJobSettingController.php index b41bf1b..bcf99a3 100644 --- a/app/Http/Controllers/FieldJobSettingController.php +++ b/app/Http/Controllers/FieldJobSettingController.php @@ -2,10 +2,11 @@ namespace App\Http\Controllers; +use App\Http\Requests\StoreFieldJobSettingRequest; +use App\Http\Requests\UpdateFieldJobSettingRequest; +use App\Models\Decision; use App\Models\FieldJobSetting; use App\Models\Segment; -use App\Models\Decision; -use App\Http\Requests\StoreFieldJobSettingRequest; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,7 +15,7 @@ class FieldJobSettingController extends Controller public function index(Request $request) { $settings = FieldJobSetting::query() - ->with(['segment', 'initialDecision', 'asignDecision', 'completeDecision']) + ->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment']) ->get(); return Inertia::render('Settings/FieldJob/Index', [ @@ -31,10 +32,30 @@ public function store(StoreFieldJobSettingRequest $request) FieldJobSetting::create([ 'segment_id' => $attributes['segment_id'], 'initial_decision_id' => $attributes['initial_decision_id'], - 'asign_decision_id' => $attributes['asign_decision_id'], + 'assign_decision_id' => $attributes['assign_decision_id'], 'complete_decision_id' => $attributes['complete_decision_id'], + 'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null, + 'return_segment_id' => $attributes['return_segment_id'] ?? null, + 'queue_segment_id' => $attributes['queue_segment_id'] ?? null, ]); return to_route('settings.fieldjob.index')->with('success', 'Field job setting created successfully!'); } + + public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $request) + { + $attributes = $request->validated(); + + $setting->update([ + 'segment_id' => $attributes['segment_id'], + 'initial_decision_id' => $attributes['initial_decision_id'], + 'assign_decision_id' => $attributes['assign_decision_id'], + 'complete_decision_id' => $attributes['complete_decision_id'], + 'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null, + 'return_segment_id' => $attributes['return_segment_id'] ?? null, + 'queue_segment_id' => $attributes['queue_segment_id'] ?? null, + ]); + + return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!'); + } } diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php new file mode 100644 index 0000000..ada4523 --- /dev/null +++ b/app/Http/Controllers/PhoneViewController.php @@ -0,0 +1,141 @@ +user()->id; + + $jobs = FieldJob::query() + ->where('assigned_user_id', $userId) + ->whereNull('completed_at') + ->whereNull('cancelled_at') + ->with([ + 'contract' => function ($q) { + $q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) { + $pq->with(['addresses', 'phones']); + }]); + }, + ]) + ->orderByDesc('assigned_at') + ->limit(100) + ->get(); + + return Inertia::render('Phone/Index', [ + 'jobs' => $jobs, + ]); + } + + public function showCase(\App\Models\ClientCase $clientCase, Request $request) + { + $userId = $request->user()->id; + + // 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); + + // Determine contracts of this case assigned to the current user via FieldJobs and still active + $assignedContractIds = 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(); + + $contracts = \App\Models\Contract::query() + ->where('client_case_id', $case->id) + ->whereIn('id', $assignedContractIds) + ->with(['type:id,name', 'account']) + ->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', $assignedContractIds) + ->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' => \App\Models\Person\AddressType::all(), + 'phone_types' => \App\Models\Person\PhoneType::all(), + ]; + + // 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; + }); + + return Inertia::render('Phone/Case/Index', [ + 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(), + 'client_case' => $case, + 'contracts' => $contracts, + 'documents' => $documents, + 'types' => $types, + 'account_types' => \App\Models\AccountType::all(), + 'actions' => \App\Models\Action::with('decisions')->get(), + 'activities' => $activities, + ]); + } +} diff --git a/app/Http/Requests/StoreContractRequest.php b/app/Http/Requests/StoreContractRequest.php index ea96a26..c2f2825 100644 --- a/app/Http/Requests/StoreContractRequest.php +++ b/app/Http/Requests/StoreContractRequest.php @@ -32,6 +32,7 @@ public function rules(): array 'description' => ['nullable', 'string', 'max:255'], 'initial_amount' => ['nullable', 'numeric'], 'balance_amount' => ['nullable', 'numeric'], + 'account_type_id' => ['nullable', 'integer', 'exists:account_types,id'], ]; } } diff --git a/app/Http/Requests/StoreFieldJobSettingRequest.php b/app/Http/Requests/StoreFieldJobSettingRequest.php index 798a0ad..fdd35bc 100644 --- a/app/Http/Requests/StoreFieldJobSettingRequest.php +++ b/app/Http/Requests/StoreFieldJobSettingRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\DB; class StoreFieldJobSettingRequest extends FormRequest { @@ -16,8 +17,11 @@ public function rules(): array return [ 'segment_id' => ['required', 'integer', 'exists:segments,id'], 'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'], - 'asign_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'], 'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'], + 'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'], + 'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'], ]; } @@ -26,8 +30,51 @@ public function messages(): array return [ 'segment_id.required' => 'Segment is required.', 'initial_decision_id.required' => 'Initial decision is required.', - 'asign_decision_id.required' => 'Assign decision is required.', + 'assign_decision_id.required' => 'Assign decision is required.', 'complete_decision_id.required' => 'Complete decision is required.', ]; } + + /** + * Configure the validator instance. + */ + public function withValidator($validator): void + { + $validator->after(function ($validator): void { + // Validate that the assign_decision_id has a mapped action + $assignDecisionId = $this->input('assign_decision_id'); + if (! empty($assignDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $assignDecisionId) + ->exists(); + + if (! $mapped) { + $validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.'); + } + } + + // Validate that the complete_decision_id has a mapped action + $completeDecisionId = $this->input('complete_decision_id'); + if (! empty($completeDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $completeDecisionId) + ->exists(); + + if (! $mapped) { + $validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.'); + } + } + + $cancelDecisionId = $this->input('cancel_decision_id'); + if (! empty($cancelDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $cancelDecisionId) + ->exists(); + + if (! $mapped) { + $validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.'); + } + } + }); + } } diff --git a/app/Http/Requests/UpdateContractRequest.php b/app/Http/Requests/UpdateContractRequest.php index f6921a0..bb72e4d 100644 --- a/app/Http/Requests/UpdateContractRequest.php +++ b/app/Http/Requests/UpdateContractRequest.php @@ -34,6 +34,7 @@ public function rules(): array 'description' => ['nullable', 'string', 'max:255'], 'initial_amount' => ['nullable', 'numeric'], 'balance_amount' => ['nullable', 'numeric'], + 'account_type_id' => ['sometimes', 'nullable', 'integer', 'exists:account_types,id'], ]; } } diff --git a/app/Http/Requests/UpdateFieldJobSettingRequest.php b/app/Http/Requests/UpdateFieldJobSettingRequest.php new file mode 100644 index 0000000..2b6139e --- /dev/null +++ b/app/Http/Requests/UpdateFieldJobSettingRequest.php @@ -0,0 +1,72 @@ + ['required', 'integer', 'exists:segments,id'], + 'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'], + 'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'], + 'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'], + ]; + } + + public function messages(): array + { + return [ + 'segment_id.required' => 'Segment is required.', + 'initial_decision_id.required' => 'Initial decision is required.', + 'assign_decision_id.required' => 'Assign decision is required.', + 'complete_decision_id.required' => 'Complete decision is required.', + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator): void { + $assignDecisionId = $this->input('assign_decision_id'); + if (! empty($assignDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $assignDecisionId) + ->exists(); + if (! $mapped) { + $validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.'); + } + } + + $completeDecisionId = $this->input('complete_decision_id'); + if (! empty($completeDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $completeDecisionId) + ->exists(); + if (! $mapped) { + $validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.'); + } + } + + $cancelDecisionId = $this->input('cancel_decision_id'); + if (! empty($cancelDecisionId)) { + $mapped = DB::table('action_decision') + ->where('decision_id', $cancelDecisionId) + ->exists(); + if (! $mapped) { + $validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.'); + } + } + }); + } +} diff --git a/app/Jobs/GenerateDocumentPreview.php b/app/Jobs/GenerateDocumentPreview.php index 07dc0b7..e934382 100644 --- a/app/Jobs/GenerateDocumentPreview.php +++ b/app/Jobs/GenerateDocumentPreview.php @@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +// Note: we intentionally use exec() with careful quoting and polling because on Windows soffice may spawn a child process. + class GenerateDocumentPreview implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -21,19 +23,49 @@ class GenerateDocumentPreview implements ShouldQueue */ public $timeout = 180; // 3 minutes - public function __construct(public int $documentId) - { - } + public function __construct(public int $documentId) {} public function handle(): void { $doc = Document::find($this->documentId); - if (!$doc) + if (! $doc) { return; + } $disk = $doc->disk ?: 'public'; - if (!Storage::disk($disk)->exists($doc->path)) - return; + // Normalize path to support legacy entries with a leading 'public/' + $relPath = ltrim($doc->path ?? '', '/\\'); + if (str_starts_with($relPath, 'public/')) { + $relPath = substr($relPath, 7); + } + $sourceBytes = null; + if (Storage::disk($disk)->exists($relPath)) { + $sourceBytes = Storage::disk($disk)->get($relPath); + } else { + // Fallback to public/ filesystem in case of legacy placement + $publicFull = public_path($relPath); + $real = @realpath($publicFull); + $publicRoot = @realpath(public_path()); + $realN = $real ? str_replace('\\', '/', $real) : null; + $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; + if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { + \Log::info('Preview job: using public path fallback for source file', [ + 'document_id' => $doc->id, + 'path' => $realN, + ]); + $sourceBytes = @file_get_contents($real); + } else { + \Log::warning('Preview job: source file missing on disk and public fallback failed', [ + 'document_id' => $doc->id, + 'disk' => $disk, + 'path' => $doc->path, + 'normalized' => $relPath, + 'public_candidate' => $publicFull, + ]); + + return; + } + } $ext = strtolower(pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_EXTENSION)); @@ -48,69 +80,52 @@ public function handle(): void 'updated_at' => (string) $doc->updated_at, 'preview_generated_at' => (string) $doc->preview_generated_at, ]); + return; } } } - if (!in_array($ext, ['doc', 'docx'])) - return; // only convert office docs here + if (! in_array($ext, ['doc', 'docx'])) { + return; + } // only convert office docs here // Prepare temp files - keep original extension so LibreOffice can detect filter - $tmpBase = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doc_in_' . uniqid(); - $tmpIn = $tmpBase . '.' . $ext; // e.g., .doc or .docx - file_put_contents($tmpIn, Storage::disk($disk)->get($doc->path)); + $tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'doc_in_'.uniqid(); + $tmpIn = $tmpBase.'.'.$ext; // e.g., .doc or .docx + file_put_contents($tmpIn, $sourceBytes); $outDir = sys_get_temp_dir(); - // Ensure exec is available - if (!function_exists('exec')) { - Log::error('Preview generation failed: exec() not available in this PHP environment', ['document_id' => $doc->id]); - return; - } - $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); - if (in_array('exec', $disabled, true)) { - Log::error('Preview generation failed: exec() is disabled in php.ini (disable_functions)', ['document_id' => $doc->id]); - return; - } - // Run soffice headless to convert to PDF $binCfg = config('files.libreoffice_bin'); $bin = $binCfg ? (string) $binCfg : 'soffice'; // If an absolute path is configured, ensure it exists to avoid long PATH resolution delays - if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && !file_exists($bin)) { + if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && ! file_exists($bin)) { Log::warning('Configured LibreOffice binary not found; falling back to PATH', [ 'document_id' => $doc->id, 'bin' => $bin, ]); $bin = 'soffice'; } - // Windows quoting differs from POSIX. Build command parts safely. - $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; - if ($isWin) { - $binPart = '"' . $bin . '"'; - $outDirPart = '"' . $outDir . '"'; - $inPart = '"' . $tmpIn . '"'; - } else { - $binPart = escapeshellcmd($bin); - $outDirPart = escapeshellarg($outDir); - $inPart = escapeshellarg($tmpIn); - } // Use a temporary user profile to avoid permissions/profile lock issues - $loProfileDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lo_profile_' . $doc->id; - if (!is_dir($loProfileDir)) { + $loProfileDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'lo_profile_'.$doc->id; + if (! is_dir($loProfileDir)) { @mkdir($loProfileDir, 0700, true); } - $loProfileUri = 'file:///' . ltrim(str_replace('\\', '/', $loProfileDir), '/'); + $loProfileUri = 'file:///'.ltrim(str_replace('\\', '/', $loProfileDir), '/'); + // Build command string for exec() + $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + $binPart = $isWin ? '"'.$bin.'"' : escapeshellcmd($bin); + $outDirPart = $isWin ? '"'.$outDir.'"' : escapeshellarg($outDir); + $inPart = $isWin ? '"'.$tmpIn.'"' : escapeshellarg($tmpIn); + $profilePart = $isWin ? '"'.$loProfileUri.'"' : escapeshellarg($loProfileUri); $cmd = sprintf( - '%s --headless --norestore --nolockcheck -env:UserInstallation=%s --convert-to pdf --outdir %s %s', + '%s --headless --norestore --nolockcheck --nologo --nodefault --nofirststartwizard -env:UserInstallation=%s --convert-to pdf --outdir %s %s', $binPart, - $isWin ? '"' . $loProfileUri . '"' : escapeshellarg($loProfileUri), + $profilePart, $outDirPart, $inPart ); - - // Capture stderr as well for diagnostics - $cmdWithStderr = $cmd . ' 2>&1'; $t0 = microtime(true); Log::info('Starting LibreOffice preview conversion', [ 'document_id' => $doc->id, @@ -119,49 +134,64 @@ public function handle(): void ]); $out = []; $ret = 0; - exec($cmdWithStderr, $out, $ret); + @exec($cmd.' 2>&1', $out, $ret); + // Some Windows installs may return before file is fully written; we'll poll for the output file below anyway. if ($ret !== 0) { Log::warning('Preview generation failed', [ 'document_id' => $doc->id, - 'ret' => $ret, - 'cmd' => $cmd, + 'exit_code' => $ret, 'output' => implode("\n", $out), ]); @unlink($tmpIn); + return; } - $elapsed = (int) round((microtime(true) - $t0) * 1000); - $pdfPathLocal = $tmpIn . '.pdf'; + $pdfPathLocal = $tmpIn.'.pdf'; // LibreOffice writes output with source filename base; derive path $base = pathinfo($tmpIn, PATHINFO_FILENAME); - $pdfPathLocal = $outDir . DIRECTORY_SEPARATOR . $base . '.pdf'; - if (!file_exists($pdfPathLocal)) { + $pdfPathLocal = $outDir.DIRECTORY_SEPARATOR.$base.'.pdf'; + // Poll for up to 10s for the PDF to appear (handles async write on Windows) + $waitUntil = microtime(true) + 10.0; + while (! file_exists($pdfPathLocal) && microtime(true) < $waitUntil) { + usleep(200 * 1000); // 200ms + } + if (! file_exists($pdfPathLocal)) { // fallback: try with original name base $origBase = pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_FILENAME); - $try = $outDir . DIRECTORY_SEPARATOR . $origBase . '.pdf'; - if (file_exists($try)) + $try = $outDir.DIRECTORY_SEPARATOR.$origBase.'.pdf'; + // brief poll for fallback name as well + $waitUntil2 = microtime(true) + 5.0; + while (! file_exists($try) && microtime(true) < $waitUntil2) { + usleep(200 * 1000); + } + if (file_exists($try)) { $pdfPathLocal = $try; + } } - if (!file_exists($pdfPathLocal)) { + if (! file_exists($pdfPathLocal)) { Log::warning('Preview generation did not produce expected PDF output', [ 'document_id' => $doc->id, 'out_dir' => $outDir, 'tmp_base' => $base, 'command' => $cmd, - 'output' => implode("\n", $out), + 'stdout' => implode("\n", $out), ]); @unlink($tmpIn); + return; } + // Compute elapsed time once output exists + $elapsed = (int) round((microtime(true) - $t0) * 1000); + // Store preview PDF to configured disk inside configured previews base path $previewDisk = config('files.preview_disk', 'public'); $base = trim(config('files.preview_base', 'previews/cases'), '/'); - $previewDir = $base . '/' . ($doc->documentable?->uuid ?? 'unknown'); - $stored = Storage::disk($previewDisk)->put($previewDir . '/' . ($doc->uuid) . '.pdf', file_get_contents($pdfPathLocal)); + $previewDir = $base.'/'.($doc->documentable?->uuid ?? 'unknown'); + $stored = Storage::disk($previewDisk)->put($previewDir.'/'.($doc->uuid).'.pdf', file_get_contents($pdfPathLocal)); if ($stored) { - $doc->preview_path = $previewDir . '/' . $doc->uuid . '.pdf'; + $doc->preview_path = $previewDir.'/'.$doc->uuid.'.pdf'; $doc->preview_mime = 'application/pdf'; $doc->preview_generated_at = now(); $doc->save(); @@ -170,6 +200,12 @@ public function handle(): void 'preview_path' => $doc->preview_path, 'elapsed_ms' => $elapsed, ]); + } else { + Log::warning('Preview generated but storing to disk failed', [ + 'document_id' => $doc->id, + 'preview_disk' => $previewDisk, + 'target' => $previewDir.'/'.$doc->uuid.'.pdf', + ]); } @unlink($tmpIn); diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 93f29e1..4e5232a 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -6,11 +6,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\DB; class Activity extends Model { /** @use HasFactory<\Database\Factories\ActivityFactory> */ use HasFactory; + use SoftDeletes; protected $fillable = [ @@ -20,7 +22,8 @@ class Activity extends Model 'action_id', 'user_id', 'decision_id', - 'contract_id' + 'contract_id', + 'client_case_id', ]; protected $hidden = [ @@ -28,19 +31,25 @@ class Activity extends Model 'decision_id', 'client_case_id', 'user_id', - 'contract_id' + 'contract_id', ]; - protected static function booted(){ + protected static function booted() + { static::creating(function (Activity $activity) { - if(!isset($activity->user_id)){ + if (! isset($activity->user_id)) { $activity->user_id = auth()->id(); } + + // If an activity with a due date is added for a contract, update the related account's promise_date + if (! empty($activity->contract_id) && ! empty($activity->due_date)) { + DB::table('accounts') + ->where('contract_id', $activity->contract_id) + ->update(['promise_date' => $activity->due_date, 'updated_at' => now()]); + } }); } - - public function action(): BelongsTo { return $this->belongsTo(\App\Models\Action::class); @@ -56,8 +65,13 @@ public function clientCase(): BelongsTo return $this->belongsTo(\App\Models\ClientCase::class); } - public function contract(): BelongsTo|null + public function contract(): ?BelongsTo { return $this->belongsTo(\App\Models\Contract::class); } + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } } diff --git a/app/Models/ClientCase.php b/app/Models/ClientCase.php index 4277d81..fb2dd04 100644 --- a/app/Models/ClientCase.php +++ b/app/Models/ClientCase.php @@ -3,34 +3,35 @@ namespace App\Models; use App\Traits\Uuid; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; -use Illuminate\Database\Eloquent\Builder; use Laravel\Scout\Searchable; class ClientCase extends Model { /** @use HasFactory<\Database\Factories\ClientCaseFactory> */ use HasFactory; - use Uuid; + use Searchable; + use Uuid; protected $fillable = [ 'client_id', - 'person_id' + 'person_id', ]; protected $hidden = [ 'id', 'client_id', - 'person_id' + 'person_id', ]; - protected function makeAllSearchableUsing(Builder $query): Builder + protected function makeAllSearchableUsing(Builder $query): Builder { return $query->with('person'); } @@ -39,11 +40,11 @@ public function toSearchableArray(): array { return [ - 'person.full_name' => '' + 'person.full_name' => '', ]; } - public function client(): BelongsTo + public function client(): BelongsTo { return $this->belongsTo(\App\Models\Client::class); } @@ -64,7 +65,8 @@ public function activities(): HasMany return $this->hasMany(\App\Models\Activity::class); } - public function segments(): BelongsToMany { + public function segments(): BelongsToMany + { return $this->belongsToMany(\App\Models\Segment::class)->withTimestamps(); } diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 5c86eab..574eb8a 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -3,24 +3,22 @@ namespace App\Models; use App\Traits\Uuid; -use Illuminate\Database\Eloquent\Factories\BelongsToManyRelationship; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasManyThrough; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough; -use Illuminate\Database\Eloquent\Relations\MorphOne; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; class Contract extends Model { /** @use HasFactory<\Database\Factories\ContractFactory> */ use HasFactory; - use Uuid; + use SoftDeletes; + use Uuid; protected $fillable = [ 'reference', @@ -28,13 +26,13 @@ class Contract extends Model 'end_date', 'client_case_id', 'type_id', - 'description' + 'description', ]; protected $hidden = [ 'id', 'client_case_id', - 'type_id' + 'type_id', ]; public function type(): BelongsTo @@ -47,8 +45,9 @@ public function clientCase(): BelongsTo return $this->belongsTo(\App\Models\ClientCase::class) ->with(['person']); } - - public function segments(): BelongsToMany { + + public function segments(): BelongsToMany + { return $this->belongsToMany(\App\Models\Segment::class) ->withPivot('active', 'created_at') ->wherePivot('active', true); @@ -65,6 +64,11 @@ public function objects(): HasMany return $this->hasMany(\App\Models\CaseObject::class, 'contract_id'); } + public function documents(): MorphMany + { + return $this->morphMany(\App\Models\Document::class, 'documentable'); + } + protected static function booted(): void { static::created(function (Contract $contract): void { @@ -96,7 +100,7 @@ protected static function booted(): void ->where('client_case_id', $contract->client_case_id) ->where('segment_id', $cfg->segment_id) ->first(); - if (!$attached) { + if (! $attached) { \DB::table('client_case_segment')->insert([ 'client_case_id' => $contract->client_case_id, 'segment_id' => $cfg->segment_id, @@ -104,7 +108,7 @@ protected static function booted(): void 'created_at' => now(), 'updated_at' => now(), ]); - } elseif (!$attached->active) { + } elseif (! $attached->active) { \DB::table('client_case_segment') ->where('id', $attached->id) ->update(['active' => true, 'updated_at' => now()]); diff --git a/app/Models/Document.php b/app/Models/Document.php index 47c1464..1c50d28 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -13,8 +13,8 @@ class Document extends Model { use HasFactory; - use Uuid; use SoftDeletes; + use Uuid; protected $fillable = [ 'uuid', @@ -80,4 +80,15 @@ protected static function booted(): void } }); } + + /** + * Include soft-deleted documents when resolving by route key (e.g. {document:uuid}). + */ + public function resolveRouteBinding($value, $field = null) + { + // Always include trashed so deep-linking to older documents works + return static::withTrashed() + ->where($field ?? $this->getRouteKeyName(), $value) + ->firstOrFail(); + } } diff --git a/app/Models/FieldJob.php b/app/Models/FieldJob.php index cc20915..f2cfeaa 100644 --- a/app/Models/FieldJob.php +++ b/app/Models/FieldJob.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\DB; class FieldJob extends Model { @@ -14,7 +15,7 @@ class FieldJob extends Model protected $fillable = [ 'field_job_setting_id', - 'asigned_user_id', + 'assigned_user_id', 'user_id', 'contract_id', 'assigned_at', @@ -33,12 +34,43 @@ class FieldJob extends Model 'address_snapshot ' => 'array', ]; - protected static function booted(){ + protected static function booted() + { static::creating(function (FieldJob $fieldJob) { - if(!isset($fieldJob->user_id)){ + if (! isset($fieldJob->user_id)) { $fieldJob->user_id = auth()->id(); } }); + + static::updated(function (FieldJob $fieldJob): void { + // If job was just completed or cancelled, move contract to configured segment + $completedChanged = $fieldJob->wasChanged('completed_at') && ! is_null($fieldJob->completed_at); + $cancelledChanged = $fieldJob->wasChanged('cancelled_at') && ! is_null($fieldJob->cancelled_at); + + if (! $completedChanged && ! $cancelledChanged) { + return; + } + + if (! $fieldJob->relationLoaded('setting')) { + $fieldJob->load('setting'); + } + + if ($cancelledChanged) { + // On cancel: redirect to queue segment + $segmentId = $fieldJob->setting?->queue_segment_id; + $fieldJob->moveContractToSegment($segmentId); + + return; + } + + if ($completedChanged) { + // On complete: redirect to return segment + $segmentId = $fieldJob->setting?->return_segment_id; + $fieldJob->moveContractToSegment($segmentId); + + return; + } + }); } public function setting(): BelongsTo @@ -48,7 +80,7 @@ public function setting(): BelongsTo public function assignedUser(): BelongsTo { - return $this->belongsTo(User::class, 'asigned_user_id'); + return $this->belongsTo(User::class, 'assigned_user_id'); } public function user(): BelongsTo @@ -60,4 +92,55 @@ public function contract(): BelongsTo { return $this->belongsTo(Contract::class, 'contract_id'); } + + /** + * Set/ensure the contract has the return segment marked active based on the field job setting. + */ + /** + * Ensure the contract has the provided segment marked active. + */ + public function moveContractToSegment(?int $segmentId): void + { + if (empty($segmentId) || empty($this->contract_id)) { + return; + } + + // First, deactivate any currently active segments for this contract + DB::table('contract_segment') + ->where('contract_id', $this->contract_id) + ->where('active', true) + ->update(['active' => false, 'updated_at' => now()]); + + // Then activate (or create) the target segment pivot + $pivot = DB::table('contract_segment') + ->where('contract_id', $this->contract_id) + ->where('segment_id', $segmentId) + ->first(); + + if ($pivot) { + DB::table('contract_segment') + ->where('id', $pivot->id) + ->update(['active' => true, 'updated_at' => now()]); + } else { + DB::table('contract_segment')->insert([ + 'contract_id' => $this->contract_id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + /** + * Back-compat convenience: move to configured return segment. + */ + public function returnContractToConfiguredSegment(): void + { + if (! $this->relationLoaded('setting')) { + $this->load('setting'); + } + + $this->moveContractToSegment($this->setting?->return_segment_id); + } } diff --git a/app/Models/FieldJobSetting.php b/app/Models/FieldJobSetting.php index 7a6f260..e70038c 100644 --- a/app/Models/FieldJobSetting.php +++ b/app/Models/FieldJobSetting.php @@ -14,8 +14,11 @@ class FieldJobSetting extends Model protected $fillable = [ 'segment_id', 'initial_decision_id', - 'asign_decision_id', + 'assign_decision_id', 'complete_decision_id', + 'cancel_decision_id', + 'return_segment_id', + 'queue_segment_id', ]; public function segment(): BelongsTo @@ -23,9 +26,9 @@ public function segment(): BelongsTo return $this->belongsTo(Segment::class); } - public function asignDecision(): BelongsTo + public function assignDecision(): BelongsTo { - return $this->belongsTo(Decision::class, 'asign_decision_id'); + return $this->belongsTo(Decision::class, 'assign_decision_id'); } public function initialDecision(): BelongsTo @@ -38,6 +41,21 @@ public function completeDecision(): BelongsTo return $this->belongsTo(Decision::class, 'complete_decision_id'); } + public function cancelDecision(): BelongsTo + { + return $this->belongsTo(Decision::class, 'cancel_decision_id'); + } + + public function returnSegment(): BelongsTo + { + return $this->belongsTo(Segment::class, 'return_segment_id'); + } + + public function queueSegment(): BelongsTo + { + return $this->belongsTo(Segment::class, 'queue_segment_id'); + } + public function fieldJobs(): HasMany { return $this->hasMany(FieldJob::class); diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 40d58a1..70c915a 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -2,23 +2,23 @@ namespace App\Services; +use App\Models\Account; +use App\Models\AccountType; +use App\Models\Client; +use App\Models\ClientCase; +use App\Models\Contract; +use App\Models\ContractType; +use App\Models\Email; use App\Models\Import; use App\Models\ImportEvent; use App\Models\ImportRow; -use App\Models\Account; -use App\Models\Contract; -use App\Models\Client; -use App\Models\ClientCase; -use App\Models\Email; -use App\Models\Person\Person; -use App\Models\Person\PersonGroup; -use App\Models\Person\PersonType; -use App\Models\Person\PersonAddress; -use App\Models\Person\PersonPhone; 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 App\Models\ContractType; -use App\Models\AccountType; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -32,11 +32,14 @@ class ImportProcessor public function process(Import $import, ?Authenticatable $user = null): array { $started = now(); - $total = 0; $skipped = 0; $imported = 0; $invalid = 0; + $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'])) { + if (! in_array($import->source_type, ['csv', 'txt'])) { ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), @@ -45,13 +48,14 @@ public function process(Import $import, ?Authenticatable $user = null): array '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') ]; + + 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']); + ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); $header = $import->meta['columns'] ?? null; $delimiter = $import->meta['detected_delimiter'] ?? ','; @@ -60,7 +64,7 @@ public function process(Import $import, ?Authenticatable $user = null): array // Parse file and create import_rows with mapped_data $fh = @fopen($path, 'r'); - if (!$fh) { + if (! $fh) { ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), @@ -69,7 +73,8 @@ public function process(Import $import, ?Authenticatable $user = null): array 'message' => 'Unable to open file for reading.', ]); $import->update(['status' => 'failed', 'failed_at' => now()]); - return [ 'ok' => false, 'status' => $import->status ]; + + return ['ok' => false, 'status' => $import->status]; } try { DB::beginTransaction(); @@ -87,8 +92,8 @@ public function process(Import $import, ?Authenticatable $user = null): array $first = fgetcsv($fh, 0, $delimiter); $rowNum++; // use actual detected header if not already stored - if (!$header) { - $header = array_map(fn($v) => trim((string) $v), $first ?: []); + if (! $header) { + $header = array_map(fn ($v) => trim((string) $v), $first ?: []); } } @@ -123,7 +128,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'level' => 'info', 'message' => $contractResult['message'] ?? 'Skipped contract (no changes).', ]); - } elseif (in_array($contractResult['action'], ['inserted','updated'])) { + } elseif (in_array($contractResult['action'], ['inserted', 'updated'])) { $imported++; $importRow->update([ 'status' => 'imported', @@ -137,7 +142,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'event' => 'row_imported', 'level' => 'info', 'message' => ucfirst($contractResult['action']).' contract', - 'context' => [ 'id' => $contractResult['contract']->id ], + 'context' => ['id' => $contractResult['contract']->id], ]); } else { $invalid++; @@ -174,7 +179,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'event' => 'row_imported', 'level' => 'info', 'message' => ucfirst($accountResult['action']).' account', - 'context' => [ 'id' => $accountResult['account']->id ], + 'context' => ['id' => $accountResult['account']->id], ]); } else { $invalid++; @@ -190,7 +195,7 @@ public function process(Import $import, ?Authenticatable $user = null): array $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); } // If we have a contract reference, resolve existing contract for this client and derive person - if (!$personIdForRow && $import->client_id && !empty($mapped['contract']['reference'] ?? null)) { + 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) @@ -202,7 +207,7 @@ public function process(Import $import, ?Authenticatable $user = null): array } } // If account processing created/resolved a contract, derive person via its client_case - if (!$personIdForRow && $accountResult) { + 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'); @@ -214,50 +219,75 @@ public function process(Import $import, ?Authenticatable $user = null): array } } // Resolve by contact values next - if (!$personIdForRow) { - $emailVal = trim((string)($mapped['email']['value'] ?? '')); + if (! $personIdForRow) { + $emailVal = trim((string) ($mapped['email']['value'] ?? '')); + $phoneNu = trim((string) ($mapped['phone']['nu'] ?? '')); + $addrLine = trim((string) ($mapped['address']['address'] ?? '')); + + // Try to resolve by existing contacts first if ($emailVal !== '') { $personIdForRow = Email::where('value', $emailVal)->value('person_id'); } - } - if (!$personIdForRow) { - $phoneNu = trim((string)($mapped['phone']['nu'] ?? '')); - if ($phoneNu !== '') { + if (! $personIdForRow && $phoneNu !== '') { $personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id'); } - } - if (!$personIdForRow) { - $addrLine = trim((string)($mapped['address']['address'] ?? '')); - if ($addrLine !== '') { + 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 + 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'] ?? [])) { + if (! $personIdForRow && ! empty($mapped['person'] ?? [])) { $personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']); } // Finally, if still unknown and person fields provided, create - if (!$personIdForRow && !empty($mapped['person'] ?? [])) { + 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) { - if (!empty($mapped['email'] ?? [])) { + if (! empty($mapped['email'] ?? [])) { $r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } } - if (!empty($mapped['address'] ?? [])) { + if (! empty($mapped['address'] ?? [])) { $r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } } - if (!empty($mapped['phone'] ?? [])) { + if (! empty($mapped['phone'] ?? [])) { $r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; } + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } } } - if (!isset($mapped['contract']) && !isset($mapped['account'])) { + if (! isset($mapped['contract']) && ! isset($mapped['account'])) { if ($contactChanged) { $imported++; $importRow->update([ @@ -272,7 +302,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'event' => 'row_imported', 'level' => 'info', 'message' => 'Contacts upserted', - 'context' => [ 'person_id' => $personIdForRow ], + 'context' => ['person_id' => $personIdForRow], ]); } else { $skipped++; @@ -297,10 +327,12 @@ public function process(Import $import, ?Authenticatable $user = null): array return [ 'ok' => true, 'status' => $import->status, - 'counts' => compact('total','skipped','imported','invalid'), + 'counts' => compact('total', 'skipped', 'imported', 'invalid'), ]; } catch (\Throwable $e) { - if (is_resource($fh)) { @fclose($fh); } + if (is_resource($fh)) { + @fclose($fh); + } DB::rollBack(); // Mark failed and log after rollback (so no partial writes persist) $import->refresh(); @@ -312,22 +344,27 @@ public function process(Import $import, ?Authenticatable $user = null): array 'level' => 'error', 'message' => $e->getMessage(), ]); - return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ]; + + return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()]; } } private function buildRowAssoc(array $row, ?array $header): array { - if (!$header) { + if (! $header) { // positional mapping: 0..N-1 $assoc = []; - foreach ($row as $i => $v) { $assoc[(string)$i] = $v; } + foreach ($row as $i => $v) { + $assoc[(string) $i] = $v; + } + return $assoc; } $assoc = []; foreach ($header as $i => $name) { $assoc[$name] = $row[$i] ?? null; } + return $assoc; } @@ -338,31 +375,40 @@ private function applyMappings(array $raw, $mappings): array foreach ($mappings as $map) { $src = $map->source_column; $target = $map->target_field; - if (!$target) continue; + if (! $target) { + continue; + } $value = $raw[$src] ?? null; // very basic transforms - if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; } - if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; } + if ($map->transform === 'trim') { + $value = is_string($value) ? trim($value) : $value; + } + if ($map->transform === 'upper') { + $value = is_string($value) ? strtoupper($value) : $value; + } // detect record type from first segment, e.g., "account.balance_amount" $parts = explode('.', $target); - if (!$recordType && isset($parts[0])) { + if (! $recordType && isset($parts[0])) { $recordType = $parts[0]; } // build nested array by dot notation $this->arraySetDot($mapped, $target, $value); } + return [$recordType, $mapped]; } private function arraySetDot(array &$arr, string $path, $value): void { $keys = explode('.', $path); - $ref =& $arr; + $ref = &$arr; foreach ($keys as $k) { - if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; } - $ref =& $ref[$k]; + if (! isset($ref[$k]) || ! is_array($ref[$k])) { + $ref[$k] = []; + } + $ref = &$ref[$k]; } $ref = $value; } @@ -374,7 +420,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $contractId = $acc['contract_id'] ?? null; $reference = $acc['reference'] ?? null; // If contract_id not provided, attempt to resolve by contract reference for the selected client - if (!$contractId) { + 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) @@ -391,15 +437,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array // Try strong identifiers first $personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []); // Create from provided person data if unresolved - if (!$personId) { + if (! $personId) { $personId = $this->findOrCreatePersonId($mapped['person'] ?? []); } // Last resort, create minimal - if (!$personId) { + if (! $personId) { $personId = $this->createMinimalPersonId(); } // Use the selected client for this import to tie the case/contract - if (!$clientId) { + if (! $clientId) { return ['action' => 'skipped', 'message' => 'Client required to create contract']; } $resolvedClientId = $clientId; @@ -410,8 +456,8 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array '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])) { + foreach (['start_date', 'end_date', 'description', 'type_id'] as $k) { + if (array_key_exists($k, $contractFields) && ! is_null($contractFields[$k])) { $newContractData[$k] = $contractFields[$k]; } } @@ -428,7 +474,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } } // Default account.reference to contract reference if missing - if (!$reference) { + if (! $reference) { $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); if ($contractRef) { $reference = $contractRef; @@ -436,7 +482,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $mapped['account'] = $acc; } } - if (!$contractId || !$reference) { + if (! $contractId || ! $reference) { return ['action' => 'skipped', 'message' => 'Missing contract_id/reference']; } @@ -449,15 +495,25 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { - if (!$map->target_field) continue; + if (! $map->target_field) { + continue; + } $parts = explode('.', $map->target_field); - if ($parts[0] !== 'account') continue; + if ($parts[0] !== 'account') { + continue; + } $field = $parts[1] ?? null; - if (!$field) continue; + if (! $field) { + continue; + } $value = $acc[$field] ?? null; $mode = $map->apply_mode ?? 'both'; - if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } - if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } + if (in_array($mode, ['insert', 'both'])) { + $applyInsert[$field] = $value; + } + if (in_array($mode, ['update', 'both'])) { + $applyUpdate[$field] = $value; + } } if ($existing) { @@ -465,25 +521,29 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array return ['action' => 'skipped', 'message' => 'No fields marked for update']; } // Only update fields that are set; skip nulls to avoid wiping unintentionally - $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); if (empty($changes)) { return ['action' => 'skipped', 'message' => 'No non-null changes']; } $existing->fill($changes); $existing->save(); + // also include contract hints for downstream contact resolution return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No fields marked for insert']; } - $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data = 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; } + if (! array_key_exists('active', $data)) { + $data['active'] = 1; + } $created = Account::create($data); + return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId]; } } @@ -493,13 +553,18 @@ 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; + 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; + if ($found) { + return $found->id; + } } + return null; } @@ -507,7 +572,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): { $contractData = $mapped['contract'] ?? []; $reference = $contractData['reference'] ?? null; - if (!$reference) { + if (! $reference) { return ['action' => 'invalid', 'message' => 'Missing contract.reference']; } @@ -527,7 +592,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } // If not found by client+reference and a specific client_case_id is provided, try that too - if (!$existing && $clientCaseId) { + if (! $existing && $clientCaseId) { $existing = Contract::query() ->where('client_case_id', $clientCaseId) ->where('reference', $reference) @@ -535,17 +600,17 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } // If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary - if (!$existing && !$clientCaseId) { + if (! $existing && ! $clientCaseId) { // Resolve by identifiers or provided person; do not use Client->person $personId = null; - if (!empty($mapped['person'] ?? [])) { + if (! empty($mapped['person'] ?? [])) { $personId = $this->findPersonIdByIdentifiers($mapped['person']); - if (!$personId) { + if (! $personId) { $personId = $this->findOrCreatePersonId($mapped['person']); } } // As a last resort, create a minimal person for this client - if ($clientId && !$personId) { + if ($clientId && ! $personId) { $personId = $this->createMinimalPersonId(); } @@ -563,39 +628,51 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): $applyInsert = []; $applyUpdate = []; foreach ($mappings as $map) { - if (!$map->target_field) continue; + if (! $map->target_field) { + continue; + } $parts = explode('.', $map->target_field); - if ($parts[0] !== 'contract') continue; + if ($parts[0] !== 'contract') { + continue; + } $field = $parts[1] ?? null; - if (!$field) continue; + if (! $field) { + continue; + } $value = $contractData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; - if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } - if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } + if (in_array($mode, ['insert', 'both'])) { + $applyInsert[$field] = $value; + } + if (in_array($mode, ['update', 'both'])) { + $applyUpdate[$field] = $value; + } } if ($existing) { if (empty($applyUpdate)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for update']; } - $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); if (empty($changes)) { return ['action' => 'skipped', 'message' => 'No non-null contract changes']; } $existing->fill($changes); $existing->save(); + return ['action' => 'updated', 'contract' => $existing]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; } - $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data = array_filter($applyInsert, fn ($v) => ! is_null($v)); $data['client_case_id'] = $clientCaseId; $data['reference'] = $reference; // ensure required defaults $data['start_date'] = $data['start_date'] ?? now()->toDateString(); $data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId(); $created = Contract::create($data); + return ['action' => 'inserted', 'contract' => $created]; } } @@ -604,33 +681,43 @@ private function findOrCreatePersonId(array $p): ?int { // Basic dedup: by tax_number, ssn, else full_name $query = Person::query(); - if (!empty($p['tax_number'] ?? null)) { + if (! empty($p['tax_number'] ?? null)) { $found = $query->where('tax_number', $p['tax_number'])->first(); - if ($found) return $found->id; + if ($found) { + return $found->id; + } } - if (!empty($p['social_security_number'] ?? null)) { + if (! empty($p['social_security_number'] ?? null)) { $found = Person::where('social_security_number', $p['social_security_number'])->first(); - if ($found) return $found->id; + 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)) { + 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]; + 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); + $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; } @@ -678,117 +765,182 @@ private function getDefaultPhoneTypeId(): int private function findOrCreateClientId(int $personId): int { $client = Client::where('person_id', $personId)->first(); - if ($client) return $client->id; + if ($client) { + return $client->id; + } + return Client::create(['person_id' => $personId])->id; } private function findOrCreateClientCaseId(int $clientId, int $personId): int { $cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first(); - if ($cc) return $cc->id; + if ($cc) { + return $cc->id; + } + return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id; } private function upsertEmail(int $personId, array $emailData, $mappings): array { - $value = trim((string)($emailData['value'] ?? '')); - if ($value === '') return ['action' => 'skipped', 'message' => 'No email value']; + $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; + if (! $map->target_field) { + continue; + } $parts = explode('.', $map->target_field); - if ($parts[0] !== 'email') continue; - $field = $parts[1] ?? null; if (!$field) continue; + 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 (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']; + $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)); + 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; + 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']; + $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'] === '') { + 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; + if (! $map->target_field) { + continue; + } $parts = explode('.', $map->target_field); - if ($parts[0] !== 'address') continue; - $field = $parts[1] ?? null; if (!$field) continue; + if ($parts[0] !== 'address') { + continue; + } + $field = $parts[1] ?? null; + if (! $field) { + continue; + } $val = $addrData[$field] ?? null; $mode = $map->apply_mode ?? 'both'; - if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } - if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } + if (in_array($mode, ['insert', 'both'])) { + $applyInsert[$field] = $val; + } + if (in_array($mode, ['update', 'both'])) { + $applyUpdate[$field] = $val; + } } if ($existing) { - $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); - if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates']; + $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)); + 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']; + $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; + if (! $map->target_field) { + continue; + } $parts = explode('.', $map->target_field); - if ($parts[0] !== 'phone') continue; - $field = $parts[1] ?? null; if (!$field) continue; + 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 (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']; + $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)); + 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]; } } diff --git a/composer.lock b/composer.lock index 3ba6e97..20fa89b 100644 --- a/composer.lock +++ b/composer.lock @@ -10335,6 +10335,6 @@ "platform": { "php": "^8.2" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/database/factories/ClientCaseFactory.php b/database/factories/ClientCaseFactory.php index d5b18bf..ccad37f 100644 --- a/database/factories/ClientCaseFactory.php +++ b/database/factories/ClientCaseFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Models\Client; +use App\Models\Person\Person; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +19,8 @@ class ClientCaseFactory extends Factory public function definition(): array { return [ - // + 'client_id' => Client::factory(), + 'person_id' => Person::factory(), ]; } } diff --git a/database/factories/ClientFactory.php b/database/factories/ClientFactory.php index 8d211a1..92b217f 100644 --- a/database/factories/ClientFactory.php +++ b/database/factories/ClientFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Person\Person; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +18,8 @@ class ClientFactory extends Factory public function definition(): array { return [ - // + 'person_id' => Person::factory(), + 'active' => 1, ]; } } diff --git a/database/factories/ContractFactory.php b/database/factories/ContractFactory.php index 901c174..ac9731b 100644 --- a/database/factories/ContractFactory.php +++ b/database/factories/ContractFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories; +use App\Models\ClientCase; +use App\Models\ContractType; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +19,12 @@ class ContractFactory extends Factory public function definition(): array { return [ - // + 'reference' => $this->faker->optional()->bothify('REF-####'), + 'start_date' => $this->faker->date(), + 'end_date' => $this->faker->optional()->date(), + 'client_case_id' => ClientCase::factory(), + 'type_id' => ContractType::factory(), + 'description' => $this->faker->optional()->sentence(), ]; } } diff --git a/database/factories/ContractTypeFactory.php b/database/factories/ContractTypeFactory.php new file mode 100644 index 0000000..c4dbb20 --- /dev/null +++ b/database/factories/ContractTypeFactory.php @@ -0,0 +1,19 @@ + + */ +class ContractTypeFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->unique()->word(), + 'description' => $this->faker->optional()->sentence(), + ]; + } +} diff --git a/database/factories/DecisionFactory.php b/database/factories/DecisionFactory.php index e9e0ade..96de789 100644 --- a/database/factories/DecisionFactory.php +++ b/database/factories/DecisionFactory.php @@ -17,7 +17,8 @@ class DecisionFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->unique()->words(2, true), + 'color_tag' => $this->faker->optional()->safeColorName(), ]; } } diff --git a/database/factories/Person/PersonFactory.php b/database/factories/Person/PersonFactory.php index 079c356..22d9d7e 100644 --- a/database/factories/Person/PersonFactory.php +++ b/database/factories/Person/PersonFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories\Person; +use App\Models\Person\PersonGroup; +use App\Models\Person\PersonType; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -19,13 +21,14 @@ public function definition(): array return [ 'first_name' => $this->faker->firstName(), 'last_name' => $this->faker->lastName(), - 'full_name' => fn(array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')), - 'gender' => $this->faker->randomElement(['m','w']), + 'full_name' => fn (array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')), + 'gender' => $this->faker->randomElement(['m', 'w']), 'birthday' => $this->faker->optional()->date(), 'tax_number' => $this->faker->optional()->bothify('########'), 'social_security_number' => $this->faker->optional()->bothify('#########'), 'description' => $this->faker->optional()->sentence(), - // group_id/type_id are required; keep null here and let tests/seeds assign or rely on defaults in code paths that use factories + 'group_id' => PersonGroup::factory(), + 'type_id' => PersonType::factory(), ]; } } diff --git a/database/factories/Person/PersonGroupFactory.php b/database/factories/Person/PersonGroupFactory.php index 19226c6..9b4564c 100644 --- a/database/factories/Person/PersonGroupFactory.php +++ b/database/factories/Person/PersonGroupFactory.php @@ -9,15 +9,12 @@ */ class PersonGroupFactory extends Factory { - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { return [ - // + 'name' => $this->faker->unique()->word(), + 'description' => $this->faker->optional()->sentence(), + 'color_tag' => $this->faker->optional()->safeColorName(), ]; } } diff --git a/database/factories/Person/PersonTypeFactory.php b/database/factories/Person/PersonTypeFactory.php index 0fbc294..9c06d4d 100644 --- a/database/factories/Person/PersonTypeFactory.php +++ b/database/factories/Person/PersonTypeFactory.php @@ -17,7 +17,8 @@ class PersonTypeFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->unique()->word(), + 'description' => $this->faker->optional()->sentence(), ]; } } diff --git a/database/factories/SegmentFactory.php b/database/factories/SegmentFactory.php index 4a60bfc..65fb49e 100644 --- a/database/factories/SegmentFactory.php +++ b/database/factories/SegmentFactory.php @@ -17,7 +17,9 @@ class SegmentFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->unique()->words(2, true), + 'description' => $this->faker->sentence(), + 'active' => true, ]; } } diff --git a/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php b/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php index e435283..b4f75d2 100644 --- a/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php +++ b/database/migrations/2025_09_28_110600_alter_segments_description_nullable.php @@ -1,19 +1,76 @@ getDriverName(); + + if ($driver === 'pgsql') { + // PostgreSQL: drop NOT NULL constraint on description + DB::statement('ALTER TABLE segments ALTER COLUMN description DROP NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + // MySQL / MariaDB + DB::statement('ALTER TABLE segments MODIFY description VARCHAR(255) NULL'); + + return; + } + + // SQLite or other drivers: avoid brittle raw SQL. If Doctrine DBAL isn't installed, + // changing a column may not be supported. Since this is only relaxing NOT NULL, + // we can safely no-op for SQLite tests. + if ($driver === 'sqlite') { + return; // no-op for tests + } + + // Fallback attempt using Schema (requires doctrine/dbal; if unavailable, it will be ignored in tests) + Schema::table('segments', function (Blueprint $table): void { + try { + $table->string('description', 255)->nullable()->change(); + } catch (\Throwable $e) { + // ignore if not supported in current driver + } + }); } public function down(): void { + $driver = DB::connection()->getDriverName(); + // Ensure no NULLs before setting NOT NULL DB::statement("UPDATE segments SET description = '' WHERE description IS NULL"); - DB::statement('ALTER TABLE segments ALTER COLUMN description SET NOT NULL'); + + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE segments ALTER COLUMN description SET NOT NULL'); + + return; + } + + if ($driver === 'mysql') { + DB::statement('ALTER TABLE segments MODIFY description VARCHAR(255) NOT NULL'); + + return; + } + + if ($driver === 'sqlite') { + return; // no-op for tests + } + + Schema::table('segments', function (Blueprint $table): void { + try { + $table->string('description', 255)->nullable(false)->change(); + } catch (\Throwable $e) { + // ignore + } + }); } }; diff --git a/database/migrations/2025_09_28_111600_add_cancel_decision_id_to_field_job_settings.php b/database/migrations/2025_09_28_111600_add_cancel_decision_id_to_field_job_settings.php new file mode 100644 index 0000000..54d2429 --- /dev/null +++ b/database/migrations/2025_09_28_111600_add_cancel_decision_id_to_field_job_settings.php @@ -0,0 +1,27 @@ +foreignId('cancel_decision_id') + ->nullable() + ->after('complete_decision_id') + ->constrained('decisions') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('field_job_settings', function (Blueprint $table) { + $table->dropForeign(['cancel_decision_id']); + $table->dropColumn('cancel_decision_id'); + }); + } +}; diff --git a/database/migrations/2025_09_28_160000_add_return_segment_id_to_field_job_settings.php b/database/migrations/2025_09_28_160000_add_return_segment_id_to_field_job_settings.php new file mode 100644 index 0000000..26b8e53 --- /dev/null +++ b/database/migrations/2025_09_28_160000_add_return_segment_id_to_field_job_settings.php @@ -0,0 +1,26 @@ +foreignId('return_segment_id') + ->nullable() + ->constrained('segments') + ->nullOnDelete() + ->after('cancel_decision_id'); + }); + } + + public function down(): void + { + Schema::table('field_job_settings', function (Blueprint $table): void { + $table->dropConstrainedForeignId('return_segment_id'); + }); + } +}; diff --git a/database/migrations/2025_09_28_161000_rename_asign_columns.php b/database/migrations/2025_09_28_161000_rename_asign_columns.php new file mode 100644 index 0000000..725e601 --- /dev/null +++ b/database/migrations/2025_09_28_161000_rename_asign_columns.php @@ -0,0 +1,20 @@ + assign, asigned -> assigned + DB::statement('ALTER TABLE field_job_settings RENAME COLUMN asign_decision_id TO assign_decision_id'); + DB::statement('ALTER TABLE field_jobs RENAME COLUMN asigned_user_id TO assigned_user_id'); + } + + public function down(): void + { + DB::statement('ALTER TABLE field_job_settings RENAME COLUMN assign_decision_id TO asign_decision_id'); + DB::statement('ALTER TABLE field_jobs RENAME COLUMN assigned_user_id TO asigned_user_id'); + } +}; diff --git a/database/migrations/2025_09_28_170500_add_queue_segment_id_to_field_job_settings.php b/database/migrations/2025_09_28_170500_add_queue_segment_id_to_field_job_settings.php new file mode 100644 index 0000000..433e89c --- /dev/null +++ b/database/migrations/2025_09_28_170500_add_queue_segment_id_to_field_job_settings.php @@ -0,0 +1,26 @@ +foreignId('queue_segment_id')->nullable()->constrained('segments')->nullOnDelete(); + } + }); + } + + public function down(): void + { + Schema::table('field_job_settings', function (Blueprint $table) { + if (Schema::hasColumn('field_job_settings', 'queue_segment_id')) { + $table->dropConstrainedForeignId('queue_segment_id'); + } + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index da45c20..dd972c5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,10 +3,9 @@ namespace Database\Seeders; use App\Models\User; -use App\Models\Person\PersonType; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; -use Illuminate\Support\Facades\Hash; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -17,11 +16,15 @@ public function run(): void { // User::factory(10)->create(); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'password' => Hash::make("password") - ]); + // Ensure a default test user exists (idempotent) + \App\Models\User::query()->updateOrCreate( + ['email' => 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); $this->call([ PersonSeeder::class, @@ -29,6 +32,7 @@ public function run(): void ActionSeeder::class, EventSeeder::class, ImportTemplateSeeder::class, + TestUserSeeder::class, ]); } } diff --git a/database/seeders/TestUserSeeder.php b/database/seeders/TestUserSeeder.php new file mode 100644 index 0000000..c5343c5 --- /dev/null +++ b/database/seeders/TestUserSeeder.php @@ -0,0 +1,37 @@ +firstOrCreate( + ['email' => $email], + [ + 'name' => $name, + // Will be auto-hashed by the User model cast. + 'password' => $password, + ] + ); + + if (! $user->wasRecentlyCreated) { + $user->name = $name; + $user->password = $password; // auto-hashed by cast + } + + if ($user->email_verified_at === null) { + $user->email_verified_at = now(); + } + + $user->save(); + } +} diff --git a/resources/examples/sample_import.csv b/resources/examples/sample_import.csv index 8cb60fe..9bfc1ea 100644 --- a/resources/examples/sample_import.csv +++ b/resources/examples/sample_import.csv @@ -3,4 +3,4 @@ REF-1001,John,Doe,"123 Maple St, Springfield",+1 555-0101,john.doe@example.com,2 REF-1002,Jane,Smith,"456 Oak Ave, Metropolis",+44 20 7946 0958,jane.smith@example.co.uk,2025-09-05,2025-10-05,320.00 REF-1003,Carlos,García,"Calle 12 #34, Madrid",+34 91 123 4567,carlos.garcia@example.es,2025-09-10,2025-10-10,78.99 REF-1004,Anna,Müller,"Hauptstrasse 5, Berlin",+49 30 123456,anna.mueller@example.de,2025-09-12,2025-10-12,980.50 -REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,45.00 +REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,46.30 diff --git a/resources/js/Components/BasicTable.vue b/resources/js/Components/BasicTable.vue index 76e9113..facced9 100644 --- a/resources/js/Components/BasicTable.vue +++ b/resources/js/Components/BasicTable.vue @@ -16,6 +16,11 @@ const props = defineProps({ description: String, header: Array, body: Array, + // Make table header sticky while body scrolls + stickyHeader: { + type: Boolean, + default: true + }, editor: { type: Boolean, default: false @@ -115,12 +120,16 @@ const remove = () => {

{{ description }}

-
+
- {{ h.data }} - - + {{ h.data }} + + @@ -208,4 +217,27 @@ const remove = () => {
- \ No newline at end of file + + + \ No newline at end of file diff --git a/resources/js/Components/DocumentUploadDialog.vue b/resources/js/Components/DocumentUploadDialog.vue index 606b6d7..9d78599 100644 --- a/resources/js/Components/DocumentUploadDialog.vue +++ b/resources/js/Components/DocumentUploadDialog.vue @@ -11,6 +11,9 @@ import { ref, watch } from 'vue' const props = defineProps({ show: { type: Boolean, default: false }, postUrl: { type: String, required: true }, + // Optional list of contracts to allow attaching the document directly to a contract + // Each item should have at least: { uuid, reference } + contracts: { type: Array, default: () => [] }, }) const emit = defineEmits(['close', 'uploaded']) @@ -21,7 +24,8 @@ const form = useForm({ name: '', description: '', file: null, - is_public: false, + is_public: true, + contract_uuid: null, }) const localError = ref('') @@ -86,6 +90,13 @@ const close = () => emit('close')