From 7227c888d4d73831fec804678ed0a988cc2bfea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sat, 27 Sep 2025 17:45:55 +0200 Subject: [PATCH] Mager updated --- .env.example | 13 +- .../Commands/PruneDocumentPreviews.php | 64 ++ app/Console/Kernel.php | 34 + app/Http/Controllers/ClientCaseContoller.php | 117 +++ app/Http/Controllers/ImportController.php | 358 ++++++++ .../Controllers/ImportTemplateController.php | 432 ++++++++++ app/Http/Controllers/PersonController.php | 61 +- app/Http/Middleware/HandleInertiaRequests.php | 7 +- app/Jobs/GenerateDocumentPreview.php | 149 ++++ app/Models/Account.php | 9 + app/Models/ClientCase.php | 9 +- app/Models/Document.php | 83 ++ app/Models/Email.php | 35 + app/Models/Import.php | 50 ++ app/Models/ImportEvent.php | 35 + app/Models/ImportRow.php | 28 + app/Models/ImportTemplate.php | 38 + app/Models/ImportTemplateMapping.php | 25 + app/Models/Person/Person.php | 22 + app/Services/CsvImportService.php | 65 ++ app/Services/ImportProcessor.php | 795 +++++++++++++++++ config/files.php | 19 + database/factories/Person/PersonFactory.php | 10 +- ...alter_table_account_add_balance_column.php | 30 + ...2025_09_26_185111_create_objects_table.php | 46 + ...9_26_191209_create_bank_accounts_table.php | 50 ++ .../2025_09_26_192522_create_emails_table.php | 47 + ...2025_09_26_193600_create_imports_table.php | 64 ++ ..._09_26_193610_create_import_rows_table.php | 47 + ...26_193620_create_import_mappings_table.php | 33 + ...9_26_193630_create_import_events_table.php | 38 + ...6_193640_create_import_templates_table.php | 43 + ..._create_import_template_mappings_table.php | 33 + ...dd_client_id_to_import_templates_table.php | 27 + ...00_add_unique_indexes_for_import_dedup.php | 78 ++ ...apply_mode_to_import_template_mappings.php | 26 + ...2310_add_apply_mode_to_import_mappings.php | 26 + ...0_add_balance_amount_to_accounts_table.php | 30 + ...dd_fk_import_template_to_imports_table.php | 27 + ...09_27_000001_alter_person_nu_to_string.php | 102 +++ ...00100_add_preview_columns_to_documents.php | 24 + ...221000_add_position_to_import_mappings.php | 28 + ...7_230500_add_entity_to_import_mappings.php | 57 ++ ...add_entity_to_import_template_mappings.php | 55 ++ database/seeders/DatabaseSeeder.php | 3 +- database/seeders/ImportTemplateSeeder.php | 51 ++ resources/examples/sample_import.csv | 6 + resources/js/Components/AddressCreateForm.vue | 6 +- resources/js/Components/BasicTable.vue | 79 +- .../js/Components/DocumentUploadDialog.vue | 116 +++ .../js/Components/DocumentViewerDialog.vue | 26 + resources/js/Components/DocumentsTable.vue | 139 +++ resources/js/Components/PersonUpdateForm.vue | 6 +- resources/js/Components/PhoneCreateForm.vue | 6 +- resources/js/Layouts/AppLayout.vue | 455 +++++----- .../js/Layouts/Partials/GlobalSearch.vue | 127 +-- .../Pages/Cases/Partials/ActivityDrawer.vue | 6 +- .../Pages/Cases/Partials/ContractDrawer.vue | 6 +- resources/js/Pages/Cases/Show.vue | 53 +- resources/js/Pages/Client/Index.vue | 6 +- .../Pages/Client/Partials/FormCreateCase.vue | 6 +- .../Client/Partials/FormUpdateClient.vue | 6 +- resources/js/Pages/Imports/Create.vue | 537 ++++++++++++ resources/js/Pages/Imports/Import.vue | 805 ++++++++++++++++++ resources/js/Pages/Imports/Index.vue | 75 ++ .../js/Pages/Imports/Templates/Create.vue | 123 +++ resources/js/Pages/Imports/Templates/Edit.vue | 495 +++++++++++ .../js/Pages/Imports/Templates/Index.vue | 88 ++ .../Pages/Settings/Partials/ActionTable.vue | 10 +- .../Pages/Settings/Partials/DecisionTable.vue | 10 +- resources/js/Services/documents.ts | 25 + resources/js/app.js | 2 + resources/js/bootstrap.js | 6 + routes/web.php | 33 + 74 files changed, 6339 insertions(+), 342 deletions(-) create mode 100644 app/Console/Commands/PruneDocumentPreviews.php create mode 100644 app/Console/Kernel.php create mode 100644 app/Http/Controllers/ImportController.php create mode 100644 app/Http/Controllers/ImportTemplateController.php create mode 100644 app/Jobs/GenerateDocumentPreview.php create mode 100644 app/Models/Document.php create mode 100644 app/Models/Email.php create mode 100644 app/Models/Import.php create mode 100644 app/Models/ImportEvent.php create mode 100644 app/Models/ImportRow.php create mode 100644 app/Models/ImportTemplate.php create mode 100644 app/Models/ImportTemplateMapping.php create mode 100644 app/Services/CsvImportService.php create mode 100644 app/Services/ImportProcessor.php create mode 100644 config/files.php create mode 100644 database/migrations/2025_09_26_184034_alter_table_account_add_balance_column.php create mode 100644 database/migrations/2025_09_26_185111_create_objects_table.php create mode 100644 database/migrations/2025_09_26_191209_create_bank_accounts_table.php create mode 100644 database/migrations/2025_09_26_192522_create_emails_table.php create mode 100644 database/migrations/2025_09_26_193600_create_imports_table.php create mode 100644 database/migrations/2025_09_26_193610_create_import_rows_table.php create mode 100644 database/migrations/2025_09_26_193620_create_import_mappings_table.php create mode 100644 database/migrations/2025_09_26_193630_create_import_events_table.php create mode 100644 database/migrations/2025_09_26_193640_create_import_templates_table.php create mode 100644 database/migrations/2025_09_26_193650_create_import_template_mappings_table.php create mode 100644 database/migrations/2025_09_26_200100_add_client_id_to_import_templates_table.php create mode 100644 database/migrations/2025_09_26_201700_add_unique_indexes_for_import_dedup.php create mode 100644 database/migrations/2025_09_26_202300_add_apply_mode_to_import_template_mappings.php create mode 100644 database/migrations/2025_09_26_202310_add_apply_mode_to_import_mappings.php create mode 100644 database/migrations/2025_09_26_202500_add_balance_amount_to_accounts_table.php create mode 100644 database/migrations/2025_09_26_202600_add_fk_import_template_to_imports_table.php create mode 100644 database/migrations/2025_09_27_000001_alter_person_nu_to_string.php create mode 100644 database/migrations/2025_09_27_000100_add_preview_columns_to_documents.php create mode 100644 database/migrations/2025_09_27_221000_add_position_to_import_mappings.php create mode 100644 database/migrations/2025_09_27_230500_add_entity_to_import_mappings.php create mode 100644 database/migrations/2025_09_27_230600_add_entity_to_import_template_mappings.php create mode 100644 database/seeders/ImportTemplateSeeder.php create mode 100644 resources/examples/sample_import.csv create mode 100644 resources/js/Components/DocumentUploadDialog.vue create mode 100644 resources/js/Components/DocumentViewerDialog.vue create mode 100644 resources/js/Components/DocumentsTable.vue create mode 100644 resources/js/Pages/Imports/Create.vue create mode 100644 resources/js/Pages/Imports/Import.vue create mode 100644 resources/js/Pages/Imports/Index.vue create mode 100644 resources/js/Pages/Imports/Templates/Create.vue create mode 100644 resources/js/Pages/Imports/Templates/Edit.vue create mode 100644 resources/js/Pages/Imports/Templates/Index.vue create mode 100644 resources/js/Services/documents.ts diff --git a/.env.example b/.env.example index f069728..6519688 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC -APP_URL=http://localhost +APP_URL=http://localhost:8001 APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -62,3 +62,14 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# LibreOffice binary path for document previews (set full path on Windows) +# Example (Windows): C:\\Program Files\\LibreOffice\\program\\soffice.exe +# Example (Linux): soffice +LIBREOFFICE_BIN=soffice + +# Storage configuration for generated previews +FILES_PREVIEW_DISK=public +FILES_PREVIEW_BASE=previews/cases +FILES_ENABLE_PREVIEW_PRUNE=true +FILES_PREVIEW_RETENTION_DAYS=90 diff --git a/app/Console/Commands/PruneDocumentPreviews.php b/app/Console/Commands/PruneDocumentPreviews.php new file mode 100644 index 0000000..0f7043a --- /dev/null +++ b/app/Console/Commands/PruneDocumentPreviews.php @@ -0,0 +1,64 @@ +option('days'); + if ($days < 1) { $days = 90; } + $cutoff = Carbon::now()->subDays($days); + + $previewDisk = config('files.preview_disk', 'public'); + $query = Document::query() + ->whereNotNull('preview_path') + ->whereNotNull('preview_generated_at') + ->where('preview_generated_at', '<', $cutoff); + + $count = $query->count(); + if ($count === 0) { + $this->info('No stale previews found.'); + return self::SUCCESS; + } + + $this->info("Found {$count} previews older than {$days} days."); + $dry = (bool) $this->option('dry-run'); + + $query->chunkById(200, function ($docs) use ($previewDisk, $dry) { + foreach ($docs as $doc) { + $path = $doc->preview_path; + if (!$path) { continue; } + if ($dry) { + $this->line("Would delete: {$previewDisk}://{$path} (document #{$doc->id})"); + continue; + } + try { + Storage::disk($previewDisk)->delete($path); + } catch (\Throwable $e) { + // ignore errors, continue processing + } + $doc->preview_path = null; + $doc->preview_mime = null; + $doc->preview_generated_at = null; + $doc->save(); + } + }); + + if ($dry) { + $this->info('Dry run completed. No files were deleted.'); + } else { + $this->info('Stale previews deleted and metadata cleared.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 0000000..86e68a0 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,34 @@ +command('documents:prune-previews', [ + '--days' => $days, + ])->dailyAt('02:00'); + } + } + + /** + * Register the commands for the application. + */ + protected function commands(): void + { + $this->load(__DIR__.'/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 1e0ad47..fb6c606 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -4,6 +4,9 @@ use App\Models\ClientCase; use App\Models\Contract; +use App\Models\Document; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Storage; use Exception; use Illuminate\Database\QueryException; use Illuminate\Http\Request; @@ -162,6 +165,119 @@ public function deleteContract(ClientCase $clientCase, String $uuid, Request $re return to_route('clientCase.show', $clientCase); } + public function storeDocument(ClientCase $clientCase, Request $request) + { + $validated = $request->validate([ + 'file' => 'required|file|max:25600|mimes:doc,docx,pdf,txt,csv,xls,xlsx,jpeg,png', // 25MB and allowed types + 'name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'is_public' => 'sometimes|boolean', + ]); + + $file = $validated['file']; + $disk = 'public'; + $directory = 'cases/' . $clientCase->uuid . '/documents'; + $path = $file->store($directory, $disk); + + $doc = new Document([ + 'name' => $validated['name'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), + 'description' => $validated['description'] ?? null, + 'user_id' => optional($request->user())->id, + 'disk' => $disk, + 'path' => $path, + 'file_name' => basename($path), + 'original_name' => $file->getClientOriginalName(), + 'extension' => $file->getClientOriginalExtension(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'checksum' => null, + 'is_public' => (bool)($validated['is_public'] ?? false), + ]); + + $clientCase->documents()->save($doc); + + // Generate preview immediately for Office docs to avoid first-view delay + $ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION)); + if (in_array($ext, ['doc','docx'])) { + \App\Jobs\GenerateDocumentPreview::dispatch($doc->id); + } + + return back()->with('success', 'Document uploaded.'); + } + + public function viewDocument(ClientCase $clientCase, Document $document, Request $request) + { + // Ensure the document belongs to this client case + if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) { + abort(404); + } + + // Optional: add authz checks here (e.g., policies) + $disk = $document->disk ?: 'public'; + + // If a preview exists (e.g., PDF generated for doc/docx), stream that + $previewDisk = config('files.preview_disk', 'public'); + if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { + $stream = Storage::disk($previewDisk)->readStream($document->preview_path); + if ($stream === false) abort(404); + return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ + 'Content-Type' => $document->preview_mime ?: 'application/pdf', + 'Content-Disposition' => 'inline; filename="' . addslashes(($document->original_name ?: $document->file_name) . '.pdf') . '"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + + // If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted + $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); + if (in_array($ext, ['doc','docx'])) { + \App\Jobs\GenerateDocumentPreview::dispatch($document->id); + return response('Preview is being generated. Please try again shortly.', 202); + } + + if (!Storage::disk($disk)->exists($document->path)) { + abort(404); + } + + $stream = Storage::disk($disk)->readStream($document->path); + if ($stream === false) { + abort(404); + } + + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'inline; filename="' . addslashes($document->original_name ?: $document->file_name) . '"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + + public function downloadDocument(ClientCase $clientCase, Document $document, Request $request) + { + if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) { + abort(404); + } + $disk = $document->disk ?: 'public'; + if (!Storage::disk($disk)->exists($document->path)) { + abort(404); + } + $name = $document->original_name ?: $document->file_name; + $stream = Storage::disk($disk)->readStream($document->path); + if ($stream === false) { + abort(404); + } + return response()->stream(function () use ($stream) { + fpassthru($stream); + }, 200, [ + 'Content-Type' => $document->mime_type ?: 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="' . addslashes($name) . '"', + 'Cache-Control' => 'private, max-age=0, no-cache', + 'Pragma' => 'no-cache', + ]); + } + /** * Display the specified resource. */ @@ -185,6 +301,7 @@ public function show(ClientCase $clientCase) 'activities' => $case->activities()->with(['action', 'decision']) ->orderByDesc('created_at') ->paginate(20, ['*'], 'activities'), + 'documents' => $case->documents()->orderByDesc('created_at')->get(), 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'actions' => \App\Models\Action::with('decisions')->get(), 'types' => $types diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php new file mode 100644 index 0000000..02d2a7b --- /dev/null +++ b/app/Http/Controllers/ImportController.php @@ -0,0 +1,358 @@ +with(['client:id,uuid', 'template:id,name']) + ->orderByDesc('created_at') + ->paginate(15); + + $imports = [ + 'data' => $paginator->items(), + 'links' => [ + 'first' => $paginator->url(1), + 'last' => $paginator->url($paginator->lastPage()), + 'prev' => $paginator->previousPageUrl(), + 'next' => $paginator->nextPageUrl(), + ], + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'from' => $paginator->firstItem(), + 'last_page' => $paginator->lastPage(), + 'path' => $paginator->path(), + 'per_page' => $paginator->perPage(), + 'to' => $paginator->lastItem(), + 'total' => $paginator->total(), + ], + ]; + // Map items into a simpler shape + $imports['data'] = array_map(function ($imp) { + return [ + 'id' => $imp->id, + 'uuid' => $imp->uuid, + 'created_at' => $imp->created_at, + 'original_name' => $imp->original_name, + 'size' => $imp->size, + 'status' => $imp->status, + 'client' => $imp->client ? [ 'id' => $imp->client_id, 'uuid' => $imp->client->uuid ] : null, + 'template' => $imp->template ? [ 'id' => $imp->import_template_id, 'name' => $imp->template->name ] : null, + ]; + }, $imports['data']); + + return Inertia::render('Imports/Index', [ + 'imports' => $imports, + ]); + } + + // Show the import creation page + public function create(Request $request) + { + $templates = ImportTemplate::query() + ->leftJoin('clients', 'clients.id', '=', 'import_templates.client_id') + ->where('import_templates.is_active', true) + ->orderBy('import_templates.name') + ->get([ + 'import_templates.id', + 'import_templates.uuid', + 'import_templates.name', + 'import_templates.source_type', + 'import_templates.default_record_type', + 'import_templates.client_id', + DB::raw('clients.uuid as client_uuid'), + ]); + + $clients = Client::query() + ->join('person', 'person.id', '=', 'clients.person_id') + ->orderBy('person.full_name') + ->get([ + 'clients.id', + 'clients.uuid', + DB::raw('person.full_name as name'), + ]); + + + return Inertia::render('Imports/Create', [ + 'templates' => $templates, + 'clients' => $clients, + // no existing import on create + ]); + } + + // Create a new import job, store file, and return basic info + public function store(Request $request) + { + $validated = $request->validate([ + 'file' => 'required|file|max:20480', // 20MB; adjust as needed + 'source_type' => 'nullable|string|in:csv,xml,xls,xlsx,json,txt', + 'sheet_name' => 'nullable|string|max:64', + 'has_header' => 'nullable|boolean', + 'import_template_id' => 'nullable|integer|exists:import_templates,id', + 'client_uuid' => 'nullable|string|exists:clients,uuid', + ]); + + $file = $validated['file']; + $ext = strtolower($file->getClientOriginalExtension()); + $sourceType = $validated['source_type'] ?? ($ext === 'txt' ? 'csv' : $ext); + + $uuid = (string) Str::uuid(); + $disk = 'local'; + $path = $file->storeAs('imports', $uuid.'.'.$ext, $disk); + + // Resolve client_uuid to client_id if provided + $clientId = null; + if (!empty($validated['client_uuid'] ?? null)) { + $clientId = Client::where('uuid', $validated['client_uuid'])->value('id'); + } + + $import = Import::create([ + 'uuid' => $uuid, + 'user_id' => $request->user()?->id, + 'import_template_id' => $validated['import_template_id'] ?? null, + 'client_id' => $clientId, + 'source_type' => $sourceType, + 'file_name' => basename($path), + 'original_name' => $file->getClientOriginalName(), + 'disk' => $disk, + 'path' => $path, + 'size' => $file->getSize(), + 'sheet_name' => $validated['sheet_name'] ?? null, + 'status' => 'uploaded', + 'meta' => [ + 'has_header' => $validated['has_header'] ?? true, + ], + ]); + + return response()->json([ + 'id' => $import->id, + 'uuid' => $import->uuid, + 'status' => $import->status, + ]); + } + + // Kick off processing of an import - simple synchronous step for now + public function process(Import $import, Request $request, ImportProcessor $processor) + { + $import->update(['status' => 'validating', 'started_at' => now()]); + $result = $processor->process($import, user: $request->user()); + return response()->json($result); + } + + // Analyze the uploaded file and return column headers or positional indices + public function columns(Request $request, Import $import, CsvImportService $csv) + { + $validated = $request->validate([ + 'has_header' => 'nullable|boolean', + ]); + + $hasHeader = array_key_exists('has_header', $validated) + ? (bool) $validated['has_header'] + : (bool) ($import->meta['has_header'] ?? true); + + // 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.', + ]); + } + + $fullPath = Storage::disk($import->disk)->path($import->path); + [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); + + // Save meta + $meta = $import->meta ?? []; + $meta['has_header'] = $hasHeader; + $meta['detected_delimiter'] = $delimiter; + $meta['columns'] = $columns; + $import->update([ + 'meta' => $meta, + 'status' => $import->status === 'uploaded' ? 'parsed' : $import->status, + ]); + + return response()->json([ + 'columns' => $columns, + 'has_header' => $hasHeader, + 'detected_delimiter' => $delimiter, + ]); + } + + // CSV helpers moved to App\Services\CsvImportService + + // Save ad-hoc mappings for a specific import (when no template is selected) + 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.*.target_field' => 'required|string', + 'mappings.*.transform' => 'nullable|string|in:trim,upper,lower', + 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both', + 'mappings.*.options' => 'nullable|array', + ]); + + // Upsert by (import_id, source_column): update existing rows; insert new ones; avoid duplicates + $now = now(); + $existing = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->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 ]; + } else { + $dupes[$src] = ($dupes[$src] ?? []); + $dupes[$src][] = $row->id; + } + } + + $basePosition = (int) (\DB::table('import_mappings')->where('import_id', $import->id)->max('position') ?? -1); + $inserted = 0; $updated = 0; $deduped = 0; + + foreach ($data['mappings'] as $pos => $m) { + $src = (string) $m['source_column']; + $payload = [ + 'entity' => $m['entity'] ?? null, + 'target_field' => $m['target_field'], + 'transform' => $m['transform'] ?? null, + 'apply_mode' => $m['apply_mode'] ?? 'both', + 'options' => $m['options'] ?? null, + 'position' => $pos, // keep UI order + 'updated_at' => $now, + ]; + + if (array_key_exists($src, $bySource)) { + // Update first occurrence + \DB::table('import_mappings')->where('id', $bySource[$src]['id'])->update($payload); + $updated++; + // Remove duplicates if any + if (!empty($dupes[$src])) { + $deleted = \DB::table('import_mappings')->whereIn('id', $dupes[$src])->delete(); + $deduped += (int) $deleted; + unset($dupes[$src]); + } + } else { + // Insert new + \DB::table('import_mappings')->insert([ + 'import_id' => $import->id, + 'entity' => $payload['entity'], + 'source_column' => $src, + 'target_field' => $payload['target_field'], + 'transform' => $payload['transform'], + 'apply_mode' => $payload['apply_mode'], + 'options' => $payload['options'], + 'position' => ++$basePosition, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $inserted++; + } + } + + // Mark this as ad-hoc mapping usage + $import->update(['import_template_id' => null]); + + return response()->json(['ok' => true, 'saved' => ($inserted + $updated), 'inserted' => $inserted, 'updated' => $updated, 'deduped' => $deduped]); + } + + // Fetch current mappings for an import (after applying a template or saving ad-hoc mappings) + public function getMappings(Import $import) + { + $rows = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->orderBy('id') + ->get([ + 'id', + 'entity', + 'source_column', + 'target_field', + 'transform', + 'apply_mode', + 'options', + 'position' + ]); + return response()->json(['mappings' => $rows]); + } + + // Fetch recent import events (logs) for an import + public function getEvents(Import $import) + { + $limit = (int) request()->query('limit', 200); + $limit = max(1, min($limit, 1000)); + $events = ImportEvent::query() + ->where('import_id', $import->id) + ->orderByDesc('id') + ->limit($limit) + ->get(['id','created_at','level','event','message','import_row_id','context']); + return response()->json(['events' => $events]); + } + + // Show an existing import by UUID to continue where left off + public function show(Import $import) + { + // Load templates (global + client specific) and clients for selection on continue page + $templates = ImportTemplate::query() + ->leftJoin('clients', 'clients.id', '=', 'import_templates.client_id') + ->where('import_templates.is_active', true) + ->orderBy('import_templates.name') + ->get([ + 'import_templates.id', + 'import_templates.uuid', + 'import_templates.name', + 'import_templates.source_type', + 'import_templates.default_record_type', + 'import_templates.client_id', + DB::raw('clients.uuid as client_uuid'), + ]); + + $clients = Client::query() + ->join('person', 'person.id', '=', 'clients.person_id') + ->orderBy('person.full_name') + ->get([ + 'clients.id', + 'clients.uuid', + DB::raw('person.full_name as name'), + ]); + + // Render a dedicated page to continue the import + return Inertia::render('Imports/Import', [ + 'import' => [ + 'id' => $import->id, + 'uuid' => $import->uuid, + 'status' => $import->status, + 'meta' => $import->meta, + 'client_id' => $import->client_id, + 'import_template_id' => $import->import_template_id, + 'total_rows' => $import->total_rows, + 'imported_rows' => $import->imported_rows, + 'invalid_rows' => $import->invalid_rows, + 'valid_rows' => $import->valid_rows, + 'finished_at' => $import->finished_at, + ], + 'templates' => $templates, + 'clients' => $clients, + ]); + } +} diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php new file mode 100644 index 0000000..43531d7 --- /dev/null +++ b/app/Http/Controllers/ImportTemplateController.php @@ -0,0 +1,432 @@ +with(['client:id,uuid,person_id', 'client.person:id,full_name']) + ->orderBy('name') + ->get(); + + return Inertia::render('Imports/Templates/Index', [ + 'templates' => $templates->map(fn($t) => [ + 'uuid' => $t->uuid, + 'name' => $t->name, + 'description' => $t->description, + 'source_type' => $t->source_type, + 'is_active' => $t->is_active, + 'client' => $t->client ? [ + 'uuid' => $t->client->uuid, + 'name' => $t->client->person?->full_name, + ] : null, + ]), + ]); + } + + // Show the template creation page + public function create() + { + // Preload clients for optional association (global when null) + $clients = Client::query() + ->join('person', 'person.id', '=', 'clients.person_id') + ->orderBy('person.full_name') + ->get([ + 'clients.id', // kept for compatibility, UI will use uuid + 'clients.uuid', + DB::raw('person.full_name as name'), + ]); + + return Inertia::render('Imports/Templates/Create', [ + 'clients' => $clients, + ]); + } + + public function store(Request $request) + { + // Normalize payload to be resilient to UI variations + $raw = $request->all(); + // Resolve client by uuid if provided, or cast string numeric to int + 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']; + } + // 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']; + return null; + }, $raw['entities']))); + } + + $data = validator($raw, [ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:255', + 'source_type' => 'required|string|in:csv,xml,xls,xlsx,json', + 'default_record_type' => 'nullable|string|max:50', + 'sample_headers' => 'nullable|array', + 'client_id' => 'nullable|integer|exists:clients,id', + 'is_active' => 'boolean', + 'entities' => 'nullable|array', + '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.*.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', + ])->validate(); + + $template = null; + DB::transaction(function () use (&$template, $request, $data) { + $template = ImportTemplate::create([ + 'uuid' => (string) Str::uuid(), + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'source_type' => $data['source_type'], + 'default_record_type' => $data['default_record_type'] ?? null, + 'sample_headers' => $data['sample_headers'] ?? null, + 'user_id' => $request->user()?->id, + 'client_id' => $data['client_id'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'meta' => [ + 'entities' => $data['entities'] ?? [], + ], + ]); + + foreach (($data['mappings'] ?? []) as $m) { + ImportTemplateMapping::create([ + 'import_template_id' => $template->id, + 'entity' => $m['entity'] ?? null, + 'source_column' => $m['source_column'], + 'target_field' => $m['target_field'] ?? null, + 'transform' => $m['transform'] ?? null, + 'apply_mode' => $m['apply_mode'] ?? 'both', + 'options' => $m['options'] ?? null, + 'position' => $m['position'] ?? null, + ]); + } + }); + + // Redirect to edit page for the newly created template + return redirect() + ->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', 'Template created successfully.'); + } + + // Edit template UI (by uuid) + public function edit(ImportTemplate $template) + { + // Eager-load mappings + $template->load(['mappings']); + + // Preload clients list (uuid + name) for possible reassignment + $clients = Client::query() + ->join('person', 'person.id', '=', 'clients.person_id') + ->orderBy('person.full_name') + ->get([ + 'clients.uuid', + DB::raw('person.full_name as name'), + ]); + + return Inertia::render('Imports/Templates/Edit', [ + 'template' => [ + 'uuid' => $template->uuid, + 'name' => $template->name, + 'description' => $template->description, + 'source_type' => $template->source_type, + 'default_record_type' => $template->default_record_type, + 'is_active' => $template->is_active, + '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']), + ], + 'clients' => $clients, + ]); + } + + // Add a new mapping to a template (by uuid) + public function addMapping(Request $request, ImportTemplate $template) + { + // Normalize empty transform to null + $raw = $request->all(); + if (array_key_exists('transform', $raw) && $raw['transform'] === '') { + $raw['transform'] = null; + } + $data = validator($raw, [ + 'source_column' => 'required|string', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'target_field' => 'nullable|string', + 'transform' => 'nullable|string|in:trim,upper,lower', + 'apply_mode' => 'nullable|string|in:insert,update,both', + 'options' => 'nullable|array', + 'position' => 'nullable|integer', + ])->validate(); + + // Avoid duplicates by source_column within the same template: update if exists + $existing = ImportTemplateMapping::where('import_template_id', $template->id) + ->where('source_column', $data['source_column']) + ->first(); + + if ($existing) { + $existing->update([ + 'target_field' => $data['target_field'] ?? $existing->target_field, + 'entity' => $data['entity'] ?? $existing->entity, + 'transform' => $data['transform'] ?? $existing->transform, + 'apply_mode' => $data['apply_mode'] ?? $existing->apply_mode ?? 'both', + '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 { + $position = $data['position'] ?? (int) (($template->mappings()->max('position') ?? 0) + 1); + ImportTemplateMapping::create([ + 'import_template_id' => $template->id, + 'entity' => $data['entity'] ?? null, + 'source_column' => $data['source_column'], + 'target_field' => $data['target_field'] ?? null, + 'transform' => $data['transform'] ?? null, + 'apply_mode' => $data['apply_mode'] ?? 'both', + 'options' => $data['options'] ?? null, + 'position' => $position, + ]); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', 'Mapping added'); + } + } + + // Update template basic fields + public function update(Request $request, ImportTemplate $template) + { + $raw = $request->all(); + if (!empty($raw['client_uuid'] ?? null)) { + $raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id'); + } + $data = validator($raw, [ + 'name' => 'required|string|max:100', + 'description' => 'nullable|string|max:255', + 'source_type' => 'required|string|in:csv,xml,xls,xlsx,json', + 'default_record_type' => 'nullable|string|max:50', + 'client_id' => 'nullable|integer|exists:clients,id', + 'is_active' => 'boolean', + 'sample_headers' => 'nullable|array', + ])->validate(); + + $template->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'source_type' => $data['source_type'], + 'default_record_type' => $data['default_record_type'] ?? null, + 'client_id' => $data['client_id'] ?? null, + 'is_active' => $data['is_active'] ?? $template->is_active, + 'sample_headers' => $data['sample_headers'] ?? $template->sample_headers, + ]); + + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', 'Template updated'); + } + + // Bulk add multiple mappings from a textarea input + public function bulkAddMappings(Request $request, ImportTemplate $template) + { + // Accept either commas or newlines as separators + $raw = $request->all(); + if (array_key_exists('transform', $raw) && $raw['transform'] === '') { + $raw['transform'] = null; + } + $data = validator($raw, [ + 'sources' => 'required|string', // comma and/or newline separated + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + '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 !== '')); + + if (empty($list)) { + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('warning', 'No valid source columns provided.'); + } + + $basePosition = (int) (($template->mappings()->max('position') ?? 0)); + $apply = $data['apply_mode'] ?? 'both'; + $transform = $data['transform'] ?? null; + $entity = $data['entity'] ?? null; + $defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all + + $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; + } elseif ($entity) { + $targetField = $entity . '.' . $source; + } + + $existing = ImportTemplateMapping::where('import_template_id', $template->id) + ->where('source_column', $source) + ->first(); + + if ($existing) { + $existing->update([ + 'target_field' => $targetField ?? $existing->target_field, + 'entity' => $entity ?? $existing->entity, + 'transform' => $transform ?? $existing->transform, + 'apply_mode' => $apply ?? $existing->apply_mode ?? 'both', + 'options' => $existing->options, + // keep existing position + ]); + $updated++; + } else { + ImportTemplateMapping::create([ + 'import_template_id' => $template->id, + 'entity' => $entity, + 'source_column' => $source, + 'target_field' => $targetField, + 'transform' => $transform, + 'apply_mode' => $apply, + 'options' => null, + 'position' => $basePosition + $idx + 1, + ]); + $created++; + } + } + }); + + $msg = []; + if ($created) $msg[] = "$created created"; + if ($updated) $msg[] = "$updated updated"; + $text = 'Mappings processed'; + if (!empty($msg)) $text .= ': ' . implode(', ', $msg); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', $text); + } + + // Update an existing mapping + public function updateMapping(Request $request, ImportTemplate $template, ImportTemplateMapping $mapping) + { + if ($mapping->import_template_id !== $template->id) abort(404); + $raw = $request->all(); + if (array_key_exists('transform', $raw) && $raw['transform'] === '') { + $raw['transform'] = null; + } + $data = validator($raw, [ + 'source_column' => 'required|string', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts', + 'target_field' => 'nullable|string', + 'transform' => 'nullable|string|in:trim,upper,lower', + 'apply_mode' => 'nullable|string|in:insert,update,both', + 'options' => 'nullable|array', + 'position' => 'nullable|integer', + ])->validate(); + $mapping->update([ + 'source_column' => $data['source_column'], + 'entity' => $data['entity'] ?? null, + 'target_field' => $data['target_field'] ?? null, + 'transform' => $data['transform'] ?? null, + 'apply_mode' => $data['apply_mode'] ?? 'both', + 'options' => $data['options'] ?? null, + 'position' => $data['position'] ?? $mapping->position, + ]); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', 'Mapping updated'); + } + + // Delete a mapping + public function deleteMapping(ImportTemplate $template, ImportTemplateMapping $mapping) + { + if ($mapping->import_template_id !== $template->id) abort(404); + $mapping->delete(); + return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) + ->with('success', 'Mapping deleted'); + } + + // Reorder mappings in bulk + public function reorderMappings(Request $request, ImportTemplate $template) + { + $data = $request->validate([ + 'order' => 'required|array', + 'order.*' => 'integer', + ]); + $ids = $data['order']; + // 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'); + // 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'); + } + + // Apply a template’s mappings to a specific import (copy into import_mappings) + public function applyToImport(Request $request, ImportTemplate $template, Import $import) + { + // optional: clear previous mappings + $clear = $request->boolean('clear', true); + $copied = 0; + DB::transaction(function () use ($clear, $template, $import, &$copied) { + if ($clear) { + \DB::table('import_mappings')->where('import_id', $import->id)->delete(); + } + + $rows = $template->mappings()->orderBy('position')->get(); + foreach ($rows as $row) { + \DB::table('import_mappings')->insert([ + 'import_id' => $import->id, + 'entity' => $row->entity, + 'source_column' => $row->source_column, + 'target_field' => $row->target_field, + 'transform' => $row->transform, + 'apply_mode' => $row->apply_mode ?? 'both', + 'options' => $row->options, + 'position' => $row->position ?? null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $copied++; + } + + $import->update(['import_template_id' => $template->id]); + }); + + return response()->json(['ok' => true, 'copied' => $copied, 'cleared' => $clear]); + } + + // Delete a template and cascade delete its mappings; detach from imports + public function destroy(ImportTemplate $template) + { + DB::transaction(function () use ($template) { + // Nullify references from imports to this template + \DB::table('imports')->where('import_template_id', $template->id)->update(['import_template_id' => null]); + // Delete mappings first (if FK cascade not set) + \DB::table('import_template_mappings')->where('import_template_id', $template->id)->delete(); + // Delete the template + $template->delete(); + }); + + return redirect()->route('importTemplates.index')->with('success', 'Template deleted'); + } +} diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index 182b542..bd5308d 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -49,10 +49,14 @@ public function createAddress(Person $person, Request $request){ 'description' => 'nullable|string|max:125' ]); - $address_id = $person->addresses()->create($attributes)->id; + // Dedup: avoid duplicate address per person by (address, country) + $address = $person->addresses()->firstOrCreate([ + 'address' => $attributes['address'], + 'country' => $attributes['country'] ?? null, + ], $attributes); return response()->json([ - 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address_id) + 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id) ]); } @@ -83,10 +87,14 @@ public function createPhone(Person $person, Request $request) 'description' => 'nullable|string|max:125' ]); - $phone_id = $person->phones()->create($attributes)->id; + // Dedup: avoid duplicate phone per person by (nu, country_code) + $phone = $person->phones()->firstOrCreate([ + 'nu' => $attributes['nu'], + 'country_code' => $attributes['country_code'] ?? null, + ], $attributes); return response()->json([ - 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone_id) + 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id) ]); } @@ -107,4 +115,49 @@ public function updatePhone(Person $person, int $phone_id, Request $request) 'phone' => $phone ]); } + + public function createEmail(Person $person, Request $request) + { + $attributes = $request->validate([ + 'value' => 'required|email:rfc,dns|max:255', + 'label' => 'nullable|string|max:50', + 'is_primary' => 'boolean', + 'is_active' => 'boolean', + 'valid' => 'boolean', + 'verified_at' => 'nullable|date', + 'preferences' => 'nullable|array', + 'meta' => 'nullable|array', + ]); + + // Dedup: avoid duplicate email per person by value + $email = $person->emails()->firstOrCreate([ + 'value' => $attributes['value'], + ], $attributes); + + return response()->json([ + 'email' => \App\Models\Email::findOrFail($email->id) + ]); + } + + public function updateEmail(Person $person, int $email_id, Request $request) + { + $attributes = $request->validate([ + 'value' => 'required|email:rfc,dns|max:255', + 'label' => 'nullable|string|max:50', + 'is_primary' => 'boolean', + 'is_active' => 'boolean', + 'valid' => 'boolean', + 'verified_at' => 'nullable|date', + 'preferences' => 'nullable|array', + 'meta' => 'nullable|array', + ]); + + $email = $person->emails()->findOrFail($email_id); + + $email->update($attributes); + + return response()->json([ + 'email' => $email + ]); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 81c999c..2f0b3a6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -36,7 +36,12 @@ public function version(Request $request): ?string public function share(Request $request): array { return array_merge(parent::share($request), [ - // + 'flash' => [ + 'success' => fn () => $request->session()->get('success'), + 'error' => fn () => $request->session()->get('error'), + 'warning' => fn () => $request->session()->get('warning'), + 'info' => fn () => $request->session()->get('info'), + ], ]); } } diff --git a/app/Jobs/GenerateDocumentPreview.php b/app/Jobs/GenerateDocumentPreview.php new file mode 100644 index 0000000..312e356 --- /dev/null +++ b/app/Jobs/GenerateDocumentPreview.php @@ -0,0 +1,149 @@ +documentId); + if (!$doc) + return; + + $disk = $doc->disk ?: 'public'; + if (!Storage::disk($disk)->exists($doc->path)) + return; + + $ext = strtolower(pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_EXTENSION)); + if (!in_array($ext, ['doc', 'docx'])) + return; // only convert office docs here + + // Prepare temp files - keep original extension so LibreOffice can detect filter + $tmpBase = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doc_in_' . uniqid(); + $tmpIn = $tmpBase . '.' . $ext; // e.g., .doc or .docx + file_put_contents($tmpIn, Storage::disk($disk)->get($doc->path)); + + $outDir = sys_get_temp_dir(); + // Ensure exec is available + if (!function_exists('exec')) { + Log::error('Preview generation failed: exec() not available in this PHP environment', ['document_id' => $doc->id]); + return; + } + $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); + if (in_array('exec', $disabled, true)) { + Log::error('Preview generation failed: exec() is disabled in php.ini (disable_functions)', ['document_id' => $doc->id]); + return; + } + + // Run soffice headless to convert to PDF + $binCfg = config('files.libreoffice_bin'); + $bin = $binCfg ? (string) $binCfg : 'soffice'; + // Windows quoting differs from POSIX. Build command parts safely. + $isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + if ($isWin) { + $binPart = '"' . $bin . '"'; + $outDirPart = '"' . $outDir . '"'; + $inPart = '"' . $tmpIn . '"'; + } else { + $binPart = escapeshellcmd($bin); + $outDirPart = escapeshellarg($outDir); + $inPart = escapeshellarg($tmpIn); + } + // Use a temporary user profile to avoid permissions/profile lock issues + $loProfileDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lo_profile_' . $doc->id; + if (!is_dir($loProfileDir)) { + @mkdir($loProfileDir, 0700, true); + } + $loProfileUri = 'file:///' . ltrim(str_replace('\\', '/', $loProfileDir), '/'); + + $cmd = sprintf( + '%s --headless --norestore --nolockcheck -env:UserInstallation=%s --convert-to pdf --outdir %s %s', + $binPart, + $isWin ? '"' . $loProfileUri . '"' : escapeshellarg($loProfileUri), + $outDirPart, + $inPart + ); + + // Capture stderr as well for diagnostics + $cmdWithStderr = $cmd . ' 2>&1'; + Log::info('Starting LibreOffice preview conversion', [ + 'document_id' => $doc->id, + 'cmd' => $cmd, + 'is_windows' => $isWin, + ]); + $out = []; + $ret = 0; + exec($cmdWithStderr, $out, $ret); + if ($ret !== 0) { + Log::warning('Preview generation failed', [ + 'document_id' => $doc->id, + 'ret' => $ret, + 'cmd' => $cmd, + 'output' => implode("\n", $out), + ]); + @unlink($tmpIn); + return; + } + + $pdfPathLocal = $tmpIn . '.pdf'; + // LibreOffice writes output with source filename base; derive path + $base = pathinfo($tmpIn, PATHINFO_FILENAME); + $pdfPathLocal = $outDir . DIRECTORY_SEPARATOR . $base . '.pdf'; + if (!file_exists($pdfPathLocal)) { + // fallback: try with original name base + $origBase = pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_FILENAME); + $try = $outDir . DIRECTORY_SEPARATOR . $origBase . '.pdf'; + if (file_exists($try)) + $pdfPathLocal = $try; + } + if (!file_exists($pdfPathLocal)) { + Log::warning('Preview generation did not produce expected PDF output', [ + 'document_id' => $doc->id, + 'out_dir' => $outDir, + 'tmp_base' => $base, + 'command' => $cmd, + 'output' => implode("\n", $out), + ]); + @unlink($tmpIn); + return; + } + + // Store preview PDF to configured disk inside configured previews base path + $previewDisk = config('files.preview_disk', 'public'); + $base = trim(config('files.preview_base', 'previews/cases'), '/'); + $previewDir = $base . '/' . ($doc->documentable?->uuid ?? 'unknown'); + $stored = Storage::disk($previewDisk)->put($previewDir . '/' . ($doc->uuid) . '.pdf', file_get_contents($pdfPathLocal)); + if ($stored) { + $doc->preview_path = $previewDir . '/' . $doc->uuid . '.pdf'; + $doc->preview_mime = 'application/pdf'; + $doc->preview_generated_at = now(); + $doc->save(); + } + + @unlink($tmpIn); + @unlink($pdfPathLocal); + // Clean up temporary LO profile directory + try { + if (is_dir($loProfileDir)) { + @rmdir($loProfileDir); + } + } catch (\Throwable $e) { + // ignore + } + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index e5f965d..b36ffb6 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -12,6 +12,15 @@ class Account extends Model /** @use HasFactory<\Database\Factories\Person/AccountFactory> */ use HasFactory; + protected $fillable = [ + 'reference', + 'description', + 'contract_id', + 'type_id', + 'active', + 'balance_amount', + ]; + public function debtor(): BelongsTo { return $this->belongsTo(\App\Models\Person\Person::class, 'debtor_id'); diff --git a/app/Models/ClientCase.php b/app/Models/ClientCase.php index 37663cb..4277d81 100644 --- a/app/Models/ClientCase.php +++ b/app/Models/ClientCase.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Builder; use Laravel\Scout\Searchable; @@ -19,7 +20,8 @@ class ClientCase extends Model use Searchable; protected $fillable = [ - 'client_id' + 'client_id', + 'person_id' ]; protected $hidden = [ @@ -65,4 +67,9 @@ public function activities(): HasMany public function segments(): BelongsToMany { return $this->belongsToMany(\App\Models\Segment::class)->withTimestamps(); } + + public function documents(): MorphMany + { + return $this->morphMany(\App\Models\Document::class, 'documentable'); + } } diff --git a/app/Models/Document.php b/app/Models/Document.php new file mode 100644 index 0000000..47c1464 --- /dev/null +++ b/app/Models/Document.php @@ -0,0 +1,83 @@ + 'boolean', + 'size' => 'integer', + 'preview_generated_at' => 'datetime', + ]; + + protected $appends = []; + + public function documentable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // No direct public URL exposure; serve via controller stream + + protected static function booted(): void + { + static::deleting(function (Document $doc) { + // Only delete files on force delete to keep data when soft-deleted + if (method_exists($doc, 'isForceDeleting') && $doc->isForceDeleting()) { + try { + if ($doc->path) { + $disk = $doc->disk ?: 'public'; + Storage::disk($disk)->delete($doc->path); + } + } catch (\Throwable $e) { + // swallow; avoid failing delete due to storage issue + } + + try { + if ($doc->preview_path) { + $previewDisk = config('files.preview_disk', 'public'); + Storage::disk($previewDisk)->delete($doc->preview_path); + } + } catch (\Throwable $e) { + // swallow + } + } + }); + } +} diff --git a/app/Models/Email.php b/app/Models/Email.php new file mode 100644 index 0000000..52e869e --- /dev/null +++ b/app/Models/Email.php @@ -0,0 +1,35 @@ + 'boolean', + 'is_active' => 'boolean', + 'valid' => 'boolean', + 'verified_at' => 'datetime', + 'preferences' => 'array', + 'meta' => 'array', + ]; + + public function person(): BelongsTo + { + return $this->belongsTo(\App\Models\Person\Person::class, 'person_id'); + } +} diff --git a/app/Models/Import.php b/app/Models/Import.php new file mode 100644 index 0000000..0dc2af0 --- /dev/null +++ b/app/Models/Import.php @@ -0,0 +1,50 @@ + 'array', + 'meta' => 'array', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function template(): BelongsTo + { + return $this->belongsTo(ImportTemplate::class, 'import_template_id'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function rows(): HasMany + { + return $this->hasMany(ImportRow::class); + } + + public function events(): HasMany + { + return $this->hasMany(ImportEvent::class); + } +} diff --git a/app/Models/ImportEvent.php b/app/Models/ImportEvent.php new file mode 100644 index 0000000..b2439a6 --- /dev/null +++ b/app/Models/ImportEvent.php @@ -0,0 +1,35 @@ + 'array', + ]; + + public function import(): BelongsTo + { + return $this->belongsTo(Import::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function row(): BelongsTo + { + return $this->belongsTo(ImportRow::class, 'import_row_id'); + } +} diff --git a/app/Models/ImportRow.php b/app/Models/ImportRow.php new file mode 100644 index 0000000..befc727 --- /dev/null +++ b/app/Models/ImportRow.php @@ -0,0 +1,28 @@ + 'array', + 'mapped_data' => 'array', + 'errors' => 'array', + 'warnings' => 'array', + ]; + + public function import(): BelongsTo + { + return $this->belongsTo(Import::class); + } +} diff --git a/app/Models/ImportTemplate.php b/app/Models/ImportTemplate.php new file mode 100644 index 0000000..5206a3c --- /dev/null +++ b/app/Models/ImportTemplate.php @@ -0,0 +1,38 @@ + 'array', + 'meta' => 'array', + 'is_active' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function mappings(): HasMany + { + return $this->hasMany(ImportTemplateMapping::class); + } +} diff --git a/app/Models/ImportTemplateMapping.php b/app/Models/ImportTemplateMapping.php new file mode 100644 index 0000000..a7da751 --- /dev/null +++ b/app/Models/ImportTemplateMapping.php @@ -0,0 +1,25 @@ + 'array', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(ImportTemplate::class, 'import_template_id'); + } +} diff --git a/app/Models/Person/Person.php b/app/Models/Person/Person.php index addd535..bcb133e 100644 --- a/app/Models/Person/Person.php +++ b/app/Models/Person/Person.php @@ -3,6 +3,7 @@ namespace App\Models\Person; use App\Traits\Uuid; +use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -52,6 +53,10 @@ protected static function booted(){ if(!isset($person->user_id)){ $person->user_id = auth()->id(); } + // Ensure a unique 6-character alphanumeric 'nu' is set globally on create + if (empty($person->nu)) { + $person->nu = static::generateUniqueNu(); + } }); } @@ -88,6 +93,13 @@ public function addresses(): HasMany ->orderBy('id'); } + public function emails(): HasMany + { + return $this->hasMany(\App\Models\Email::class, 'person_id') + ->where('is_active', '=', 1) + ->orderBy('id'); + } + public function group(): BelongsTo { return $this->belongsTo(\App\Models\Person\PersonGroup::class, 'group_id'); @@ -108,4 +120,14 @@ public function clientCase(): HasOne return $this->hasOne(\App\Models\ClientCase::class); } + /** + * Generate a unique 6-character alphanumeric identifier for 'nu'. + */ + protected static function generateUniqueNu(): string + { + do { + $nu = Str::random(6); // [A-Za-z0-9] + } while (static::where('nu', $nu)->exists()); + return $nu; + } } diff --git a/app/Services/CsvImportService.php b/app/Services/CsvImportService.php new file mode 100644 index 0000000..e654f02 --- /dev/null +++ b/app/Services/CsvImportService.php @@ -0,0 +1,65 @@ +readFirstLine($path); + if ($firstLine === null) { + return [$bestDelim, []]; + } + + $maxCount = 0; + foreach ($delims as $d) { + $row = str_getcsv($firstLine, $d); + $count = is_array($row) ? count($row) : 0; + if ($count > $maxCount) { + $maxCount = $count; + $bestDelim = $d; + $bestCols = $row; + } + } + + if (!$hasHeader) { + // return positional indices 0..N-1 + $cols = []; + for ($i = 0; $i < $maxCount; $i++) { + $cols[] = (string) $i; + } + return [$bestDelim, $cols]; + } + + // Clean header names + $clean = array_map(function ($v) { + $v = trim((string) $v); + $v = preg_replace('/\s+/', ' ', $v); + return $v; + }, $bestCols); + + return [$bestDelim, $clean]; + } +} diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php new file mode 100644 index 0000000..40d58a1 --- /dev/null +++ b/app/Services/ImportProcessor.php @@ -0,0 +1,795 @@ +source_type, ['csv','txt'])) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_skipped', + 'level' => 'warning', + 'message' => 'Only CSV/TXT supported in this pass.', + ]); + $import->update(['status' => 'completed', 'finished_at' => now()]); + return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid') ]; + } + + // Get mappings for this import (with apply_mode) + $mappings = DB::table('import_mappings') + ->where('import_id', $import->id) + ->get(['source_column','target_field','transform','apply_mode','options']); + + $header = $import->meta['columns'] ?? null; + $delimiter = $import->meta['detected_delimiter'] ?? ','; + $hasHeader = (bool) ($import->meta['has_header'] ?? true); + $path = Storage::disk($import->disk)->path($import->path); + + // Parse file and create import_rows with mapped_data + $fh = @fopen($path, 'r'); + if (!$fh) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_failed', + 'level' => 'error', + 'message' => 'Unable to open file for reading.', + ]); + $import->update(['status' => 'failed', 'failed_at' => now()]); + return [ 'ok' => false, 'status' => $import->status ]; + } + try { + DB::beginTransaction(); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_started', + 'level' => 'info', + 'message' => 'Processing started.', + ]); + + $rowNum = 0; + 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 ?: []); + } + } + + while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { + $rowNum++; + $total++; + + $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', + ]); + + // Contracts + $contractResult = null; + if (isset($mapped['contract'])) { + $contractResult = $this->upsertContractChain($import, $mapped, $mappings); + if ($contractResult['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' => $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 ], + ]); + } else { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]); + } + } + + // Accounts + $accountResult = null; + if (isset($mapped['account'])) { + $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).', + ]); + } elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Account::class, + 'entity_id' => $accountResult['account']->id, + ]); + 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 ], + ]); + } else { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => ['Unhandled result']]); + } + } + + // Contacts: resolve person strictly via Contract -> ClientCase -> Person, 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'); + } + // 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 contact values next + if (!$personIdForRow) { + $emailVal = trim((string)($mapped['email']['value'] ?? '')); + if ($emailVal !== '') { + $personIdForRow = Email::where('value', $emailVal)->value('person_id'); + } + } + if (!$personIdForRow) { + $phoneNu = trim((string)($mapped['phone']['nu'] ?? '')); + if ($phoneNu !== '') { + $personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id'); + } + } + if (!$personIdForRow) { + $addrLine = trim((string)($mapped['address']['address'] ?? '')); + if ($addrLine !== '') { + $personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id'); + } + } + // 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']); + } + + $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']); + } + } + } + + fclose($fh); + + $import->update([ + 'status' => 'completed', + 'finished_at' => now(), + 'total_rows' => $total, + 'imported_rows' => $imported, + 'invalid_rows' => $invalid, + 'valid_rows' => $total - $invalid, + ]); + + DB::commit(); + + return [ + 'ok' => true, + 'status' => $import->status, + 'counts' => compact('total','skipped','imported','invalid'), + ]; + } catch (\Throwable $e) { + if (is_resource($fh)) { @fclose($fh); } + DB::rollBack(); + // Mark failed and log after rollback (so no partial writes persist) + $import->refresh(); + $import->update(['status' => 'failed', 'failed_at' => now()]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_failed', + 'level' => 'error', + 'message' => $e->getMessage(), + ]); + return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ]; + } + } + + private function buildRowAssoc(array $row, ?array $header): array + { + if (!$header) { + // positional mapping: 0..N-1 + $assoc = []; + foreach ($row as $i => $v) { $assoc[(string)$i] = $v; } + return $assoc; + } + $assoc = []; + foreach ($header as $i => $name) { + $assoc[$name] = $row[$i] ?? null; + } + return $assoc; + } + + private function applyMappings(array $raw, $mappings): array + { + $recordType = null; + $mapped = []; + foreach ($mappings as $map) { + $src = $map->source_column; + $target = $map->target_field; + if (!$target) continue; + $value = $raw[$src] ?? null; + + // very basic transforms + if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; } + if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; } + + // detect record type from first segment, e.g., "account.balance_amount" + $parts = explode('.', $target); + if (!$recordType && isset($parts[0])) { + $recordType = $parts[0]; + } + // build nested array by dot notation + $this->arraySetDot($mapped, $target, $value); + } + return [$recordType, $mapped]; + } + + private function arraySetDot(array &$arr, string $path, $value): void + { + $keys = explode('.', $path); + $ref =& $arr; + foreach ($keys as $k) { + if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; } + $ref =& $ref[$k]; + } + $ref = $value; + } + + private function upsertAccount(Import $import, array $mapped, $mappings): array + { + $clientId = $import->client_id; // may be null, used for contract lookup/creation + $acc = $mapped['account'] ?? []; + $contractId = $acc['contract_id'] ?? null; + $reference = $acc['reference'] ?? null; + // If contract_id not provided, attempt to resolve by contract reference for the selected client + if (!$contractId) { + $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); + if ($clientId && $contractRef) { + // 1) Search existing contract by reference for that client (across its client cases) + $existingContract = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $contractRef) + ->select('contracts.*') + ->first(); + if ($existingContract) { + $contractId = $existingContract->id; + } else { + // 2) Not found: attempt to resolve debtor via identifiers or provided person, then create case+contract + // Try strong identifiers first + $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 + $contractFields = $mapped['contract'] ?? []; + $newContractData = [ + 'client_case_id' => $clientCaseId, + 'reference' => $contractRef, + ]; + foreach (['start_date','end_date','description','type_id'] as $k) { + if (array_key_exists($k, $contractFields) && !is_null($contractFields[$k])) { + $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; + } + if ($contractId) { + $acc['contract_id'] = $contractId; + $mapped['account'] = $acc; + } + } + } + // Default account.reference to contract reference if missing + if (!$reference) { + $contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null); + if ($contractRef) { + $reference = $contractRef; + $acc['reference'] = $reference; + $mapped['account'] = $acc; + } + } + if (!$contractId || !$reference) { + return ['action' => 'skipped', 'message' => 'Missing contract_id/reference']; + } + + $existing = Account::query() + ->where('contract_id', $contractId) + ->where('reference', $reference) + ->first(); + + // Build applyable data based on apply_mode + $applyInsert = []; + $applyUpdate = []; + foreach ($mappings as $map) { + if (!$map->target_field) continue; + $parts = explode('.', $map->target_field); + if ($parts[0] !== 'account') continue; + $field = $parts[1] ?? null; + if (!$field) continue; + $value = $acc[$field] ?? null; + $mode = $map->apply_mode ?? 'both'; + if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } + if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } + } + + if ($existing) { + if (empty($applyUpdate)) { + return ['action' => 'skipped', 'message' => 'No fields marked for update']; + } + // Only update fields that are set; skip nulls to avoid wiping unintentionally + $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + if (empty($changes)) { + return ['action' => 'skipped', 'message' => 'No non-null changes']; + } + $existing->fill($changes); + $existing->save(); + // also include contract hints for downstream contact resolution + return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId]; + } else { + if (empty($applyInsert)) { + return ['action' => 'skipped', 'message' => 'No fields marked for insert']; + } + $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data['contract_id'] = $contractId; + $data['reference'] = $reference; + // ensure required defaults + $data['type_id'] = $data['type_id'] ?? $this->getDefaultAccountTypeId(); + if (!array_key_exists('active', $data)) { $data['active'] = 1; } + $created = Account::create($data); + return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId]; + } + } + + private function findPersonIdByIdentifiers(array $p): ?int + { + $tax = $p['tax_number'] ?? null; + if ($tax) { + $found = Person::where('tax_number', $tax)->first(); + if ($found) return $found->id; + } + $ssn = $p['social_security_number'] ?? null; + if ($ssn) { + $found = Person::where('social_security_number', $ssn)->first(); + if ($found) return $found->id; + } + return null; + } + + private function upsertContractChain(Import $import, array $mapped, $mappings): array + { + $contractData = $mapped['contract'] ?? []; + $reference = $contractData['reference'] ?? null; + if (!$reference) { + return ['action' => 'invalid', 'message' => 'Missing contract.reference']; + } + + // Determine client_case_id: prefer provided, else derive via person/client + $clientCaseId = $contractData['client_case_id'] ?? null; + $clientId = $import->client_id; // may be null + + // Try to find existing contract EARLY by (client_id, reference) across all cases to prevent duplicates + $existing = null; + if ($clientId) { + $existing = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->select('contracts.*') + ->first(); + } + + // If not found by client+reference and a specific client_case_id is provided, try that too + if (!$existing && $clientCaseId) { + $existing = Contract::query() + ->where('client_case_id', $clientCaseId) + ->where('reference', $reference) + ->first(); + } + + // If 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']); + } + } + // 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)']; + } + } + + // Build applyable data based on apply_mode + $applyInsert = []; + $applyUpdate = []; + foreach ($mappings as $map) { + if (!$map->target_field) continue; + $parts = explode('.', $map->target_field); + if ($parts[0] !== 'contract') continue; + $field = $parts[1] ?? null; + if (!$field) continue; + $value = $contractData[$field] ?? null; + $mode = $map->apply_mode ?? 'both'; + if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; } + if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; } + } + + if ($existing) { + if (empty($applyUpdate)) { + return ['action' => 'skipped', 'message' => 'No contract fields marked for update']; + } + $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + if (empty($changes)) { + return ['action' => 'skipped', 'message' => 'No non-null contract changes']; + } + $existing->fill($changes); + $existing->save(); + return ['action' => 'updated', 'contract' => $existing]; + } else { + if (empty($applyInsert)) { + return ['action' => 'skipped', 'message' => 'No contract fields marked for insert']; + } + $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data['client_case_id'] = $clientCaseId; + $data['reference'] = $reference; + // ensure required defaults + $data['start_date'] = $data['start_date'] ?? now()->toDateString(); + $data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId(); + $created = Contract::create($data); + return ['action' => 'inserted', 'contract' => $created]; + } + } + + private function findOrCreatePersonId(array $p): ?int + { + // Basic dedup: by tax_number, ssn, else full_name + $query = Person::query(); + if (!empty($p['tax_number'] ?? null)) { + $found = $query->where('tax_number', $p['tax_number'])->first(); + if ($found) return $found->id; + } + if (!empty($p['social_security_number'] ?? null)) { + $found = Person::where('social_security_number', $p['social_security_number'])->first(); + if ($found) return $found->id; + } + // Do NOT use full_name as an identifier + // Create person if any fields present; ensure required foreign keys + if (!empty($p)) { + $data = []; + foreach (['first_name','last_name','full_name','tax_number','social_security_number','birthday','gender','description','group_id','type_id'] as $k) { + if (array_key_exists($k, $p)) $data[$k] = $p[$k]; + } + // derive full_name if missing + if (empty($data['full_name'])) { + $fn = trim((string)($data['first_name'] ?? '')); + $ln = trim((string)($data['last_name'] ?? '')); + if ($fn || $ln) $data['full_name'] = trim($fn.' '.$ln); + } + // ensure required group/type ids + $data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId(); + $data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId(); + $created = Person::create($data); + return $created->id; + } + return null; + } + + private function createMinimalPersonId(): int + { + return Person::create([ + 'group_id' => $this->getDefaultPersonGroupId(), + 'type_id' => $this->getDefaultPersonTypeId(), + // names and identifiers can be null; 'nu' will be auto-generated (unique 6-char) + ])->id; + } + + private function getDefaultPersonGroupId(): int + { + return (int) (PersonGroup::min('id') ?? 1); + } + + private function getDefaultPersonTypeId(): int + { + return (int) (PersonType::min('id') ?? 1); + } + + private function getDefaultContractTypeId(): int + { + return (int) (ContractType::min('id') ?? 1); + } + + private function getDefaultAccountTypeId(): int + { + return (int) (AccountType::min('id') ?? 1); + } + + private function getDefaultAddressTypeId(): int + { + return (int) (AddressType::min('id') ?? 1); + } + + private function getDefaultPhoneTypeId(): int + { + return (int) (PhoneType::min('id') ?? 1); + } + + // Removed getExistingPersonIdForClient: resolution should come from Contract -> ClientCase -> Person or identifiers + + private function findOrCreateClientId(int $personId): int + { + $client = Client::where('person_id', $personId)->first(); + if ($client) return $client->id; + return Client::create(['person_id' => $personId])->id; + } + + private function findOrCreateClientCaseId(int $clientId, int $personId): int + { + $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; + } + + private function upsertEmail(int $personId, array $emailData, $mappings): array + { + $value = trim((string)($emailData['value'] ?? '')); + if ($value === '') return ['action' => 'skipped', 'message' => 'No email value']; + $existing = Email::where('person_id', $personId)->where('value', $value)->first(); + $applyInsert = []; + $applyUpdate = []; + foreach ($mappings as $map) { + if (!$map->target_field) continue; + $parts = explode('.', $map->target_field); + if ($parts[0] !== 'email') continue; + $field = $parts[1] ?? null; if (!$field) continue; + $val = $emailData[$field] ?? null; + $mode = $map->apply_mode ?? 'both'; + if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } + if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } + } + if ($existing) { + $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + if (empty($changes)) return ['action' => 'skipped', 'message' => 'No email updates']; + $existing->fill($changes); + $existing->save(); + return ['action' => 'updated', 'email' => $existing]; + } else { + if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No email fields for insert']; + $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data['person_id'] = $personId; + if (!array_key_exists('is_active', $data)) $data['is_active'] = true; + $created = Email::create($data); + return ['action' => 'inserted', 'email' => $created]; + } + } + + private function upsertAddress(int $personId, array $addrData, $mappings): array + { + $addressLine = trim((string)($addrData['address'] ?? '')); + if ($addressLine === '') return ['action' => 'skipped', 'message' => 'No address value']; + // Default country SLO if not provided + if (!isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { + $addrData['country'] = 'SLO'; + } + $existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first(); + $applyInsert = []; + $applyUpdate = []; + foreach ($mappings as $map) { + if (!$map->target_field) continue; + $parts = explode('.', $map->target_field); + if ($parts[0] !== 'address') continue; + $field = $parts[1] ?? null; if (!$field) continue; + $val = $addrData[$field] ?? null; + $mode = $map->apply_mode ?? 'both'; + if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } + if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } + } + if ($existing) { + $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates']; + $existing->fill($changes); + $existing->save(); + return ['action' => 'updated', 'address' => $existing]; + } else { + if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No address fields for insert']; + $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data['person_id'] = $personId; + $data['country'] = $data['country'] ?? 'SLO'; + $data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId(); + $created = PersonAddress::create($data); + return ['action' => 'inserted', 'address' => $created]; + } + } + + private function upsertPhone(int $personId, array $phoneData, $mappings): array + { + $nu = trim((string)($phoneData['nu'] ?? '')); + if ($nu === '') return ['action' => 'skipped', 'message' => 'No phone value']; + $existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first(); + $applyInsert = []; + $applyUpdate = []; + foreach ($mappings as $map) { + if (!$map->target_field) continue; + $parts = explode('.', $map->target_field); + if ($parts[0] !== 'phone') continue; + $field = $parts[1] ?? null; if (!$field) continue; + $val = $phoneData[$field] ?? null; + $mode = $map->apply_mode ?? 'both'; + if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; } + if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; } + } + if ($existing) { + $changes = array_filter($applyUpdate, fn($v) => !is_null($v)); + if (empty($changes)) return ['action' => 'skipped', 'message' => 'No phone updates']; + $existing->fill($changes); + $existing->save(); + return ['action' => 'updated', 'phone' => $existing]; + } else { + if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No phone fields for insert']; + $data = array_filter($applyInsert, fn($v) => !is_null($v)); + $data['person_id'] = $personId; + $data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId(); + $created = PersonPhone::create($data); + return ['action' => 'inserted', 'phone' => $created]; + } + } +} diff --git a/config/files.php b/config/files.php new file mode 100644 index 0000000..b31b405 --- /dev/null +++ b/config/files.php @@ -0,0 +1,19 @@ + env('LIBREOFFICE_BIN', 'soffice'), + + // Disk used to store generated previews (PDFs) + 'preview_disk' => env('FILES_PREVIEW_DISK', 'public'), + + // Base directory within the disk for previews + 'preview_base' => env('FILES_PREVIEW_BASE', 'previews/cases'), + + // Whether to enable scheduled pruning of old previews + 'enable_preview_prune' => env('FILES_ENABLE_PREVIEW_PRUNE', true), + + // How many days to retain previews before pruning (when pruning enabled) + 'preview_retention_days' => env('FILES_PREVIEW_RETENTION_DAYS', 90), +]; diff --git a/database/factories/Person/PersonFactory.php b/database/factories/Person/PersonFactory.php index 0ce45f0..079c356 100644 --- a/database/factories/Person/PersonFactory.php +++ b/database/factories/Person/PersonFactory.php @@ -17,7 +17,15 @@ class PersonFactory extends Factory public function definition(): array { return [ - // + 'first_name' => $this->faker->firstName(), + 'last_name' => $this->faker->lastName(), + 'full_name' => fn(array $attrs) => trim(($attrs['first_name'] ?? '').' '.($attrs['last_name'] ?? '')), + 'gender' => $this->faker->randomElement(['m','w']), + 'birthday' => $this->faker->optional()->date(), + 'tax_number' => $this->faker->optional()->bothify('########'), + 'social_security_number' => $this->faker->optional()->bothify('#########'), + 'description' => $this->faker->optional()->sentence(), + // group_id/type_id are required; keep null here and let tests/seeds assign or rely on defaults in code paths that use factories ]; } } diff --git a/database/migrations/2025_09_26_184034_alter_table_account_add_balance_column.php b/database/migrations/2025_09_26_184034_alter_table_account_add_balance_column.php new file mode 100644 index 0000000..7fdb077 --- /dev/null +++ b/database/migrations/2025_09_26_184034_alter_table_account_add_balance_column.php @@ -0,0 +1,30 @@ +decimal("initial_amount", 20, 4)->default(0); + $table->decimal("balance_amount", 20, 4)->default(0); + $table->date("promise_date")->nullable(); + $table->index('balance_amount'); + $table->index('promise_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2025_09_26_185111_create_objects_table.php b/database/migrations/2025_09_26_185111_create_objects_table.php new file mode 100644 index 0000000..579e9d3 --- /dev/null +++ b/database/migrations/2025_09_26_185111_create_objects_table.php @@ -0,0 +1,46 @@ +id(); + $table->string('name',50); + $table->string('description',125)->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('objects', function (Blueprint $table) { + $table->id(); + $table->string('reference', 125)->nullable(); + $table->string('name', 255); + $table->string('description', 255)->nullable(); + // If you keep the column name as 'type_id', specify the table explicitly + $table->foreignId('type_id')->constrained('object_types')->nullOnDelete(); + // Indexes for faster lookups + $table->softDeletes(); + $table->timestamps(); + + $table->index('reference'); + $table->index('type_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('objects'); + Schema::dropIfExists('object_types'); + } +}; diff --git a/database/migrations/2025_09_26_191209_create_bank_accounts_table.php b/database/migrations/2025_09_26_191209_create_bank_accounts_table.php new file mode 100644 index 0000000..7e8eb80 --- /dev/null +++ b/database/migrations/2025_09_26_191209_create_bank_accounts_table.php @@ -0,0 +1,50 @@ +id(); + // Ownership (Person-specific). Change to your actual Person table name. + $table->foreignId('person_id')->constrained('person')->cascadeOnDelete(); + // Account details + $table->string('bank_name', 100); + $table->string('iban', 34)->nullable(); + $table->string('bic_swift', 11)->nullable(); + $table->string('account_number', 34)->nullable(); + $table->string('routing_number', 20)->nullable(); + $table->char('currency', 3)->default('EUR'); + $table->char('country_code', 2)->nullable(); + $table->string('holder_name', 125)->nullable(); + + // Status and lifecycle + $table->boolean('is_active')->default(true); + + // Misc + $table->text('notes')->nullable(); + $table->json('meta')->nullable(); + + // Indexes + $table->index('person_id'); + $table->index('iban'); + $table->softDeletes(); + $table->timestamps(); + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bank_accounts'); + } +}; diff --git a/database/migrations/2025_09_26_192522_create_emails_table.php b/database/migrations/2025_09_26_192522_create_emails_table.php new file mode 100644 index 0000000..76fd9f9 --- /dev/null +++ b/database/migrations/2025_09_26_192522_create_emails_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('person_id')->constrained('person')->cascadeOnDelete(); + // The email address + $table->string('value', 255); + // Optional label like "work", "home", etc. + $table->string('label', 50)->nullable(); + // Mark a preferred email for the person (enforce at most one in app logic) + $table->boolean('is_primary')->default(false); + // Whether this email is considered currently active/usable + $table->boolean('is_active')->default(true); + // Whether validation checks passed (syntax/deliverability) + $table->boolean('valid')->default(true); + // When the email was verified (e.g., via confirmation link) + $table->timestamp('verified_at')->nullable(); + // JSON columns for notification preferences and arbitrary metadata + $table->json('preferences')->nullable(); + $table->json('meta')->nullable(); + // Soft delete support + $table->softDeletes(); + // Avoid duplicate emails per person among non-deleted records + $table->unique(['person_id', 'value', 'deleted_at'], 'emails_person_value_unique'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('emails'); + } +}; diff --git a/database/migrations/2025_09_26_193600_create_imports_table.php b/database/migrations/2025_09_26_193600_create_imports_table.php new file mode 100644 index 0000000..9173d90 --- /dev/null +++ b/database/migrations/2025_09_26_193600_create_imports_table.php @@ -0,0 +1,64 @@ +id(); + $table->uuid('uuid')->unique(); + + // Who initiated the import + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + // Optional template applied to this import (FK added in a later migration to avoid ordering issues) + $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 + { + Schema::dropIfExists('imports'); + } +}; diff --git a/database/migrations/2025_09_26_193610_create_import_rows_table.php b/database/migrations/2025_09_26_193610_create_import_rows_table.php new file mode 100644 index 0000000..787a1cd --- /dev/null +++ b/database/migrations/2025_09_26_193610_create_import_rows_table.php @@ -0,0 +1,47 @@ +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 + { + Schema::dropIfExists('import_rows'); + } +}; diff --git a/database/migrations/2025_09_26_193620_create_import_mappings_table.php b/database/migrations/2025_09_26_193620_create_import_mappings_table.php new file mode 100644 index 0000000..9e7bae0 --- /dev/null +++ b/database/migrations/2025_09_26_193620_create_import_mappings_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('import_id')->constrained('imports')->cascadeOnDelete(); + + // Column/header from the source file and the target field in the system + $table->string('source_column', 255); + $table->string('target_field', 255)->nullable(); + $table->string('transform', 50)->nullable(); // e.g., trim|upper|date:dd.MM.yyyy + $table->json('options')->nullable(); // any extra config for mapping/transforms + + // Indexes + $table->index(['import_id', 'source_column']); + $table->index(['import_id', 'target_field']); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_mappings'); + } +}; diff --git a/database/migrations/2025_09_26_193630_create_import_events_table.php b/database/migrations/2025_09_26_193630_create_import_events_table.php new file mode 100644 index 0000000..0105c55 --- /dev/null +++ b/database/migrations/2025_09_26_193630_create_import_events_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('import_id')->constrained('imports')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + // Event details + $table->string('event', 50); // created|parsing_started|parsed|validating|importing|completed|failed|retry|... + $table->string('level', 10)->default('info'); // info|warning|error + $table->text('message')->nullable(); + $table->json('context')->nullable(); + + // An optional pointer to a specific row related to the event + $table->foreignId('import_row_id')->nullable()->constrained('import_rows')->nullOnDelete(); + + // Indexes + $table->index(['import_id', 'event']); + $table->index(['import_id', 'level']); + $table->index('user_id'); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_events'); + } +}; diff --git a/database/migrations/2025_09_26_193640_create_import_templates_table.php b/database/migrations/2025_09_26_193640_create_import_templates_table.php new file mode 100644 index 0000000..acdd4b6 --- /dev/null +++ b/database/migrations/2025_09_26_193640_create_import_templates_table.php @@ -0,0 +1,43 @@ +id(); + $table->uuid('uuid')->unique(); + + $table->string('name', 100); + $table->string('description', 255)->nullable(); + + // What kind of source this template is for (csv|xml|xls|xlsx|json) + $table->string('source_type', 12)->default('csv'); + + // Defaults for records handled by this template (e.g., person, account) + $table->string('default_record_type', 50)->nullable(); + + // Optional sample header row for UI assistance + $table->json('sample_headers')->nullable(); + + // Ownership and lifecycle + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->json('meta')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index(['source_type', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_templates'); + } +}; diff --git a/database/migrations/2025_09_26_193650_create_import_template_mappings_table.php b/database/migrations/2025_09_26_193650_create_import_template_mappings_table.php new file mode 100644 index 0000000..64ec3d9 --- /dev/null +++ b/database/migrations/2025_09_26_193650_create_import_template_mappings_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('import_template_id')->constrained('import_templates')->cascadeOnDelete(); + + $table->string('source_column', 255); + $table->string('target_field', 255)->nullable(); + $table->string('transform', 50)->nullable(); + $table->json('options')->nullable(); + + $table->unsignedInteger('position')->nullable(); // order in the header + + $table->timestamps(); + + $table->index(['import_template_id', 'source_column']); + $table->index(['import_template_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_template_mappings'); + } +}; diff --git a/database/migrations/2025_09_26_200100_add_client_id_to_import_templates_table.php b/database/migrations/2025_09_26_200100_add_client_id_to_import_templates_table.php new file mode 100644 index 0000000..466aee8 --- /dev/null +++ b/database/migrations/2025_09_26_200100_add_client_id_to_import_templates_table.php @@ -0,0 +1,27 @@ +foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete()->after('user_id'); + $table->index('client_id'); + }); + } + + public function down(): void + { + Schema::table('import_templates', function (Blueprint $table) { + if (Schema::hasColumn('import_templates', 'client_id')) { + $table->dropForeign(['client_id']); + $table->dropIndex(['client_id']); + $table->dropColumn('client_id'); + } + }); + } +}; diff --git a/database/migrations/2025_09_26_201700_add_unique_indexes_for_import_dedup.php b/database/migrations/2025_09_26_201700_add_unique_indexes_for_import_dedup.php new file mode 100644 index 0000000..62ad0c6 --- /dev/null +++ b/database/migrations/2025_09_26_201700_add_unique_indexes_for_import_dedup.php @@ -0,0 +1,78 @@ +unique(['tax_number', 'social_security_number', 'deleted_at'], 'person_identity_unique'); + } + }); + + // Phones: unique by (person_id, nu, country_code, deleted_at) + Schema::table('person_phones', function (Blueprint $table) { + if (!self::hasIndex('person_phones', 'person_phones_unique')) { + $table->unique(['person_id', 'nu', 'country_code', 'deleted_at'], 'person_phones_unique'); + } + }); + + // Addresses: unique by (person_id, address, country, deleted_at) + Schema::table('person_addresses', function (Blueprint $table) { + if (!self::hasIndex('person_addresses', 'person_addresses_unique')) { + $table->unique(['person_id', 'address', 'country', 'deleted_at'], 'person_addresses_unique'); + } + }); + + // Contracts: unique by (client_case_id, reference, deleted_at) + Schema::table('contracts', function (Blueprint $table) { + if (!self::hasIndex('contracts', 'contracts_reference_unique')) { + $table->unique(['client_case_id', 'reference', 'deleted_at'], 'contracts_reference_unique'); + } + }); + + // Accounts: unique by (contract_id, reference, deleted_at) + Schema::table('accounts', function (Blueprint $table) { + if (!self::hasIndex('accounts', 'accounts_reference_unique')) { + $table->unique(['contract_id', 'reference', 'deleted_at'], 'accounts_reference_unique'); + } + }); + } + + public function down(): void + { + Schema::table('person', function (Blueprint $table) { + $table->dropUnique('person_identity_unique'); + }); + Schema::table('person_phones', function (Blueprint $table) { + $table->dropUnique('person_phones_unique'); + }); + Schema::table('person_addresses', function (Blueprint $table) { + $table->dropUnique('person_addresses_unique'); + }); + Schema::table('contracts', function (Blueprint $table) { + $table->dropUnique('contracts_reference_unique'); + }); + Schema::table('accounts', function (Blueprint $table) { + $table->dropUnique('accounts_reference_unique'); + }); + } + + private static function hasIndex(string $table, string $index): bool + { + // Attempt to detect index presence; if not supported, return false to try creating + try { + $connection = Schema::getConnection(); + $schemaManager = $connection->getDoctrineSchemaManager(); + $doctrineTable = $schemaManager->listTableDetails($table); + return $doctrineTable->hasIndex($index); + } catch (\Throwable $e) { + return false; + } + } +}; diff --git a/database/migrations/2025_09_26_202300_add_apply_mode_to_import_template_mappings.php b/database/migrations/2025_09_26_202300_add_apply_mode_to_import_template_mappings.php new file mode 100644 index 0000000..125bc8d --- /dev/null +++ b/database/migrations/2025_09_26_202300_add_apply_mode_to_import_template_mappings.php @@ -0,0 +1,26 @@ +string('apply_mode', 10)->default('both')->after('transform'); // insert|update|both + $table->index(['import_template_id', 'apply_mode']); + }); + } + + public function down(): void + { + Schema::table('import_template_mappings', function (Blueprint $table) { + if (Schema::hasColumn('import_template_mappings', 'apply_mode')) { + $table->dropIndex(['import_template_id', 'apply_mode']); + $table->dropColumn('apply_mode'); + } + }); + } +}; diff --git a/database/migrations/2025_09_26_202310_add_apply_mode_to_import_mappings.php b/database/migrations/2025_09_26_202310_add_apply_mode_to_import_mappings.php new file mode 100644 index 0000000..6b19dc4 --- /dev/null +++ b/database/migrations/2025_09_26_202310_add_apply_mode_to_import_mappings.php @@ -0,0 +1,26 @@ +string('apply_mode', 10)->default('both')->after('transform'); + $table->index(['import_id', 'apply_mode']); + }); + } + + public function down(): void + { + Schema::table('import_mappings', function (Blueprint $table) { + if (Schema::hasColumn('import_mappings', 'apply_mode')) { + $table->dropIndex(['import_id', 'apply_mode']); + $table->dropColumn('apply_mode'); + } + }); + } +}; diff --git a/database/migrations/2025_09_26_202500_add_balance_amount_to_accounts_table.php b/database/migrations/2025_09_26_202500_add_balance_amount_to_accounts_table.php new file mode 100644 index 0000000..324df87 --- /dev/null +++ b/database/migrations/2025_09_26_202500_add_balance_amount_to_accounts_table.php @@ -0,0 +1,30 @@ +decimal('balance_amount', 18, 4)->nullable()->after('description'); + $table->index('balance_amount'); + } + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + if (Schema::hasColumn('accounts', 'balance_amount')) { + $table->dropIndex(['balance_amount']); + $table->dropColumn('balance_amount'); + } + }); + } +}; diff --git a/database/migrations/2025_09_26_202600_add_fk_import_template_to_imports_table.php b/database/migrations/2025_09_26_202600_add_fk_import_template_to_imports_table.php new file mode 100644 index 0000000..887c773 --- /dev/null +++ b/database/migrations/2025_09_26_202600_add_fk_import_template_to_imports_table.php @@ -0,0 +1,27 @@ +foreignId('import_template_id')->nullable(); + } + // Add foreign key if not exists (Postgres will error if duplicate, so wrap in try/catch in runtime, but Schema builder doesn't support conditional FKs) + $table->foreign('import_template_id', 'imports_import_template_id_foreign') + ->references('id')->on('import_templates')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('imports', function (Blueprint $table) { + $table->dropForeign('imports_import_template_id_foreign'); + }); + } +}; diff --git a/database/migrations/2025_09_27_000001_alter_person_nu_to_string.php b/database/migrations/2025_09_27_000001_alter_person_nu_to_string.php new file mode 100644 index 0000000..aaaea91 --- /dev/null +++ b/database/migrations/2025_09_27_000001_alter_person_nu_to_string.php @@ -0,0 +1,102 @@ +getDriverName(); + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE person ALTER COLUMN nu DROP NOT NULL'); + DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE TEXT USING nu::text'); + } elseif ($driver === 'mysql') { + DB::statement('ALTER TABLE person MODIFY nu VARCHAR(32) NULL'); + } else { + // Fallback: try schema change (may require doctrine/dbal) + Schema::table('person', function (Blueprint $table) { + $table->string('nu', 32)->nullable()->change(); + }); + } + + // Backfill unique 6-char alphanumeric 'nu' values + $rows = DB::table('person')->select('id', 'nu')->orderBy('id')->get(); + $used = []; + foreach ($rows as $row) { + if (is_string($row->nu) && preg_match('/^[A-Za-z0-9]{6}$/', $row->nu)) { + if (!isset($used[$row->nu])) { + $used[$row->nu] = true; + continue; + } + // duplicate will be regenerated below + } + // mark to regenerate + $used[$row->nu] = false; + } + + $updates = []; + foreach ($rows as $row) { + $needsNew = true; + if (is_string($row->nu) && preg_match('/^[A-Za-z0-9]{6}$/', $row->nu) && ($used[$row->nu] === true)) { + // valid and unique + $needsNew = false; + } + if ($needsNew) { + do { + $nu = Str::random(6); // [A-Za-z0-9] + } while (isset($used[$nu])); + $used[$nu] = true; + $updates[] = ['id' => $row->id, 'nu' => $nu]; + } + } + + // Apply updates in chunks + foreach (array_chunk($updates, 500) as $chunk) { + foreach ($chunk as $u) { + DB::table('person')->where('id', $u['id'])->update(['nu' => $u['nu']]); + } + } + + // Add unique index and then narrow type to VARCHAR(6) and make not nullable + Schema::table('person', function (Blueprint $table) { + $table->unique('nu'); + }); + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE VARCHAR(6) USING nu::varchar(6)'); + DB::statement('ALTER TABLE person ALTER COLUMN nu SET NOT NULL'); + } elseif ($driver === 'mysql') { + DB::statement('ALTER TABLE person MODIFY nu VARCHAR(6) NOT NULL'); + } else { + Schema::table('person', function (Blueprint $table) { + $table->string('nu', 6)->nullable(false)->change(); + }); + } + } + + public function down(): void + { + // Drop unique and revert to integer (best-effort) + Schema::table('person', function (Blueprint $table) { + $table->dropUnique(['nu']); + }); + $driver = DB::connection()->getDriverName(); + // Coerce values back to numeric to avoid issues on some DBs + DB::table('person')->update(['nu' => '0']); + if ($driver === 'pgsql') { + DB::statement('ALTER TABLE person ALTER COLUMN nu TYPE BIGINT USING nu::bigint'); + DB::statement('ALTER TABLE person ALTER COLUMN nu SET DEFAULT 0'); + DB::statement('ALTER TABLE person ALTER COLUMN nu SET NOT NULL'); + } elseif ($driver === 'mysql') { + DB::statement('ALTER TABLE person MODIFY nu BIGINT UNSIGNED NOT NULL DEFAULT 0'); + } else { + Schema::table('person', function (Blueprint $table) { + $table->unsignedBigInteger('nu')->default(0)->change(); + }); + } + } +}; diff --git a/database/migrations/2025_09_27_000100_add_preview_columns_to_documents.php b/database/migrations/2025_09_27_000100_add_preview_columns_to_documents.php new file mode 100644 index 0000000..17e7512 --- /dev/null +++ b/database/migrations/2025_09_27_000100_add_preview_columns_to_documents.php @@ -0,0 +1,24 @@ +string('preview_path', 2048)->nullable()->after('path'); + $table->string('preview_mime', 100)->nullable()->after('preview_path'); + $table->timestamp('preview_generated_at')->nullable()->after('preview_mime'); + }); + } + + public function down(): void + { + Schema::table('documents', function (Blueprint $table) { + $table->dropColumn(['preview_path', 'preview_mime', 'preview_generated_at']); + }); + } +}; diff --git a/database/migrations/2025_09_27_221000_add_position_to_import_mappings.php b/database/migrations/2025_09_27_221000_add_position_to_import_mappings.php new file mode 100644 index 0000000..8ac6866 --- /dev/null +++ b/database/migrations/2025_09_27_221000_add_position_to_import_mappings.php @@ -0,0 +1,28 @@ +unsignedInteger('position')->nullable()->after('options'); + } + $table->index(['import_id', 'position']); + }); + } + + public function down(): void + { + Schema::table('import_mappings', function (Blueprint $table) { + if (Schema::hasColumn('import_mappings', 'position')) { + $table->dropIndex(['import_id', 'position']); + $table->dropColumn('position'); + } + }); + } +}; diff --git a/database/migrations/2025_09_27_230500_add_entity_to_import_mappings.php b/database/migrations/2025_09_27_230500_add_entity_to_import_mappings.php new file mode 100644 index 0000000..be9ba66 --- /dev/null +++ b/database/migrations/2025_09_27_230500_add_entity_to_import_mappings.php @@ -0,0 +1,57 @@ +string('entity', 64)->nullable()->after('import_id'); + } + $table->index(['import_id', 'entity']); + }); + + // Backfill entity from target_field's first segment where possible + DB::table('import_mappings')->orderBy('id')->chunkById(1000, function ($rows) { + foreach ($rows as $row) { + if (!empty($row->entity)) continue; + $entity = null; + if (!empty($row->target_field)) { + $parts = explode('.', $row->target_field); + $record = $parts[0] ?? null; + if ($record) { + // Map record segment to UI entity key + $map = [ + 'person' => 'person', + 'address' => 'person_addresses', + 'phone' => 'person_phones', + 'email' => 'emails', + 'account' => 'accounts', + 'contract' => 'contracts', + ]; + $entity = $map[$record] ?? $record; + } + } + if ($entity) { + DB::table('import_mappings')->where('id', $row->id)->update(['entity' => $entity]); + } + } + }); + } + + public function down(): void + { + Schema::table('import_mappings', function (Blueprint $table) { + if (Schema::hasColumn('import_mappings', 'entity')) { + // drop composite index if exists + try { $table->dropIndex(['import_id', 'entity']); } catch (\Throwable $e) { /* ignore */ } + $table->dropColumn('entity'); + } + }); + } +}; diff --git a/database/migrations/2025_09_27_230600_add_entity_to_import_template_mappings.php b/database/migrations/2025_09_27_230600_add_entity_to_import_template_mappings.php new file mode 100644 index 0000000..8b28441 --- /dev/null +++ b/database/migrations/2025_09_27_230600_add_entity_to_import_template_mappings.php @@ -0,0 +1,55 @@ +string('entity', 64)->nullable()->after('import_template_id'); + } + $table->index(['import_template_id', 'entity']); + }); + + // Backfill entity from target_field first segment + DB::table('import_template_mappings')->orderBy('id')->chunkById(1000, function ($rows) { + foreach ($rows as $row) { + if (!empty($row->entity)) continue; + $entity = null; + if (!empty($row->target_field)) { + $parts = explode('.', $row->target_field); + $record = $parts[0] ?? null; + if ($record) { + $map = [ + 'person' => 'person', + 'address' => 'person_addresses', + 'phone' => 'person_phones', + 'email' => 'emails', + 'account' => 'accounts', + 'contract' => 'contracts', + ]; + $entity = $map[$record] ?? $record; + } + } + if ($entity) { + DB::table('import_template_mappings')->where('id', $row->id)->update(['entity' => $entity]); + } + } + }); + } + + public function down(): void + { + Schema::table('import_template_mappings', function (Blueprint $table) { + if (Schema::hasColumn('import_template_mappings', 'entity')) { + try { $table->dropIndex(['import_template_id', 'entity']); } catch (\Throwable $e) { /* ignore */ } + $table->dropColumn('entity'); + } + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 8dee10b..da45c20 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -27,7 +27,8 @@ public function run(): void PersonSeeder::class, SegmentSeeder::class, ActionSeeder::class, - EventSeeder::class + EventSeeder::class, + ImportTemplateSeeder::class, ]); } } diff --git a/database/seeders/ImportTemplateSeeder.php b/database/seeders/ImportTemplateSeeder.php new file mode 100644 index 0000000..4c1adf0 --- /dev/null +++ b/database/seeders/ImportTemplateSeeder.php @@ -0,0 +1,51 @@ +firstOrCreate([ + 'name' => 'Person basic CSV', + ], [ + 'uuid' => (string) Str::uuid(), + 'description' => 'Basic person import: name, email, phone, address', + 'source_type' => 'csv', + 'default_record_type' => 'person', + 'sample_headers' => ['first_name','last_name','email','phone','address','city','postal_code','country'], + 'is_active' => true, + 'meta' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + ], + ]); + + $mappings = [ + ['source_column' => 'first_name', 'target_field' => 'person.first_name', 'position' => 1], + ['source_column' => 'last_name', 'target_field' => 'person.last_name', 'position' => 2], + ['source_column' => 'email', 'target_field' => 'person.email', 'position' => 3], + ['source_column' => 'phone', 'target_field' => 'person.phone', 'position' => 4], + ['source_column' => 'address', 'target_field' => 'person.address.street', 'position' => 5], + ['source_column' => 'city', 'target_field' => 'person.address.city', 'position' => 6], + ['source_column' => 'postal_code', 'target_field' => 'person.address.postal_code', 'position' => 7], + ['source_column' => 'country', 'target_field' => 'person.address.country', 'position' => 8], + ]; + + foreach ($mappings as $map) { + ImportTemplateMapping::firstOrCreate([ + 'import_template_id' => $template->id, + 'source_column' => $map['source_column'], + ], [ + 'target_field' => $map['target_field'], + 'position' => $map['position'], + ]); + } + } +} diff --git a/resources/examples/sample_import.csv b/resources/examples/sample_import.csv new file mode 100644 index 0000000..8cb60fe --- /dev/null +++ b/resources/examples/sample_import.csv @@ -0,0 +1,6 @@ +reference,first name,last name,address,phone number,email,invoice date,due date,amount +REF-1001,John,Doe,"123 Maple St, Springfield",+1 555-0101,john.doe@example.com,2025-09-01,2025-10-01,150.75 +REF-1002,Jane,Smith,"456 Oak Ave, Metropolis",+44 20 7946 0958,jane.smith@example.co.uk,2025-09-05,2025-10-05,320.00 +REF-1003,Carlos,García,"Calle 12 #34, Madrid",+34 91 123 4567,carlos.garcia@example.es,2025-09-10,2025-10-10,78.99 +REF-1004,Anna,Müller,"Hauptstrasse 5, Berlin",+49 30 123456,anna.mueller@example.de,2025-09-12,2025-10-12,980.50 +REF-1005,Luka,Novak,"Ilica 10, Zagreb",+385 1 2345 678,luka.novak@example.hr,2025-09-15,2025-10-15,45.00 diff --git a/resources/js/Components/AddressCreateForm.vue b/resources/js/Components/AddressCreateForm.vue index fb673d1..66dd364 100644 --- a/resources/js/Components/AddressCreateForm.vue +++ b/resources/js/Components/AddressCreateForm.vue @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/resources/js/Components/BasicTable.vue b/resources/js/Components/BasicTable.vue index 5676d56..b9d14cd 100644 --- a/resources/js/Components/BasicTable.vue +++ b/resources/js/Components/BasicTable.vue @@ -1,6 +1,6 @@