Mager updated
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Document;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PruneDocumentPreviews extends Command
|
||||
{
|
||||
protected $signature = 'documents:prune-previews {--days=90 : Delete previews older than this many days} {--dry-run : Show what would be deleted without deleting}';
|
||||
protected $description = 'Deletes generated document preview files older than N days and clears their metadata.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// Optionally prune old previews daily
|
||||
if (config('files.enable_preview_prune', true)) {
|
||||
$days = (int) config('files.preview_retention_days', 90);
|
||||
if ($days < 1) { $days = 90; }
|
||||
$schedule->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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Models\ImportRow;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use App\Services\CsvImportService;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
// List imports (paginated)
|
||||
public function index(Request $request)
|
||||
{
|
||||
$paginator = Import::query()
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Models\ImportTemplateMapping;
|
||||
use App\Models\Client;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ImportTemplateController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$templates = ImportTemplate::query()
|
||||
->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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class GenerateDocumentPreview implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $documentId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$doc = Document::find($this->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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\Uuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Document extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use Uuid;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'description',
|
||||
'user_id',
|
||||
'disk',
|
||||
'path',
|
||||
'file_name',
|
||||
'original_name',
|
||||
'extension',
|
||||
'mime_type',
|
||||
'size',
|
||||
'checksum',
|
||||
'is_public',
|
||||
'preview_path',
|
||||
'preview_mime',
|
||||
'preview_generated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_public' => '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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Email extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'person_id',
|
||||
'value',
|
||||
'label',
|
||||
'is_primary',
|
||||
'is_active',
|
||||
'valid',
|
||||
'verified_at',
|
||||
'preferences',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Import extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid','user_id','import_template_id','client_id','source_type','file_name','original_name','disk','path','size','sheet_name','status','total_rows','valid_rows','invalid_rows','imported_rows','started_at','finished_at','failed_at','error_summary','meta'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'error_summary' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ImportEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'import_id','user_id','event','level','message','context','import_row_id'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ImportRow extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'import_id','row_number','sheet_name','record_type','raw_data','mapped_data','status','errors','warnings','entity_type','entity_id','fingerprint'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
'mapped_data' => 'array',
|
||||
'errors' => 'array',
|
||||
'warnings' => 'array',
|
||||
];
|
||||
|
||||
public function import(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Import::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ImportTemplate extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'meta'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sample_headers' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ImportTemplateMapping extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'import_template_id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ImportTemplate::class, 'import_template_id');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class CsvImportService
|
||||
{
|
||||
/**
|
||||
* Read the first line of a file; returns null on failure.
|
||||
*/
|
||||
public function readFirstLine(string $path): ?string
|
||||
{
|
||||
$fh = @fopen($path, 'r');
|
||||
if (!$fh) return null;
|
||||
$line = fgets($fh);
|
||||
fclose($fh);
|
||||
return $line === false ? null : $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect delimiter and return columns for first row.
|
||||
* If $hasHeader is false, returns positional indices instead of header names.
|
||||
* Returns [delimiter, columns].
|
||||
*/
|
||||
public function detectColumnsFromCsv(string $path, bool $hasHeader): array
|
||||
{
|
||||
// Use actual tab character for TSV; keep other common delimiters
|
||||
$delims = [',',';','|',"\t"];
|
||||
$bestDelim = ',';
|
||||
$bestCols = [];
|
||||
|
||||
$firstLine = $this->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportRow;
|
||||
use App\Models\Account;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Email;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonGroup;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\AccountType;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ImportProcessor
|
||||
{
|
||||
/**
|
||||
* Process an import and apply basic dedup checks.
|
||||
* Returns summary counts.
|
||||
*/
|
||||
public function process(Import $import, ?Authenticatable $user = null): array
|
||||
{
|
||||
$started = now();
|
||||
$total = 0; $skipped = 0; $imported = 0; $invalid = 0;
|
||||
$fh = null;
|
||||
|
||||
// Only CSV/TSV supported in this pass
|
||||
if (!in_array($import->source_type, ['csv','txt'])) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user