From a2bb75fdcc80027504eb41c768049d18b3bc9479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Tue, 30 Sep 2025 00:06:47 +0200 Subject: [PATCH] changes 0230092025 --- app/Http/Controllers/ImportController.php | 89 +- .../Controllers/ImportEntityController.php | 115 ++ .../Controllers/ImportTemplateController.php | 160 ++- app/Models/ClientCase.php | 1 + app/Models/ImportEntity.php | 27 + app/Services/CsvImportService.php | 107 +- app/Services/ImportProcessor.php | 638 ++++++++++- ...29_000000_create_import_entities_table.php | 29 + ...1_add_client_ref_to_client_cases_table.php | 39 + ...0_add_initial_amount_to_accounts_table.php | 28 + ...0000_recreate_imports_table_if_missing.php | 69 ++ ..._recreate_import_rows_table_if_missing.php | 51 + ...reate_import_mappings_table_if_missing.php | 35 + ...ecreate_import_events_table_if_missing.php | 39 + ...000_alter_contracts_description_length.php | 24 + ...00_alter_contracts_description_to_text.php | 24 + database/seeders/DatabaseSeeder.php | 2 + database/seeders/ImportEntitySeeder.php | 115 ++ resources/js/Pages/Cases/Show.vue | 6 +- resources/js/Pages/Imports/Create.vue | 153 ++- resources/js/Pages/Imports/Import.vue | 1014 ++++++++++------- .../js/Pages/Imports/Templates/Create.vue | 46 + resources/js/Pages/Imports/Templates/Edit.vue | 160 ++- resources/js/Pages/Profile/Show.vue | 6 +- routes/api.php | 14 +- routes/web.php | 21 + .../GlobalSearchContractReferenceTest.php | 39 + .../ImportAccountInitialAmountTest.php | 96 ++ tests/Feature/ImportClientRefDedupTest.php | 87 ++ tests/Feature/ImportPersonDedupByRefTest.php | 90 ++ tests/Unit/CsvImportServiceTest.php | 33 + 31 files changed, 2729 insertions(+), 628 deletions(-) create mode 100644 app/Http/Controllers/ImportEntityController.php create mode 100644 app/Models/ImportEntity.php create mode 100644 database/migrations/2025_09_29_000000_create_import_entities_table.php create mode 100644 database/migrations/2025_09_29_000001_add_client_ref_to_client_cases_table.php create mode 100644 database/migrations/2025_09_29_120000_add_initial_amount_to_accounts_table.php create mode 100644 database/migrations/2025_09_29_120000_recreate_imports_table_if_missing.php create mode 100644 database/migrations/2025_09_29_120100_recreate_import_rows_table_if_missing.php create mode 100644 database/migrations/2025_09_29_120110_recreate_import_mappings_table_if_missing.php create mode 100644 database/migrations/2025_09_29_120120_recreate_import_events_table_if_missing.php create mode 100644 database/migrations/2025_09_29_130000_alter_contracts_description_length.php create mode 100644 database/migrations/2025_09_29_131000_alter_contracts_description_to_text.php create mode 100644 database/seeders/ImportEntitySeeder.php create mode 100644 tests/Feature/GlobalSearchContractReferenceTest.php create mode 100644 tests/Feature/ImportAccountInitialAmountTest.php create mode 100644 tests/Feature/ImportClientRefDedupTest.php create mode 100644 tests/Feature/ImportPersonDedupByRefTest.php create mode 100644 tests/Unit/CsvImportServiceTest.php diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index c552989..6c8facd 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -4,16 +4,15 @@ use App\Models\Client; use App\Models\Import; -use App\Models\ImportTemplate; -use App\Models\ImportRow; use App\Models\ImportEvent; +use App\Models\ImportTemplate; +use App\Services\CsvImportService; use App\Services\ImportProcessor; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Inertia\Inertia; -use App\Services\CsvImportService; class ImportController extends Controller { @@ -64,7 +63,7 @@ public function index(Request $request) 'full_name' => $imp->client->person->full_name, ] : null, ] : null, - 'template' => $imp->template ? [ 'id' => $imp->template->id, 'name' => $imp->template->name ] : null, + 'template' => $imp->template ? ['id' => $imp->template->id, 'name' => $imp->template->name] : null, ]; }, $imports['data']); @@ -99,7 +98,6 @@ public function create(Request $request) DB::raw('person.full_name as name'), ]); - return Inertia::render('Imports/Create', [ 'templates' => $templates, 'clients' => $clients, @@ -129,7 +127,7 @@ public function store(Request $request) // Resolve client_uuid to client_id if provided $clientId = null; - if (!empty($validated['client_uuid'] ?? null)) { + if (! empty($validated['client_uuid'] ?? null)) { $clientId = Client::where('uuid', $validated['client_uuid'])->value('id'); } @@ -163,6 +161,7 @@ public function process(Import $import, Request $request, ImportProcessor $proce { $import->update(['status' => 'validating', 'started_at' => now()]); $result = $processor->process($import, user: $request->user()); + return response()->json($result); } @@ -188,25 +187,44 @@ public function columns(Request $request, Import $import, CsvImportService $csv) if ($tplDelimiter) { $explicitDelimiter = (string) $tplDelimiter; } - } elseif (!empty($import->meta['forced_delimiter'] ?? null)) { + } elseif (! empty($import->meta['forced_delimiter'] ?? null)) { $explicitDelimiter = (string) $import->meta['forced_delimiter']; } - // Only implement CSV/TSV detection for now; others can be added later - if (!in_array($import->source_type, ['csv','txt'])) { - return response()->json([ - 'columns' => [], - 'note' => 'Column preview supported for CSV/TXT at this step.', - ]); + // Prefer CSV/TXT; if source_type is unknown, attempt best-effort based on file extension + $treatAsText = in_array($import->source_type, ['csv', 'txt']); + if (! $treatAsText) { + $orig = strtolower(pathinfo($import->original_name ?? '', PATHINFO_EXTENSION)); + if (in_array($orig, ['csv', 'txt'])) { + $treatAsText = true; + } } - $fullPath = Storage::disk($import->disk)->path($import->path); - if ($explicitDelimiter !== null && $explicitDelimiter !== '') { - $columns = $csv->parseColumnsFromCsv($fullPath, $explicitDelimiter, $hasHeader); - $delimiter = $explicitDelimiter; - } else { - [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); - } + $fullPath = Storage::disk($import->disk)->path($import->path); + $note = ''; + if ($treatAsText) { + if ($explicitDelimiter !== null && $explicitDelimiter !== '') { + $columns = $csv->parseColumnsFromCsv($fullPath, $explicitDelimiter, $hasHeader); + $delimiter = $explicitDelimiter; + } else { + [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); + // Backstop: if single column but file clearly has separators, try common ones + if (is_array($columns) && count($columns) <= 1) { + foreach ([';', "\t", '|', ' ', ','] as $try) { + $alt = $csv->parseColumnsFromCsv($fullPath, $try, $hasHeader); + if (is_array($alt) && count($alt) > 1) { + $delimiter = $try; + $columns = $alt; + $note = 'Delimiter auto-detection backstopped to '.json_encode($try); + break; + } + } + } + } + } else { + // Best-effort: try detect anyway + [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); + } // Save meta $meta = $import->meta ?? []; @@ -225,6 +243,7 @@ public function columns(Request $request, Import $import, CsvImportService $csv) 'columns' => $columns, 'has_header' => $hasHeader, 'detected_delimiter' => $delimiter, + 'note' => $note, ]); } @@ -236,9 +255,9 @@ public function saveMappings(Request $request, Import $import) $data = $request->validate([ 'mappings' => 'required|array', 'mappings.*.source_column' => 'required|string', - 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', 'mappings.*.target_field' => 'required|string', - 'mappings.*.transform' => 'nullable|string|in:trim,upper,lower', + 'mappings.*.transform' => 'nullable|string|in:trim,upper,lower,decimal,ref', 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both', 'mappings.*.options' => 'nullable|array', ]); @@ -247,14 +266,14 @@ public function saveMappings(Request $request, Import $import) $now = now(); $existing = \DB::table('import_mappings') ->where('import_id', $import->id) - ->get(['id','source_column','position']); + ->get(['id', 'source_column', 'position']); $bySource = []; $dupes = []; foreach ($existing as $row) { $src = (string) $row->source_column; - if (!array_key_exists($src, $bySource)) { - $bySource[$src] = [ 'id' => $row->id, 'position' => $row->position ]; + if (! array_key_exists($src, $bySource)) { + $bySource[$src] = ['id' => $row->id, 'position' => $row->position]; } else { $dupes[$src] = ($dupes[$src] ?? []); $dupes[$src][] = $row->id; @@ -262,7 +281,9 @@ public function saveMappings(Request $request, Import $import) } $basePosition = (int) (\DB::table('import_mappings')->where('import_id', $import->id)->max('position') ?? -1); - $inserted = 0; $updated = 0; $deduped = 0; + $inserted = 0; + $updated = 0; + $deduped = 0; foreach ($data['mappings'] as $pos => $m) { $src = (string) $m['source_column']; @@ -281,7 +302,7 @@ public function saveMappings(Request $request, Import $import) \DB::table('import_mappings')->where('id', $bySource[$src]['id'])->update($payload); $updated++; // Remove duplicates if any - if (!empty($dupes[$src])) { + if (! empty($dupes[$src])) { $deleted = \DB::table('import_mappings')->whereIn('id', $dupes[$src])->delete(); $deduped += (int) $deleted; unset($dupes[$src]); @@ -325,8 +346,9 @@ public function getMappings(Import $import) 'transform', 'apply_mode', 'options', - 'position' + 'position', ]); + return response()->json(['mappings' => $rows]); } @@ -339,7 +361,8 @@ public function getEvents(Import $import) ->where('import_id', $import->id) ->orderByDesc('id') ->limit($limit) - ->get(['id','created_at','level','event','message','import_row_id','context']); + ->get(['id', 'created_at', 'level', 'event', 'message', 'import_row_id', 'context']); + return response()->json(['events' => $events]); } @@ -363,7 +386,6 @@ public function show(Import $import) 'clients.uuid as client_uuid', ]); - $clients = Client::query() ->join('person', 'person.id', '=', 'clients.person_id') ->orderBy('person.full_name') @@ -371,7 +393,7 @@ public function show(Import $import) ->get([ 'clients.id', 'clients.uuid', - 'person.full_name as name' + 'person.full_name as name', ]); // Import client @@ -383,7 +405,6 @@ public function show(Import $import) 'person.full_name as name', ]); - // Render a dedicated page to continue the import return Inertia::render('Imports/Import', [ 'import' => [ @@ -398,11 +419,11 @@ public function show(Import $import) 'imported_rows' => $import->imported_rows, 'invalid_rows' => $import->invalid_rows, 'valid_rows' => $import->valid_rows, - 'finished_at' => $import->finished_at + 'finished_at' => $import->finished_at, ], 'templates' => $templates, 'clients' => $clients, - 'client' => $client + 'client' => $client, ]); } } diff --git a/app/Http/Controllers/ImportEntityController.php b/app/Http/Controllers/ImportEntityController.php new file mode 100644 index 0000000..fb69dd5 --- /dev/null +++ b/app/Http/Controllers/ImportEntityController.php @@ -0,0 +1,115 @@ +orderByRaw("(ui->>'order')::int nulls last") + ->get(); + + // Fallback: if no entities are seeded yet, return a sensible default set + if ($entities->isEmpty()) { + $entities = collect([ + [ + 'key' => 'person', + 'canonical_root' => 'person', + 'label' => 'Person', + 'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'], + 'ui' => ['order' => 1], + ], + [ + 'key' => 'person_addresses', + 'canonical_root' => 'address', + 'label' => 'Person Addresses', + 'fields' => ['address', 'country', 'type_id', 'description'], + 'ui' => ['order' => 2], + ], + [ + 'key' => 'person_phones', + 'canonical_root' => 'phone', + 'label' => 'Person Phones', + 'fields' => ['nu', 'country_code', 'type_id', 'description'], + 'ui' => ['order' => 3], + ], + [ + 'key' => 'emails', + 'canonical_root' => 'email', + 'label' => 'Emails', + 'fields' => ['value', 'is_primary', 'label'], + 'ui' => ['order' => 4], + ], + [ + 'key' => 'contracts', + 'canonical_root' => 'contract', + 'label' => 'Contracts', + 'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id'], + 'ui' => ['order' => 5], + ], + [ + 'key' => 'accounts', + 'canonical_root' => 'account', + 'label' => 'Accounts', + 'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], + 'ui' => ['order' => 6], + ], + ]); + } else { + // Ensure fields are arrays for frontend consumption + $entities = $entities->map(function ($ent) { + $ent->fields = is_array($ent->fields) ? $ent->fields : []; + + return $ent; + }); + } + + return response()->json(['entities' => $entities]); + } + + public function suggest(Request $request) + { + $cols = $request->input('columns', []); + if (! is_array($cols)) { + $cols = []; + } + $entities = ImportEntity::all(); + $suggestions = []; + foreach ($cols as $col) { + $s = $this->suggestFor($col, $entities); + if ($s) { + $suggestions[$col] = $s; + } + } + + return response()->json(['suggestions' => $suggestions]); + } + + private function suggestFor(string $source, $entities): ?array + { + $s = trim(mb_strtolower($source)); + foreach ($entities as $ent) { + $rules = (array) ($ent->rules ?? []); + foreach ($rules as $rule) { + $pattern = $rule['pattern'] ?? null; + $field = $rule['field'] ?? null; + if (! $pattern || ! $field) { + continue; + } + if (@preg_match($pattern, $s)) { + return [ + 'entity' => $ent->key, // UI key (plural except person) + 'field' => $field, + 'canonical_root' => $ent->canonical_root, + ]; + } + } + } + + return null; + } +} diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index 88cb909..9e8fd48 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -2,10 +2,13 @@ namespace App\Http\Controllers; +use App\Models\Action; +use App\Models\Client; +use App\Models\Decision; use App\Models\Import; use App\Models\ImportTemplate; use App\Models\ImportTemplateMapping; -use App\Models\Client; +use App\Models\Segment; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -21,7 +24,7 @@ public function index() ->get(); return Inertia::render('Imports/Templates/Index', [ - 'templates' => $templates->map(fn($t) => [ + 'templates' => $templates->map(fn ($t) => [ 'uuid' => $t->uuid, 'name' => $t->name, 'description' => $t->description, @@ -48,8 +51,22 @@ public function create() DB::raw('person.full_name as name'), ]); + $segments = Segment::query()->orderBy('name')->get(['id', 'name']); + $decisions = Decision::query()->orderBy('name')->get(['id', 'name']); + $actions = Action::with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), + ]); + return Inertia::render('Imports/Templates/Create', [ 'clients' => $clients, + 'segments' => $segments, + 'decisions' => $decisions, + 'actions' => $actions, ]); } @@ -57,8 +74,15 @@ public function store(Request $request) { // Normalize payload to be resilient to UI variations $raw = $request->all(); + // Allow passing default segment/decision either inside meta or as top-level + if (isset($raw['segment_id']) && ! isset($raw['meta']['segment_id'])) { + $raw['meta']['segment_id'] = $raw['segment_id']; + } + if (isset($raw['decision_id']) && ! isset($raw['meta']['decision_id'])) { + $raw['meta']['decision_id'] = $raw['decision_id']; + } // Resolve client by uuid if provided, or cast string numeric to int - if (!empty($raw['client_uuid'] ?? null)) { + if (! empty($raw['client_uuid'] ?? null)) { $raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id'); } elseif (isset($raw['client_id']) && is_string($raw['client_id']) && ctype_digit($raw['client_id'])) { $raw['client_id'] = (int) $raw['client_id']; @@ -66,8 +90,13 @@ public function store(Request $request) // Normalize entities to array of strings if (isset($raw['entities']) && is_array($raw['entities'])) { $raw['entities'] = array_values(array_filter(array_map(function ($e) { - if (is_string($e)) return $e; - if (is_array($e) && array_key_exists('value', $e)) return (string) $e['value']; + if (is_string($e)) { + return $e; + } + if (is_array($e) && array_key_exists('value', $e)) { + return (string) $e['value']; + } + return null; }, $raw['entities']))); } @@ -84,14 +113,29 @@ public function store(Request $request) 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts', 'mappings' => 'array', 'mappings.*.source_column' => 'required|string', - 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', 'mappings.*.target_field' => 'nullable|string', 'mappings.*.transform' => 'nullable|string|max:50', 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both', 'mappings.*.options' => 'nullable|array', 'mappings.*.position' => 'nullable|integer', + 'meta' => 'nullable|array', + 'meta.segment_id' => 'nullable|integer|exists:segments,id', + 'meta.decision_id' => 'nullable|integer|exists:decisions,id', + 'meta.action_id' => 'nullable|integer|exists:actions,id', ])->validate(); + // Ensure decision belongs to action if both provided in meta + $meta = $data['meta'] ?? []; + if (! empty($meta['action_id']) && ! empty($meta['decision_id'])) { + $belongs = \DB::table('action_decision') + ->where('action_id', $meta['action_id']) + ->where('decision_id', $meta['decision_id']) + ->exists(); + if (! $belongs) { + return back()->withErrors(['meta.decision_id' => 'Selected decision is not associated with the chosen action.'])->withInput(); + } + } $template = null; DB::transaction(function () use (&$template, $request, $data) { $template = ImportTemplate::create([ @@ -104,9 +148,12 @@ public function store(Request $request) 'user_id' => $request->user()?->id, 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? true, - 'meta' => [ + 'meta' => array_filter([ 'entities' => $data['entities'] ?? [], - ], + 'segment_id' => data_get($data, 'meta.segment_id'), + 'decision_id' => data_get($data, 'meta.decision_id'), + 'action_id' => data_get($data, 'meta.action_id'), + ], fn ($v) => ! is_null($v) && $v !== ''), ]); foreach (($data['mappings'] ?? []) as $m) { @@ -144,6 +191,17 @@ public function edit(ImportTemplate $template) DB::raw('person.full_name as name'), ]); + $segments = Segment::query()->orderBy('name')->get(['id', 'name']); + $decisions = Decision::query()->orderBy('name')->get(['id', 'name']); + $actions = Action::with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), + ]); + return Inertia::render('Imports/Templates/Edit', [ 'template' => [ 'uuid' => $template->uuid, @@ -155,9 +213,12 @@ public function edit(ImportTemplate $template) 'client_uuid' => $template->client?->uuid, 'sample_headers' => $template->sample_headers, 'meta' => $template->meta, - 'mappings' => $template->mappings()->orderBy('position')->get(['id','entity','source_column','target_field','transform','apply_mode','options','position']), + 'mappings' => $template->mappings()->orderBy('position')->get(['id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position']), ], 'clients' => $clients, + 'segments' => $segments, + 'decisions' => $decisions, + 'actions' => $actions, ]); } @@ -171,7 +232,7 @@ public function addMapping(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'source_column' => 'required|string', - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', 'target_field' => 'nullable|string', 'transform' => 'nullable|string|in:trim,upper,lower', 'apply_mode' => 'nullable|string|in:insert,update,both', @@ -193,6 +254,7 @@ public function addMapping(Request $request, ImportTemplate $template) 'options' => $data['options'] ?? $existing->options, 'position' => $data['position'] ?? $existing->position, ]); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('info', 'Mapping already existed. Updated existing mapping.'); } else { @@ -207,6 +269,7 @@ public function addMapping(Request $request, ImportTemplate $template) 'options' => $data['options'] ?? null, 'position' => $position, ]); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('success', 'Mapping added'); } @@ -216,7 +279,7 @@ public function addMapping(Request $request, ImportTemplate $template) public function update(Request $request, ImportTemplate $template) { $raw = $request->all(); - if (!empty($raw['client_uuid'] ?? null)) { + if (! empty($raw['client_uuid'] ?? null)) { $raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id'); } $data = validator($raw, [ @@ -229,16 +292,35 @@ public function update(Request $request, ImportTemplate $template) 'sample_headers' => 'nullable|array', 'meta' => 'nullable|array', 'meta.delimiter' => 'nullable|string|max:4', + 'meta.segment_id' => 'nullable|integer|exists:segments,id', + 'meta.decision_id' => 'nullable|integer|exists:decisions,id', + 'meta.action_id' => 'nullable|integer|exists:actions,id', ])->validate(); + // Validate decision/action consistency on update as well + $meta = $data['meta'] ?? []; + if (! empty($meta['action_id']) && ! empty($meta['decision_id'])) { + $belongs = \DB::table('action_decision') + ->where('action_id', $meta['action_id']) + ->where('decision_id', $meta['decision_id']) + ->exists(); + if (! $belongs) { + return back()->withErrors(['meta.decision_id' => 'Selected decision is not associated with the chosen action.'])->withInput(); + } + } // Merge meta safely, preserving existing keys when not provided $newMeta = $template->meta ?? []; if (array_key_exists('meta', $data) && is_array($data['meta'])) { $newMeta = array_merge($newMeta, $data['meta']); // Drop empty delimiter to allow auto-detect - if (array_key_exists('delimiter', $newMeta) && (!is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) { + if (array_key_exists('delimiter', $newMeta) && (! is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) { unset($newMeta['delimiter']); } + foreach (['segment_id', 'decision_id', 'action_id'] as $k) { + if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) { + unset($newMeta[$k]); + } + } } $template->update([ @@ -266,14 +348,14 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'sources' => 'required|string', // comma and/or newline separated - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases', 'default_field' => 'nullable|string', // if provided, used as the field name for all entries 'apply_mode' => 'nullable|string|in:insert,update,both', 'transform' => 'nullable|string|in:trim,upper,lower', ])->validate(); $list = preg_split('/\r?\n|,/', $data['sources']); - $list = array_values(array_filter(array_map(fn($s) => trim($s), $list), fn($s) => $s !== '')); + $list = array_values(array_filter(array_map(fn ($s) => trim($s), $list), fn ($s) => $s !== '')); if (empty($list)) { return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) @@ -286,14 +368,15 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) $entity = $data['entity'] ?? null; $defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all - $created = 0; $updated = 0; + $created = 0; + $updated = 0; DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) { foreach ($list as $idx => $source) { $targetField = null; if ($defaultField) { - $targetField = $entity ? ($entity . '.' . $defaultField) : $defaultField; + $targetField = $entity ? ($entity.'.'.$defaultField) : $defaultField; } elseif ($entity) { - $targetField = $entity . '.' . $source; + $targetField = $entity.'.'.$source; } $existing = ImportTemplateMapping::where('import_template_id', $template->id) @@ -327,10 +410,17 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) }); $msg = []; - if ($created) $msg[] = "$created created"; - if ($updated) $msg[] = "$updated updated"; + if ($created) { + $msg[] = "$created created"; + } + if ($updated) { + $msg[] = "$updated updated"; + } $text = 'Mappings processed'; - if (!empty($msg)) $text .= ': ' . implode(', ', $msg); + if (! empty($msg)) { + $text .= ': '.implode(', ', $msg); + } + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('success', $text); } @@ -338,7 +428,9 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) // Update an existing mapping public function updateMapping(Request $request, ImportTemplate $template, ImportTemplateMapping $mapping) { - if ($mapping->import_template_id !== $template->id) abort(404); + if ($mapping->import_template_id !== $template->id) { + abort(404); + } $raw = $request->all(); if (array_key_exists('transform', $raw) && $raw['transform'] === '') { $raw['transform'] = null; @@ -361,6 +453,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import 'options' => $data['options'] ?? null, 'position' => $data['position'] ?? $mapping->position, ]); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('success', 'Mapping updated'); } @@ -368,8 +461,11 @@ public function updateMapping(Request $request, ImportTemplate $template, Import // Delete a mapping public function deleteMapping(ImportTemplate $template, ImportTemplateMapping $mapping) { - if ($mapping->import_template_id !== $template->id) abort(404); + if ($mapping->import_template_id !== $template->id) { + abort(404); + } $mapping->delete(); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('success', 'Mapping deleted'); } @@ -385,11 +481,14 @@ public function reorderMappings(Request $request, ImportTemplate $template) // Ensure all ids belong to template $validIds = ImportTemplateMapping::where('import_template_id', $template->id) ->whereIn('id', $ids)->pluck('id')->all(); - if (count($validIds) !== count($ids)) abort(422, 'Invalid mapping ids'); + if (count($validIds) !== count($ids)) { + abort(422, 'Invalid mapping ids'); + } // Apply new positions foreach ($ids as $idx => $id) { ImportTemplateMapping::where('id', $id)->update(['position' => $idx]); } + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) ->with('success', 'Mappings reordered'); } @@ -422,7 +521,20 @@ public function applyToImport(Request $request, ImportTemplate $template, Import $copied++; } - $import->update(['import_template_id' => $template->id]); + // Merge default actions (segment/decision) into import meta for processing + $importMeta = $import->meta ?? []; + $tplMeta = $template->meta ?? []; + $merged = array_merge($importMeta, array_filter([ + 'segment_id' => $tplMeta['segment_id'] ?? null, + 'decision_id' => $tplMeta['decision_id'] ?? null, + 'action_id' => $tplMeta['action_id'] ?? null, + 'template_name' => $template->name, + ], fn ($v) => ! is_null($v) && $v !== '')); + + $import->update([ + 'import_template_id' => $template->id, + 'meta' => $merged, + ]); }); return response()->json(['ok' => true, 'copied' => $copied, 'cleared' => $clear]); diff --git a/app/Models/ClientCase.php b/app/Models/ClientCase.php index fb2dd04..78c567a 100644 --- a/app/Models/ClientCase.php +++ b/app/Models/ClientCase.php @@ -23,6 +23,7 @@ class ClientCase extends Model protected $fillable = [ 'client_id', 'person_id', + 'client_ref', ]; protected $hidden = [ diff --git a/app/Models/ImportEntity.php b/app/Models/ImportEntity.php new file mode 100644 index 0000000..6ac281d --- /dev/null +++ b/app/Models/ImportEntity.php @@ -0,0 +1,27 @@ + 'array', + 'field_aliases' => 'array', + 'aliases' => 'array', + 'rules' => 'array', + 'ui' => 'array', + ]; +} diff --git a/app/Services/CsvImportService.php b/app/Services/CsvImportService.php index b54b336..f3fc53f 100644 --- a/app/Services/CsvImportService.php +++ b/app/Services/CsvImportService.php @@ -5,15 +5,79 @@ class CsvImportService { /** - * Read the first line of a file; returns null on failure. + * Normalize a line to UTF-8 and strip BOM / control characters for robust splitting. */ - public function readFirstLine(string $path): ?string + private function normalizeLine(string $line): string + { + // Strip UTF-8 BOM + if (str_starts_with($line, "\xEF\xBB\xBF")) { + $line = substr($line, 3); + } + // Detect UTF-16 BOMs + $hasNulls = strpos($line, "\x00") !== false; + if (str_starts_with($line, "\xFF\xFE")) { + // UTF-16LE BOM + $line = substr($line, 2); + $line = function_exists('mb_convert_encoding') ? @mb_convert_encoding($line, 'UTF-8', 'UTF-16LE') : preg_replace('/\x00/', '', $line); + } elseif (str_starts_with($line, "\xFE\xFF")) { + // UTF-16BE BOM + $line = substr($line, 2); + $line = function_exists('mb_convert_encoding') ? @mb_convert_encoding($line, 'UTF-8', 'UTF-16BE') : preg_replace('/\x00/', '', $line); + } elseif ($hasNulls) { + // Likely UTF-16 without BOM, try LE then BE + if (function_exists('mb_convert_encoding')) { + $try = @mb_convert_encoding($line, 'UTF-8', 'UTF-16LE'); + if ($try !== false) { + $line = $try; + } else { + $try = @mb_convert_encoding($line, 'UTF-8', 'UTF-16BE'); + if ($try !== false) { + $line = $try; + } else { + $line = preg_replace('/\x00/', '', $line); + } + } + } else { + $line = preg_replace('/\x00/', '', $line); + } + } else { + // Non UTF-16: try detect common encodings and convert to UTF-8 if needed + if (function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) { + // Use default detection order for portability across environments + $enc = @mb_detect_encoding($line, null, true); + if ($enc && strtoupper($enc) !== 'UTF-8') { + $line = @mb_convert_encoding($line, 'UTF-8', $enc) ?: $line; + } + } + } + // Replace non-breaking space with regular space + $line = str_replace("\xC2\xA0", ' ', $line); + + return $line; + } + + /** + * Read the first meaningful (non-empty after normalization) line of a file; returns null on failure. + * Skips BOM-only lines and leading blank lines. Limits scanning to first 50 lines to be safe. + */ + public function readFirstMeaningfulLine(string $path): ?string { $fh = @fopen($path, 'r'); - if (!$fh) return null; - $line = fgets($fh); + if (! $fh) { + return null; + } + $line = null; + $limit = 50; + while ($limit-- > 0 && ($raw = fgets($fh)) !== false) { + $normalized = $this->normalizeLine($raw); + if (trim($normalized) !== '') { + $line = $normalized; + break; + } + } fclose($fh); - return $line === false ? null : $line; + + return $line; } /** @@ -24,14 +88,15 @@ public function readFirstLine(string $path): ?string public function detectColumnsFromCsv(string $path, bool $hasHeader): array { // Use actual tab character for TSV; keep other common delimiters - $delims = [',',';','|',"\t"]; + $delims = [',', ';', '|', "\t"]; $bestDelim = ','; $bestCols = []; - $firstLine = $this->readFirstLine($path); + $firstLine = $this->readFirstMeaningfulLine($path); if ($firstLine === null) { return [$bestDelim, []]; } + // Already normalized by readFirstMeaningfulLine $maxCount = 0; foreach ($delims as $d) { @@ -44,12 +109,27 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array } } - if (!$hasHeader) { + // Fallback: if str_getcsv failed to split but we clearly see delimiters, do a simple explode + if ($maxCount <= 1) { + foreach (["\t", ';', ',', '|'] as $d) { + if (substr_count($firstLine, $d) >= 1) { + $parts = explode($d, $firstLine); + if (count($parts) > $maxCount) { + $bestDelim = $d; + $bestCols = $parts; + $maxCount = count($parts); + } + } + } + } + + if (! $hasHeader) { // return positional indices 0..N-1 $cols = []; for ($i = 0; $i < $maxCount; $i++) { $cols[] = (string) $i; } + return [$bestDelim, $cols]; } @@ -57,6 +137,7 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array $clean = array_map(function ($v) { $v = trim((string) $v); $v = preg_replace('/\s+/', ' ', $v); + return $v; }, $bestCols); @@ -69,16 +150,23 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array */ public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHeader): array { - $firstLine = $this->readFirstLine($path); + $firstLine = $this->readFirstMeaningfulLine($path); if ($firstLine === null) { return []; } + // Already normalized by readFirstMeaningfulLine $row = str_getcsv($firstLine, $delimiter); $count = is_array($row) ? count($row) : 0; + // Fallback explode if str_getcsv failed to split + if ($count <= 1 && substr_count($firstLine, $delimiter) >= 1) { + $row = explode($delimiter, $firstLine); + $count = count($row); + } if ($hasHeader) { return array_map(function ($v) { $v = trim((string) $v); $v = preg_replace('/\s+/', ' ', $v); + return $v; }, $row ?: []); } @@ -86,6 +174,7 @@ public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHe for ($i = 0; $i < $count; $i++) { $cols[] = (string) $i; } + return $cols; } } diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index c65bfc6..e3ad8ea 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -4,12 +4,15 @@ use App\Models\Account; use App\Models\AccountType; +use App\Models\Activity; use App\Models\Client; use App\Models\ClientCase; use App\Models\Contract; use App\Models\ContractType; +use App\Models\Decision; use App\Models\Email; use App\Models\Import; +use App\Models\ImportEntity; use App\Models\ImportEvent; use App\Models\ImportRow; use App\Models\Person\AddressType; @@ -57,6 +60,13 @@ public function process(Import $import, ?Authenticatable $user = null): array ->where('import_id', $import->id) ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); + // Load dynamic entity config + [$rootAliasMap, $fieldAliasMap, $validRoots] = $this->loadImportEntityConfig(); + // Normalize aliases (plural/legacy roots, field names) before validation + $mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap); + // Validate mapping roots early to avoid silent failures due to typos + $this->validateMappingRoots($mappings, $validRoots); + $header = $import->meta['columns'] ?? null; // Prefer explicitly chosen delimiter, then template meta, else detected $delimiter = $import->meta['forced_delimiter'] @@ -66,6 +76,8 @@ public function process(Import $import, ?Authenticatable $user = null): array $hasHeader = (bool) ($import->meta['has_header'] ?? true); $path = Storage::disk($import->disk)->path($import->path); + // Note: Do not auto-detect or infer mappings/fields beyond what the template mapping provides + // Parse file and create import_rows with mapped_data $fh = @fopen($path, 'r'); if (! $fh) { @@ -95,12 +107,55 @@ public function process(Import $import, ?Authenticatable $user = null): array if ($hasHeader) { $first = fgetcsv($fh, 0, $delimiter); $rowNum++; - // use actual detected header if not already stored - if (! $header) { - $header = array_map(fn ($v) => trim((string) $v), $first ?: []); + // Always use the actual header from the file for parsing + $header = array_map(fn ($v) => $this->sanitizeHeaderName((string) $v), $first ?: []); + // Heuristic: if header parsed as a single column but contains common delimiters, warn about mismatch + if (count($header) === 1) { + $rawHeader = $first[0] ?? ''; + if (is_string($rawHeader) && (str_contains($rawHeader, ';') || str_contains($rawHeader, "\t"))) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'delimiter_mismatch_suspected', + 'level' => 'warning', + 'message' => 'Header parsed as a single column. Suspected delimiter mismatch. Set a forced delimiter in the template or import settings.', + 'context' => [ + 'current_delimiter' => $delimiter, + 'raw_header' => $rawHeader, + ], + ]); + } + } + // Preflight: warn if any mapped source columns are not present in the header (exact match) + $headerSet = []; + foreach ($header as $h) { + $headerSet[$h] = true; + } + $missingSources = []; + foreach ($mappings as $map) { + $src = (string) ($map->source_column ?? ''); + if ($src !== '' && ! array_key_exists($src, $headerSet)) { + $missingSources[] = $src; + } + } + if (! empty($missingSources)) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'source_columns_missing_in_header', + 'level' => 'warning', + 'message' => 'Some mapped source columns are not present in the file header (exact match required).', + 'context' => [ + 'missing' => $missingSources, + 'header' => $header, + ], + ]); } } + // If mapping contains contract.reference, we require each row to successfully resolve/create a contract + $requireContract = $this->mappingIncludes($mappings, 'contract.reference'); + while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { $rowNum++; $total++; @@ -108,6 +163,8 @@ public function process(Import $import, ?Authenticatable $user = null): array $rawAssoc = $this->buildRowAssoc($row, $header); [$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings); + // Do not auto-derive or fallback values; only use explicitly mapped fields + $importRow = ImportRow::create([ 'import_id' => $import->id, 'row_number' => $rowNum, @@ -122,6 +179,31 @@ public function process(Import $import, ?Authenticatable $user = null): array if (isset($mapped['contract'])) { $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) { + 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([ @@ -148,15 +230,64 @@ public function process(Import $import, ?Authenticatable $user = null): array 'message' => ucfirst($contractResult['action']).' contract', 'context' => ['id' => $contractResult['contract']->id], ]); + + // Post-contract actions from template/import meta + try { + $this->postContractActions($import, $contractResult['contract']); + } catch (\Throwable $e) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'post_contract_action_failed', + 'level' => 'warning', + 'message' => $e->getMessage(), + ]); + } } else { $invalid++; $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); } } + // Enforce hard requirement: if template mapped contract.reference but we didn't resolve/create a contract, mark row invalid and continue + if ($requireContract) { + $contractEnsured = false; + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $contractEnsured = true; + } + if (! $contractEnsured) { + $srcCol = $this->findSourceColumnFor($mappings, 'contract.reference'); + $rawVal = $srcCol !== null ? ($rawAssoc[$srcCol] ?? null) : null; + $extra = $srcCol !== null ? ' Source column: "'.$srcCol.'" value: '.(is_null($rawVal) || $rawVal === '' ? '(empty)' : (is_scalar($rawVal) ? (string) $rawVal : json_encode($rawVal))) : ''; + $msg = 'Row '.$rowNum.': Contract was required (contract.reference mapped) but not created/resolved. '.($contractResult['message'] ?? '').$extra; + + // Avoid double-counting invalid if already set by contract processing + if ($importRow->status !== 'invalid') { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => [$msg]]); + } + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_invalid', + 'level' => 'error', + 'message' => $msg, + ]); + + // Skip further processing for this row + continue; + } + } + // Accounts $accountResult = null; if (isset($mapped['account'])) { + // If a contract was just created or resolved above, pass its id to account mapping for this row + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $mapped['account']['contract_id'] = $contractResult['contract']->id; + } $accountResult = $this->upsertAccount($import, $mapped, $mappings); if ($accountResult['action'] === 'skipped') { $skipped++; @@ -168,6 +299,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'event' => 'row_skipped', 'level' => 'info', 'message' => $accountResult['message'] ?? 'Skipped (no changes).', + 'context' => $accountResult['context'] ?? null, ]); } elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') { $imported++; @@ -191,7 +323,7 @@ public function process(Import $import, ?Authenticatable $user = null): array } } - // Contacts: resolve person strictly via Contract -> ClientCase -> Person, contacts, or identifiers + // 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) { @@ -222,6 +354,16 @@ public function process(Import $import, ?Authenticatable $user = null): array } } } + // 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'] ?? '')); @@ -240,6 +382,22 @@ public function process(Import $import, ?Authenticatable $user = null): array } // 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([ @@ -384,12 +542,29 @@ private function applyMappings(array $raw, $mappings): array } $value = $raw[$src] ?? null; - // very basic transforms - if ($map->transform === 'trim') { - $value = is_string($value) ? trim($value) : $value; - } - if ($map->transform === 'upper') { - $value = is_string($value) ? strtoupper($value) : $value; + // Transform chain support: e.g. "trim|decimal" or "upper|alnum" + $transform = (string) ($map->transform ?? ''); + if ($transform !== '') { + $parts = explode('|', $transform); + foreach ($parts as $t) { + $t = trim($t); + if ($t === 'trim') { + $value = is_string($value) ? trim($value) : $value; + } elseif ($t === 'upper') { + $value = is_string($value) ? strtoupper($value) : $value; + } elseif ($t === 'lower') { + $value = is_string($value) ? strtolower($value) : $value; + } elseif ($t === 'digits' || $t === 'numeric') { + $value = is_string($value) ? preg_replace('/[^0-9]/', '', $value) : $value; + } elseif ($t === 'decimal') { + $value = is_string($value) ? $this->normalizeDecimal($value) : $value; + } elseif ($t === 'alnum') { + $value = is_string($value) ? preg_replace('/[^A-Za-z0-9]/', '', $value) : $value; + } elseif ($t === 'ref') { + // Reference safe: keep letters+digits only, uppercase + $value = is_string($value) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $value)) : $value; + } + } } // detect record type from first segment, e.g., "account.balance_amount" @@ -423,6 +598,16 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $acc = $mapped['account'] ?? []; $contractId = $acc['contract_id'] ?? null; $reference = $acc['reference'] ?? null; + // Determine if the template includes any contract mappings; if not, do not create contracts here + $hasContractRoot = $this->mappingsContainRoot($mappings, 'contract'); + // Normalize references (remove spaces) for consistent matching + if (! is_null($reference)) { + $reference = preg_replace('/\s+/', '', trim((string) $reference)); + $acc['reference'] = $reference; + } + if (! empty($acc['contract_reference'] ?? null)) { + $acc['contract_reference'] = preg_replace('/\s+/', '', trim((string) $acc['contract_reference'])); + } // If contract_id not provided, attempt to resolve by contract reference for the selected client if (! $contractId) { $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); @@ -436,25 +621,17 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array ->first(); if ($existingContract) { $contractId = $existingContract->id; - } else { - // 2) Not found: attempt to resolve debtor via identifiers or provided person, then create case+contract - // Try strong identifiers first + } elseif ($hasContractRoot) { + // Only create a new contract if the template explicitly includes contract mappings + // Resolve debtor via identifiers or provided person $personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []); - // Create from provided person data if unresolved if (! $personId) { $personId = $this->findOrCreatePersonId($mapped['person'] ?? []); } - // Last resort, create minimal if (! $personId) { $personId = $this->createMinimalPersonId(); } - // Use the selected client for this import to tie the case/contract - if (! $clientId) { - return ['action' => 'skipped', 'message' => 'Client required to create contract']; - } - $resolvedClientId = $clientId; - $clientCaseId = $this->findOrCreateClientCaseId($resolvedClientId, $personId); - // Build minimal/new contract + $clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId, $mapped['client_case']['client_ref'] ?? null); $contractFields = $mapped['contract'] ?? []; $newContractData = [ 'client_case_id' => $clientCaseId, @@ -465,11 +642,13 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array $newContractData[$k] = $contractFields[$k]; } } - // ensure required fields on contracts $newContractData['start_date'] = $newContractData['start_date'] ?? now()->toDateString(); $newContractData['type_id'] = $newContractData['type_id'] ?? $this->getDefaultContractTypeId(); $createdContract = Contract::create($newContractData); $contractId = $createdContract->id; + } else { + // Do not create contracts implicitly when not mapped in the template + $contractId = null; } if ($contractId) { $acc['contract_id'] = $contractId; @@ -477,17 +656,35 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } } } - // Default account.reference to contract reference if missing - if (! $reference) { - $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); - if ($contractRef) { - $reference = $contractRef; + // Fallback: if account.reference is empty but contract.reference is present, use it + if ((is_null($reference) || $reference === '') && ! empty($mapped['contract']['reference'] ?? null)) { + $reference = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference'])); + if ($reference !== '') { $acc['reference'] = $reference; $mapped['account'] = $acc; } } + // Do not default or infer account.reference from other fields; rely solely on mapped values if (! $contractId || ! $reference) { - return ['action' => 'skipped', 'message' => 'Missing contract_id/reference']; + $issues = []; + if (! $contractId) { + $issues[] = 'contract_id unresolved'; + } + if (! $reference) { + $issues[] = 'account.reference empty'; + } + $candidateContractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); + + return [ + 'action' => 'skipped', + 'message' => 'Prerequisite missing: '.implode(' & ', $issues), + 'context' => [ + 'has_contract_root_mapped' => $hasContractRoot, + 'candidate_contract_reference' => $candidateContractRef, + 'account_reference_provided' => $reference, + 'account_fields_present' => array_keys(array_filter($acc, fn ($v) => ! is_null($v) && $v !== '')), + ], + ]; } $existing = Account::query() @@ -498,6 +695,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array // Build applyable data based on apply_mode $applyInsert = []; $applyUpdate = []; + $applyModeByField = []; foreach ($mappings as $map) { if (! $map->target_field) { continue; @@ -511,7 +709,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array continue; } $value = $acc[$field] ?? null; + if (in_array($field, ['balance_amount','initial_amount'], true) && is_string($value)) { + $value = $this->normalizeDecimal($value); + } $mode = $map->apply_mode ?? 'both'; + $applyModeByField[$field] = $mode; if (in_array($mode, ['insert', 'both'])) { $applyInsert[$field] = $value; } @@ -535,6 +737,15 @@ 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]; } else { + // On insert: if initial_amount is not provided but balance_amount is, allow defaulting + // Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null). + $initMode = $applyModeByField['initial_amount'] ?? null; + if ((! array_key_exists('initial_amount', $applyInsert) || is_null($applyInsert['initial_amount'] ?? null)) + && array_key_exists('balance_amount', $applyInsert) + && ($applyInsert['balance_amount'] !== null && $applyInsert['balance_amount'] !== '') + && ($initMode === null || in_array($initMode, ['insert','both'], true))) { + $applyInsert['initial_amount'] = $applyInsert['balance_amount']; + } if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No fields marked for insert']; } @@ -552,6 +763,18 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } } + private function mappingsContainRoot($mappings, string $root): bool + { + foreach ($mappings as $map) { + $target = (string) ($map->target_field ?? ''); + if ($target !== '' && str_starts_with($target, $root.'.')) { + return true; + } + } + + return false; + } + private function findPersonIdByIdentifiers(array $p): ?int { $tax = $p['tax_number'] ?? null; @@ -576,6 +799,10 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): { $contractData = $mapped['contract'] ?? []; $reference = $contractData['reference'] ?? null; + if (! is_null($reference)) { + $reference = preg_replace('/\s+/', '', trim((string) $reference)); + $contractData['reference'] = $reference; + } if (! $reference) { return ['action' => 'invalid', 'message' => 'Missing contract.reference']; } @@ -605,26 +832,53 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): // If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary if (! $existing && ! $clientCaseId) { - // Resolve by identifiers or provided person; do not use Client->person - $personId = null; - if (! empty($mapped['person'] ?? [])) { - $personId = $this->findPersonIdByIdentifiers($mapped['person']); - if (! $personId) { - $personId = $this->findOrCreatePersonId($mapped['person']); + $clientRef = $mapped['client_case']['client_ref'] ?? null; + // First, if we have a client and client_ref, try to reuse existing case to avoid creating extra persons + if ($clientId && $clientRef) { + $cc = ClientCase::where('client_id', $clientId)->where('client_ref', $clientRef)->first(); + if ($cc) { + // Reuse this case + $clientCaseId = $cc->id; + // If case has no person yet and we have mapped person identifiers/data, set it once + if (! $cc->person_id) { + $pid = null; + if (! empty($mapped['person'] ?? [])) { + $pid = $this->findPersonIdByIdentifiers($mapped['person']); + if (! $pid) { + $pid = $this->findOrCreatePersonId($mapped['person']); + } + } + if (! $pid) { + $pid = $this->createMinimalPersonId(); + } + $cc->person_id = $pid; + $cc->save(); + } } } - // As a last resort, create a minimal person for this client - if ($clientId && ! $personId) { - $personId = $this->createMinimalPersonId(); - } - if ($clientId && $personId) { - $clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId); - } elseif ($personId) { - // require an import client to attach case/contract - return ['action' => 'invalid', 'message' => 'Import must be linked to a client to create a case']; - } else { - return ['action' => 'invalid', 'message' => 'Unable to resolve client_case (need import client)']; + if (! $clientCaseId) { + // Resolve by identifiers or provided person; do not use Client->person + $personId = null; + if (! empty($mapped['person'] ?? [])) { + $personId = $this->findPersonIdByIdentifiers($mapped['person']); + if (! $personId) { + $personId = $this->findOrCreatePersonId($mapped['person']); + } + } + // As a last resort, create a minimal person for this client + if ($clientId && ! $personId) { + $personId = $this->createMinimalPersonId(); + } + + if ($clientId && $personId) { + $clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId, $clientRef); + } elseif ($personId) { + // require an import client to attach case/contract + return ['action' => 'invalid', 'message' => 'Import must be linked to a client to create a case']; + } else { + return ['action' => 'invalid', 'message' => 'Unable to resolve client_case (need import client)']; + } } } @@ -644,6 +898,9 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): continue; } $value = $contractData[$field] ?? null; + if ($field === 'reference' && ! is_null($value)) { + $value = preg_replace('/\s+/', '', trim((string) $value)); + } $mode = $map->apply_mode ?? 'both'; if (in_array($mode, ['insert', 'both'])) { $applyInsert[$field] = $value; @@ -655,11 +912,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): if ($existing) { if (empty($applyUpdate)) { - return ['action' => 'skipped', 'message' => 'No contract fields marked for update']; + // Return existing contract reference even when skipped so callers can treat as resolved + return ['action' => 'skipped', 'message' => 'No contract fields marked for update', 'contract' => $existing]; } $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); if (empty($changes)) { - return ['action' => 'skipped', 'message' => 'No non-null contract changes']; + return ['action' => 'skipped', 'message' => 'No non-null contract changes', 'contract' => $existing]; } $existing->fill($changes); $existing->save(); @@ -681,6 +939,197 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } } + private function sanitizeHeaderName(string $v): string + { + // Strip UTF-8 BOM and trim whitespace/control characters + $v = preg_replace('/^\xEF\xBB\xBF/', '', $v) ?? $v; + + return trim($v); + } + + private function findSourceColumnFor($mappings, string $targetField): ?string + { + foreach ($mappings as $map) { + if ((string) ($map->target_field ?? '') === $targetField) { + $src = (string) ($map->source_column ?? ''); + + return $src !== '' ? $src : null; + } + } + + return null; + } + + // Removed auto-detection helpers by request: no pattern scanning or fallback derivation + + private function normalizeDecimal(string $raw): string + { + // Keep digits, comma, dot, and minus to detect separators + $s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? ''; + $s = trim($s); + if ($s === '') { + return $s; + } + $lastComma = strrpos($s, ','); + $lastDot = strrpos($s, '.'); + // Determine decimal separator by last occurrence + $decimalSep = null; + if ($lastComma !== false || $lastDot !== false) { + if ($lastComma === false) { + $decimalSep = '.'; + } elseif ($lastDot === false) { + $decimalSep = ','; + } else { + $decimalSep = $lastComma > $lastDot ? ',' : '.'; + } + } + // Remove all thousand separators (the other one) and unify decimal to '.' + if ($decimalSep === ',') { + // remove all dots + $s = str_replace('.', '', $s); + // replace last comma with dot + $pos = strrpos($s, ','); + if ($pos !== false) { + $s[$pos] = '.'; + } + // remove any remaining commas (unlikely) + $s = str_replace(',', '', $s); + } elseif ($decimalSep === '.') { + // remove all commas + $s = str_replace(',', '', $s); + // dot already decimal + } else { + // no decimal separator: remove commas/dots entirely + $s = str_replace([',', '.'], '', $s); + } + + // Collapse multiple minus signs, keep leading only + $s = ltrim($s, '+'); + $neg = false; + if (str_starts_with($s, '-')) { + $neg = true; + $s = ltrim($s, '-'); + } + // Remove any stray minus signs + $s = str_replace('-', '', $s); + if ($neg) { + $s = '-'.$s; + } + + return $s; + } + + /** + * Ensure mapping roots are recognized; fail fast if unknown roots found. + */ + private function validateMappingRoots($mappings, array $validRoots): void + { + foreach ($mappings as $map) { + $target = (string) ($map->target_field ?? ''); + if ($target === '') { + continue; + } + $root = explode('.', $target)[0]; + if (! in_array($root, $validRoots, true)) { + // Common typos guidance + $hint = ''; + if (str_starts_with($root, 'contract')) { + $hint = ' Did you mean "contract"?'; + } + throw new \InvalidArgumentException('Unknown mapping root "'.$root.'" in target_field "'.$target.'".'.$hint); + } + } + } + + private function mappingIncludes($mappings, string $targetField): bool + { + foreach ($mappings as $map) { + if ((string) ($map->target_field ?? '') === $targetField) { + return true; + } + } + + return false; + } + + /** + * Normalize mapping target_field to canonical forms. + * Examples: + * - contracts.reference => contract.reference + * - accounts.balance_amount => account.balance_amount + * - person_phones.nu => phone.nu + * - person_addresses.address => address.address + * - emails.email|emails.value => email.value + */ + private function normalizeMappings($mappings, array $rootAliasMap, array $fieldAliasMap) + { + $normalized = []; + foreach ($mappings as $map) { + $clone = clone $map; + $clone->target_field = $this->normalizeTargetField((string) ($map->target_field ?? ''), $rootAliasMap, $fieldAliasMap); + $normalized[] = $clone; + } + + return collect($normalized); + } + + private function normalizeTargetField(string $target, array $rootAliasMap, array $fieldAliasMap): string + { + if ($target === '') { + return $target; + } + $parts = explode('.', $target); + $root = $parts[0] ?? ''; + $field = $parts[1] ?? null; + + // Root aliases (plural to canonical) from DB + $root = $rootAliasMap[$root] ?? $root; + + // Field aliases per root from DB + $aliases = $fieldAliasMap[$root] ?? []; + if ($field === null && isset($aliases['__default'])) { + $field = $aliases['__default']; + } elseif (isset($aliases[$field])) { + $field = $aliases[$field]; + } + + // Rebuild + if ($field !== null) { + return $root.'.'.$field; + } + + return $root; + } + + private function loadImportEntityConfig(): array + { + $entities = ImportEntity::all(); + $rootAliasMap = []; + $fieldAliasMap = []; + $validRoots = []; + foreach ($entities as $ent) { + $canonical = $ent->canonical_root; + $validRoots[] = $canonical; + foreach ((array) ($ent->aliases ?? []) as $alias) { + $rootAliasMap[$alias] = $canonical; + } + // Also ensure canonical maps to itself + $rootAliasMap[$canonical] = $canonical; + $aliases = (array) ($ent->field_aliases ?? []); + // Allow default field per entity via '__default' + if (is_array($ent->fields) && count($ent->fields)) { + $aliases['__default'] = $aliases['__default'] ?? null; + } + $fieldAliasMap[$canonical] = $aliases; + } + // sensible defaults when DB empty + if (empty($validRoots)) { + $validRoots = ['person', 'contract', 'account', 'address', 'phone', 'email', 'client_case']; + } + + return [$rootAliasMap, $fieldAliasMap, $validRoots]; + } + private function findOrCreatePersonId(array $p): ?int { // Basic dedup: by tax_number, ssn, else full_name @@ -776,14 +1225,30 @@ private function findOrCreateClientId(int $personId): int return Client::create(['person_id' => $personId])->id; } - private function findOrCreateClientCaseId(int $clientId, int $personId): int + private function findOrCreateClientCaseId(int $clientId, int $personId, ?string $clientRef = null): int { + // Prefer existing by client_ref if provided + if ($clientRef) { + $cc = ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + if ($cc) { + // Ensure person_id is set (if missing) when matching by client_ref + if (! $cc->person_id) { + $cc->person_id = $personId; + $cc->save(); + } + + return $cc->id; + } + } + // Fallback: by (client_id, person_id) $cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first(); if ($cc) { return $cc->id; } - return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id; + return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId, 'client_ref' => $clientRef])->id; } private function upsertEmail(int $personId, array $emailData, $mappings): array @@ -948,4 +1413,77 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array return ['action' => 'inserted', 'phone' => $created]; } } + + /** + * After a contract is inserted/updated, attach default segment and create an activity + * using decision_id from import/template meta. Activity note includes template name. + */ + private function postContractActions(Import $import, Contract $contract): void + { + $meta = $import->meta ?? []; + $segmentId = (int) ($meta['segment_id'] ?? 0); + $decisionId = (int) ($meta['decision_id'] ?? 0); + $templateName = (string) ($meta['template_name'] ?? optional($import->template)->name ?? ''); + $actionId = (int) ($meta['action_id'] ?? 0); + + // Attach segment to contract as the main (active) segment if provided + if ($segmentId > 0) { + // Ensure the segment exists on the client case and is active + $ccSeg = \DB::table('client_case_segment') + ->where('client_case_id', $contract->client_case_id) + ->where('segment_id', $segmentId) + ->first(); + if (! $ccSeg) { + \DB::table('client_case_segment')->insert([ + 'client_case_id' => $contract->client_case_id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif (! $ccSeg->active) { + \DB::table('client_case_segment') + ->where('id', $ccSeg->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Deactivate all other segments for this contract to make this the main one + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', '!=', $segmentId) + ->update(['active' => false, 'updated_at' => now()]); + + // Upsert the selected segment as active for this contract + $pivot = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $segmentId) + ->first(); + if ($pivot) { + if (! $pivot->active) { + \DB::table('contract_segment') + ->where('id', $pivot->id) + ->update(['active' => true, 'updated_at' => now()]); + } + } else { + \DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + // Create activity if decision provided + if ($decisionId > 0) { + Activity::create([ + 'decision_id' => $decisionId, + 'action_id' => $actionId > 0 ? $actionId : null, + 'contract_id' => $contract->id, + 'client_case_id' => $contract->client_case_id, + 'note' => trim('Imported via template'.($templateName ? ': '.$templateName : '')), + ]); + } + } } diff --git a/database/migrations/2025_09_29_000000_create_import_entities_table.php b/database/migrations/2025_09_29_000000_create_import_entities_table.php new file mode 100644 index 0000000..2e27a52 --- /dev/null +++ b/database/migrations/2025_09_29_000000_create_import_entities_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('key')->unique(); // UI key (plural except person) + $table->string('canonical_root'); // canonical root for processor (singular) + $table->string('label'); + $table->json('fields')->nullable(); // array of field names for UI + $table->json('field_aliases')->nullable(); // map alias -> canonical field + $table->json('aliases')->nullable(); // array of root aliases (e.g., ["contracts","contract"]) + $table->json('rules')->nullable(); // array of suggestion rules: { pattern, field, priority? } + $table->json('ui')->nullable(); // optional UI hints (default_field, order, etc.) + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_entities'); + } +}; diff --git a/database/migrations/2025_09_29_000001_add_client_ref_to_client_cases_table.php b/database/migrations/2025_09_29_000001_add_client_ref_to_client_cases_table.php new file mode 100644 index 0000000..cd532c7 --- /dev/null +++ b/database/migrations/2025_09_29_000001_add_client_ref_to_client_cases_table.php @@ -0,0 +1,39 @@ +string('client_ref', 191)->nullable()->after('person_id'); + } + // Add indexes + $table->index('client_ref'); + // Composite unique per client for client_ref + $table->unique(['client_id', 'client_ref']); + }); + } + + public function down(): void + { + Schema::table('client_cases', function (Blueprint $table) { + if (Schema::hasColumn('client_cases', 'client_ref')) { + // Drop constraints first + try { + $table->dropUnique('client_cases_client_id_client_ref_unique'); + } catch (\Throwable $e) { + } + try { + $table->dropIndex('client_cases_client_ref_index'); + } catch (\Throwable $e) { + } + $table->dropColumn('client_ref'); + } + }); + } +}; diff --git a/database/migrations/2025_09_29_120000_add_initial_amount_to_accounts_table.php b/database/migrations/2025_09_29_120000_add_initial_amount_to_accounts_table.php new file mode 100644 index 0000000..b8922b5 --- /dev/null +++ b/database/migrations/2025_09_29_120000_add_initial_amount_to_accounts_table.php @@ -0,0 +1,28 @@ +decimal('initial_amount', 18, 4)->nullable()->after('description'); + $table->index('initial_amount'); + } + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + if (Schema::hasColumn('accounts', 'initial_amount')) { + $table->dropIndex(['initial_amount']); + $table->dropColumn('initial_amount'); + } + }); + } +}; diff --git a/database/migrations/2025_09_29_120000_recreate_imports_table_if_missing.php b/database/migrations/2025_09_29_120000_recreate_imports_table_if_missing.php new file mode 100644 index 0000000..d275930 --- /dev/null +++ b/database/migrations/2025_09_29_120000_recreate_imports_table_if_missing.php @@ -0,0 +1,69 @@ +id(); + $table->uuid('uuid')->unique(); + + // Who initiated the import + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + // Optional template applied to this import (FK can be added separately if needed) + $table->foreignId('import_template_id')->nullable(); + + // Optional client this import is for (many imports per client) + $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete(); + + // File/source metadata + $table->string('source_type', 12); // csv|xml|xls|xlsx|json + $table->string('file_name', 255); + $table->string('original_name', 255)->nullable(); + $table->string('disk', 50)->default('local'); + $table->string('path', 2048); + $table->unsignedBigInteger('size')->nullable(); // bytes + $table->string('sheet_name', 64)->nullable(); // for Excel + + // Progress/status + $table->string('status', 20)->default('uploaded'); // uploaded|parsing|parsed|validating|importing|completed|failed + $table->unsignedInteger('total_rows')->default(0); + $table->unsignedInteger('valid_rows')->default(0); + $table->unsignedInteger('invalid_rows')->default(0); + $table->unsignedInteger('imported_rows')->default(0); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + + // Diagnostics and flexibility + $table->json('error_summary')->nullable(); + $table->json('meta')->nullable(); + + // Helpful indexes + $table->index('user_id'); + $table->index('import_template_id'); + $table->index('status'); + $table->index('client_id'); + $table->index('source_type'); + $table->index(['disk', 'path']); + + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + // Only drop if this migration created it (to be safe if others depend on it) + if (Schema::hasTable('imports')) { + Schema::drop('imports'); + } + } +}; diff --git a/database/migrations/2025_09_29_120100_recreate_import_rows_table_if_missing.php b/database/migrations/2025_09_29_120100_recreate_import_rows_table_if_missing.php new file mode 100644 index 0000000..0a5699a --- /dev/null +++ b/database/migrations/2025_09_29_120100_recreate_import_rows_table_if_missing.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('import_id')->constrained('imports')->cascadeOnDelete(); + + $table->unsignedInteger('row_number'); + $table->string('sheet_name', 64)->nullable(); + + // Type of record represented in this row (person, account, etc.) + $table->string('record_type', 50)->nullable(); + + // Data and results + $table->json('raw_data')->nullable(); + $table->json('mapped_data')->nullable(); + $table->string('status', 20)->default('pending'); // pending|valid|invalid|imported|skipped + $table->json('errors')->nullable(); + $table->json('warnings')->nullable(); + + // Link to created entity (optional, polymorphic) + $table->nullableMorphs('entity'); // entity_type + entity_id + + // Dedup/trace + $table->string('fingerprint', 64)->nullable()->index(); + + // Helpful indexes + $table->index(['import_id', 'status']); + $table->index(['import_id', 'row_number']); + $table->index('record_type'); + + $table->timestamps(); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('import_rows')) { + Schema::drop('import_rows'); + } + } +}; diff --git a/database/migrations/2025_09_29_120110_recreate_import_mappings_table_if_missing.php b/database/migrations/2025_09_29_120110_recreate_import_mappings_table_if_missing.php new file mode 100644 index 0000000..542e22e --- /dev/null +++ b/database/migrations/2025_09_29_120110_recreate_import_mappings_table_if_missing.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('import_id')->constrained('imports')->cascadeOnDelete(); + + $table->string('source_column', 255); + $table->string('target_field', 255)->nullable(); + $table->string('transform', 50)->nullable(); + $table->json('options')->nullable(); + + $table->index(['import_id', 'source_column']); + $table->index(['import_id', 'target_field']); + + $table->timestamps(); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('import_mappings')) { + Schema::drop('import_mappings'); + } + } +}; diff --git a/database/migrations/2025_09_29_120120_recreate_import_events_table_if_missing.php b/database/migrations/2025_09_29_120120_recreate_import_events_table_if_missing.php new file mode 100644 index 0000000..8ac295e --- /dev/null +++ b/database/migrations/2025_09_29_120120_recreate_import_events_table_if_missing.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('import_id')->constrained('imports')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->string('event', 50); + $table->string('level', 10)->default('info'); + $table->text('message')->nullable(); + $table->json('context')->nullable(); + + $table->foreignId('import_row_id')->nullable()->constrained('import_rows')->nullOnDelete(); + + $table->index(['import_id', 'event']); + $table->index(['import_id', 'level']); + $table->index('user_id'); + + $table->timestamps(); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('import_events')) { + Schema::drop('import_events'); + } + } +}; diff --git a/database/migrations/2025_09_29_130000_alter_contracts_description_length.php b/database/migrations/2025_09_29_130000_alter_contracts_description_length.php new file mode 100644 index 0000000..3e2a3fa --- /dev/null +++ b/database/migrations/2025_09_29_130000_alter_contracts_description_length.php @@ -0,0 +1,24 @@ +string('description', 500)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('contracts', function (Blueprint $table) { + // Revert to original length 255, keep nullable + $table->string('description', 255)->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2025_09_29_131000_alter_contracts_description_to_text.php b/database/migrations/2025_09_29_131000_alter_contracts_description_to_text.php new file mode 100644 index 0000000..a455f83 --- /dev/null +++ b/database/migrations/2025_09_29_131000_alter_contracts_description_to_text.php @@ -0,0 +1,24 @@ +text('description')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('contracts', function (Blueprint $table) { + // Revert back to VARCHAR(500) nullable + $table->string('description', 500)->nullable()->change(); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c94987e..18b3c76 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -14,6 +14,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + // \App\Models\User::factory(10)->create(); + $this->call(ImportEntitySeeder::class); // User::factory(10)->create(); // Ensure a default test user exists (idempotent) diff --git a/database/seeders/ImportEntitySeeder.php b/database/seeders/ImportEntitySeeder.php new file mode 100644 index 0000000..8e536cb --- /dev/null +++ b/database/seeders/ImportEntitySeeder.php @@ -0,0 +1,115 @@ + 'person', + 'canonical_root' => 'person', + 'label' => 'Person', + 'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'], + 'field_aliases' => [ + 'dob' => 'birthday', + 'date_of_birth' => 'birthday', + 'name' => 'full_name', + ], + 'aliases' => ['person'], + 'rules' => [ + ['pattern' => '/^(ime|first\s*name|firstname)\b|\bime\b/i', 'field' => 'first_name'], + ['pattern' => '/^(priimek|last\s*name|lastname)\b|\bpriimek\b/i', 'field' => 'last_name'], + ['pattern' => '/^(naziv|ime\s+in\s+priimek|full\s*name|name)\b|\bnaziv\b/i', 'field' => 'full_name'], + ['pattern' => '/^(davcna|davčna|tax|tax\s*number|tin)\b/i', 'field' => 'tax_number'], + ['pattern' => '/^(emso|emšo|ssn|social|social\s*security)\b/i', 'field' => 'social_security_number'], + ['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'], + ['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ], + 'ui' => ['order' => 1], + ], + [ + 'key' => 'person_addresses', + 'canonical_root' => 'address', + 'label' => 'Person Addresses', + 'fields' => ['address', 'country', 'type_id', 'description'], + 'aliases' => ['address', 'person_addresses'], + 'rules' => [ + ['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'], + ['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'], + ], + 'ui' => ['order' => 2], + ], + [ + 'key' => 'person_phones', + 'canonical_root' => 'phone', + 'label' => 'Person Phones', + 'fields' => ['nu', 'country_code', 'type_id', 'description'], + 'field_aliases' => ['number' => 'nu'], + 'aliases' => ['phone', 'person_phones'], + 'rules' => [ + ['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'], + ], + 'ui' => ['order' => 3], + ], + [ + 'key' => 'emails', + 'canonical_root' => 'email', + 'label' => 'Emails', + 'fields' => ['value', 'is_primary', 'label'], + 'field_aliases' => ['email' => 'value'], + 'aliases' => ['email', 'emails'], + 'rules' => [ + ['pattern' => '/^(email|e-?mail|mail)\b/i', 'field' => 'value'], + ], + 'ui' => ['order' => 4], + ], + [ + 'key' => 'contracts', + 'canonical_root' => 'contract', + 'label' => 'Contracts', + 'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id'], + 'aliases' => ['contract', 'contracts', 'contracs'], + 'rules' => [ + ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], + ['pattern' => '/^(od|from|start|zacetek|začetek)\b/i', 'field' => 'start_date'], + ['pattern' => '/^(do|to|end|konec)\b/i', 'field' => 'end_date'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ], + 'ui' => ['order' => 5], + ], + [ + 'key' => 'accounts', + 'canonical_root' => 'account', + 'label' => 'Accounts', + 'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], + 'aliases' => ['account', 'accounts'], + 'rules' => [ + ['pattern' => '/^(dolg|znesek|amount|saldo|balance|debt)\b/i', 'field' => 'balance_amount'], + ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], + ], + 'ui' => ['order' => 6], + ], + [ + 'key' => 'client_cases', + 'canonical_root' => 'client_case', + 'label' => 'Client Cases', + 'fields' => ['client_ref'], + 'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'], + 'rules' => [ + ['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'], + ], + 'ui' => ['order' => 7], + ], + ]; + + foreach ($defs as $d) { + ImportEntity::updateOrCreate(['key' => $d['key']], $d); + } + } +} diff --git a/resources/js/Pages/Cases/Show.vue b/resources/js/Pages/Cases/Show.vue index feebcb6..1cad929 100644 --- a/resources/js/Pages/Cases/Show.vue +++ b/resources/js/Pages/Cases/Show.vue @@ -171,10 +171,14 @@ const submitAttachSegment = () => {
-
+
+
+ Ref: + {{ client_case.client_ref }} +
diff --git a/resources/js/Pages/Imports/Create.vue b/resources/js/Pages/Imports/Create.vue index 8024d24..79ef59f 100644 --- a/resources/js/Pages/Imports/Create.vue +++ b/resources/js/Pages/Imports/Create.vue @@ -1,6 +1,6 @@ @@ -472,7 +451,13 @@ function recordToEntityKey(record) {
-

Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})

+
+

Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})

