From fe91c7e4bc5980e47793876fa93aaa1565fce6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sat, 4 Oct 2025 23:36:18 +0200 Subject: [PATCH] Mass changes --- app/Http/Controllers/ClientCaseContoller.php | 49 + app/Http/Controllers/FieldJobController.php | 123 +- .../Controllers/FieldJobSettingController.php | 7 +- app/Http/Controllers/ImportController.php | 154 ++ .../Requests/StoreFieldJobSettingRequest.php | 19 +- .../Requests/UpdateFieldJobSettingRequest.php | 21 +- app/Models/Activity.php | 7 +- app/Models/Email.php | 3 +- app/Models/FieldJobSetting.php | 26 +- app/Models/ImportRow.php | 2 +- app/Services/ImportProcessor.php | 1254 ++++++++++------- app/Services/ImportSimulationService.php | 1200 ++++++++++++++++ database/factories/EmailFactory.php | 27 + database/factories/ImportFactory.php | 32 + .../factories/Person/AddressTypeFactory.php | 3 +- .../factories/Person/PersonAddressFactory.php | 7 +- database/factories/Person/PersonFactory.php | 2 + .../factories/Person/PersonPhoneFactory.php | 6 +- .../factories/Person/PhoneTypeFactory.php | 3 +- ...alter_field_job_settings_add_action_id.php | 33 + ...0001_add_raw_sha1_to_import_rows_table.php | 26 + resources/examples/payments_sample.csv | 2 +- resources/js/Components/DocumentsTable.vue | 476 +++++-- resources/js/Components/Dropdown.vue | 24 +- resources/js/Components/Modal.vue | 151 +- .../js/Pages/Cases/Partials/ActivityTable.vue | 289 ++-- resources/js/Pages/Imports/Create.vue | 667 ++------- resources/js/Pages/Imports/Import.vue | 791 ++++++----- resources/js/Pages/Imports/Index.vue | 138 +- .../js/Pages/Imports/Partials/ActionsBar.vue | 63 + .../Pages/Imports/Partials/ChecklistSteps.vue | 16 + .../Imports/Partials/CsvPreviewModal.vue | 61 + .../js/Pages/Imports/Partials/LogsTable.vue | 268 ++++ .../Pages/Imports/Partials/MappingTable.vue | 85 ++ .../Pages/Imports/Partials/ProcessResult.vue | 9 + .../Imports/Partials/SavedMappingsTable.vue | 28 + .../Imports/Partials/SimulationModal.vue | 793 +++++++++++ .../Imports/Partials/TemplateControls.vue | 159 +++ .../js/Pages/Imports/useCurrencyFormat.js | 68 + resources/js/Pages/Imports/useEurFormat.js | 14 + .../js/Pages/Settings/FieldJob/Index.vue | 311 +++- routes/web.php | 9 + .../GlobalSearchContractReferenceTest.php | 2 +- .../ImportSimulationContractChainTest.php | 64 + tests/Feature/ImportSimulationGenericTest.php | 59 + .../ImportSimulationMultiRootsTest.php | 60 + 46 files changed, 5738 insertions(+), 1873 deletions(-) create mode 100644 app/Services/ImportSimulationService.php create mode 100644 database/factories/EmailFactory.php create mode 100644 database/factories/ImportFactory.php create mode 100644 database/migrations/2025_10_03_164536_alter_field_job_settings_add_action_id.php create mode 100644 database/migrations/2025_10_04_000001_add_raw_sha1_to_import_rows_table.php create mode 100644 resources/js/Pages/Imports/Partials/ActionsBar.vue create mode 100644 resources/js/Pages/Imports/Partials/ChecklistSteps.vue create mode 100644 resources/js/Pages/Imports/Partials/CsvPreviewModal.vue create mode 100644 resources/js/Pages/Imports/Partials/LogsTable.vue create mode 100644 resources/js/Pages/Imports/Partials/MappingTable.vue create mode 100644 resources/js/Pages/Imports/Partials/ProcessResult.vue create mode 100644 resources/js/Pages/Imports/Partials/SavedMappingsTable.vue create mode 100644 resources/js/Pages/Imports/Partials/SimulationModal.vue create mode 100644 resources/js/Pages/Imports/Partials/TemplateControls.vue create mode 100644 resources/js/Pages/Imports/useCurrencyFormat.js create mode 100644 resources/js/Pages/Imports/useEurFormat.js create mode 100644 tests/Feature/ImportSimulationContractChainTest.php create mode 100644 tests/Feature/ImportSimulationGenericTest.php create mode 100644 tests/Feature/ImportSimulationMultiRootsTest.php diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 8494737..a9a25a0 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -1121,4 +1121,53 @@ public function destroy(string $id) { // } + + /** + * Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts. + */ + public function deleteDocument(ClientCase $clientCase, Document $document, Request $request) + { + // Ownership check: direct case document? + $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; + + // Or document of a contract that belongs to this case (include trashed contracts) + $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)) { + abort(404); + } + + // (Optional future) $this->authorize('delete', $document); + + $document->delete(); // soft delete + + return $request->wantsJson() + ? response()->json(['status' => 'ok']) + : back()->with('success', 'Document deleted.'); + } + + /** + * Delete a document accessed through a contract route binding. + */ + public function deleteContractDocument(Contract $contract, Document $document, Request $request) + { + $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; + if (! $belongs) { + abort(404); + } + + // (Optional future) $this->authorize('delete', $document); + + $document->delete(); + + return $request->wantsJson() + ? response()->json(['status' => 'ok']) + : back()->with('success', 'Document deleted.'); + } } diff --git a/app/Http/Controllers/FieldJobController.php b/app/Http/Controllers/FieldJobController.php index 0fe98fa..17d3aeb 100644 --- a/app/Http/Controllers/FieldJobController.php +++ b/app/Http/Controllers/FieldJobController.php @@ -7,6 +7,8 @@ use App\Models\FieldJob; use App\Models\FieldJobSetting; use App\Models\User; +use Exception; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Inertia\Inertia; @@ -83,48 +85,51 @@ public function assign(Request $request) '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.']); + try { + DB::transaction(function () use ($data) { + $setting = FieldJobSetting::query()->latest('id')->first(); + + if (! $setting) { + throw new Exception('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 + if ($setting->action_id && $setting->assign_decision_id) { + $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' => $setting->action_id, + 'decision_id' => $setting->assign_decision_id, + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + ]); + + // Move contract to the configured segment for field jobs + $job->moveContractToSegment($setting->segment_id); + } else { + throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.'); + } + }); + + return back()->with('success', 'Field job assigned.'); + } catch (QueryException $e) { + return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]); + } catch (Exception $e) { + return back()->withErrors(['error' => 'Error: '.$e->getMessage()]); } - $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) @@ -149,24 +154,22 @@ public function cancel(Request $request) // Create an activity for the cancellation, mirroring the assign flow // Prefer the job's setting for a consistent decision $job->loadMissing('setting'); + $actionId = optional($job->setting)->action_id; $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, - ]); - } + // If no decision configured, skip logging + if ($actionId && $decisionId) { + + 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, + ]); + } } @@ -183,13 +186,7 @@ public function complete(Request $request, \App\Models\ClientCase $clientCase) } $decisionId = $setting->complete_decision_id; - $actionId = null; - if ($decisionId) { - $actionId = DB::table('action_decision') - ->where('decision_id', $decisionId) - ->orderBy('id') - ->value('action_id'); - } + $actionId = $setting->action_id; // Find all active jobs for this case for the current user $jobs = FieldJob::query() diff --git a/app/Http/Controllers/FieldJobSettingController.php b/app/Http/Controllers/FieldJobSettingController.php index bcf99a3..a09a975 100644 --- a/app/Http/Controllers/FieldJobSettingController.php +++ b/app/Http/Controllers/FieldJobSettingController.php @@ -15,13 +15,14 @@ class FieldJobSettingController extends Controller public function index(Request $request) { $settings = FieldJobSetting::query() - ->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment']) + ->with(['action', 'segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment']) ->get(); return Inertia::render('Settings/FieldJob/Index', [ 'settings' => $settings, 'segments' => Segment::query()->get(), - 'decisions' => Decision::query()->get(), + 'actions' => \App\Models\Action::query()->with(['decisions:id'])->get(), + 'decisions' => Decision::query()->with(['actions:id'])->get(), ]); } @@ -34,6 +35,7 @@ public function store(StoreFieldJobSettingRequest $request) 'initial_decision_id' => $attributes['initial_decision_id'], 'assign_decision_id' => $attributes['assign_decision_id'], 'complete_decision_id' => $attributes['complete_decision_id'], + 'action_id' => $attributes['action_id'] ?? null, 'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null, 'return_segment_id' => $attributes['return_segment_id'] ?? null, 'queue_segment_id' => $attributes['queue_segment_id'] ?? null, @@ -54,6 +56,7 @@ public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $r 'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null, 'return_segment_id' => $attributes['return_segment_id'] ?? null, 'queue_segment_id' => $attributes['queue_segment_id'] ?? null, + 'action_id' => $attributes['action_id'] ?? null, ]); return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!'); diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 0b51aa5..9c0a852 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use App\Models\Account; use App\Models\Client; +use App\Models\Contract; use App\Models\Import; use App\Models\ImportEvent; use App\Models\ImportTemplate; @@ -366,6 +368,122 @@ public function getEvents(Import $import) return response()->json(['events' => $events]); } + // Preview (up to N) raw CSV rows for an import for mapping review + public function preview(Import $import, Request $request) + { + $validated = $request->validate([ + 'limit' => 'nullable|integer|min:1|max:500', + ]); + $limit = (int) ($validated['limit'] ?? 200); + + // Determine header/delimiter the same way as columns() stored them + $meta = $import->meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + // Forced delimiter overrides everything; else detected; fallback comma + $delimiter = $meta['forced_delimiter'] + ?? $meta['detected_delimiter'] + ?? ','; + + $rows = []; + $columns = []; + $truncated = false; + $path = Storage::disk($import->disk)->path($import->path); + if (! is_readable($path)) { + return response()->json([ + 'error' => 'File not readable', + ], 422); + } + $fh = @fopen($path, 'r'); + if (! $fh) { + return response()->json([ + 'error' => 'Unable to open file', + ], 422); + } + try { + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter) ?: []; + $columns = array_map(function ($h) { + return is_string($h) ? trim($h) : (string) $h; + }, $header); + } else { + // Use meta stored columns when available, else infer later from widest row + $columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : []; + } + $count = 0; + $widest = count($columns); + while (($data = fgetcsv($fh, 0, $delimiter)) !== false) { + if ($count >= $limit) { + $truncated = true; + break; + } + // Track widest for non-header scenario + if (! $hasHeader) { + $widest = max($widest, count($data)); + } + $rows[] = $data; + $count++; + } + if (! $hasHeader && $widest > count($columns)) { + // Generate positional column labels if missing + $columns = []; + for ($i = 0; $i < $widest; $i++) { + $columns[] = 'col_'.($i + 1); + } + } + } finally { + fclose($fh); + } + + // Normalize each row into assoc keyed by columns (pad/truncate as needed) + $assocRows = []; + foreach ($rows as $r) { + $assoc = []; + foreach ($columns as $i => $colName) { + $assoc[$colName] = array_key_exists($i, $r) ? $r[$i] : null; + } + $assocRows[] = $assoc; + } + + return response()->json([ + 'columns' => $columns, + 'rows' => $assocRows, + 'limit' => $limit, + 'truncated' => $truncated, + 'has_header' => $hasHeader, + ]); + } + + /** + * Simulate application of payment rows for a payments import without persisting changes. + * Returns per-row projected balance changes and resolution of contract / account references. + */ + public function simulatePayments(Import $import, Request $request) + { + // Delegate to the generic simulate method for backward compatibility. + return $this->simulate($import, $request); + } + + /** + * Generic simulation endpoint: projects what would happen if the import were processed + * using the first N rows and current saved mappings. Works for both payments and non-payments + * templates. For payments templates, payment-specific summaries/entities will be included + * automatically by the simulation service when mappings contain the payment root. + */ + public function simulate(Import $import, Request $request) + { + $validated = $request->validate([ + 'limit' => 'nullable|integer|min:1|max:500', + 'verbose' => 'nullable|boolean', + ]); + $limit = (int) ($validated['limit'] ?? 100); + $verbose = (bool) ($validated['verbose'] ?? false); + + $service = app(\App\Services\ImportSimulationService::class); + $result = $service->simulate($import, $limit, $verbose); + + return response()->json($result); + } + // Show an existing import by UUID to continue where left off public function show(Import $import) { @@ -426,4 +544,40 @@ public function show(Import $import) 'client' => $client, ]); } + + // Delete an import if not finished (statuses allowed: uploaded, mapping, processing_failed etc.) + public function destroy(Request $request, Import $import) + { + // Only allow deletion if not completed or processing + if (in_array($import->status, ['completed', 'processing'])) { + return back()->with([ + 'ok' => false, + 'message' => 'Import can not be deleted in its current status.', + ], 422); + } + + // Attempt to delete stored file + try { + if ($import->disk && $import->path && Storage::disk($import->disk)->exists($import->path)) { + Storage::disk($import->disk)->delete($import->path); + } + } catch (\Throwable $e) { + // Log event but proceed with deletion + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $request->user()?->getAuthIdentifier(), + 'event' => 'file_delete_failed', + 'level' => 'warning', + 'message' => 'Failed to delete import file: '.$e->getMessage(), + ]); + } + + // Clean up related events/rows optionally (soft approach: rely on FKs if cascade configured) + // If not cascaded, we could manually delete; check quickly + // Assuming foreign key ON DELETE CASCADE for import_rows & import_events + + $import->delete(); + + return back()->with(['ok' => true]); + } } diff --git a/app/Http/Requests/StoreFieldJobSettingRequest.php b/app/Http/Requests/StoreFieldJobSettingRequest.php index fdd35bc..8fc9397 100644 --- a/app/Http/Requests/StoreFieldJobSettingRequest.php +++ b/app/Http/Requests/StoreFieldJobSettingRequest.php @@ -18,6 +18,7 @@ public function rules(): array 'segment_id' => ['required', 'integer', 'exists:segments,id'], 'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'], 'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'], + 'action_id' => ['nullable', 'integer', 'exists:actions,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'], @@ -42,14 +43,26 @@ public function withValidator($validator): void { $validator->after(function ($validator): void { // Validate that the assign_decision_id has a mapped action + $actionId = $this->input('action_id'); + if (! empty($actionId)) { + $mapped = \App\Models\Action::query() + ->where('id', $actionId) + ->exists(); + + if (! $mapped) { + $validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.'); + } + } + $assignDecisionId = $this->input('assign_decision_id'); if (! empty($assignDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $assignDecisionId) + ->where('action_id', $actionId) ->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.'); + $validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.'); } } @@ -58,10 +71,11 @@ public function withValidator($validator): void if (! empty($completeDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $completeDecisionId) + ->where('action_id', $actionId) ->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.'); + $validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.'); } } @@ -69,6 +83,7 @@ public function withValidator($validator): void if (! empty($cancelDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $cancelDecisionId) + ->where('action_id', $actionId) ->exists(); if (! $mapped) { diff --git a/app/Http/Requests/UpdateFieldJobSettingRequest.php b/app/Http/Requests/UpdateFieldJobSettingRequest.php index 2b6139e..f11cce0 100644 --- a/app/Http/Requests/UpdateFieldJobSettingRequest.php +++ b/app/Http/Requests/UpdateFieldJobSettingRequest.php @@ -22,6 +22,7 @@ public function rules(): array 'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'], 'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'], 'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], ]; } @@ -38,13 +39,25 @@ public function messages(): array public function withValidator($validator): void { $validator->after(function ($validator): void { + $actionId = $this->input('action_id'); + if (! empty($actionId)) { + $mapped = \App\Models\Action::query() + ->where('id', $actionId) + ->exists(); + + if (! $mapped) { + $validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.'); + } + } + $assignDecisionId = $this->input('assign_decision_id'); if (! empty($assignDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $assignDecisionId) + ->where('action_id', $actionId) ->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.'); + $validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.'); } } @@ -52,9 +65,10 @@ public function withValidator($validator): void if (! empty($completeDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $completeDecisionId) + ->where('action_id', $actionId) ->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.'); + $validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.'); } } @@ -62,9 +76,10 @@ public function withValidator($validator): void if (! empty($cancelDecisionId)) { $mapped = DB::table('action_decision') ->where('decision_id', $cancelDecisionId) + ->where('action_id', $actionId) ->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.'); + $validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map the correct action to this decision first.'); } } }); diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 4e5232a..bef1bf6 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -6,7 +6,6 @@ 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 { @@ -43,9 +42,11 @@ protected static function booted() // 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') + \App\Models\Account::query() ->where('contract_id', $activity->contract_id) - ->update(['promise_date' => $activity->due_date, 'updated_at' => now()]); + ->update( + ['promise_date' => $activity->due_date, 'updated_at' => now()], + ); } }); } diff --git a/app/Models/Email.php b/app/Models/Email.php index 7059d5a..93e2ae2 100644 --- a/app/Models/Email.php +++ b/app/Models/Email.php @@ -2,13 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class Email extends Model { - use SoftDeletes; + use HasFactory, SoftDeletes; protected $fillable = [ 'person_id', diff --git a/app/Models/FieldJobSetting.php b/app/Models/FieldJobSetting.php index e70038c..1013698 100644 --- a/app/Models/FieldJobSetting.php +++ b/app/Models/FieldJobSetting.php @@ -19,8 +19,14 @@ class FieldJobSetting extends Model 'cancel_decision_id', 'return_segment_id', 'queue_segment_id', + 'action_id', ]; + public function action(): BelongsTo + { + return $this->belongsTo(\App\Models\Action::class); + } + public function segment(): BelongsTo { return $this->belongsTo(Segment::class); @@ -28,22 +34,34 @@ public function segment(): BelongsTo public function assignDecision(): BelongsTo { - return $this->belongsTo(Decision::class, 'assign_decision_id'); + return $this->belongsTo(Decision::class, 'assign_decision_id') + ->with(['actions' => function ($query) { + $query->select('actions.id'); + }]); } public function initialDecision(): BelongsTo { - return $this->belongsTo(Decision::class, 'initial_decision_id'); + return $this->belongsTo(Decision::class, 'initial_decision_id') + ->with(['actions' => function ($query) { + $query->select('actions.id'); + }]); } public function completeDecision(): BelongsTo { - return $this->belongsTo(Decision::class, 'complete_decision_id'); + return $this->belongsTo(Decision::class, 'complete_decision_id') + ->with(['actions' => function ($query) { + $query->select('actions.id'); + }]); } public function cancelDecision(): BelongsTo { - return $this->belongsTo(Decision::class, 'cancel_decision_id'); + return $this->belongsTo(Decision::class, 'cancel_decision_id') + ->with(['actions' => function ($query) { + $query->select('actions.id'); + }]); } public function returnSegment(): BelongsTo diff --git a/app/Models/ImportRow.php b/app/Models/ImportRow.php index befc727..0f989b9 100644 --- a/app/Models/ImportRow.php +++ b/app/Models/ImportRow.php @@ -11,7 +11,7 @@ class ImportRow extends Model use HasFactory; protected $fillable = [ - 'import_id','row_number','sheet_name','record_type','raw_data','mapped_data','status','errors','warnings','entity_type','entity_id','fingerprint' + 'import_id', 'row_number', 'sheet_name', 'record_type', 'raw_data', 'mapped_data', 'status', 'errors', 'warnings', 'entity_type', 'entity_id', 'fingerprint', 'raw_sha1', ]; protected $casts = [ diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 41279f5..6c346bd 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -198,68 +198,125 @@ public function process(Import $import, ?Authenticatable $user = null): array // If mapping contains contract.reference, we require each row to successfully resolve/create a contract $requireContract = $this->mappingIncludes($mappings, 'contract.reference'); + $isPg = DB::connection()->getDriverName() === 'pgsql'; + $failedRows = []; while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { $rowNum++; $total++; - $rawAssoc = $this->buildRowAssoc($row, $header); - [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); + if ($isPg) { + // Establish a savepoint so a failing row does not poison the whole transaction + DB::statement('SAVEPOINT import_row_'.$rowNum); + } - // Do not auto-derive or fallback values; only use explicitly mapped fields + // Scope variables per row so they aren't reused after exception + $importRow = null; + try { + $rawAssoc = $this->buildRowAssoc($row, $header); + [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); - $importRow = ImportRow::create([ - 'import_id' => $import->id, - 'row_number' => $rowNum, - 'record_type' => $recordType, - 'raw_data' => $rawAssoc, - 'mapped_data' => $mapped, - 'status' => 'valid', - ]); + // Do not auto-derive or fallback values; only use explicitly mapped fields - // Contracts - $contractResult = null; - if (isset($mapped['contract'])) { - // In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only - if ($paymentsImport && $contractKeyMode === 'reference') { - $ref = $mapped['contract']['reference'] ?? null; - if (is_string($ref)) { - $ref = preg_replace('/\s+/', '', trim($ref)); - } - if ($ref) { - $q = Contract::query() - ->when($import->client_id, function ($q2, $clientId) { - $q2->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') - ->where('client_cases.client_id', $clientId); - }) - ->where('contracts.reference', $ref) - ->select('contracts.*'); - $found = $q->first(); - if ($found) { - $contractResult = ['action' => 'resolved', 'contract' => $found]; + $rawSha1 = sha1(json_encode($rawAssoc)); + $importRow = ImportRow::create([ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'record_type' => $recordType, + 'raw_data' => $rawAssoc, + 'mapped_data' => $mapped, + 'status' => 'valid', + 'raw_sha1' => $rawSha1, + ]); + + // Contracts + $contractResult = null; + if (isset($mapped['contract'])) { + // In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only + if ($paymentsImport && $contractKeyMode === 'reference') { + $ref = $mapped['contract']['reference'] ?? null; + if (is_string($ref)) { + $ref = preg_replace('/\s+/', '', trim($ref)); + } + if ($ref) { + $q = Contract::query() + ->when($import->client_id, function ($q2, $clientId) { + $q2->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId); + }) + ->where('contracts.reference', $ref) + ->select('contracts.*'); + $found = $q->first(); + if ($found) { + $contractResult = ['action' => 'resolved', 'contract' => $found]; + } else { + $contractResult = null; // let requireContract logic flag invalid later + } } else { - $contractResult = null; // let requireContract logic flag invalid later + $contractResult = null; } } else { - $contractResult = null; + $contractResult = $this->upsertContractChain($import, $mapped, $mappings); } - } else { - $contractResult = $this->upsertContractChain($import, $mapped, $mappings); - } - if ($contractResult['action'] === 'skipped') { - // Even if no contract fields were updated, we may still need to apply template meta - // like attaching a segment or creating an activity. Do that if we have the contract. - if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + if ($contractResult['action'] === 'skipped') { + // Even if no contract fields were updated, we may still need to apply template meta + // like attaching a segment or creating an activity. Do that if we have the contract. + if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + try { + $this->postContractActions($import, $contractResult['contract']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'post_contract_actions_applied', + 'level' => 'info', + 'message' => 'Applied template post-actions on existing contract.', + 'context' => ['contract_id' => $contractResult['contract']->id], + ]); + } catch (\Throwable $e) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'post_contract_action_failed', + 'level' => 'warning', + 'message' => $e->getMessage(), + ]); + } + } + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_skipped', + 'level' => 'info', + 'message' => $contractResult['message'] ?? 'Skipped contract (no changes).', + ]); + } elseif (in_array($contractResult['action'], ['inserted', 'updated'])) { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Contract::class, + 'entity_id' => $contractResult['contract']->id, + ]); + $contractFieldsStr = ''; + if (! empty($contractResult['applied_fields'] ?? [])) { + $contractFieldsStr = $this->formatAppliedFieldMessage('contract', $contractResult['applied_fields']); + } + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_imported', + 'level' => 'info', + 'message' => ucfirst($contractResult['action']).' contract'.($contractFieldsStr ? ' '.$contractFieldsStr : ''), + 'context' => ['id' => $contractResult['contract']->id, 'fields' => $contractResult['applied_fields'] ?? []], + ]); + + // Post-contract actions from template/import meta try { $this->postContractActions($import, $contractResult['contract']); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'post_contract_actions_applied', - 'level' => 'info', - 'message' => 'Applied template post-actions on existing contract.', - 'context' => ['contract_id' => $contractResult['contract']->id], - ]); } catch (\Throwable $e) { ImportEvent::create([ 'import_id' => $import->id, @@ -270,505 +327,537 @@ public function process(Import $import, ?Authenticatable $user = null): array 'message' => $e->getMessage(), ]); } + } else { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); } - $skipped++; - $importRow->update(['status' => 'skipped']); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_skipped', - 'level' => 'info', - 'message' => $contractResult['message'] ?? 'Skipped contract (no changes).', - ]); - } elseif (in_array($contractResult['action'], ['inserted', 'updated'])) { - $imported++; - $importRow->update([ - 'status' => 'imported', - 'entity_type' => Contract::class, - 'entity_id' => $contractResult['contract']->id, - ]); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_imported', - 'level' => 'info', - 'message' => ucfirst($contractResult['action']).' contract', - 'context' => ['id' => $contractResult['contract']->id], - ]); + } - // Post-contract actions from template/import meta + // Enforce hard requirement: if template mapped contract.reference but we didn't resolve/create a contract, mark row invalid and continue + if ($requireContract) { + $contractEnsured = false; + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $contractEnsured = true; + } + if (! $contractEnsured) { + $srcCol = $this->findSourceColumnFor($mappings, 'contract.reference'); + $rawVal = $srcCol !== null ? ($rawAssoc[$srcCol] ?? null) : null; + $extra = $srcCol !== null ? ' Source column: "'.$srcCol.'" value: '.(is_null($rawVal) || $rawVal === '' ? '(empty)' : (is_scalar($rawVal) ? (string) $rawVal : json_encode($rawVal))) : ''; + $msg = 'Row '.$rowNum.': Contract was required (contract.reference mapped) but not created/resolved. '.($contractResult['message'] ?? '').$extra; + + // Avoid double-counting invalid if already set by contract processing + if ($importRow->status !== 'invalid') { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => [$msg]]); + } + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_invalid', + 'level' => 'error', + 'message' => $msg, + ]); + + // Skip further processing for this row + continue; + } + } + + // Accounts + $accountResult = null; + if (isset($mapped['account'])) { + // If a contract was just created or resolved above, pass its id to account mapping for this row + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $mapped['account']['contract_id'] = $contractResult['contract']->id; + } + $accountResult = $this->upsertAccount($import, $mapped, $mappings); + if ($accountResult['action'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_skipped', + 'level' => 'info', + 'message' => $accountResult['message'] ?? 'Skipped (no changes).', + 'context' => $accountResult['context'] ?? null, + ]); + } elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Account::class, + 'entity_id' => $accountResult['account']->id, + ]); + $accountFieldsStr = ''; + if (! empty($accountResult['applied_fields'] ?? [])) { + $accountFieldsStr = $this->formatAppliedFieldMessage('account', $accountResult['applied_fields']); + } + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_imported', + 'level' => 'info', + 'message' => ucfirst($accountResult['action']).' account'.($accountFieldsStr ? ' '.$accountFieldsStr : ''), + 'context' => ['id' => $accountResult['account']->id, 'fields' => $accountResult['applied_fields'] ?? []], + ]); + } else { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]); + } + } + + // Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers + $personIdForRow = null; + // Prefer person from contract created/updated above + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $ccId = $contractResult['contract']->client_case_id; + $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); + } + + // Payments: when present, require account resolution and create payment + if (isset($mapped['payment'])) { + // If no account yet, try to resolve via contract + first account + $accountIdForPayment = $accountResult['account']->id ?? null; + + // If payments-import mode with contract_key_mode=reference and we have contract.reference mapped, resolve by reference only + $tplMeta = optional($import->template)->meta ?? []; + $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); + $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; + if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') { + $contractRef = $mapped['contract']['reference'] ?? null; + if ($contractRef) { + $contract = \App\Models\Contract::query() + ->when($import->client_id, function ($q, $clientId) { + $q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId); + }) + ->where('contracts.reference', $contractRef) + ->select('contracts.id') + ->first(); + if ($contract) { + $accountIdForPayment = \App\Models\Account::where('contract_id', $contract->id)->value('id'); + } + } + } + + if (! $accountIdForPayment) { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => ['Payment requires an account. Could not resolve account for payment.']]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_invalid', + 'level' => 'error', + 'message' => 'Payment requires an account (not resolved).', + ]); + + continue; + } + + // Build payment payload + $p = $mapped['payment']; + // Normalize reference and payment number (if provided) + $refVal = isset($p['reference']) ? (is_string($p['reference']) ? trim($p['reference']) : $p['reference']) : null; + $nuVal = isset($p['payment_nu']) ? (is_string($p['payment_nu']) ? trim($p['payment_nu']) : $p['payment_nu']) : null; + $payload = [ + 'account_id' => $accountIdForPayment, + 'reference' => $p['reference'] ?? null, + 'paid_at' => $p['payment_date'] ?? ($p['paid_at'] ?? null), + 'currency' => $p['currency'] ?? 'EUR', + 'created_by' => $user?->getAuthIdentifier(), + ]; + // Attach payment_nu into meta for idempotency if provided + $meta = []; + if (is_array($p['meta'] ?? null)) { + $meta = $p['meta']; + } + if (! empty($nuVal)) { + $meta['payment_nu'] = $nuVal; + } + if (! empty($meta)) { + $payload['meta'] = $meta; + } + // Amount: accept either amount (preferred) or legacy amount_cents; convert cents -> decimal + if (array_key_exists('amount', $p)) { + $payload['amount'] = is_string($p['amount']) ? (float) $this->normalizeDecimal($p['amount']) : (float) $p['amount']; + } elseif (array_key_exists('amount_cents', $p)) { + $payload['amount'] = ((int) $p['amount_cents']) / 100.0; + } + + // Idempotency: skip creating if a payment with same (account_id, reference) already exists + if (! empty($refVal)) { + $exists = Payment::query() + ->where('account_id', $accountIdForPayment) + ->where('reference', $refVal) + ->exists(); + if ($exists) { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_duplicate_skipped', + 'level' => 'info', + 'message' => 'Skipped duplicate payment (by reference).', + 'context' => [ + 'account_id' => $accountIdForPayment, + 'reference' => $refVal, + ], + ]); + + continue; + } + } + + $payment = new Payment; + $payment->fill($payload); + // Save the account balance before applying this payment + $accForBal = Account::find($accountIdForPayment); + if ($accForBal) { + $payment->balance_before = (float) ($accForBal->balance_amount ?? 0); + } + // If amount not in payload yet but provided, set it directly + if (! array_key_exists('amount', $payload) && isset($p['amount'])) { + $payment->amount = (float) $this->normalizeDecimal($p['amount']); + } try { - $this->postContractActions($import, $contractResult['contract']); + $payment->save(); + } catch (\Throwable $e) { + // Gracefully skip if unique index on (account_id, reference) is violated due to race conditions + if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_duplicate_skipped_db', + 'level' => 'info', + 'message' => 'Skipped duplicate payment due to database unique constraint (account_id, reference).', + 'context' => [ + 'account_id' => $accountIdForPayment, + 'reference' => $refVal, + ], + ]); + + continue; + } + throw $e; + } + + // Option A: create a credit booking so account balance updates via booking events + try { + if (isset($payment->amount)) { + \App\Models\Booking::query()->create([ + 'account_id' => $accountIdForPayment, + 'payment_id' => $payment->id, + 'amount_cents' => (int) round(((float) $payment->amount) * 100), + 'type' => 'credit', + 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', + 'booked_at' => $payment->paid_at ?? now(), + ]); + } } catch (\Throwable $e) { ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, - 'event' => 'post_contract_action_failed', + 'event' => 'booking_create_failed', 'level' => 'warning', - 'message' => $e->getMessage(), + 'message' => 'Failed to create booking for payment: '.$e->getMessage(), ]); } - } else { - $invalid++; - $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); - } - } - // Enforce hard requirement: if template mapped contract.reference but we didn't resolve/create a contract, mark row invalid and continue - if ($requireContract) { - $contractEnsured = false; - if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { - $contractEnsured = true; - } - if (! $contractEnsured) { - $srcCol = $this->findSourceColumnFor($mappings, 'contract.reference'); - $rawVal = $srcCol !== null ? ($rawAssoc[$srcCol] ?? null) : null; - $extra = $srcCol !== null ? ' Source column: "'.$srcCol.'" value: '.(is_null($rawVal) || $rawVal === '' ? '(empty)' : (is_scalar($rawVal) ? (string) $rawVal : json_encode($rawVal))) : ''; - $msg = 'Row '.$rowNum.': Contract was required (contract.reference mapped) but not created/resolved. '.($contractResult['message'] ?? '').$extra; + // Optionally create an activity entry for this payment + try { + $settings = \App\Models\PaymentSetting::query()->first(); + if ($settings && ($settings->create_activity_on_payment ?? false)) { + $amountCents = (int) round(((float) $payment->amount) * 100); + $note = $settings->activity_note_template ?? 'Prejeto plačilo'; + $note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], $note); - // Avoid double-counting invalid if already set by contract processing - if ($importRow->status !== 'invalid') { - $invalid++; - $importRow->update(['status' => 'invalid', 'errors' => [$msg]]); + // Append balance context (before/after) and mark cause as payment + // At this point, booking has been created so the account balance should reflect the new amount + $accountAfter = Account::find($accountIdForPayment); + $beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $afterStr = number_format((float) ($accountAfter?->balance_amount ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; + + // Resolve client_case_id via account->contract + $accountForActivity = $accForBal ?: Account::find($accountIdForPayment); + $accountForActivity?->loadMissing('contract'); + $contractId = $accountForActivity?->contract_id; + $clientCaseId = $accountForActivity?->contract?->client_case_id; + + if ($clientCaseId) { + $activity = \App\Models\Activity::query()->create([ + 'due_date' => null, + 'amount' => $amountCents / 100, + 'note' => $note, + 'action_id' => $settings->default_action_id, + 'decision_id' => $settings->default_decision_id, + 'client_case_id' => $clientCaseId, + 'contract_id' => $contractId, + ]); + $payment->update(['activity_id' => $activity->id]); + } else { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_activity_skipped', + 'level' => 'info', + 'message' => 'Skipped creating activity for payment due to missing client_case_id on contract.', + ]); + } + } + } catch (\Throwable $e) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'payment_activity_failed', + 'level' => 'warning', + 'message' => 'Failed to create activity for payment: '.$e->getMessage(), + ]); } - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_invalid', - 'level' => 'error', - 'message' => $msg, - ]); - // Skip further processing for this row - continue; - } - } - - // Accounts - $accountResult = null; - if (isset($mapped['account'])) { - // If a contract was just created or resolved above, pass its id to account mapping for this row - if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { - $mapped['account']['contract_id'] = $contractResult['contract']->id; - } - $accountResult = $this->upsertAccount($import, $mapped, $mappings); - if ($accountResult['action'] === 'skipped') { - $skipped++; - $importRow->update(['status' => 'skipped']); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_skipped', - 'level' => 'info', - 'message' => $accountResult['message'] ?? 'Skipped (no changes).', - 'context' => $accountResult['context'] ?? null, - ]); - } elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') { $imported++; $importRow->update([ 'status' => 'imported', - 'entity_type' => Account::class, - 'entity_id' => $accountResult['account']->id, + 'entity_type' => Payment::class, + 'entity_id' => $payment->id, ]); + $paymentFields = $this->collectPaymentAppliedFields($payload, $payment); + $paymentFieldsStr = $this->formatAppliedFieldMessage('payment', $paymentFields); ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), 'import_row_id' => $importRow->id, 'event' => 'row_imported', 'level' => 'info', - 'message' => ucfirst($accountResult['action']).' account', - 'context' => ['id' => $accountResult['account']->id], + 'message' => 'Inserted payment'.($paymentFieldsStr ? ' '.$paymentFieldsStr : ''), + 'context' => ['id' => $payment->id, 'fields' => $paymentFields], ]); + } + // If we have a contract reference, resolve existing contract for this client and derive person + if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) { + $existingContract = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $import->client_id) + ->where('contracts.reference', $mapped['contract']['reference']) + ->select('contracts.client_case_id') + ->first(); + if ($existingContract) { + $personIdForRow = ClientCase::where('id', $existingContract->client_case_id)->value('person_id'); + } + } + // If account processing created/resolved a contract, derive person via its client_case + if (! $personIdForRow && $accountResult) { + if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) { + $ccId = $accountResult['contract']->client_case_id; + $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); + } elseif (isset($accountResult['contract_id'])) { + $ccId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id'); + if ($ccId) { + $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); + } + } + } + // Resolve by client_case.client_ref for this client (prefer reusing existing person) + if (! $personIdForRow && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) { + $cc = ClientCase::where('client_id', $import->client_id) + ->where('client_ref', $mapped['client_case']['client_ref']) + ->first(); + if ($cc) { + $personIdForRow = $cc->person_id ?: null; + } + } + + // Resolve by contact values next + if (! $personIdForRow) { + $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 !== '') { + $personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id'); + } + if (! $personIdForRow && $addrLine !== '') { + $personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id'); + } + + // If still no person but we have any contact value, auto-create a minimal person + // BUT if we can map to an existing client_case by client_ref, reuse that case and set person there (avoid separate person rows) + if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) { + if ($import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) { + $cc = ClientCase::where('client_id', $import->client_id) + ->where('client_ref', $mapped['client_case']['client_ref']) + ->first(); + if ($cc) { + $pid = $cc->person_id ?: $this->createMinimalPersonId(); + if (! $cc->person_id) { + $cc->person_id = $pid; + $cc->save(); + } + $personIdForRow = $pid; + } + } + } + if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) { + $personIdForRow = $this->createMinimalPersonId(); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id ?? null, + 'event' => 'person_autocreated_for_contacts', + 'level' => 'info', + 'message' => 'Created minimal person to attach contact data (email/phone/address).', + 'context' => [ + 'email' => $emailVal ?: null, + 'phone' => $phoneNu ?: null, + 'address' => $addrLine ?: null, + 'person_id' => $personIdForRow, + ], + ]); + } + } + // Try identifiers from mapped person (no creation yet) + if (! $personIdForRow && ! empty($mapped['person'] ?? [])) { + $personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']); + } + // Finally, if still unknown and person fields provided, create + if (! $personIdForRow && ! empty($mapped['person'] ?? [])) { + $personIdForRow = $this->findOrCreatePersonId($mapped['person']); + } + + // At this point, personIdForRow is either resolved or remains null (no contacts/person data) + + $contactChanged = false; + if ($personIdForRow) { + if (! empty($mapped['email'] ?? [])) { + $r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings); + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } + } + if (! empty($mapped['address'] ?? [])) { + $r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings); + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } + } + if (! empty($mapped['phone'] ?? [])) { + $r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings); + if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { + $contactChanged = true; + } + } + } + + if (! isset($mapped['contract']) && ! isset($mapped['account'])) { + if ($contactChanged) { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Person::class, + 'entity_id' => $personIdForRow, + ]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_imported', + 'level' => 'info', + 'message' => 'Contacts upserted', + 'context' => ['person_id' => $personIdForRow], + ]); + } else { + $skipped++; + $importRow->update(['status' => 'skipped']); + } + } + } catch (\Throwable $e) { + if ($isPg) { + // Roll back only this row's work + try { + DB::statement('ROLLBACK TO SAVEPOINT import_row_'.$rowNum); + } catch (\Throwable $ignored) { /* noop */ + } + } + // Ensure importRow exists for logging if failure happened before its creation + if (! $importRow) { + try { + $msg = $this->safeErrorMessage($e->getMessage()); + $rawPreviewSha1 = isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null; + $importRow = ImportRow::create([ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'record_type' => null, + 'raw_data' => isset($rawAssoc) ? $rawAssoc : [], + 'mapped_data' => [], + 'status' => 'invalid', + 'errors' => [$msg], + 'raw_sha1' => $rawPreviewSha1, + ]); + } catch (\Throwable $inner) { + // Last resort: cannot persist row; log only event + } } else { - $invalid++; - $importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]); - } - } - - // Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers - $personIdForRow = null; - // Prefer person from contract created/updated above - if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { - $ccId = $contractResult['contract']->client_case_id; - $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); - } - - // Payments: when present, require account resolution and create payment - if (isset($mapped['payment'])) { - // If no account yet, try to resolve via contract + first account - $accountIdForPayment = $accountResult['account']->id ?? null; - - // If payments-import mode with contract_key_mode=reference and we have contract.reference mapped, resolve by reference only - $tplMeta = optional($import->template)->meta ?? []; - $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); - $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; - if (! $accountIdForPayment && $paymentsImport && $contractKeyMode === 'reference') { - $contractRef = $mapped['contract']['reference'] ?? null; - if ($contractRef) { - $contract = \App\Models\Contract::query() - ->when($import->client_id, function ($q, $clientId) { - $q->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') - ->where('client_cases.client_id', $clientId); - }) - ->where('contracts.reference', $contractRef) - ->select('contracts.id') - ->first(); - if ($contract) { - $accountIdForPayment = \App\Models\Account::where('contract_id', $contract->id)->value('id'); - } + // Mark existing row as invalid (avoid double increment if already invalid) + if ($importRow->status !== 'invalid') { + $importRow->update(['status' => 'invalid', 'errors' => [$this->safeErrorMessage($e->getMessage())]]); } } - - if (! $accountIdForPayment) { - $invalid++; - $importRow->update(['status' => 'invalid', 'errors' => ['Payment requires an account. Could not resolve account for payment.']]); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_invalid', - 'level' => 'error', - 'message' => 'Payment requires an account (not resolved).', - ]); - - continue; - } - - // Build payment payload - $p = $mapped['payment']; - // Normalize reference and payment number (if provided) - $refVal = isset($p['reference']) ? (is_string($p['reference']) ? trim($p['reference']) : $p['reference']) : null; - $nuVal = isset($p['payment_nu']) ? (is_string($p['payment_nu']) ? trim($p['payment_nu']) : $p['payment_nu']) : null; - $payload = [ - 'account_id' => $accountIdForPayment, - 'reference' => $p['reference'] ?? null, - 'paid_at' => $p['payment_date'] ?? ($p['paid_at'] ?? null), - 'currency' => $p['currency'] ?? 'EUR', - 'created_by' => $user?->getAuthIdentifier(), - ]; - // Attach payment_nu into meta for idempotency if provided - $meta = []; - if (is_array($p['meta'] ?? null)) { - $meta = $p['meta']; - } - if (! empty($nuVal)) { - $meta['payment_nu'] = $nuVal; - } - if (! empty($meta)) { - $payload['meta'] = $meta; - } - // Amount: accept either amount (preferred) or legacy amount_cents; convert cents -> decimal - if (array_key_exists('amount', $p)) { - $payload['amount'] = is_string($p['amount']) ? (float) $this->normalizeDecimal($p['amount']) : (float) $p['amount']; - } elseif (array_key_exists('amount_cents', $p)) { - $payload['amount'] = ((int) $p['amount_cents']) / 100.0; - } - - // Idempotency: skip creating if a payment with same (account_id, reference) already exists - if (! empty($refVal)) { - $exists = Payment::query() - ->where('account_id', $accountIdForPayment) - ->where('reference', $refVal) - ->exists(); - if ($exists) { - $skipped++; - $importRow->update(['status' => 'skipped']); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'payment_duplicate_skipped', - 'level' => 'info', - 'message' => 'Skipped duplicate payment (by reference).', - 'context' => [ - 'account_id' => $accountIdForPayment, - 'reference' => $refVal, - ], - ]); - - continue; - } - } - - $payment = new Payment; - $payment->fill($payload); - // Save the account balance before applying this payment - $accForBal = Account::find($accountIdForPayment); - if ($accForBal) { - $payment->balance_before = (float) ($accForBal->balance_amount ?? 0); - } - // If amount not in payload yet but provided, set it directly - if (! array_key_exists('amount', $payload) && isset($p['amount'])) { - $payment->amount = (float) $this->normalizeDecimal($p['amount']); - } - try { - $payment->save(); - } catch (\Throwable $e) { - // Gracefully skip if unique index on (account_id, reference) is violated due to race conditions - if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) { - $skipped++; - $importRow->update(['status' => 'skipped']); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'payment_duplicate_skipped_db', - 'level' => 'info', - 'message' => 'Skipped duplicate payment due to database unique constraint (account_id, reference).', - 'context' => [ - 'account_id' => $accountIdForPayment, - 'reference' => $refVal, - ], - ]); - - continue; - } - throw $e; - } - - // Option A: create a credit booking so account balance updates via booking events - try { - if (isset($payment->amount)) { - \App\Models\Booking::query()->create([ - 'account_id' => $accountIdForPayment, - 'payment_id' => $payment->id, - 'amount_cents' => (int) round(((float) $payment->amount) * 100), - 'type' => 'credit', - 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', - 'booked_at' => $payment->paid_at ?? now(), - ]); - } - } catch (\Throwable $e) { - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'booking_create_failed', - 'level' => 'warning', - 'message' => 'Failed to create booking for payment: '.$e->getMessage(), - ]); - } - - // Optionally create an activity entry for this payment - try { - $settings = \App\Models\PaymentSetting::query()->first(); - if ($settings && ($settings->create_activity_on_payment ?? false)) { - $amountCents = (int) round(((float) $payment->amount) * 100); - $note = $settings->activity_note_template ?? 'Prejeto plačilo'; - $note = str_replace(['{amount}', '{currency}'], [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], $note); - - // Append balance context (before/after) and mark cause as payment - // At this point, booking has been created so the account balance should reflect the new amount - $accountAfter = Account::find($accountIdForPayment); - $beforeStr = number_format((float) ($payment->balance_before ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); - $afterStr = number_format((float) ($accountAfter?->balance_amount ?? 0), 2, ',', '.').' '.($payment->currency ?? 'EUR'); - $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; - - // Resolve client_case_id via account->contract - $accountForActivity = $accForBal ?: Account::find($accountIdForPayment); - $accountForActivity?->loadMissing('contract'); - $contractId = $accountForActivity?->contract_id; - $clientCaseId = $accountForActivity?->contract?->client_case_id; - - if ($clientCaseId) { - $activity = \App\Models\Activity::query()->create([ - 'due_date' => null, - 'amount' => $amountCents / 100, - 'note' => $note, - 'action_id' => $settings->default_action_id, - 'decision_id' => $settings->default_decision_id, - 'client_case_id' => $clientCaseId, - 'contract_id' => $contractId, - ]); - $payment->update(['activity_id' => $activity->id]); - } else { - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'payment_activity_skipped', - 'level' => 'info', - 'message' => 'Skipped creating activity for payment due to missing client_case_id on contract.', - ]); - } - } - } catch (\Throwable $e) { - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'payment_activity_failed', - 'level' => 'warning', - 'message' => 'Failed to create activity for payment: '.$e->getMessage(), - ]); - } - - $imported++; - $importRow->update([ - 'status' => 'imported', - 'entity_type' => Payment::class, - 'entity_id' => $payment->id, - ]); + $failedRows[] = $rowNum; + $invalid++; ImportEvent::create([ 'import_id' => $import->id, 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_imported', - 'level' => 'info', - 'message' => 'Inserted payment', - 'context' => ['id' => $payment->id], + 'import_row_id' => $importRow?->id, + 'event' => 'row_exception', + 'level' => 'error', + 'message' => $this->safeErrorMessage($e->getMessage()), + 'context' => [ + 'classification' => $this->classifyRowException($e), + 'driver' => DB::connection()->getDriverName(), + 'row_number' => $rowNum, + 'raw_sha1' => isset($rawAssoc) ? sha1(json_encode($rawAssoc)) : null, + 'raw_data_preview' => isset($rawAssoc) ? $this->buildRawDataPreview($rawAssoc) : [], + ], ]); - } - // If we have a contract reference, resolve existing contract for this client and derive person - if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) { - $existingContract = Contract::query() - ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') - ->where('client_cases.client_id', $import->client_id) - ->where('contracts.reference', $mapped['contract']['reference']) - ->select('contracts.client_case_id') - ->first(); - if ($existingContract) { - $personIdForRow = ClientCase::where('id', $existingContract->client_case_id)->value('person_id'); - } - } - // If account processing created/resolved a contract, derive person via its client_case - if (! $personIdForRow && $accountResult) { - if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) { - $ccId = $accountResult['contract']->client_case_id; - $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); - } elseif (isset($accountResult['contract_id'])) { - $ccId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id'); - if ($ccId) { - $personIdForRow = ClientCase::where('id', $ccId)->value('person_id'); - } - } - } - // Resolve by client_case.client_ref for this client (prefer reusing existing person) - if (! $personIdForRow && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) { - $cc = ClientCase::where('client_id', $import->client_id) - ->where('client_ref', $mapped['client_case']['client_ref']) - ->first(); - if ($cc) { - $personIdForRow = $cc->person_id ?: null; - } - } - // Resolve by contact values next - if (! $personIdForRow) { - $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 !== '') { - $personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id'); - } - if (! $personIdForRow && $addrLine !== '') { - $personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id'); - } - - // If still no person but we have any contact value, auto-create a minimal person - // BUT if we can map to an existing client_case by client_ref, reuse that case and set person there (avoid separate person rows) - if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) { - if ($import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) { - $cc = ClientCase::where('client_id', $import->client_id) - ->where('client_ref', $mapped['client_case']['client_ref']) - ->first(); - if ($cc) { - $pid = $cc->person_id ?: $this->createMinimalPersonId(); - if (! $cc->person_id) { - $cc->person_id = $pid; - $cc->save(); - } - $personIdForRow = $pid; - } - } - } - if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) { - $personIdForRow = $this->createMinimalPersonId(); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id ?? null, - 'event' => 'person_autocreated_for_contacts', - 'level' => 'info', - 'message' => 'Created minimal person to attach contact data (email/phone/address).', - 'context' => [ - 'email' => $emailVal ?: null, - 'phone' => $phoneNu ?: null, - 'address' => $addrLine ?: null, - 'person_id' => $personIdForRow, - ], - ]); - } - } - // Try identifiers from mapped person (no creation yet) - if (! $personIdForRow && ! empty($mapped['person'] ?? [])) { - $personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']); - } - // Finally, if still unknown and person fields provided, create - if (! $personIdForRow && ! empty($mapped['person'] ?? [])) { - $personIdForRow = $this->findOrCreatePersonId($mapped['person']); - } - - // At this point, personIdForRow is either resolved or remains null (no contacts/person data) - - $contactChanged = false; - if ($personIdForRow) { - if (! empty($mapped['email'] ?? [])) { - $r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; - } - } - if (! empty($mapped['address'] ?? [])) { - $r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; - } - } - if (! empty($mapped['phone'] ?? [])) { - $r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings); - if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) { - $contactChanged = true; - } - } - } - - if (! isset($mapped['contract']) && ! isset($mapped['account'])) { - if ($contactChanged) { - $imported++; - $importRow->update([ - 'status' => 'imported', - 'entity_type' => Person::class, - 'entity_id' => $personIdForRow, - ]); - ImportEvent::create([ - 'import_id' => $import->id, - 'user_id' => $user?->getAuthIdentifier(), - 'import_row_id' => $importRow->id, - 'event' => 'row_imported', - 'level' => 'info', - 'message' => 'Contacts upserted', - 'context' => ['person_id' => $personIdForRow], - ]); - } else { - $skipped++; - $importRow->update(['status' => 'skipped']); - } + // Skip to next row without aborting whole import + continue; } } fclose($fh); + if (! empty($failedRows)) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_exceptions_summary', + 'level' => 'warning', + 'message' => 'Rows failed: '.(count($failedRows) > 30 ? (implode(',', array_slice($failedRows, 0, 30)).' (+'.(count($failedRows) - 30).' more)') : implode(',', $failedRows)), + 'context' => [ + 'failed_count' => count($failedRows), + 'rows' => count($failedRows) > 200 ? array_slice($failedRows, 0, 200) : $failedRows, + ], + ]); + } + $import->update([ 'status' => 'completed', 'finished_at' => now(), @@ -1048,15 +1137,22 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; if ($clientCaseId) { - Activity::create([ - 'due_date' => null, - 'amount' => null, - 'note' => $note, - 'action_id' => null, - 'decision_id' => null, - 'client_case_id' => $clientCaseId, - 'contract_id' => $contractId, - ]); + // Use action_id from import meta if available to satisfy NOT NULL constraint on activities.action_id + $metaActionId = (int) ($import->meta['action_id'] ?? 0); + + if ($metaActionId > 0) { + Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => $metaActionId, + 'decision_id' => $import->meta['decision_id'] ?? null, + 'client_case_id' => $clientCaseId, + 'contract_id' => $contractId, + ]); + } else { + // If no action id is provided, skip creating the activity to avoid NOT NULL violation + } } } catch (\Throwable $e) { // Non-fatal: ignore activity creation failures @@ -1065,7 +1161,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } // also include contract hints for downstream contact resolution - return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId]; + return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId, 'applied_fields' => $changes]; } else { // On insert: if initial_amount is not provided but balance_amount is, allow defaulting // Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null). @@ -1089,7 +1185,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } $created = Account::create($data); - return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId]; + return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data]; } } @@ -1258,7 +1354,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): $existing->fill($changes); $existing->save(); - return ['action' => 'updated', 'contract' => $existing]; + return ['action' => 'updated', 'contract' => $existing, 'applied_fields' => $changes]; } else { if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; @@ -1271,7 +1367,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): $data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId(); $created = Contract::create($data); - return ['action' => 'inserted', 'contract' => $created]; + return ['action' => 'inserted', 'contract' => $created, 'applied_fields' => $data]; } } @@ -1355,6 +1451,120 @@ private function normalizeDecimal(string $raw): string return $s; } + /** + * Classify a row-level exception into a coarse category for diagnostics. + * duplicate|constraint|integrity|validation|db|unknown + */ + private function classifyRowException(\Throwable $e): string + { + $msg = strtolower($e->getMessage()); + if (str_contains($msg, 'duplicate') || str_contains($msg, 'unique') || str_contains($msg, 'already exists')) { + return 'duplicate'; + } + if (str_contains($msg, 'foreign key') || str_contains($msg, 'not-null') || str_contains($msg, 'violates') || str_contains($msg, 'constraint')) { + return 'constraint'; + } + if (str_contains($msg, 'integrity')) { + return 'integrity'; + } + if (str_contains($msg, 'missing') || str_contains($msg, 'required')) { + return 'validation'; + } + if (str_contains($msg, 'sqlstate') || str_contains($msg, 'syntax error') || str_contains($msg, 'invalid input')) { + return 'db'; + } + + return 'unknown'; + } + + /** + * Ensure error message is valid UTF-8 and safely truncated. + */ + private function safeErrorMessage(string $msg): string + { + // Convert to UTF-8, dropping invalid sequences + if (! mb_detect_encoding($msg, 'UTF-8', true)) { + $msg = mb_convert_encoding($msg, 'UTF-8', 'UTF-8'); + } + // Fallback strip invalid bytes + $msg = @iconv('UTF-8', 'UTF-8//IGNORE', $msg) ?: $msg; + if (strlen($msg) > 500) { + $msg = substr($msg, 0, 497).'...'; + } + + return $msg; + } + + /** + * Build a trimmed raw data preview (first 8 columns, truncated values) for logging. + */ + private function buildRawDataPreview(array $raw): array + { + $out = []; + $i = 0; + foreach ($raw as $k => $v) { + if ($i >= 8) { + break; + } + $val = is_scalar($v) || is_null($v) ? (string) $v : json_encode($v); + if (mb_strlen($val) > 80) { + $val = mb_substr($val, 0, 77).'...'; + } + $out[$k] = $val; + $i++; + } + + return $out; + } + + /** + * Build a concise human-readable field=value list for logging. + * Example: [account] reference=ACC123 balance_amount=100.00 + */ + private function formatAppliedFieldMessage(string $root, array $fields): string + { + if (empty($fields)) { + return ''; + } + $parts = []; + foreach ($fields as $k => $v) { + if (is_scalar($v) || is_null($v)) { + $disp = is_null($v) ? 'NULL' : (string) $v; + } elseif (is_array($v)) { + $disp = json_encode($v); + } else { + $disp = method_exists($v, '__toString') ? (string) $v : gettype($v); + } + // Truncate very long values for log safety + if (strlen($disp) > 60) { + $disp = substr($disp, 0, 57).'...'; + } + $parts[] = $k.'='.$disp; + } + + return '['.$root.'] '.implode(' ', $parts); + } + + /** + * Collect persisted payment fields (sanitized) for event logging. + */ + private function collectPaymentAppliedFields(array $payload, \App\Models\Payment $payment): array + { + $fields = []; + foreach (['account_id', 'reference', 'amount', 'paid_at', 'currency'] as $f) { + if (array_key_exists($f, $payload)) { + $fields[$f] = $payload[$f]; + } elseif (isset($payment->$f)) { + $fields[$f] = $payment->$f; + } + } + if (isset($payload['meta'])) { + $fields['meta'] = $payload['meta']; + } + + return $fields; + } + /** * Ensure mapping roots are recognized; fail fast if unknown roots found. */ diff --git a/app/Services/ImportSimulationService.php b/app/Services/ImportSimulationService.php new file mode 100644 index 0000000..7e92028 --- /dev/null +++ b/app/Services/ImportSimulationService.php @@ -0,0 +1,1200 @@ +meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; + $columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : []; + + $targetToSource = $this->buildTargetLookup($import); + if (! $targetToSource) { + return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); + } + + $fileResult = $this->readFileRows($import, $hasHeader, $delimiter, $columns, $limit); + if (isset($fileResult['error'])) { + return $this->errorPayload($fileResult['error']); + } + // Extract by reference modifications (columns adjusted if no header) + $rows = $fileResult['rows']; + $columns = $fileResult['columns']; + + // Discover mapped entity roots and then filter by supported list with safe fallbacks + $detectedRoots = $this->detectEntityRoots($targetToSource); + $supported = $this->loadSupportedEntityRoots(); + $entityRoots = $this->filterEntityRoots($detectedRoots, $supported, $targetToSource); + $summaries = $this->initSummaries($entityRoots); + + // Caches & running state + $contractCache = []; + $accountCache = []; + $genericCaches = []; // per root generic caches: [root => [reference => model|null]] + $runningBalances = []; + // Duplicate detection state: existing payment references per account + seen in this simulation + $existingPaymentRefs = []; // [account_id => [ref => true]] + $seenPaymentRefs = []; // [account_id => [ref => true]] + // Generic duplicate detection (by identity keys per root) + $genericExistingIdentities = []; // [root => [identity => true]] + $genericSeenIdentities = []; // [root => [identity => true]] + + $translatedActions = $this->actionTranslations(); + $translatedStatuses = $this->statusTranslations(); + + $simRows = []; + foreach ($rows as $idx => $rawValues) { + $assoc = $this->associateRow($columns, $rawValues); + $rowEntities = []; + + // Helper closure to resolve mapping value (with normalization fallbacks) + $val = function (string $tf) use ($assoc, $targetToSource) { + // Direct hit + if (isset($targetToSource[$tf])) { + return $assoc[$targetToSource[$tf]] ?? null; + } + // Fallback: normalize root part (contracts.reference -> contract.reference) + if (str_contains($tf, '.')) { + [$root, $rest] = explode('.', $tf, 2); + $norm = $this->normalizeRoot($root); + if ($norm !== $root) { + $alt = $norm.'.'.$rest; + if (isset($targetToSource[$alt])) { + return $assoc[$targetToSource[$alt]] ?? null; + } + } + } + + return null; + }; + + // Contract + if (isset($entityRoots['contract'])) { + [$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference')); + $rowEntities['contract'] = $contractEntity + [ + 'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'], + ]; + } + + // Account (explicit mapping with fallback inheritance from contract.reference when missing) + if (isset($entityRoots['account'])) { + $rawAccountRef = $val('account.reference'); + $inherited = false; + if (($rawAccountRef === null || $rawAccountRef === '') && isset($entityRoots['contract'])) { + $contractRef = $val('contract.reference'); + if ($contractRef !== null && $contractRef !== '') { + $rawAccountRef = $contractRef; + $inherited = true; + } + } + [$accountEntity, $summaries, $accountCache] = $this->simulateAccount($val, $summaries, $accountCache, $rawAccountRef); + if ($inherited) { + $accountEntity['inherited_reference'] = true; + } + $rowEntities['account'] = $accountEntity + [ + 'action_label' => $translatedActions[$accountEntity['action']] ?? $accountEntity['action'], + ]; + } + + // Determine if we have an existing contract (update) to derive chain entities later + $existingContract = isset($rowEntities['contract']['action']) && $rowEntities['contract']['action'] === 'update'; + + // Generic roots (person, address, email, phone, client_case, etc.) excluding already handled ones + foreach (array_keys($entityRoots) as $rootKey) { + if (in_array($rootKey, ['contract', 'account', 'payment'], true)) { + continue; // already simulated explicitly + } + // If contract already exists, we skip simulating person / client_case generically. + // ImportProcessor will not create new ones in that scenario; it reuses the chain. + if ($existingContract && in_array($rootKey, ['person', 'client_case'], true)) { + continue; + } + $reference = $val($rootKey.'.reference'); + $identityCandidates = $this->genericIdentityCandidates($rootKey, $val); + [$genericEntity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities] + = $this->simulateGenericRoot( + $rootKey, + $val, + $summaries, + $genericCaches, + $reference, + $identityCandidates, + $genericExistingIdentities, + $genericSeenIdentities, + $verbose, + $targetToSource, + ); + $rowEntities[$rootKey] = $genericEntity + [ + 'action_label' => $translatedActions[$genericEntity['action']] ?? $genericEntity['action'], + ]; + } + + // Attach chain entities (client_case, person) if contract already existed + if ($existingContract && isset($rowEntities['contract']['reference'])) { + $contractRef = $rowEntities['contract']['reference']; + $contractModel = $contractRef && isset($contractCache[$contractRef]) ? $contractCache[$contractRef] : null; + if ($contractModel) { + // Load client_case if mapped root present + if (isset($entityRoots['client_case']) && $contractModel->client_case_id) { + $cc = ClientCase::query()->find($contractModel->client_case_id, ['id', 'client_ref', 'person_id']); + if ($cc) { + if (! isset($summaries['client_case'])) { + $summaries['client_case'] = [ + 'root' => 'client_case', + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + $summaries['client_case']['total_rows']++; + $summaries['client_case']['update']++; + $rowEntities['client_case'] = [ + 'id' => $cc->id, + 'reference' => $cc->client_ref, + 'exists' => true, + 'action' => 'update', + 'action_label' => $translatedActions['update'] ?? 'posodobi', + 'existing_chain' => true, + ]; + // Person from chain if mapped + if (isset($entityRoots['person']) && $cc->person_id) { + $p = Person::query()->find($cc->person_id, ['id', 'nu', 'full_name', 'first_name', 'last_name', 'birthday', 'description']); + if ($p) { + if (! isset($summaries['person'])) { + $summaries['person'] = [ + 'root' => 'person', + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + $summaries['person']['total_rows']++; + $summaries['person']['update']++; + $rowEntities['person'] = [ + 'id' => $p->id, + 'reference' => $p->nu ?? (string) $p->id, + 'exists' => true, + 'action' => 'update', + 'action_label' => $translatedActions['update'] ?? 'posodobi', + 'existing_chain' => true, + 'full_name' => $p->full_name, + 'first_name' => $p->first_name, + 'last_name' => $p->last_name, + 'birthday' => $p->birthday, + 'description' => $p->description, + ]; + // Attach email/phone/address if their roots are mapped and we skipped generic simulation + if ($p->id) { + // Email + if (isset($entityRoots['email']) && ! isset($rowEntities['email'])) { + $em = Email::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'value']); + if ($em) { + if (! isset($summaries['email'])) { + $summaries['email'] = [ + 'root' => 'email', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, + ]; + } + $summaries['email']['total_rows']++; + $summaries['email']['update']++; + $rowEntities['email'] = [ + 'id' => $em->id, + 'reference' => $em->value, + 'value' => $em->value, + 'exists' => true, + 'action' => 'update', + 'action_label' => $translatedActions['update'] ?? 'posodobi', + 'existing_chain' => true, + ]; + } + } + // Phone + if (isset($entityRoots['phone']) && ! isset($rowEntities['phone'])) { + $ph = PersonPhone::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'nu']); + if ($ph) { + if (! isset($summaries['phone'])) { + $summaries['phone'] = [ + 'root' => 'phone', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, + ]; + } + $summaries['phone']['total_rows']++; + $summaries['phone']['update']++; + $rowEntities['phone'] = [ + 'id' => $ph->id, + 'reference' => $ph->nu, + 'nu' => $ph->nu, + 'exists' => true, + 'action' => 'update', + 'action_label' => $translatedActions['update'] ?? 'posodobi', + 'existing_chain' => true, + ]; + } + } + // Address + if (isset($entityRoots['address']) && ! isset($rowEntities['address'])) { + $ad = PersonAddress::query()->where('person_id', $p->id)->orderBy('id')->first(['id', 'address', 'country']); + if ($ad) { + if (! isset($summaries['address'])) { + $summaries['address'] = [ + 'root' => 'address', 'total_rows' => 0, 'create' => 0, 'update' => 0, 'missing_ref' => 0, 'invalid' => 0, 'duplicate' => 0, 'duplicate_db' => 0, + ]; + } + $summaries['address']['total_rows']++; + $summaries['address']['update']++; + $rowEntities['address'] = [ + 'id' => $ad->id, + 'reference' => $ad->address, + 'address' => $ad->address, + // postal_code removed (not in schema) + 'country' => $ad->country, + 'exists' => true, + 'action' => 'update', + 'action_label' => $translatedActions['update'] ?? 'posodobi', + 'existing_chain' => true, + ]; + } + } + } + } + } + } + } + } + } + + // If existing contract: upgrade generic email/phone/address entities (already simulated) to mark as chain if corresponding person attached + if ($existingContract && isset($rowEntities['person']['id'])) { + foreach (['email', 'phone', 'address'] as $gRoot) { + if (isset($rowEntities[$gRoot]) && ! ($rowEntities[$gRoot]['existing_chain'] ?? false)) { + $rowEntities[$gRoot]['existing_chain'] = true; // mark for UI toggle + } + } + } + + // Payment (affects account balance; may create implicit account) + if (isset($entityRoots['payment'])) { + // Inject inferred account if none mapped explicitly + if (! isset($entityRoots['account']) && isset($rowEntities['contract']['id'])) { + [$implicitAccount, $summaries, $accountCache] = $this->simulateImplicitAccount($rowEntities['contract']['id'], $summaries, $accountCache); + if ($implicitAccount) { + $rowEntities['account'] = $implicitAccount + [ + 'action_label' => $translatedActions[$implicitAccount['action']] ?? $implicitAccount['action'], + ]; + } + } + [$paymentEntity, $rowEntities, $summaries, $runningBalances, $existingPaymentRefs, $seenPaymentRefs] = $this->simulatePayment( + $val, + $rowEntities, + $summaries, + $runningBalances, + $targetToSource, + $verbose, + $existingPaymentRefs, + $seenPaymentRefs + ); + $paymentEntity['status_label'] = $translatedStatuses[$paymentEntity['status']] ?? $paymentEntity['status']; + $rowEntities['payment'] = $paymentEntity; + } + + // If verbose, attach source metadata for non-payment entities (reference fields) to aid debugging + if ($verbose) { + foreach ($rowEntities as $eroot => &$ent) { + $tf = $eroot.'.reference'; + if (isset($targetToSource[$tf])) { + $ent['sources'] = $ent['sources'] ?? []; + if (! isset($ent['sources'][$tf])) { + $ent['sources'][$tf] = [ + 'source_column' => $targetToSource[$tf], + 'value' => $val($tf), + ]; + } + } + } + unset($ent); + } + + // Compute delta for account if present (frontend may filter on this) + if (isset($rowEntities['account']['balance_before'], $rowEntities['account']['balance_after'])) { + $rowEntities['account']['delta'] = $rowEntities['account']['balance_after'] - $rowEntities['account']['balance_before']; + } + + $rowStatus = 'ok'; + if (isset($rowEntities['payment']['status']) && $rowEntities['payment']['status'] !== 'ok') { + $rowStatus = $rowEntities['payment']['status']; + } + $simRows[] = [ + 'index' => $idx + 1, + 'entities' => $rowEntities, + 'status' => $rowStatus, + ]; + } + + // Prune roots that are entirely empty (all rows action=skip and no identity or preview data) + $nonEmptyRoots = []; + // Map whether root has any mapping keys (after normalization) to avoid hiding legitimately mapped-but-empty columns early + $rootHasMapping = []; + foreach (array_keys($targetToSource) as $tfKey) { + if (str_contains($tfKey, '.')) { + [$r] = explode('.', $tfKey, 2); + $rootHasMapping[$r] = true; + } + } + foreach ($simRows as $row) { + if (! isset($row['entities'])) { + continue; + } + foreach ($row['entities'] as $root => $ent) { + if (! isset($entityRoots[$root])) { + continue; + } + // Determine if entity has meaningful data + $hasData = false; + foreach (['reference', 'identity_used', 'identity_candidates', 'full_name', 'first_name', 'last_name', 'address', 'country', 'nu', 'value'] as $k) { + if (isset($ent[$k]) && $ent[$k]) { + $hasData = true; + break; + } + } + // Some entities (e.g. payment) do not have 'action'; treat them as non-empty if they have data or status + if (! isset($ent['action']) || $ent['action'] !== 'skip' || $hasData || isset($ent['status'])) { + $nonEmptyRoots[$root] = true; + } + } + } + // Filter entityRoots and rows + $neverPrune = ['person', 'address', 'client_case']; + foreach (array_keys($entityRoots) as $root) { + if (! isset($nonEmptyRoots[$root]) + && ! in_array($root, ['contract', 'account', 'payment'], true) + && ! in_array($root, $neverPrune, true) + && empty($rootHasMapping[$root]) + ) { + unset($entityRoots[$root]); + unset($summaries[$root]); + // Remove from each row + foreach ($simRows as &$row) { + if (isset($row['entities'][$root])) { + unset($row['entities'][$root]); + } + } + unset($row); + } + } + + // Add Slovenian summary mirror (does not replace original machine keys) + $localizedSummaries = $this->localizeSummaries($summaries); + + return [ + 'rows' => $simRows, + 'entities' => array_keys($entityRoots), + 'summaries' => $summaries, + 'povzetki' => $localizedSummaries, // Slovenian friendly summaries + 'lokalizacija' => [ + 'dejanja' => $translatedActions, + 'statusi' => $translatedStatuses, + ], + ]; + } + + /* ---------------------------- Helper: structure ---------------------------- */ + + private function buildTargetLookup(Import $import): array + { + $mappings = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->get(['source_column', 'target_field']); + + $lookup = []; + foreach ($mappings as $m) { + $target = trim((string) $m->target_field); + $source = trim((string) $m->source_column); + if ($target === '' || $source === '') { + continue; + } + if (! isset($lookup[$target])) { + $lookup[$target] = $source; + } + // If mapping uses *.client_ref, also register *.reference alias for simulation reference purposes + if (str_ends_with($target, '.client_ref')) { + $alias = substr($target, 0, -strlen('.client_ref')).'.reference'; + if (! isset($lookup[$alias])) { + $lookup[$alias] = $source; + } + } + if (str_contains($target, '.')) { + [$root, $rest] = explode('.', $target, 2); + $norm = $this->normalizeRoot($root); + if ($norm !== $root) { + $alt = $norm.'.'.$rest; + if (! isset($lookup[$alt])) { + $lookup[$alt] = $source; + } + } + if (str_ends_with($root, 's')) { + $sing = substr($root, 0, -1); + if ($sing && $sing !== $root) { + $alt2 = $sing.'.'.$rest; + if (! isset($lookup[$alt2])) { + $lookup[$alt2] = $source; + } + } + } + } + } + + return $lookup; + } + + private function readFileRows(Import $import, bool $hasHeader, string $delimiter, array $columns, int $limit): array + { + $path = Storage::disk($import->disk)->path($import->path); + if (! is_readable($path)) { + return ['error' => 'Datoteka ni berljiva']; + } + $fh = @fopen($path, 'r'); + if (! $fh) { + return ['error' => 'Datoteke ni mogoče odpreti']; + } + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter) ?: []; + $columns = array_map(static fn ($h) => is_string($h) ? trim($h) : (string) $h, $header); + } + $rows = []; + $widest = count($columns); + while (($data = fgetcsv($fh, 0, $delimiter)) !== false && count($rows) < $limit) { + if (! $hasHeader) { + $widest = max($widest, count($data)); + } + $rows[] = $data; + } + fclose($fh); + if (! $hasHeader && $widest > count($columns)) { + $columns = array_map(static fn ($i) => 'col_'.($i + 1), range(0, $widest - 1)); + } + + return compact('rows', 'columns'); + } + + private function detectEntityRoots(array $targetToSource): array + { + $roots = []; + foreach (array_keys($targetToSource) as $tf) { + if (str_contains($tf, '.')) { + [$root] = explode('.', $tf, 2); + $roots[$this->normalizeRoot($root)] = true; + } + } + + return $roots; // associative for faster isset checks + } + + /** + * Normalize mapping root keys (plural or table-like) to canonical simulation roots. + */ + private function normalizeRoot(string $root): string + { + static $map = [ + 'contracts' => 'contract', + 'contract' => 'contract', + 'accounts' => 'account', + 'account' => 'account', + 'payments' => 'payment', + 'payment' => 'payment', + 'emails' => 'email', + 'email' => 'email', + 'person_addresses' => 'address', + 'person_address' => 'address', + 'person_addresse' => 'address', + 'addresses' => 'address', + 'address' => 'address', + 'person_phones' => 'phone', + 'person_phone' => 'phone', + 'phones' => 'phone', + 'phone' => 'phone', + 'client_cases' => 'client_case', + 'client_case' => 'client_case', + 'people' => 'person', + 'persons' => 'person', + 'person' => 'person', + ]; + + return $map[$root] ?? $root; + } + + /** + * Filter detected entity roots against supported list coming from import_entities table. + * Guarantees that core roots (payment, account, contract) are retained if they are mapped. + * If supported list is empty (e.g. table empty / query failure), falls back to all detected. + * Additionally, if filtering would yield an empty set while we still have mappings, it will + * keep the original detected set to avoid hiding entities (fail-open strategy for UX). + */ + private function filterEntityRoots(array $detected, array $supported, array $targetToSource): array + { + // Fail-open if no supported list gathered + if (empty($supported)) { + return $detected; + } + + $supportedFlip = array_flip($supported); + $filtered = []; + foreach ($detected as $root => $flag) { + if (isset($supportedFlip[$root])) { + $filtered[$root] = $flag; + } + } + + // Always retain core roots if they were mapped, even if not in supported list + foreach (['payment', 'account', 'contract', 'address'] as $core) { + if (isset($detected[$core])) { + $filtered[$core] = true; + } + } + + // If after filtering nothing remains but mappings exist, revert (avoid confusing empty output) + if (empty($filtered) && ! empty($detected)) { + return $detected; + } + + return $filtered; + } + + private function initSummaries(array $entityRoots): array + { + $summaries = []; + foreach (array_keys($entityRoots) as $root) { + $summaries[$root] = [ + 'root' => $root, + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + + return $summaries; + } + + private function associateRow(array $columns, array $values): array + { + $assoc = []; + foreach ($columns as $i => $col) { + $assoc[$col] = $values[$i] ?? null; + } + + return $assoc; + } + + /* -------------------------- Entity simulation parts -------------------------- */ + + private function simulateContract(callable $val, array $summaries, array $cache, ?string $reference): array + { + $contract = null; + if ($reference) { + if (array_key_exists($reference, $cache)) { + $contract = $cache[$reference]; + } else { + $contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id']); + $cache[$reference] = $contract; // may be null + } + } + $entity = [ + 'reference' => $reference, + 'id' => $contract?->id, + 'exists' => (bool) $contract, + 'client_case_id' => $contract?->client_case_id, + 'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'), + ]; + $summaries['contract']['total_rows']++; + if (! $reference) { + $summaries['contract']['missing_ref']++; + } elseif ($contract) { + $summaries['contract']['update']++; + } else { + $summaries['contract']['create']++; + } + + return [$entity, $summaries, $cache]; + } + + private function simulateAccount(callable $val, array $summaries, array $cache, ?string $reference): array + { + $account = null; + if ($reference) { + if (array_key_exists($reference, $cache)) { + $account = $cache[$reference]; + } else { + $account = Account::query()->where('reference', $reference)->first(['id', 'reference', 'balance_amount']); + $cache[$reference] = $account; + } + } + $entity = [ + 'reference' => $reference, + 'id' => $account?->id, + 'exists' => (bool) $account, + 'balance_before' => $account?->balance_amount, + 'balance_after' => $account?->balance_amount, + 'action' => $account ? 'update' : ($reference ? 'create' : 'skip'), + ]; + + // Direct balance override support. + // Some mappings may have stored the plural root ("accounts.balance_amount") instead of the singular + // that the value resolver expects. Also allow a simpler fallback key (account.balance). + $rawIncoming = $val('account.balance_amount') + ?? $val('accounts.balance_amount') + ?? $val('account.balance'); + if ($rawIncoming !== null && $rawIncoming !== '') { + $rawStr = (string) $rawIncoming; + // Remove currency symbols and non numeric punctuation except , . - + $clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? ''; + if ($clean !== '') { + // If both comma and dot exist, assume dot is decimal separator => strip commas (thousands) + if (str_contains($clean, ',') && str_contains($clean, '.')) { + $normalized = str_replace(',', '', $clean); + } else { + // Only one of them present -> treat comma as decimal separator + $normalized = str_replace(',', '.', $clean); + } + // Collapse multiple dots keeping last as decimal (edge case). If multiple appear, remove all but last. + if (substr_count($normalized, '.') > 1) { + $parts = explode('.', $normalized); + $last = array_pop($parts); + $normalized = preg_replace('/\.+/', '', implode('', $parts)).'.'.$last; // join integer part + } + if (is_numeric($normalized)) { + $incoming = (float) $normalized; + $entity['balance_after'] = $incoming; + $entity['direct_balance_override'] = true; + } + } + } + $summaries['account']['total_rows']++; + if (! $reference) { + $summaries['account']['missing_ref']++; + } elseif ($account) { + $summaries['account']['update']++; + } else { + $summaries['account']['create']++; + } + + return [$entity, $summaries, $cache]; + } + + private function simulateImplicitAccount(int $contractId, array $summaries, array $cache): array + { + $acct = Account::query()->where('contract_id', $contractId)->orderBy('id')->first(['id', 'reference', 'balance_amount']); + if (! $acct) { + return [null, $summaries, $cache]; + } + $entity = [ + 'reference' => $acct->reference, + 'id' => $acct->id, + 'exists' => true, + 'balance_before' => $acct->balance_amount, + 'balance_after' => $acct->balance_amount, + 'action' => 'implicit', + 'inferred' => true, + ]; + if (! isset($summaries['account'])) { + $summaries['account'] = [ + 'root' => 'account', + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + ]; + } + $summaries['account']['total_rows']++; + $summaries['account']['update']++; + $cache[$acct->reference] = $acct; + + return [$entity, $summaries, $cache]; + } + + private function simulatePayment( + callable $val, + array $rowEntities, + array $summaries, + array $runningBalances, + array $targetToSource, + bool $verbose, + array $existingPaymentRefs, + array $seenPaymentRefs, + ): array { + $rawAmount = $val('payment.amount'); + $amount = null; + if ($rawAmount !== null && $rawAmount !== '') { + $norm = str_replace([' ', ','], ['', '.'], (string) $rawAmount); + if (is_numeric($norm)) { + $amount = (float) $norm; + } + } + $date = $val('payment.payment_date'); + $reference = $val('payment.reference'); + + // Adjust account running balance + if (isset($rowEntities['account']['id']) && empty($rowEntities['account']['direct_balance_override'])) { + $accId = $rowEntities['account']['id']; + $initial = $runningBalances[$accId] ?? (float) $rowEntities['account']['balance_before']; + $before = $initial; + $after = $initial; + if ($amount !== null) { + $after = $initial - $amount; // payment reduces balance + $runningBalances[$accId] = $after; + $rowEntities['account']['balance_before'] = $before; + $rowEntities['account']['balance_after'] = $after; + } + } + + $entity = [ + 'amount' => $amount, + 'payment_date' => $date, + 'reference' => $reference, + 'status' => $amount === null ? 'invalid_amount' : 'ok', + ]; + + if ($verbose) { // Only include verbose structures when requested + $effectiveSources = []; + foreach (['payment.amount', 'payment.payment_date', 'payment.reference', 'contract.reference', 'account.reference'] as $tf) { + if (isset($targetToSource[$tf])) { + $effectiveSources[$tf] = [ + 'source_column' => $targetToSource[$tf], + 'value' => $val($tf), + ]; + if ($tf === 'payment.amount') { + $effectiveSources[$tf]['normalized'] = $amount; + } + } + } + $entity['sources'] = $effectiveSources; + $entity['raw_amount'] = $rawAmount; + } + + // Duplicate detection (only if have reference and an account id and status ok so far) + if ($entity['status'] === 'ok' && $reference !== null && $reference !== '' && isset($rowEntities['account']['id'])) { + $accId = $rowEntities['account']['id']; + // Load existing refs lazily + if (! isset($existingPaymentRefs[$accId])) { + $existingPaymentRefs[$accId] = []; + // Only query if account exists in DB (id assumed existing if action update/implicit) + if (! empty($accId)) { + foreach (Payment::query()->where('account_id', $accId)->pluck('reference') as $ref) { + if ($ref !== null && $ref !== '') { + $existingPaymentRefs[$accId][$ref] = true; + } + } + } + } + if (isset($existingPaymentRefs[$accId][$reference])) { + $entity['status'] = 'duplicate_db'; + } else { + if (! isset($seenPaymentRefs[$accId])) { + $seenPaymentRefs[$accId] = []; + } + if (isset($seenPaymentRefs[$accId][$reference])) { + $entity['status'] = 'duplicate'; + } else { + $seenPaymentRefs[$accId][$reference] = true; + } + } + } + + $summaries['payment']['total_rows'] = ($summaries['payment']['total_rows'] ?? 0) + 1; + if ($amount === null) { + $summaries['payment']['invalid'] = ($summaries['payment']['invalid'] ?? 0) + 1; + } + if (isset($entity['status']) && $entity['status'] === 'duplicate') { + $summaries['payment']['duplicate'] = ($summaries['payment']['duplicate'] ?? 0) + 1; + } + if (isset($entity['status']) && $entity['status'] === 'duplicate_db') { + $summaries['payment']['duplicate_db'] = ($summaries['payment']['duplicate_db'] ?? 0) + 1; + } + + return [$entity, $rowEntities, $summaries, $runningBalances, $existingPaymentRefs, $seenPaymentRefs]; + } + + private function simulateGenericRoot( + string $root, + callable $val, + array $summaries, + array $genericCaches, + ?string $reference, + array $identityCandidates, + array $genericExistingIdentities, + array $genericSeenIdentities, + bool $verbose = false, + array $targetToSource = [], + ): array { + // Ensure summary bucket exists + if (! isset($summaries[$root])) { + $summaries[$root] = [ + 'root' => $root, + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + $summaries[$root]['total_rows']++; + + $modelClass = $this->modelClassForGeneric($root); + $record = null; + if ($reference) { + if (! isset($genericCaches[$root])) { + $genericCaches[$root] = []; + } + if (array_key_exists($reference, $genericCaches[$root])) { + $record = $genericCaches[$root][$reference]; + } elseif ($modelClass && class_exists($modelClass)) { + // Try/catch to avoid issues if column doesn't exist + try { + if (\Schema::hasColumn((new $modelClass)->getTable(), 'reference')) { + $record = $modelClass::query()->where('reference', $reference)->first(['id', 'reference']); + } + } catch (\Throwable) { + $record = null; + } + $genericCaches[$root][$reference] = $record; // may be null + } + } + + // Fallback reference derivation for specific roots when explicit reference missing + if (! $reference) { + if ($root === 'client_case') { + $reference = $val('client_case.client_ref'); + } elseif ($root === 'person') { + // Derive pseudo-reference from first_name (or full_name) if nothing else present so UI shows something + $reference = $val('person.first_name') ?: $val('person.full_name'); + } elseif ($root === 'address') { + $reference = $val('address.address'); + } elseif ($root === 'phone') { + $reference = $val('phone.nu'); + } elseif ($root === 'email') { + $reference = $val('email.value'); + } + } + + $entity = [ + 'reference' => $reference, + 'id' => $record?->id, + 'exists' => (bool) $record, + 'action' => $reference ? ($record ? 'update' : 'create') : 'skip', + // collect identity candidates for UI (raw list) and chosen identity marker + 'identity_candidates' => $identityCandidates, + ]; + + // Lightweight attribute previews (non-persistent, for UI clarity only) + switch ($root) { + case 'person': + $entity['full_name'] = $val('person.full_name') ?? null; + $entity['first_name'] = $val('person.first_name') ?? null; + $entity['last_name'] = $val('person.last_name') ?? null; + $entity['description'] = $val('person.description') ?? null; + $entity['birthday'] = $val('person.birthday') ?? null; + break; + case 'address': + $entity['address'] = $val('address.address') ?? null; + // postal_code not present in schema + $entity['country'] = $val('address.country') ?? null; + break; + case 'phone': + $entity['nu'] = $val('phone.nu') ?? null; + break; + case 'email': + $entity['value'] = $val('email.value') ?? null; + break; + case 'client_case': + $entity['title'] = $val('client_case.title') ?? null; + $entity['status'] = $val('client_case.status') ?? null; + break; + } + + if ($verbose) { + $srcs = []; + foreach ($targetToSource as $tf => $col) { + if (str_starts_with($tf, $root.'.')) { + $srcs[$tf] = [ + 'source_column' => $col, + 'value' => $val($tf), + ]; + } + } + if ($srcs) { + $entity['sources'] = $entity['sources'] ?? []; + $entity['sources'] += $srcs; + } + } + if (! $reference) { + $summaries[$root]['missing_ref']++; + } elseif ($record) { + $summaries[$root]['update']++; + } else { + $summaries[$root]['create']++; + } + // Duplicate detection based on identity candidates (first successful identity used) + foreach ($identityCandidates as $identity) { + if ($identity === null || $identity === '') { + continue; + } + // Load existing identities once per root + if (! isset($genericExistingIdentities[$root])) { + $genericExistingIdentities[$root] = $this->loadExistingGenericIdentities($root); + } + if (isset($genericExistingIdentities[$root][$identity])) { + $entity['duplicate_db'] = true; + $entity['identity_used'] = $identity; + $summaries[$root]['duplicate_db']++; + break; + } + if (! isset($genericSeenIdentities[$root])) { + $genericSeenIdentities[$root] = []; + } + if (isset($genericSeenIdentities[$root][$identity])) { + $entity['duplicate'] = true; + $entity['identity_used'] = $identity; + $summaries[$root]['duplicate']++; + break; + } + // Mark seen and continue to next identity candidate (only first unique tracked) + $genericSeenIdentities[$root][$identity] = true; + $entity['identity_used'] = $identity; + break; + } + + return [$entity, $summaries, $genericCaches, $genericExistingIdentities, $genericSeenIdentities]; + } + + private function genericIdentityCandidates(string $root, callable $val): array + { + switch ($root) { + case 'email': + $v = $val('email.value'); + + return $v ? ['value:'.mb_strtolower(trim((string) $v))] : []; + case 'phone': + $nu = $val('phone.nu'); + if ($nu) { + $norm = preg_replace('/\D+/', '', (string) $nu) ?? ''; + + return $norm ? ['nu:'.$norm] : []; + } + + return []; + case 'person': + $ids = []; + $tax = $val('person.tax_number'); + if ($tax) { + $ids[] = 'tax:'.mb_strtolower(trim((string) $tax)); + } + $ssn = $val('person.social_security_number'); + if ($ssn) { + $ids[] = 'ssn:'.mb_strtolower(trim((string) $ssn)); + } + $full = $val('person.full_name'); + if ($full) { + $ids[] = 'full:'.mb_strtolower(trim((string) $full)); + } + + return $ids; + case 'address': + $addr = $val('address.address'); + $pc = null; // postal code not stored + $country = $val('address.country'); + if ($addr || $pc || $country) { + $key = mb_strtolower(trim((string) ($addr ?? ''))).'|'.mb_strtolower(trim((string) ($pc ?? ''))).'|'.mb_strtolower(trim((string) ($country ?? ''))); + + return ['addr:'.$key]; + } + + return []; + default: + return []; + } + } + + private function loadExistingGenericIdentities(string $root): array + { + $set = []; + try { + switch ($root) { + case 'email': + foreach (\App\Models\Email::query()->pluck('value') as $v) { + if ($v) { + $set['value:'.mb_strtolower(trim((string) $v))] = true; + } + } + break; + case 'phone': + foreach (\App\Models\Person\PersonPhone::query()->pluck('nu') as $p) { + if ($p) { + $set['nu:'.preg_replace('/\D+/', '', (string) $p)] = true; + } + } + break; + case 'person': + foreach (\App\Models\Person\Person::query()->get(['tax_number', 'social_security_number', 'full_name']) as $rec) { + if ($rec->tax_number) { + $set['tax:'.mb_strtolower(trim((string) $rec->tax_number))] = true; + } + if ($rec->social_security_number) { + $set['ssn:'.mb_strtolower(trim((string) $rec->social_security_number))] = true; + } + if ($rec->full_name) { + $set['full:'.mb_strtolower(trim((string) $rec->full_name))] = true; + } + } + break; + case 'address': + foreach (\App\Models\Person\PersonAddress::query()->get(['address', 'country']) as $rec) { + $key = mb_strtolower(trim((string) ($rec->address ?? ''))).'|'.mb_strtolower(trim((string) ($rec->country ?? ''))); + if (trim($key, '|') !== '') { + $set['addr:'.$key] = true; + } + } + break; + } + } catch (\Throwable) { + // swallow and return what we have + } + + return $set; + } + + private function modelClassForGeneric(string $root): ?string + { + // Explicit mapping for known roots; extend as needed + return [ + 'person' => \App\Models\Person\Person::class, + 'address' => \App\Models\Person\PersonAddress::class, + 'phone' => \App\Models\Person\PersonPhone::class, + 'email' => \App\Models\Email::class, + 'booking' => \App\Models\Booking::class, + 'activity' => \App\Models\Activity::class, + 'client' => \App\Models\Client::class, + 'client_case' => \App\Models\ClientCase::class, + ][$root] ?? null; + } + + private function loadSupportedEntityRoots(): array + { + // Pull keys + canonical_root from import_entities table to determine allowed roots + try { + $rows = \App\Models\ImportEntity::query()->get(['key', 'canonical_root']); + $roots = []; + foreach ($rows as $r) { + if ($r->canonical_root) { + $roots[] = $r->canonical_root; + } + if ($r->key) { + // keys sometimes plural; we only want canonical forms for simulation root detection + // keep both to be safe + $roots[] = $r->key; + } + } + // Normalize underscores plural forms to canonical ones (contracts -> contract) where possible + $roots = array_unique(array_map(function ($v) { + if (str_ends_with($v, 's')) { + $sing = substr($v, 0, -1); + + return $sing ?: $v; + } + + return $v; + }, $roots)); + + return $roots; + } catch (\Throwable) { + // Fallback: allow existing known roots if table unavailable + return ['contract', 'account', 'payment', 'person', 'address', 'phone', 'email', 'booking', 'activity', 'client', 'client_case']; + } + } + + /* ------------------------------- Localization ------------------------------- */ + + private function actionTranslations(): array + { + return [ + 'create' => 'ustvari', + 'update' => 'posodobi', + 'skip' => 'preskoči', + 'implicit' => 'posredno', + ]; + } + + private function statusTranslations(): array + { + return [ + 'ok' => 'v_redu', + 'invalid_amount' => 'neveljaven_znesek', + 'duplicate' => 'podvojen', + 'duplicate_db' => 'podvojen_v_bazi', + ]; + } + + private function localizeSummaries(array $summaries): array + { + $map = []; + foreach ($summaries as $root => $s) { + $map[$root] = [ + 'koren' => $root, + 'vrstice_skupaj' => $s['total_rows'], + 'za_ustvariti' => $s['create'], + 'za_posodobiti' => $s['update'], + 'manjkajoca_referenca' => $s['missing_ref'], + 'neveljavno' => $s['invalid'], + 'podvojeni' => $s['duplicate'] ?? 0, + 'podvojeni_v_bazi' => $s['duplicate_db'] ?? 0, + ]; + } + + return $map; + } + + private function errorPayload(string $message): array + { + return [ + 'rows' => [], + 'entities' => [], + 'summaries' => [], + 'error' => $message, + ]; + } +} diff --git a/database/factories/EmailFactory.php b/database/factories/EmailFactory.php new file mode 100644 index 0000000..5430fe1 --- /dev/null +++ b/database/factories/EmailFactory.php @@ -0,0 +1,27 @@ + + */ +class EmailFactory extends Factory +{ + protected $model = Email::class; + + public function definition(): array + { + return [ + 'person_id' => Person::factory(), + 'value' => $this->faker->unique()->safeEmail(), + 'label' => null, + 'is_primary' => false, + 'is_active' => true, + 'valid' => true, + ]; + } +} diff --git a/database/factories/ImportFactory.php b/database/factories/ImportFactory.php new file mode 100644 index 0000000..b12ba1a --- /dev/null +++ b/database/factories/ImportFactory.php @@ -0,0 +1,32 @@ + (string) Str::uuid(), + 'user_id' => null, + 'import_template_id' => null, + 'client_id' => null, + 'source_type' => 'csv', + 'file_name' => 'test.csv', + 'original_name' => 'test.csv', + 'disk' => 'local', + 'path' => 'imports/test.csv', + 'status' => 'uploaded', + 'meta' => [ + 'has_header' => true, + 'forced_delimiter' => ',', + ], + ]; + } +} diff --git a/database/factories/Person/AddressTypeFactory.php b/database/factories/Person/AddressTypeFactory.php index c28c5a0..e04d600 100644 --- a/database/factories/Person/AddressTypeFactory.php +++ b/database/factories/Person/AddressTypeFactory.php @@ -17,7 +17,8 @@ class AddressTypeFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->randomElement(['Home', 'Work', 'Other']), + 'description' => $this->faker->optional()->sentence(), ]; } } diff --git a/database/factories/Person/PersonAddressFactory.php b/database/factories/Person/PersonAddressFactory.php index a521d80..b7b7968 100644 --- a/database/factories/Person/PersonAddressFactory.php +++ b/database/factories/Person/PersonAddressFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories\Person; +use App\Models\Person\AddressType; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +19,10 @@ class PersonAddressFactory extends Factory public function definition(): array { return [ - // + 'address' => $this->faker->streetAddress(), + 'country' => 'SI', + 'type_id' => AddressType::factory(), + 'user_id' => User::factory(), ]; } } diff --git a/database/factories/Person/PersonFactory.php b/database/factories/Person/PersonFactory.php index 22d9d7e..33fc9cb 100644 --- a/database/factories/Person/PersonFactory.php +++ b/database/factories/Person/PersonFactory.php @@ -4,6 +4,7 @@ use App\Models\Person\PersonGroup; use App\Models\Person\PersonType; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -29,6 +30,7 @@ public function definition(): array 'description' => $this->faker->optional()->sentence(), 'group_id' => PersonGroup::factory(), 'type_id' => PersonType::factory(), + 'user_id' => User::factory(), ]; } } diff --git a/database/factories/Person/PersonPhoneFactory.php b/database/factories/Person/PersonPhoneFactory.php index 52b2667..b3b5062 100644 --- a/database/factories/Person/PersonPhoneFactory.php +++ b/database/factories/Person/PersonPhoneFactory.php @@ -2,6 +2,8 @@ namespace Database\Factories\Person; +use App\Models\Person\PhoneType; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,7 +19,9 @@ class PersonPhoneFactory extends Factory public function definition(): array { return [ - // + 'nu' => $this->faker->numerify('06########'), + 'type_id' => PhoneType::factory(), + 'user_id' => User::factory(), ]; } } diff --git a/database/factories/Person/PhoneTypeFactory.php b/database/factories/Person/PhoneTypeFactory.php index ac55bae..c719ba2 100644 --- a/database/factories/Person/PhoneTypeFactory.php +++ b/database/factories/Person/PhoneTypeFactory.php @@ -17,7 +17,8 @@ class PhoneTypeFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->randomElement(['GSM', 'Home', 'Work']), + 'description' => $this->faker->optional()->sentence(), ]; } } diff --git a/database/migrations/2025_10_03_164536_alter_field_job_settings_add_action_id.php b/database/migrations/2025_10_03_164536_alter_field_job_settings_add_action_id.php new file mode 100644 index 0000000..49f1942 --- /dev/null +++ b/database/migrations/2025_10_03_164536_alter_field_job_settings_add_action_id.php @@ -0,0 +1,33 @@ +foreignId('action_id')->nullable()->constrained('actions')->nullOnDelete()->after('segment_id'); + $table->index('action_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('field_job_settings', function (Blueprint $table): void { + if (Schema::hasColumn('field_job_settings', 'action_id')) { + $table->dropConstrainedForeignId('action_id'); + } + }); + } +}; diff --git a/database/migrations/2025_10_04_000001_add_raw_sha1_to_import_rows_table.php b/database/migrations/2025_10_04_000001_add_raw_sha1_to_import_rows_table.php new file mode 100644 index 0000000..152e78c --- /dev/null +++ b/database/migrations/2025_10_04_000001_add_raw_sha1_to_import_rows_table.php @@ -0,0 +1,26 @@ +string('raw_sha1', 40)->nullable()->after('fingerprint')->index(); + } + }); + } + + public function down(): void + { + Schema::table('import_rows', function (Blueprint $table) { + if (Schema::hasColumn('import_rows', 'raw_sha1')) { + $table->dropColumn('raw_sha1'); + } + }); + } +}; diff --git a/resources/examples/payments_sample.csv b/resources/examples/payments_sample.csv index b1626cd..e25ce6b 100644 --- a/resources/examples/payments_sample.csv +++ b/resources/examples/payments_sample.csv @@ -2,4 +2,4 @@ "5362030581","P-2025-0001","2025-09-01","120.50","REF-53620-A" "5362017358","P-2025-0002","2025-09-15","80.00","REF-53620-B" "5362011838","P-2025-0201","2025-09-03","300.00","REF-53413-A" -"5362017783","P-2025-0202","2025-09-20","150.00","REF-53413-B" \ No newline at end of file +"5341384934","P-2025-0202","2025-09-20","150.00","REF-53413-B" \ No newline at end of file diff --git a/resources/js/Components/DocumentsTable.vue b/resources/js/Components/DocumentsTable.vue index 5366775..c72763c 100644 --- a/resources/js/Components/DocumentsTable.vue +++ b/resources/js/Components/DocumentsTable.vue @@ -1,198 +1,394 @@ diff --git a/resources/js/Components/Dropdown.vue b/resources/js/Components/Dropdown.vue index 21f0890..15ec59d 100644 --- a/resources/js/Components/Dropdown.vue +++ b/resources/js/Components/Dropdown.vue @@ -14,6 +14,10 @@ const props = defineProps({ type: Array, default: () => ['py-1', 'bg-white'], }, + closeOnContentClick: { + type: Boolean, + default: true, + }, }); let open = ref(false); @@ -77,9 +81,16 @@ onUnmounted(() => { }); const widthClass = computed(() => { - return { - '48': 'w-48', - }[props.width.toString()]; + const map = { + '48': 'w-48', // 12rem + '64': 'w-64', // 16rem + '72': 'w-72', // 18rem + '80': 'w-80', // 20rem + '96': 'w-96', // 24rem + 'wide': 'w-[34rem] max-w-[90vw]', + 'auto': '', + }; + return map[props.width.toString()] ?? ''; }); const alignmentClasses = computed(() => { @@ -93,6 +104,11 @@ const alignmentClasses = computed(() => { return 'origin-top'; }); +const onContentClick = () => { + if (props.closeOnContentClick) { + open.value = false; + } +};