+ +
@@ -487,7 +472,15 @@ function recordToEntityKey(record) { - + - - + +
{{ row.source_column }} +
{{ row.source_column }}
+
+ Suggest: + +
+
+
- @@ -680,21 +815,30 @@ async function fetchEvents() { -

Template default: {{ selectedTemplateOption?.meta?.delimiter || 'auto' }}

+

+ Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }} +

- - + +
-
- Import not found. -
-
- Apply a template or select Entity and Field for one or more columns, then click Save Mappings to enable processing. +
Import not found.
+
+ Apply a template or select Entity and Field for one or more columns, then + click Save Mappings to enable processing.
@@ -737,8 +890,8 @@ async function fetchEvents() {
{{ m.source_column }} {{ m.target_field }}{{ m.transform || '—' }}{{ m.apply_mode || 'both' }}{{ m.transform || "—" }}{{ m.apply_mode || "both" }}
@@ -747,12 +900,19 @@ async function fetchEvents() {

- - + diff --git a/resources/js/Pages/Imports/Templates/Create.vue b/resources/js/Pages/Imports/Templates/Create.vue index b76edaa..dfae43f 100644 --- a/resources/js/Pages/Imports/Templates/Create.vue +++ b/resources/js/Pages/Imports/Templates/Create.vue @@ -3,9 +3,13 @@ import AppLayout from '@/Layouts/AppLayout.vue'; import { ref } from 'vue'; import { useForm } from '@inertiajs/vue3'; import Multiselect from 'vue-multiselect'; +import { computed, watch } from 'vue'; const props = defineProps({ clients: Array, + segments: Array, + decisions: Array, + actions: Array, }); const form = useForm({ @@ -16,6 +20,22 @@ const form = useForm({ is_active: true, client_uuid: null, entities: [], + meta: { + segment_id: null, + decision_id: null, + action_id: null, + delimiter: '', + }, +}); + +const decisionsForSelectedAction = computed(() => { + const act = (props.actions || []).find(a => a.id === form.meta.action_id); + return act?.decisions || []; +}); + +watch(() => form.meta.action_id, () => { + // Clear decision when action changes to enforce valid pair + form.meta.decision_id = null; }); function submit() { @@ -75,6 +95,32 @@ function submit() {

Choose which tables this template targets. You can still define per-column mappings later.

+ + +
+
+ + +
+
+ + +

Select an Action to see its Decisions.

+
+
+ + +
+
diff --git a/resources/js/Pages/Imports/Templates/Edit.vue b/resources/js/Pages/Imports/Templates/Edit.vue index 441c1b7..932aa3f 100644 --- a/resources/js/Pages/Imports/Templates/Edit.vue +++ b/resources/js/Pages/Imports/Templates/Edit.vue @@ -1,12 +1,17 @@