changes 0328092025

This commit is contained in:
Simon Pocrnjič
2025-09-28 22:36:47 +02:00
parent b40ee9dcde
commit 7e8e0a479b
61 changed files with 4306 additions and 654 deletions
@@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class DebugDocumentView extends Command
{
protected $signature = 'debug:document {doc_uuid} {case_uuid}';
protected $description = 'Diagnose why document view returns 404 (binding, ownership, file presence)';
public function handle(): int
{
$docUuid = (string) $this->argument('doc_uuid');
$caseUuid = (string) $this->argument('case_uuid');
$case = ClientCase::where('uuid', $caseUuid)->first();
if (! $case) {
$this->error('ClientCase not found by uuid: '.$caseUuid);
return self::FAILURE;
}
$this->info('ClientCase found: id='.$case->id.' uuid='.$case->uuid);
$doc = Document::withTrashed()->where('uuid', $docUuid)->first();
if (! $doc) {
$this->error('Document not found by uuid (including trashed): '.$docUuid);
return self::FAILURE;
}
$this->info('Document found: id='.$doc->id.' uuid='.$doc->uuid.' trashed='.(int) ($doc->deleted_at !== null));
$this->line(' documentable_type='.$doc->documentable_type.' documentable_id='.$doc->documentable_id);
$this->line(' disk='.$doc->disk.' path='.$doc->path);
$this->line(' preview_path='.(string) $doc->preview_path.' preview_mime='.(string) $doc->preview_mime);
// Ownership check like in controller
$belongsToCase = $doc->documentable_type === ClientCase::class && $doc->documentable_id === $case->id;
$belongsToContractOfCase = false;
if ($doc->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $doc->documentable_id)
->where('client_case_id', $case->id)
->exists();
}
$this->line('Ownership: belongsToCase='.(int) $belongsToCase.' belongsToContractOfCase='.(int) $belongsToContractOfCase);
// File existence checks
$disk = $doc->disk ?: 'public';
$relPath = ltrim($doc->path ?? '', '/\\');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
$existsOnDisk = Storage::disk($disk)->exists($relPath);
$this->line('Source exists on disk='.$existsOnDisk.' (disk='.$disk.' relPath='.$relPath.')');
if (! $existsOnDisk) {
$publicFull = public_path($relPath);
$this->line('Public candidate='.$publicFull.' exists='.(int) is_file($publicFull));
}
$previewDisk = config('files.preview_disk', 'public');
$previewExists = $doc->preview_path ? Storage::disk($previewDisk)->exists($doc->preview_path) : false;
$this->line('Preview exists on previewDisk='.$previewExists.' (disk='.$previewDisk.' path='.(string) $doc->preview_path.')');
$this->info('Done. Compare with controller logic to pin the 404 branch.');
return self::SUCCESS;
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Console\Commands;
use App\Jobs\GenerateDocumentPreview;
use App\Models\Document;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class GenerateMissingPreviews extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'documents:generate-previews {--now : Run the preview job synchronously instead of queueing it} {--limit=100 : Max documents to process}';
/**
* The console command description.
*/
protected $description = 'Queue or run preview generation for DOC/DOCX documents that are missing a generated preview.';
/**
* Execute the console command.
*/
public function handle(): int
{
$limit = (int) $this->option('limit');
$now = (bool) $this->option('now');
$docs = Document::query()
->whereNull('deleted_at')
->where(function ($q) {
$q->whereRaw("lower(extension) in ('doc','docx')");
})
->orderByDesc('updated_at')
->limit($limit * 5)
->get();
if ($docs->isEmpty()) {
$this->info('No documents requiring preview generation.');
return self::SUCCESS;
}
$this->info('Scanning '.$docs->count().' candidate document(s) for (re)generation...');
$dispatched = 0;
foreach ($docs as $doc) {
// Verify source file exists on disk or under public before dispatching
$disk = $doc->disk ?: 'public';
$relPath = ltrim($doc->path ?? '', '/\\');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
$has = Storage::disk($disk)->exists($relPath);
if (! $has) {
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
$has = $realN && $rootN && str_starts_with($realN, $rootN) && is_file($real);
}
if (! $has) {
$this->warn('Skipping doc '.$doc->id.' (source file missing): '.$doc->path);
continue;
}
// Determine if (re)generation is required
$needs = false;
$previewDisk = config('files.preview_disk', 'public');
if (empty($doc->preview_path)) {
$needs = true;
} else {
$existsPreview = Storage::disk($previewDisk)->exists($doc->preview_path);
if (! $existsPreview) {
$needs = true;
} elseif ($doc->preview_generated_at && $doc->updated_at && $doc->updated_at->gt($doc->preview_generated_at)) {
$needs = true;
}
}
if (! $needs) {
continue;
}
if ($now) {
GenerateDocumentPreview::dispatchSync($doc->id);
} else {
GenerateDocumentPreview::dispatch($doc->id);
}
$dispatched++;
}
$this->info(($now ? 'Ran' : 'Queued').' preview generation for '.$dispatched.' document(s).');
return self::SUCCESS;
}
}
+623 -74
View File
@@ -2,16 +2,15 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreContractRequest;
use App\Http\Requests\UpdateContractRequest;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use App\Http\Requests\StoreContractRequest;
use App\Http\Requests\UpdateContractRequest;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class ClientCaseContoller extends Controller
@@ -23,17 +22,16 @@ public function index(ClientCase $clientCase, Request $request)
{
return Inertia::render('Cases/Index', [
'client_cases' => $clientCase::with(['person'])
->when($request->input('search'), fn($que, $search) =>
$que->whereHas(
'person',
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
)
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
)
)
->where('active', 1)
->orderByDesc('created_at')
->paginate(15, ['*'], 'client-cases-page')
->withQueryString(),
'filters' => $request->only(['search'])
'filters' => $request->only(['search']),
]);
}
@@ -55,13 +53,13 @@ public function store(Request $request)
$client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail();
if( isset($client->id) ){
if (isset($client->id)) {
\DB::transaction(function() use ($request, $client){
\DB::transaction(function () use ($request, $client) {
$pq = $request->input('person');
$person = $client->person()->create([
'nu' => rand(100000,200000),
'nu' => rand(100000, 200000),
'first_name' => $pq['first_name'],
'last_name' => $pq['last_name'],
'full_name' => $pq['full_name'],
@@ -71,23 +69,23 @@ public function store(Request $request)
'social_security_number' => $pq['social_security_number'],
'description' => $pq['description'],
'group_id' => 2,
'type_id' => 1
'type_id' => 1,
]);
$person->addresses()->create([
'address' => $pq['address']['address'],
'country' => $pq['address']['country'],
'type_id' => $pq['address']['type_id']
'type_id' => $pq['address']['type_id'],
]);
$person->phones()->create([
'nu' => $pq['phone']['nu'],
'country_code' => $pq['phone']['country_code'],
'type_id' => $pq['phone']['type_id']
'type_id' => $pq['phone']['type_id'],
]);
$person->clientCase()->create([
'client_id' => $client->id
'client_id' => $client->id,
]);
});
}
@@ -97,8 +95,8 @@ public function store(Request $request)
public function storeContract(ClientCase $clientCase, StoreContractRequest $request)
{
\DB::transaction(function() use ($request, $clientCase){
\DB::transaction(function () use ($request, $clientCase) {
// Create contract
$contract = $clientCase->contracts()->create([
@@ -110,11 +108,13 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
// Note: Contract config auto-application is handled in Contract model created hook.
// Optionally create/update related account amounts
// Optionally create related account with amounts and/or type
$initial = $request->input('initial_amount');
$balance = $request->input('balance_amount');
if (!is_null($initial) || !is_null($balance)) {
$accountTypeId = $request->input('account_type_id');
if (! is_null($initial) || ! is_null($balance) || ! is_null($accountTypeId)) {
$contract->account()->create([
'type_id' => $accountTypeId,
'initial_amount' => $initial ?? 0,
'balance_amount' => $balance ?? 0,
]);
@@ -125,11 +125,11 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ
return to_route('clientCase.show', $clientCase);
}
public function updateContract(ClientCase $clientCase, String $uuid, UpdateContractRequest $request)
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
{
$contract = Contract::where('uuid', $uuid)->firstOrFail();
\DB::transaction(function() use ($request, $contract){
\DB::transaction(function () use ($request, $contract) {
$contract->update([
'reference' => $request->input('reference'),
'type_id' => $request->input('type_id'),
@@ -139,14 +139,24 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr
$initial = $request->input('initial_amount');
$balance = $request->input('balance_amount');
if (!is_null($initial) || !is_null($balance)) {
$accountData = [
'initial_amount' => $initial ?? 0,
'balance_amount' => $balance ?? 0,
];
$shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id');
if ($shouldUpsertAccount) {
$accountData = [];
if (! is_null($initial)) {
$accountData['initial_amount'] = $initial;
}
if (! is_null($balance)) {
$accountData['balance_amount'] = $balance;
}
if ($request->has('account_type_id')) {
$accountData['type_id'] = $request->input('account_type_id');
}
if ($contract->account) {
$contract->account->update($accountData);
} else {
// For create, ensure defaults exist if not provided
$accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData);
$contract->account()->create($accountData);
}
}
@@ -156,7 +166,8 @@ public function updateContract(ClientCase $clientCase, String $uuid, UpdateContr
return to_route('clientCase.show', $clientCase);
}
public function storeActivity(ClientCase $clientCase, Request $request) {
public function storeActivity(ClientCase $clientCase, Request $request)
{
try {
$attributes = $request->validate([
'due_date' => 'nullable|date',
@@ -166,16 +177,16 @@ public function storeActivity(ClientCase $clientCase, Request $request) {
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
]);
// Map contract_uuid to contract_id within the same client case, if provided
$contractId = null;
if (!empty($attributes['contract_uuid'])) {
if (! empty($attributes['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id');
if ($contract) {
$contractId = $contract->id;
}
}
// Create activity
$row = $clientCase->activities()->create([
'due_date' => $attributes['due_date'] ?? null,
@@ -189,24 +200,30 @@ public function storeActivity(ClientCase $clientCase, Request $request) {
$class = '\\App\\Events\\' . $e->name;
event(new $class($clientCase));
}*/
logger()->info('Activity successfully inserted', $attributes);
return to_route('clientCase.show', $clientCase)->with('success', 'Successful created!');
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!');
} catch (QueryException $e) {
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to insert activity. ' . $e->getMessage());
return back()->with('error', 'Failed to insert activity. '.$e->getMessage());
} catch (Exception $e) {
logger()->error('An unexpected error occurred:', ['error' => $e->getMessage()]);
// Return a generic error response
return back()->with('error', 'An unexpected error occurred. Please try again later.');
}
}
public function deleteContract(ClientCase $clientCase, String $uuid, Request $request) {
public function deleteContract(ClientCase $clientCase, string $uuid, Request $request)
{
$contract = Contract::where('uuid', $uuid)->firstOrFail();
\DB::transaction(function() use ($request, $contract){
\DB::transaction(function () use ($contract) {
$contract->delete();
});
@@ -254,22 +271,22 @@ public function attachSegment(ClientCase $clientCase, Request $request)
'make_active_for_contract' => ['sometimes', 'boolean'],
]);
\DB::transaction(function () use ($clientCase, $validated) {
\DB::transaction(function () use ($clientCase, $validated) {
// Attach segment to client case if not already attached
$attached = \DB::table('client_case_segment')
->where('client_case_id', $clientCase->id)
->where('segment_id', $validated['segment_id'])
->first();
if (!$attached) {
if (! $attached) {
$clientCase->segments()->attach($validated['segment_id'], ['active' => true]);
} else if (!$attached->active) {
} elseif (! $attached->active) {
\DB::table('client_case_segment')
->where('id', $attached->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Optionally make it active for a specific contract
if (!empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail();
\DB::table('contract_segment')
->where('contract_id', $contract->id)
@@ -300,11 +317,18 @@ public function storeDocument(ClientCase $clientCase, Request $request)
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_public' => 'sometimes|boolean',
'contract_uuid' => 'nullable|uuid',
]);
$file = $validated['file'];
$disk = 'public';
$directory = 'cases/' . $clientCase->uuid . '/documents';
$contract = null;
if (! empty($validated['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
}
$directory = $contract
? ('contracts/'.$contract->uuid.'/documents')
: ('cases/'.$clientCase->uuid.'/documents');
$path = $file->store($directory, $disk);
$doc = new Document([
@@ -319,14 +343,18 @@ public function storeDocument(ClientCase $clientCase, Request $request)
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'checksum' => null,
'is_public' => (bool)($validated['is_public'] ?? false),
'is_public' => (bool) ($validated['is_public'] ?? false),
]);
$clientCase->documents()->save($doc);
if ($contract) {
$contract->documents()->save($doc);
} else {
$clientCase->documents()->save($doc);
}
// Generate preview immediately for Office docs to avoid first-view delay
$ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc','docx'])) {
if (in_array($ext, ['doc', 'docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($doc->id);
}
@@ -335,22 +363,52 @@ public function storeDocument(ClientCase $clientCase, Request $request)
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Ensure the document belongs to this client case
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
// Ensure the document belongs to this client case or its contracts
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
// Include soft-deleted contracts when verifying ownership to this case
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document view 404: document does not belong to case or its contracts', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'documentable_type' => $document->documentable_type,
'documentable_id' => $document->documentable_id,
'client_case_id' => $clientCase->id,
'client_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
// Optional: add authz checks here (e.g., policies)
$disk = $document->disk ?: 'public';
// Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows)
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath); // unify slashes
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
// If a preview exists (e.g., PDF generated for doc/docx), stream that
$previewDisk = config('files.preview_disk', 'public');
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
if ($stream === false) abort(404);
return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [
if ($stream === false) {
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="' . addslashes(($document->original_name ?: $document->file_name) . '.pdf') . '"',
'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
@@ -358,17 +416,147 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
// If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
if (in_array($ext, ['doc','docx'])) {
if (in_array($ext, ['doc', 'docx'])) {
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
if (!Storage::disk($disk)->exists($document->path)) {
// Try multiple path candidates to account for legacy prefixes
$candidates = [];
$candidates[] = $relPath;
// also try raw original (normalized slashes, trimmed)
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
// if path accidentally contains 'storage/' prefix (public symlink), strip it
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
$existsOnDisk = false;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$existsOnDisk = true;
$relPath = $cand;
break;
}
}
if (! $existsOnDisk) {
// Fallback: some legacy files may live directly under public/, attempt to stream from there
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
logger()->info('Document view fallback: serving from public path', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp === false) {
abort(404);
}
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
logger()->warning('Document view 404: file missing on disk and public fallback failed', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'disk' => $disk,
'path' => $document->path,
'normalizedCandidates' => $candidates,
'public_candidate' => $publicFull,
]);
abort(404);
}
$stream = Storage::disk($disk)->readStream($document->path);
$stream = Storage::disk($disk)->readStream($relPath);
if ($stream === false) {
logger()->warning('Document view: readStream failed, attempting fallbacks', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($relPath);
} catch (\Throwable $e) {
$bytes = null;
}
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
// Fallback 2: open via absolute path (local driver)
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($relPath);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
logger()->info('Document view fallback: serving from absolute storage path', [
'document_id' => $document->id,
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
]);
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
logger()->info('Document view fallback: serving from public path (post-readStream failure)', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
logger()->warning('Document view 404: all fallbacks failed after readStream failure', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
abort(404);
}
@@ -376,7 +564,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="' . addslashes($document->original_name ?: $document->file_name) . '"',
'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
@@ -384,57 +572,418 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
{
if ($document->documentable_type !== ClientCase::class || $document->documentable_id !== $clientCase->id) {
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document download 404: document does not belong to case or its contracts', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'documentable_type' => $document->documentable_type,
'documentable_id' => $document->documentable_id,
'client_case_id' => $clientCase->id,
'client_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
$disk = $document->disk ?: 'public';
if (!Storage::disk($disk)->exists($document->path)) {
// Normalize relative path for Windows and legacy prefixes
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath);
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
$candidates = [];
$candidates[] = $relPath;
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
$existsOnDisk = false;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$existsOnDisk = true;
$relPath = $cand;
break;
}
}
if (! $existsOnDisk) {
// Fallback to public/ direct path if present
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
logger()->info('Document download fallback: serving from public path', [
'document_id' => $document->id,
'path' => $realN,
]);
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
$fp = @fopen($real, 'rb');
if ($fp === false) {
abort(404);
}
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
logger()->warning('Document download 404: file missing on disk and public fallback failed', [
'document_id' => $document->id,
'document_uuid' => $document->uuid,
'disk' => $disk,
'path' => $document->path,
'normalizedCandidates' => $candidates,
'public_candidate' => $publicFull,
]);
abort(404);
}
$name = $document->original_name ?: $document->file_name;
$stream = Storage::disk($disk)->readStream($document->path);
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
$stream = Storage::disk($disk)->readStream($relPath);
if ($stream === false) {
logger()->warning('Document download: readStream failed, attempting fallbacks', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($relPath);
} catch (\Throwable $e) {
$bytes = null;
}
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
// Fallback 2: open via absolute storage path
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($relPath);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
logger()->info('Document download fallback: serving from absolute storage path', [
'document_id' => $document->id,
'abs' => str_replace('\\\\', '/', (string) realpath($abs)),
]);
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
logger()->info('Document download fallback: serving from public path (post-readStream failure)', [
'document_id' => $document->id,
'path' => $realN,
]);
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
logger()->warning('Document download 404: all fallbacks failed after readStream failure', [
'document_id' => $document->id,
'disk' => $disk,
'relPath' => $relPath,
]);
abort(404);
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . addslashes($name) . '"',
'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
/**
* View a contract document using contract route binding.
*/
public function viewContractDocument(Contract $contract, Document $document, Request $request)
{
// Ensure the document belongs to this contract (including trashed docs)
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
// Reuse the existing logic by delegating to a small helper
return $this->streamDocumentForDisk($document, inline: true);
}
/**
* Download a contract document using contract route binding.
*/
public function downloadContractDocument(Contract $contract, Document $document, Request $request)
{
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
return $this->streamDocumentForDisk($document, inline: false);
}
/**
* Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks.
*/
protected function streamDocumentForDisk(Document $document, bool $inline = true)
{
$disk = $document->disk ?: 'public';
$relPath = $document->path ?? '';
$relPath = str_replace('\\', '/', $relPath);
$relPath = ltrim($relPath, '/');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
// Previews for DOC/DOCX
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$previewDisk = config('files.preview_disk', 'public');
if ($inline && in_array($ext, ['doc', 'docx'])) {
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
if ($stream !== false) {
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
}
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
// Try storage candidates
$candidates = [$relPath];
$raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
$found = null;
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
$found = $cand;
break;
}
}
$headers = [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
if (! $found) {
// public/ fallback
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
abort(404);
}
$stream = Storage::disk($disk)->readStream($found);
if ($stream !== false) {
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, $headers);
}
// Fallbacks on readStream failure
try {
$bytes = Storage::disk($disk)->get($found);
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
} catch (\Throwable $e) {
}
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($found);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// public/ again as last try
$publicFull = public_path($found);
$real = @realpath($publicFull);
if ($real && is_file($real)) {
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
abort(404);
}
/**
* Display the specified resource.
*/
public function show(ClientCase $clientCase)
{
$case = $clientCase::with([
'person' => fn($que) => $que->with(['addresses', 'phones'])
])->where('active', 1)->findOrFail($clientCase->id);
'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']),
])->where('active', 1)->findOrFail($clientCase->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all()
'phone_types' => \App\Models\Person\PhoneType::all(),
];
return Inertia::render('Cases/Show', [
'client' => $case->client()->with('person', fn($q) => $q->with(['addresses', 'phones']))->firstOrFail(),
// Prepare contracts and a reference map
$contracts = $case->contracts()
->with(['type', 'account', 'objects', 'segments:id,name'])
->orderByDesc('created_at')
->get();
$contractRefMap = [];
foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference;
}
// Merge client case and contract documents into a single array and include contract reference when applicable
$contractIds = $contracts->pluck('id');
$contractDocs = Document::query()
->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds)
->orderByDesc('created_at')
->get()
->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = Contract::class;
$arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid;
return $arr;
});
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['documentable_type'] = ClientCase::class;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$mergedDocs = $caseDocs
->concat($contractDocs)
->sortByDesc('created_at')
->values();
return Inertia::render('Cases/Show', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(),
'client_case' => $case,
'contracts' => $case->contracts()
->with(['type', 'account', 'objects', 'segments:id,name'])
->orderByDesc('created_at')->get(),
'activities' => $case->activities()->with(['action', 'decision', 'contract:id,uuid,reference'])
->orderByDesc('created_at')
->paginate(20, ['*'], 'activities'),
'documents' => $case->documents()->orderByDesc('created_at')->get(),
'contracts' => $contracts,
'activities' => tap(
$case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->paginate(20, ['*'], 'activities'),
function ($p) {
$p->getCollection()->transform(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
}
),
'documents' => $mergedDocs,
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions')->get(),
'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id','segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id','name'])
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
]);
}
+26 -25
View File
@@ -9,62 +9,62 @@
class ClientController extends Controller
{
public function index(Client $client, Request $request){
return Inertia::render('Client/Index',[
public function index(Client $client, Request $request)
{
return Inertia::render('Client/Index', [
'clients' => $client::query()
->with('person')
->when($request->input('search'), fn($que, $search) =>
$que->whereHas(
'person',
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
)
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
)
)
->where('active', 1)
->orderByDesc('created_at')
->paginate(15)
->withQueryString(),
'filters' => $request->only(['search'])
'filters' => $request->only(['search']),
]);
}
public function show(Client $client, Request $request) {
public function show(Client $client, Request $request)
{
$data = $client::query()
->with(['person' => fn($que) => $que->with(['addresses','phones'])])
->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts'])])
->findOrFail($client->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all()
'phone_types' => \App\Models\Person\PhoneType::all(),
];
return Inertia::render('Client/Show', [
'client' => $data,
'client_cases' => $data->clientCases()
->with('person')
->when($request->input('search'), fn($que, $search) =>
$que->whereHas(
'person',
fn($q) => $q->where('full_name', 'ilike', '%' . $search . '%')
)
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
)
)
->where('active', 1)
->orderByDesc('created_at')
->paginate(15)
->withQueryString(),
'types' => $types,
'filters' => $request->only(['search'])
'filters' => $request->only(['search']),
]);
}
public function store(Request $request)
{
DB::transaction(function() use ($request){
DB::transaction(function () use ($request) {
$address = $request->input('address');
$phone = $request->input('phone');
$person = \App\Models\Person\Person::create([
'nu' => rand(100000,200000),
'nu' => rand(100000, 200000),
'first_name' => $request->input('first_name'),
'last_name' => $request->input('last_name'),
'full_name' => $request->input('full_name'),
@@ -74,31 +74,32 @@ public function store(Request $request)
'social_security_number' => $request->input('social_security_number'),
'description' => $request->input('description'),
'group_id' => 1,
'type_id' => 2
'type_id' => 2,
]);
$person->addresses()->create([
'address' => $address['address'],
'country' => $address['country'],
'type_id' => $address['type_id']
'type_id' => $address['type_id'],
]);
$person->phones()->create([
'nu' => $phone['nu'],
'country_code' => $phone['country_code'],
'type_id' => $phone['type_id']
'type_id' => $phone['type_id'],
]);
$person->client()->create();
});
//\App\Models\Person\PersonAddress::create($address);
// \App\Models\Person\PersonAddress::create($address);
return to_route('client');
}
public function update(Client $client, Request $request) {
public function update(Client $client, Request $request)
{
return to_route('client.show', $client);
}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace App\Http\Controllers;
use App\Models\Activity;
use App\Models\Contract;
use App\Models\FieldJob;
use App\Models\FieldJobSetting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class FieldJobController extends Controller
{
public function index(Request $request)
{
$setting = FieldJobSetting::query()->latest('id')->first();
// Only fetch contracts that are currently in either the primary segment
// or the optional queue segment defined on the latest FieldJobSetting.
$segmentIds = collect([
optional($setting)->queue_segment_id,
optional($setting)->segment_id,
])->filter()->unique()->values();
$contracts = Contract::query()
->with(['clientCase.person', 'type', 'account'])
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($sq) use ($segmentIds) {
// Relation already filters on active pivots
$sq->whereIn('segments.id', $segmentIds);
});
}, function ($q) {
// No segments configured on FieldJobSetting -> return none
$q->whereRaw('1 = 0');
})
->latest('id')
->limit(50)
->get();
// Build active assignment map keyed by contract uuid for quicker UI checks
$assignments = collect();
if ($contracts->isNotEmpty()) {
$activeJobs = FieldJob::query()
->whereIn('contract_id', $contracts->pluck('id'))
->whereNull('completed_at')
->whereNull('cancelled_at')
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
->get();
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
return [
optional($job->contract)->uuid => [
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
'assigned_at' => $job->assigned_at,
],
];
})->filter();
}
$users = User::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('FieldJob/Index', [
'setting' => $setting,
'contracts' => $contracts,
'users' => $users,
'assignments' => $assignments,
]);
}
public function assign(Request $request)
{
$data = $request->validate([
'contract_uuid' => 'required|string|exists:contracts,uuid',
'assigned_user_id' => 'required|integer|exists:users,id',
]);
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
return back()->withErrors(['setting' => 'No Field Job Setting found. Create one in Settings → Field Job Settings.']);
}
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
$job = FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $data['assigned_user_id'],
'contract_id' => $contract->id,
'assigned_at' => now(),
]);
// Create an activity for the assignment
// Find the first action linked to the assign decision via pivot; also prefer actions within the same segment as the setting
$decisionId = $setting->assign_decision_id;
$actionId = null;
if ($decisionId) {
// Strictly use the action_decision pivot: take the first action mapped to this decision
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
}
if ($actionId) {
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
return back()->with('success', 'Field job assigned.');
}
public function cancel(Request $request)
{
$data = $request->validate([
'contract_uuid' => 'required|string|exists:contracts,uuid',
]);
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
$job = FieldJob::query()
->where('contract_id', $contract->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->latest('id')
->first();
if ($job) {
$job->cancelled_at = now();
$job->save();
// Create an activity for the cancellation, mirroring the assign flow
// Prefer the job's setting for a consistent decision
$job->loadMissing('setting');
$decisionId = optional($job->setting)->cancel_decision_id;
if ($decisionId) {
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
if ($actionId) {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Terensko opravilo preklicano',
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
}
}
return back()->with('success', 'Field job cancelled.');
}
public function complete(Request $request, \App\Models\ClientCase $clientCase)
{
// Complete all active field jobs for contracts of this case assigned to current user
$userId = optional($request->user())->id;
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
return back()->withErrors(['setting' => 'No Field Job Setting found.']);
}
$decisionId = $setting->complete_decision_id;
$actionId = null;
if ($decisionId) {
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
}
// Find all active jobs for this case for the current user
$jobs = FieldJob::query()
->whereHas('contract', function ($q) use ($clientCase) {
$q->where('client_case_id', $clientCase->id);
})
->where(function ($q) use ($userId) {
if ($userId) {
$q->where('assigned_user_id', $userId);
}
})
->whereNull('completed_at')
->whereNull('cancelled_at')
->with(['contract:id,client_case_id', 'setting'])
->get();
DB::transaction(function () use ($jobs, $decisionId, $actionId) {
foreach ($jobs as $job) {
// Mark job complete
$job->completed_at = now();
$job->save();
// Log completion activity on the contract/case
if ($actionId && $decisionId) {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Terensko opravilo zaključeno',
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $job->contract->client_case_id,
'contract_id' => $job->contract_id,
]);
}
// Move contract to configured return segment
$job->returnContractToConfiguredSegment();
}
});
// Redirect back to phone index
return to_route('phone.index');
}
}
@@ -2,10 +2,11 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreFieldJobSettingRequest;
use App\Http\Requests\UpdateFieldJobSettingRequest;
use App\Models\Decision;
use App\Models\FieldJobSetting;
use App\Models\Segment;
use App\Models\Decision;
use App\Http\Requests\StoreFieldJobSettingRequest;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -14,7 +15,7 @@ class FieldJobSettingController extends Controller
public function index(Request $request)
{
$settings = FieldJobSetting::query()
->with(['segment', 'initialDecision', 'asignDecision', 'completeDecision'])
->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
->get();
return Inertia::render('Settings/FieldJob/Index', [
@@ -31,10 +32,30 @@ public function store(StoreFieldJobSettingRequest $request)
FieldJobSetting::create([
'segment_id' => $attributes['segment_id'],
'initial_decision_id' => $attributes['initial_decision_id'],
'asign_decision_id' => $attributes['asign_decision_id'],
'assign_decision_id' => $attributes['assign_decision_id'],
'complete_decision_id' => $attributes['complete_decision_id'],
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
'return_segment_id' => $attributes['return_segment_id'] ?? null,
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
]);
return to_route('settings.fieldjob.index')->with('success', 'Field job setting created successfully!');
}
public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $request)
{
$attributes = $request->validated();
$setting->update([
'segment_id' => $attributes['segment_id'],
'initial_decision_id' => $attributes['initial_decision_id'],
'assign_decision_id' => $attributes['assign_decision_id'],
'complete_decision_id' => $attributes['complete_decision_id'],
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
'return_segment_id' => $attributes['return_segment_id'] ?? null,
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
]);
return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!');
}
}
@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use App\Models\FieldJob;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PhoneViewController extends Controller
{
public function index(Request $request)
{
$userId = $request->user()->id;
$jobs = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->with([
'contract' => function ($q) {
$q->with(['type:id,name', 'account', 'clientCase.person' => function ($pq) {
$pq->with(['addresses', 'phones']);
}]);
},
])
->orderByDesc('assigned_at')
->limit(100)
->get();
return Inertia::render('Phone/Index', [
'jobs' => $jobs,
]);
}
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
{
$userId = $request->user()->id;
// Eager load client case with person details
$case = \App\Models\ClientCase::query()
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
->findOrFail($clientCase->id);
// Determine contracts of this case assigned to the current user via FieldJobs and still active
$assignedContractIds = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
->pluck('contract_id')
->unique()
->values();
$contracts = \App\Models\Contract::query()
->where('client_case_id', $case->id)
->whereIn('id', $assignedContractIds)
->with(['type:id,name', 'account'])
->orderByDesc('created_at')
->get();
// Attach latest object (if any) to each contract as last_object for display
if ($contracts->isNotEmpty()) {
$byId = $contracts->keyBy('id');
$latestObjects = \App\Models\CaseObject::query()
->whereIn('contract_id', $byId->keys())
->whereNull('deleted_at')
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
->orderByDesc('created_at')
->get()
->groupBy('contract_id')
->map(function ($group) {
return $group->first();
});
foreach ($latestObjects as $cid => $obj) {
if (isset($byId[$cid])) {
$byId[$cid]->setAttribute('last_object', $obj);
}
}
}
// Build merged documents: case documents + documents of assigned contracts
$contractRefMap = [];
foreach ($contracts as $c) {
$contractRefMap[$c->id] = $c->reference;
}
$contractDocs = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->whereIn('documentable_id', $assignedContractIds)
->orderByDesc('created_at')
->get()
->map(function ($d) use ($contractRefMap) {
$arr = $d->toArray();
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
$arr['documentable_type'] = \App\Models\Contract::class;
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
return $arr;
});
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
$arr = $d->toArray();
$arr['documentable_type'] = \App\Models\ClientCase::class;
$arr['client_case_uuid'] = $case->uuid;
return $arr;
});
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
// Provide minimal types for PersonInfoGrid
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
];
// Case activities (compact for phone): latest 20 with relations
$activities = $case->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at')
->limit(20)
->get()
->map(function ($a) {
$a->setAttribute('user_name', optional($a->user)->name);
return $a;
});
return Inertia::render('Phone/Case/Index', [
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,
'types' => $types,
'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions')->get(),
'activities' => $activities,
]);
}
}
@@ -32,6 +32,7 @@ public function rules(): array
'description' => ['nullable', 'string', 'max:255'],
'initial_amount' => ['nullable', 'numeric'],
'balance_amount' => ['nullable', 'numeric'],
'account_type_id' => ['nullable', 'integer', 'exists:account_types,id'],
];
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
class StoreFieldJobSettingRequest extends FormRequest
{
@@ -16,8 +17,11 @@ public function rules(): array
return [
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'asign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
];
}
@@ -26,8 +30,51 @@ public function messages(): array
return [
'segment_id.required' => 'Segment is required.',
'initial_decision_id.required' => 'Initial decision is required.',
'asign_decision_id.required' => 'Assign decision is required.',
'assign_decision_id.required' => 'Assign decision is required.',
'complete_decision_id.required' => 'Complete decision is required.',
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
// Validate that the assign_decision_id has a mapped action
$assignDecisionId = $this->input('assign_decision_id');
if (! empty($assignDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $assignDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
}
}
// Validate that the complete_decision_id has a mapped action
$completeDecisionId = $this->input('complete_decision_id');
if (! empty($completeDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $completeDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
}
}
$cancelDecisionId = $this->input('cancel_decision_id');
if (! empty($cancelDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $cancelDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
}
}
});
}
}
@@ -34,6 +34,7 @@ public function rules(): array
'description' => ['nullable', 'string', 'max:255'],
'initial_amount' => ['nullable', 'numeric'],
'balance_amount' => ['nullable', 'numeric'],
'account_type_id' => ['sometimes', 'nullable', 'integer', 'exists:account_types,id'],
];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
class UpdateFieldJobSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
];
}
public function messages(): array
{
return [
'segment_id.required' => 'Segment is required.',
'initial_decision_id.required' => 'Initial decision is required.',
'assign_decision_id.required' => 'Assign decision is required.',
'complete_decision_id.required' => 'Complete decision is required.',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$assignDecisionId = $this->input('assign_decision_id');
if (! empty($assignDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $assignDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
}
}
$completeDecisionId = $this->input('complete_decision_id');
if (! empty($completeDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $completeDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
}
}
$cancelDecisionId = $this->input('cancel_decision_id');
if (! empty($cancelDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $cancelDecisionId)
->exists();
if (! $mapped) {
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
}
}
});
}
}
+92 -56
View File
@@ -11,6 +11,8 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
// Note: we intentionally use exec() with careful quoting and polling because on Windows soffice may spawn a child process.
class GenerateDocumentPreview implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -21,19 +23,49 @@ class GenerateDocumentPreview implements ShouldQueue
*/
public $timeout = 180; // 3 minutes
public function __construct(public int $documentId)
{
}
public function __construct(public int $documentId) {}
public function handle(): void
{
$doc = Document::find($this->documentId);
if (!$doc)
if (! $doc) {
return;
}
$disk = $doc->disk ?: 'public';
if (!Storage::disk($disk)->exists($doc->path))
return;
// Normalize path to support legacy entries with a leading 'public/'
$relPath = ltrim($doc->path ?? '', '/\\');
if (str_starts_with($relPath, 'public/')) {
$relPath = substr($relPath, 7);
}
$sourceBytes = null;
if (Storage::disk($disk)->exists($relPath)) {
$sourceBytes = Storage::disk($disk)->get($relPath);
} else {
// Fallback to public/ filesystem in case of legacy placement
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
\Log::info('Preview job: using public path fallback for source file', [
'document_id' => $doc->id,
'path' => $realN,
]);
$sourceBytes = @file_get_contents($real);
} else {
\Log::warning('Preview job: source file missing on disk and public fallback failed', [
'document_id' => $doc->id,
'disk' => $disk,
'path' => $doc->path,
'normalized' => $relPath,
'public_candidate' => $publicFull,
]);
return;
}
}
$ext = strtolower(pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_EXTENSION));
@@ -48,69 +80,52 @@ public function handle(): void
'updated_at' => (string) $doc->updated_at,
'preview_generated_at' => (string) $doc->preview_generated_at,
]);
return;
}
}
}
if (!in_array($ext, ['doc', 'docx']))
return; // only convert office docs here
if (! in_array($ext, ['doc', 'docx'])) {
return;
} // only convert office docs here
// Prepare temp files - keep original extension so LibreOffice can detect filter
$tmpBase = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'doc_in_' . uniqid();
$tmpIn = $tmpBase . '.' . $ext; // e.g., .doc or .docx
file_put_contents($tmpIn, Storage::disk($disk)->get($doc->path));
$tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'doc_in_'.uniqid();
$tmpIn = $tmpBase.'.'.$ext; // e.g., .doc or .docx
file_put_contents($tmpIn, $sourceBytes);
$outDir = sys_get_temp_dir();
// Ensure exec is available
if (!function_exists('exec')) {
Log::error('Preview generation failed: exec() not available in this PHP environment', ['document_id' => $doc->id]);
return;
}
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
if (in_array('exec', $disabled, true)) {
Log::error('Preview generation failed: exec() is disabled in php.ini (disable_functions)', ['document_id' => $doc->id]);
return;
}
// Run soffice headless to convert to PDF
$binCfg = config('files.libreoffice_bin');
$bin = $binCfg ? (string) $binCfg : 'soffice';
// If an absolute path is configured, ensure it exists to avoid long PATH resolution delays
if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && !file_exists($bin)) {
if ($binCfg && preg_match('/^[a-zA-Z]:\\\\|^\//', $bin) && ! file_exists($bin)) {
Log::warning('Configured LibreOffice binary not found; falling back to PATH', [
'document_id' => $doc->id,
'bin' => $bin,
]);
$bin = 'soffice';
}
// Windows quoting differs from POSIX. Build command parts safely.
$isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
if ($isWin) {
$binPart = '"' . $bin . '"';
$outDirPart = '"' . $outDir . '"';
$inPart = '"' . $tmpIn . '"';
} else {
$binPart = escapeshellcmd($bin);
$outDirPart = escapeshellarg($outDir);
$inPart = escapeshellarg($tmpIn);
}
// Use a temporary user profile to avoid permissions/profile lock issues
$loProfileDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lo_profile_' . $doc->id;
if (!is_dir($loProfileDir)) {
$loProfileDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'lo_profile_'.$doc->id;
if (! is_dir($loProfileDir)) {
@mkdir($loProfileDir, 0700, true);
}
$loProfileUri = 'file:///' . ltrim(str_replace('\\', '/', $loProfileDir), '/');
$loProfileUri = 'file:///'.ltrim(str_replace('\\', '/', $loProfileDir), '/');
// Build command string for exec()
$isWin = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
$binPart = $isWin ? '"'.$bin.'"' : escapeshellcmd($bin);
$outDirPart = $isWin ? '"'.$outDir.'"' : escapeshellarg($outDir);
$inPart = $isWin ? '"'.$tmpIn.'"' : escapeshellarg($tmpIn);
$profilePart = $isWin ? '"'.$loProfileUri.'"' : escapeshellarg($loProfileUri);
$cmd = sprintf(
'%s --headless --norestore --nolockcheck -env:UserInstallation=%s --convert-to pdf --outdir %s %s',
'%s --headless --norestore --nolockcheck --nologo --nodefault --nofirststartwizard -env:UserInstallation=%s --convert-to pdf --outdir %s %s',
$binPart,
$isWin ? '"' . $loProfileUri . '"' : escapeshellarg($loProfileUri),
$profilePart,
$outDirPart,
$inPart
);
// Capture stderr as well for diagnostics
$cmdWithStderr = $cmd . ' 2>&1';
$t0 = microtime(true);
Log::info('Starting LibreOffice preview conversion', [
'document_id' => $doc->id,
@@ -119,49 +134,64 @@ public function handle(): void
]);
$out = [];
$ret = 0;
exec($cmdWithStderr, $out, $ret);
@exec($cmd.' 2>&1', $out, $ret);
// Some Windows installs may return before file is fully written; we'll poll for the output file below anyway.
if ($ret !== 0) {
Log::warning('Preview generation failed', [
'document_id' => $doc->id,
'ret' => $ret,
'cmd' => $cmd,
'exit_code' => $ret,
'output' => implode("\n", $out),
]);
@unlink($tmpIn);
return;
}
$elapsed = (int) round((microtime(true) - $t0) * 1000);
$pdfPathLocal = $tmpIn . '.pdf';
$pdfPathLocal = $tmpIn.'.pdf';
// LibreOffice writes output with source filename base; derive path
$base = pathinfo($tmpIn, PATHINFO_FILENAME);
$pdfPathLocal = $outDir . DIRECTORY_SEPARATOR . $base . '.pdf';
if (!file_exists($pdfPathLocal)) {
$pdfPathLocal = $outDir.DIRECTORY_SEPARATOR.$base.'.pdf';
// Poll for up to 10s for the PDF to appear (handles async write on Windows)
$waitUntil = microtime(true) + 10.0;
while (! file_exists($pdfPathLocal) && microtime(true) < $waitUntil) {
usleep(200 * 1000); // 200ms
}
if (! file_exists($pdfPathLocal)) {
// fallback: try with original name base
$origBase = pathinfo($doc->original_name ?: $doc->file_name, PATHINFO_FILENAME);
$try = $outDir . DIRECTORY_SEPARATOR . $origBase . '.pdf';
if (file_exists($try))
$try = $outDir.DIRECTORY_SEPARATOR.$origBase.'.pdf';
// brief poll for fallback name as well
$waitUntil2 = microtime(true) + 5.0;
while (! file_exists($try) && microtime(true) < $waitUntil2) {
usleep(200 * 1000);
}
if (file_exists($try)) {
$pdfPathLocal = $try;
}
}
if (!file_exists($pdfPathLocal)) {
if (! file_exists($pdfPathLocal)) {
Log::warning('Preview generation did not produce expected PDF output', [
'document_id' => $doc->id,
'out_dir' => $outDir,
'tmp_base' => $base,
'command' => $cmd,
'output' => implode("\n", $out),
'stdout' => implode("\n", $out),
]);
@unlink($tmpIn);
return;
}
// Compute elapsed time once output exists
$elapsed = (int) round((microtime(true) - $t0) * 1000);
// Store preview PDF to configured disk inside configured previews base path
$previewDisk = config('files.preview_disk', 'public');
$base = trim(config('files.preview_base', 'previews/cases'), '/');
$previewDir = $base . '/' . ($doc->documentable?->uuid ?? 'unknown');
$stored = Storage::disk($previewDisk)->put($previewDir . '/' . ($doc->uuid) . '.pdf', file_get_contents($pdfPathLocal));
$previewDir = $base.'/'.($doc->documentable?->uuid ?? 'unknown');
$stored = Storage::disk($previewDisk)->put($previewDir.'/'.($doc->uuid).'.pdf', file_get_contents($pdfPathLocal));
if ($stored) {
$doc->preview_path = $previewDir . '/' . $doc->uuid . '.pdf';
$doc->preview_path = $previewDir.'/'.$doc->uuid.'.pdf';
$doc->preview_mime = 'application/pdf';
$doc->preview_generated_at = now();
$doc->save();
@@ -170,6 +200,12 @@ public function handle(): void
'preview_path' => $doc->preview_path,
'elapsed_ms' => $elapsed,
]);
} else {
Log::warning('Preview generated but storing to disk failed', [
'document_id' => $doc->id,
'preview_disk' => $previewDisk,
'target' => $previewDir.'/'.$doc->uuid.'.pdf',
]);
}
@unlink($tmpIn);
+21 -7
View File
@@ -6,11 +6,13 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Activity extends Model
{
/** @use HasFactory<\Database\Factories\ActivityFactory> */
use HasFactory;
use SoftDeletes;
protected $fillable = [
@@ -20,7 +22,8 @@ class Activity extends Model
'action_id',
'user_id',
'decision_id',
'contract_id'
'contract_id',
'client_case_id',
];
protected $hidden = [
@@ -28,19 +31,25 @@ class Activity extends Model
'decision_id',
'client_case_id',
'user_id',
'contract_id'
'contract_id',
];
protected static function booted(){
protected static function booted()
{
static::creating(function (Activity $activity) {
if(!isset($activity->user_id)){
if (! isset($activity->user_id)) {
$activity->user_id = auth()->id();
}
// If an activity with a due date is added for a contract, update the related account's promise_date
if (! empty($activity->contract_id) && ! empty($activity->due_date)) {
DB::table('accounts')
->where('contract_id', $activity->contract_id)
->update(['promise_date' => $activity->due_date, 'updated_at' => now()]);
}
});
}
public function action(): BelongsTo
{
return $this->belongsTo(\App\Models\Action::class);
@@ -56,8 +65,13 @@ public function clientCase(): BelongsTo
return $this->belongsTo(\App\Models\ClientCase::class);
}
public function contract(): BelongsTo|null
public function contract(): ?BelongsTo
{
return $this->belongsTo(\App\Models\Contract::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
}
+10 -8
View File
@@ -3,34 +3,35 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Scout\Searchable;
class ClientCase extends Model
{
/** @use HasFactory<\Database\Factories\ClientCaseFactory> */
use HasFactory;
use Uuid;
use Searchable;
use Uuid;
protected $fillable = [
'client_id',
'person_id'
'person_id',
];
protected $hidden = [
'id',
'client_id',
'person_id'
'person_id',
];
protected function makeAllSearchableUsing(Builder $query): Builder
protected function makeAllSearchableUsing(Builder $query): Builder
{
return $query->with('person');
}
@@ -39,11 +40,11 @@ public function toSearchableArray(): array
{
return [
'person.full_name' => ''
'person.full_name' => '',
];
}
public function client(): BelongsTo
public function client(): BelongsTo
{
return $this->belongsTo(\App\Models\Client::class);
}
@@ -64,7 +65,8 @@ public function activities(): HasMany
return $this->hasMany(\App\Models\Activity::class);
}
public function segments(): BelongsToMany {
public function segments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class)->withTimestamps();
}
+16 -12
View File
@@ -3,24 +3,22 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Factories\BelongsToManyRelationship;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Contract extends Model
{
/** @use HasFactory<\Database\Factories\ContractFactory> */
use HasFactory;
use Uuid;
use SoftDeletes;
use Uuid;
protected $fillable = [
'reference',
@@ -28,13 +26,13 @@ class Contract extends Model
'end_date',
'client_case_id',
'type_id',
'description'
'description',
];
protected $hidden = [
'id',
'client_case_id',
'type_id'
'type_id',
];
public function type(): BelongsTo
@@ -47,8 +45,9 @@ public function clientCase(): BelongsTo
return $this->belongsTo(\App\Models\ClientCase::class)
->with(['person']);
}
public function segments(): BelongsToMany {
public function segments(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Segment::class)
->withPivot('active', 'created_at')
->wherePivot('active', true);
@@ -65,6 +64,11 @@ public function objects(): HasMany
return $this->hasMany(\App\Models\CaseObject::class, 'contract_id');
}
public function documents(): MorphMany
{
return $this->morphMany(\App\Models\Document::class, 'documentable');
}
protected static function booted(): void
{
static::created(function (Contract $contract): void {
@@ -96,7 +100,7 @@ protected static function booted(): void
->where('client_case_id', $contract->client_case_id)
->where('segment_id', $cfg->segment_id)
->first();
if (!$attached) {
if (! $attached) {
\DB::table('client_case_segment')->insert([
'client_case_id' => $contract->client_case_id,
'segment_id' => $cfg->segment_id,
@@ -104,7 +108,7 @@ protected static function booted(): void
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (!$attached->active) {
} elseif (! $attached->active) {
\DB::table('client_case_segment')
->where('id', $attached->id)
->update(['active' => true, 'updated_at' => now()]);
+12 -1
View File
@@ -13,8 +13,8 @@
class Document extends Model
{
use HasFactory;
use Uuid;
use SoftDeletes;
use Uuid;
protected $fillable = [
'uuid',
@@ -80,4 +80,15 @@ protected static function booted(): void
}
});
}
/**
* Include soft-deleted documents when resolving by route key (e.g. {document:uuid}).
*/
public function resolveRouteBinding($value, $field = null)
{
// Always include trashed so deep-linking to older documents works
return static::withTrashed()
->where($field ?? $this->getRouteKeyName(), $value)
->firstOrFail();
}
}
+87 -4
View File
@@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class FieldJob extends Model
{
@@ -14,7 +15,7 @@ class FieldJob extends Model
protected $fillable = [
'field_job_setting_id',
'asigned_user_id',
'assigned_user_id',
'user_id',
'contract_id',
'assigned_at',
@@ -33,12 +34,43 @@ class FieldJob extends Model
'address_snapshot ' => 'array',
];
protected static function booted(){
protected static function booted()
{
static::creating(function (FieldJob $fieldJob) {
if(!isset($fieldJob->user_id)){
if (! isset($fieldJob->user_id)) {
$fieldJob->user_id = auth()->id();
}
});
static::updated(function (FieldJob $fieldJob): void {
// If job was just completed or cancelled, move contract to configured segment
$completedChanged = $fieldJob->wasChanged('completed_at') && ! is_null($fieldJob->completed_at);
$cancelledChanged = $fieldJob->wasChanged('cancelled_at') && ! is_null($fieldJob->cancelled_at);
if (! $completedChanged && ! $cancelledChanged) {
return;
}
if (! $fieldJob->relationLoaded('setting')) {
$fieldJob->load('setting');
}
if ($cancelledChanged) {
// On cancel: redirect to queue segment
$segmentId = $fieldJob->setting?->queue_segment_id;
$fieldJob->moveContractToSegment($segmentId);
return;
}
if ($completedChanged) {
// On complete: redirect to return segment
$segmentId = $fieldJob->setting?->return_segment_id;
$fieldJob->moveContractToSegment($segmentId);
return;
}
});
}
public function setting(): BelongsTo
@@ -48,7 +80,7 @@ public function setting(): BelongsTo
public function assignedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'asigned_user_id');
return $this->belongsTo(User::class, 'assigned_user_id');
}
public function user(): BelongsTo
@@ -60,4 +92,55 @@ public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class, 'contract_id');
}
/**
* Set/ensure the contract has the return segment marked active based on the field job setting.
*/
/**
* Ensure the contract has the provided segment marked active.
*/
public function moveContractToSegment(?int $segmentId): void
{
if (empty($segmentId) || empty($this->contract_id)) {
return;
}
// First, deactivate any currently active segments for this contract
DB::table('contract_segment')
->where('contract_id', $this->contract_id)
->where('active', true)
->update(['active' => false, 'updated_at' => now()]);
// Then activate (or create) the target segment pivot
$pivot = DB::table('contract_segment')
->where('contract_id', $this->contract_id)
->where('segment_id', $segmentId)
->first();
if ($pivot) {
DB::table('contract_segment')
->where('id', $pivot->id)
->update(['active' => true, 'updated_at' => now()]);
} else {
DB::table('contract_segment')->insert([
'contract_id' => $this->contract_id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
/**
* Back-compat convenience: move to configured return segment.
*/
public function returnContractToConfiguredSegment(): void
{
if (! $this->relationLoaded('setting')) {
$this->load('setting');
}
$this->moveContractToSegment($this->setting?->return_segment_id);
}
}
+21 -3
View File
@@ -14,8 +14,11 @@ class FieldJobSetting extends Model
protected $fillable = [
'segment_id',
'initial_decision_id',
'asign_decision_id',
'assign_decision_id',
'complete_decision_id',
'cancel_decision_id',
'return_segment_id',
'queue_segment_id',
];
public function segment(): BelongsTo
@@ -23,9 +26,9 @@ public function segment(): BelongsTo
return $this->belongsTo(Segment::class);
}
public function asignDecision(): BelongsTo
public function assignDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'asign_decision_id');
return $this->belongsTo(Decision::class, 'assign_decision_id');
}
public function initialDecision(): BelongsTo
@@ -38,6 +41,21 @@ public function completeDecision(): BelongsTo
return $this->belongsTo(Decision::class, 'complete_decision_id');
}
public function cancelDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'cancel_decision_id');
}
public function returnSegment(): BelongsTo
{
return $this->belongsTo(Segment::class, 'return_segment_id');
}
public function queueSegment(): BelongsTo
{
return $this->belongsTo(Segment::class, 'queue_segment_id');
}
public function fieldJobs(): HasMany
{
return $this->hasMany(FieldJob::class);
+287 -135
View File
@@ -2,23 +2,23 @@
namespace App\Services;
use App\Models\Account;
use App\Models\AccountType;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\ContractType;
use App\Models\Email;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Models\Account;
use App\Models\Contract;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Email;
use App\Models\Person\Person;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonType;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use App\Models\Person\AddressType;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonPhone;
use App\Models\Person\PersonType;
use App\Models\Person\PhoneType;
use App\Models\ContractType;
use App\Models\AccountType;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -32,11 +32,14 @@ class ImportProcessor
public function process(Import $import, ?Authenticatable $user = null): array
{
$started = now();
$total = 0; $skipped = 0; $imported = 0; $invalid = 0;
$total = 0;
$skipped = 0;
$imported = 0;
$invalid = 0;
$fh = null;
// Only CSV/TSV supported in this pass
if (!in_array($import->source_type, ['csv','txt'])) {
if (! in_array($import->source_type, ['csv', 'txt'])) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
@@ -45,13 +48,14 @@ public function process(Import $import, ?Authenticatable $user = null): array
'message' => 'Only CSV/TXT supported in this pass.',
]);
$import->update(['status' => 'completed', 'finished_at' => now()]);
return [ 'ok' => true, 'status' => $import->status, 'counts' => compact('total','skipped','imported','invalid') ];
return ['ok' => true, 'status' => $import->status, 'counts' => compact('total', 'skipped', 'imported', 'invalid')];
}
// Get mappings for this import (with apply_mode)
$mappings = DB::table('import_mappings')
->where('import_id', $import->id)
->get(['source_column','target_field','transform','apply_mode','options']);
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
$header = $import->meta['columns'] ?? null;
$delimiter = $import->meta['detected_delimiter'] ?? ',';
@@ -60,7 +64,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Parse file and create import_rows with mapped_data
$fh = @fopen($path, 'r');
if (!$fh) {
if (! $fh) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
@@ -69,7 +73,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
'message' => 'Unable to open file for reading.',
]);
$import->update(['status' => 'failed', 'failed_at' => now()]);
return [ 'ok' => false, 'status' => $import->status ];
return ['ok' => false, 'status' => $import->status];
}
try {
DB::beginTransaction();
@@ -87,8 +92,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
$first = fgetcsv($fh, 0, $delimiter);
$rowNum++;
// use actual detected header if not already stored
if (!$header) {
$header = array_map(fn($v) => trim((string) $v), $first ?: []);
if (! $header) {
$header = array_map(fn ($v) => trim((string) $v), $first ?: []);
}
}
@@ -123,7 +128,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'level' => 'info',
'message' => $contractResult['message'] ?? 'Skipped contract (no changes).',
]);
} elseif (in_array($contractResult['action'], ['inserted','updated'])) {
} elseif (in_array($contractResult['action'], ['inserted', 'updated'])) {
$imported++;
$importRow->update([
'status' => 'imported',
@@ -137,7 +142,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($contractResult['action']).' contract',
'context' => [ 'id' => $contractResult['contract']->id ],
'context' => ['id' => $contractResult['contract']->id],
]);
} else {
$invalid++;
@@ -174,7 +179,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'event' => 'row_imported',
'level' => 'info',
'message' => ucfirst($accountResult['action']).' account',
'context' => [ 'id' => $accountResult['account']->id ],
'context' => ['id' => $accountResult['account']->id],
]);
} else {
$invalid++;
@@ -190,7 +195,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
}
// If we have a contract reference, resolve existing contract for this client and derive person
if (!$personIdForRow && $import->client_id && !empty($mapped['contract']['reference'] ?? null)) {
if (! $personIdForRow && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) {
$existingContract = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $import->client_id)
@@ -202,7 +207,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
// If account processing created/resolved a contract, derive person via its client_case
if (!$personIdForRow && $accountResult) {
if (! $personIdForRow && $accountResult) {
if (isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) {
$ccId = $accountResult['contract']->client_case_id;
$personIdForRow = ClientCase::where('id', $ccId)->value('person_id');
@@ -214,50 +219,75 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
// Resolve by contact values next
if (!$personIdForRow) {
$emailVal = trim((string)($mapped['email']['value'] ?? ''));
if (! $personIdForRow) {
$emailVal = trim((string) ($mapped['email']['value'] ?? ''));
$phoneNu = trim((string) ($mapped['phone']['nu'] ?? ''));
$addrLine = trim((string) ($mapped['address']['address'] ?? ''));
// Try to resolve by existing contacts first
if ($emailVal !== '') {
$personIdForRow = Email::where('value', $emailVal)->value('person_id');
}
}
if (!$personIdForRow) {
$phoneNu = trim((string)($mapped['phone']['nu'] ?? ''));
if ($phoneNu !== '') {
if (! $personIdForRow && $phoneNu !== '') {
$personIdForRow = PersonPhone::where('nu', $phoneNu)->value('person_id');
}
}
if (!$personIdForRow) {
$addrLine = trim((string)($mapped['address']['address'] ?? ''));
if ($addrLine !== '') {
if (! $personIdForRow && $addrLine !== '') {
$personIdForRow = PersonAddress::where('address', $addrLine)->value('person_id');
}
// If still no person but we have any contact value, auto-create a minimal person
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
$personIdForRow = $this->createMinimalPersonId();
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id ?? null,
'event' => 'person_autocreated_for_contacts',
'level' => 'info',
'message' => 'Created minimal person to attach contact data (email/phone/address).',
'context' => [
'email' => $emailVal ?: null,
'phone' => $phoneNu ?: null,
'address' => $addrLine ?: null,
'person_id' => $personIdForRow,
],
]);
}
}
// Try identifiers from mapped person (no creation yet)
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
if (! $personIdForRow && ! empty($mapped['person'] ?? [])) {
$personIdForRow = $this->findPersonIdByIdentifiers($mapped['person']);
}
// Finally, if still unknown and person fields provided, create
if (!$personIdForRow && !empty($mapped['person'] ?? [])) {
if (! $personIdForRow && ! empty($mapped['person'] ?? [])) {
$personIdForRow = $this->findOrCreatePersonId($mapped['person']);
}
// At this point, personIdForRow is either resolved or remains null (no contacts/person data)
$contactChanged = false;
if ($personIdForRow) {
if (!empty($mapped['email'] ?? [])) {
if (! empty($mapped['email'] ?? [])) {
$r = $this->upsertEmail($personIdForRow, $mapped['email'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
$contactChanged = true;
}
}
if (!empty($mapped['address'] ?? [])) {
if (! empty($mapped['address'] ?? [])) {
$r = $this->upsertAddress($personIdForRow, $mapped['address'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
$contactChanged = true;
}
}
if (!empty($mapped['phone'] ?? [])) {
if (! empty($mapped['phone'] ?? [])) {
$r = $this->upsertPhone($personIdForRow, $mapped['phone'], $mappings);
if (in_array($r['action'] ?? 'skipped', ['inserted','updated'])) { $contactChanged = true; }
if (in_array($r['action'] ?? 'skipped', ['inserted', 'updated'])) {
$contactChanged = true;
}
}
}
if (!isset($mapped['contract']) && !isset($mapped['account'])) {
if (! isset($mapped['contract']) && ! isset($mapped['account'])) {
if ($contactChanged) {
$imported++;
$importRow->update([
@@ -272,7 +302,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'event' => 'row_imported',
'level' => 'info',
'message' => 'Contacts upserted',
'context' => [ 'person_id' => $personIdForRow ],
'context' => ['person_id' => $personIdForRow],
]);
} else {
$skipped++;
@@ -297,10 +327,12 @@ public function process(Import $import, ?Authenticatable $user = null): array
return [
'ok' => true,
'status' => $import->status,
'counts' => compact('total','skipped','imported','invalid'),
'counts' => compact('total', 'skipped', 'imported', 'invalid'),
];
} catch (\Throwable $e) {
if (is_resource($fh)) { @fclose($fh); }
if (is_resource($fh)) {
@fclose($fh);
}
DB::rollBack();
// Mark failed and log after rollback (so no partial writes persist)
$import->refresh();
@@ -312,22 +344,27 @@ public function process(Import $import, ?Authenticatable $user = null): array
'level' => 'error',
'message' => $e->getMessage(),
]);
return [ 'ok' => false, 'status' => 'failed', 'error' => $e->getMessage() ];
return ['ok' => false, 'status' => 'failed', 'error' => $e->getMessage()];
}
}
private function buildRowAssoc(array $row, ?array $header): array
{
if (!$header) {
if (! $header) {
// positional mapping: 0..N-1
$assoc = [];
foreach ($row as $i => $v) { $assoc[(string)$i] = $v; }
foreach ($row as $i => $v) {
$assoc[(string) $i] = $v;
}
return $assoc;
}
$assoc = [];
foreach ($header as $i => $name) {
$assoc[$name] = $row[$i] ?? null;
}
return $assoc;
}
@@ -338,31 +375,40 @@ private function applyMappings(array $raw, $mappings): array
foreach ($mappings as $map) {
$src = $map->source_column;
$target = $map->target_field;
if (!$target) continue;
if (! $target) {
continue;
}
$value = $raw[$src] ?? null;
// very basic transforms
if ($map->transform === 'trim') { $value = is_string($value) ? trim($value) : $value; }
if ($map->transform === 'upper') { $value = is_string($value) ? strtoupper($value) : $value; }
if ($map->transform === 'trim') {
$value = is_string($value) ? trim($value) : $value;
}
if ($map->transform === 'upper') {
$value = is_string($value) ? strtoupper($value) : $value;
}
// detect record type from first segment, e.g., "account.balance_amount"
$parts = explode('.', $target);
if (!$recordType && isset($parts[0])) {
if (! $recordType && isset($parts[0])) {
$recordType = $parts[0];
}
// build nested array by dot notation
$this->arraySetDot($mapped, $target, $value);
}
return [$recordType, $mapped];
}
private function arraySetDot(array &$arr, string $path, $value): void
{
$keys = explode('.', $path);
$ref =& $arr;
$ref = &$arr;
foreach ($keys as $k) {
if (!isset($ref[$k]) || !is_array($ref[$k])) { $ref[$k] = []; }
$ref =& $ref[$k];
if (! isset($ref[$k]) || ! is_array($ref[$k])) {
$ref[$k] = [];
}
$ref = &$ref[$k];
}
$ref = $value;
}
@@ -374,7 +420,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$contractId = $acc['contract_id'] ?? null;
$reference = $acc['reference'] ?? null;
// If contract_id not provided, attempt to resolve by contract reference for the selected client
if (!$contractId) {
if (! $contractId) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
if ($clientId && $contractRef) {
// 1) Search existing contract by reference for that client (across its client cases)
@@ -391,15 +437,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
// Try strong identifiers first
$personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []);
// Create from provided person data if unresolved
if (!$personId) {
if (! $personId) {
$personId = $this->findOrCreatePersonId($mapped['person'] ?? []);
}
// Last resort, create minimal
if (!$personId) {
if (! $personId) {
$personId = $this->createMinimalPersonId();
}
// Use the selected client for this import to tie the case/contract
if (!$clientId) {
if (! $clientId) {
return ['action' => 'skipped', 'message' => 'Client required to create contract'];
}
$resolvedClientId = $clientId;
@@ -410,8 +456,8 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
'client_case_id' => $clientCaseId,
'reference' => $contractRef,
];
foreach (['start_date','end_date','description','type_id'] as $k) {
if (array_key_exists($k, $contractFields) && !is_null($contractFields[$k])) {
foreach (['start_date', 'end_date', 'description', 'type_id'] as $k) {
if (array_key_exists($k, $contractFields) && ! is_null($contractFields[$k])) {
$newContractData[$k] = $contractFields[$k];
}
}
@@ -428,7 +474,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
}
}
// Default account.reference to contract reference if missing
if (!$reference) {
if (! $reference) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
if ($contractRef) {
$reference = $contractRef;
@@ -436,7 +482,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$mapped['account'] = $acc;
}
}
if (!$contractId || !$reference) {
if (! $contractId || ! $reference) {
return ['action' => 'skipped', 'message' => 'Missing contract_id/reference'];
}
@@ -449,15 +495,25 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
if (! $map->target_field) {
continue;
}
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'account') continue;
if ($parts[0] !== 'account') {
continue;
}
$field = $parts[1] ?? null;
if (!$field) continue;
if (! $field) {
continue;
}
$value = $acc[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $value;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $value;
}
}
if ($existing) {
@@ -465,25 +521,29 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
return ['action' => 'skipped', 'message' => 'No fields marked for update'];
}
// Only update fields that are set; skip nulls to avoid wiping unintentionally
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No non-null changes'];
}
$existing->fill($changes);
$existing->save();
// also include contract hints for downstream contact resolution
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
}
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$data['contract_id'] = $contractId;
$data['reference'] = $reference;
// ensure required defaults
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAccountTypeId();
if (!array_key_exists('active', $data)) { $data['active'] = 1; }
if (! array_key_exists('active', $data)) {
$data['active'] = 1;
}
$created = Account::create($data);
return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId];
}
}
@@ -493,13 +553,18 @@ private function findPersonIdByIdentifiers(array $p): ?int
$tax = $p['tax_number'] ?? null;
if ($tax) {
$found = Person::where('tax_number', $tax)->first();
if ($found) return $found->id;
if ($found) {
return $found->id;
}
}
$ssn = $p['social_security_number'] ?? null;
if ($ssn) {
$found = Person::where('social_security_number', $ssn)->first();
if ($found) return $found->id;
if ($found) {
return $found->id;
}
}
return null;
}
@@ -507,7 +572,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
{
$contractData = $mapped['contract'] ?? [];
$reference = $contractData['reference'] ?? null;
if (!$reference) {
if (! $reference) {
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
}
@@ -527,7 +592,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
// If not found by client+reference and a specific client_case_id is provided, try that too
if (!$existing && $clientCaseId) {
if (! $existing && $clientCaseId) {
$existing = Contract::query()
->where('client_case_id', $clientCaseId)
->where('reference', $reference)
@@ -535,17 +600,17 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
// If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary
if (!$existing && !$clientCaseId) {
if (! $existing && ! $clientCaseId) {
// Resolve by identifiers or provided person; do not use Client->person
$personId = null;
if (!empty($mapped['person'] ?? [])) {
if (! empty($mapped['person'] ?? [])) {
$personId = $this->findPersonIdByIdentifiers($mapped['person']);
if (!$personId) {
if (! $personId) {
$personId = $this->findOrCreatePersonId($mapped['person']);
}
}
// As a last resort, create a minimal person for this client
if ($clientId && !$personId) {
if ($clientId && ! $personId) {
$personId = $this->createMinimalPersonId();
}
@@ -563,39 +628,51 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
if (! $map->target_field) {
continue;
}
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'contract') continue;
if ($parts[0] !== 'contract') {
continue;
}
$field = $parts[1] ?? null;
if (!$field) continue;
if (! $field) {
continue;
}
$value = $contractData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $value; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $value; }
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $value;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $value;
}
}
if ($existing) {
if (empty($applyUpdate)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for update'];
}
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No non-null contract changes'];
}
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'contract' => $existing];
} else {
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for insert'];
}
$data = array_filter($applyInsert, fn($v) => !is_null($v));
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$data['client_case_id'] = $clientCaseId;
$data['reference'] = $reference;
// ensure required defaults
$data['start_date'] = $data['start_date'] ?? now()->toDateString();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultContractTypeId();
$created = Contract::create($data);
return ['action' => 'inserted', 'contract' => $created];
}
}
@@ -604,33 +681,43 @@ private function findOrCreatePersonId(array $p): ?int
{
// Basic dedup: by tax_number, ssn, else full_name
$query = Person::query();
if (!empty($p['tax_number'] ?? null)) {
if (! empty($p['tax_number'] ?? null)) {
$found = $query->where('tax_number', $p['tax_number'])->first();
if ($found) return $found->id;
if ($found) {
return $found->id;
}
}
if (!empty($p['social_security_number'] ?? null)) {
if (! empty($p['social_security_number'] ?? null)) {
$found = Person::where('social_security_number', $p['social_security_number'])->first();
if ($found) return $found->id;
if ($found) {
return $found->id;
}
}
// Do NOT use full_name as an identifier
// Create person if any fields present; ensure required foreign keys
if (!empty($p)) {
if (! empty($p)) {
$data = [];
foreach (['first_name','last_name','full_name','tax_number','social_security_number','birthday','gender','description','group_id','type_id'] as $k) {
if (array_key_exists($k, $p)) $data[$k] = $p[$k];
foreach (['first_name', 'last_name', 'full_name', 'tax_number', 'social_security_number', 'birthday', 'gender', 'description', 'group_id', 'type_id'] as $k) {
if (array_key_exists($k, $p)) {
$data[$k] = $p[$k];
}
}
// derive full_name if missing
if (empty($data['full_name'])) {
$fn = trim((string)($data['first_name'] ?? ''));
$ln = trim((string)($data['last_name'] ?? ''));
if ($fn || $ln) $data['full_name'] = trim($fn.' '.$ln);
$fn = trim((string) ($data['first_name'] ?? ''));
$ln = trim((string) ($data['last_name'] ?? ''));
if ($fn || $ln) {
$data['full_name'] = trim($fn.' '.$ln);
}
}
// ensure required group/type ids
$data['group_id'] = $data['group_id'] ?? $this->getDefaultPersonGroupId();
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPersonTypeId();
$created = Person::create($data);
return $created->id;
}
return null;
}
@@ -678,117 +765,182 @@ private function getDefaultPhoneTypeId(): int
private function findOrCreateClientId(int $personId): int
{
$client = Client::where('person_id', $personId)->first();
if ($client) return $client->id;
if ($client) {
return $client->id;
}
return Client::create(['person_id' => $personId])->id;
}
private function findOrCreateClientCaseId(int $clientId, int $personId): int
{
$cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first();
if ($cc) return $cc->id;
if ($cc) {
return $cc->id;
}
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id;
}
private function upsertEmail(int $personId, array $emailData, $mappings): array
{
$value = trim((string)($emailData['value'] ?? ''));
if ($value === '') return ['action' => 'skipped', 'message' => 'No email value'];
$value = trim((string) ($emailData['value'] ?? ''));
if ($value === '') {
return ['action' => 'skipped', 'message' => 'No email value'];
}
$existing = Email::where('person_id', $personId)->where('value', $value)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
if (! $map->target_field) {
continue;
}
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'email') continue;
$field = $parts[1] ?? null; if (!$field) continue;
if ($parts[0] !== 'email') {
continue;
}
$field = $parts[1] ?? null;
if (! $field) {
continue;
}
$val = $emailData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $val;
}
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No email updates'];
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No email updates'];
}
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'email' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No email fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No email fields for insert'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$data['person_id'] = $personId;
if (!array_key_exists('is_active', $data)) $data['is_active'] = true;
if (! array_key_exists('is_active', $data)) {
$data['is_active'] = true;
}
$created = Email::create($data);
return ['action' => 'inserted', 'email' => $created];
}
}
private function upsertAddress(int $personId, array $addrData, $mappings): array
{
$addressLine = trim((string)($addrData['address'] ?? ''));
if ($addressLine === '') return ['action' => 'skipped', 'message' => 'No address value'];
$addressLine = trim((string) ($addrData['address'] ?? ''));
if ($addressLine === '') {
return ['action' => 'skipped', 'message' => 'No address value'];
}
// Default country SLO if not provided
if (!isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') {
$addrData['country'] = 'SLO';
}
$existing = PersonAddress::where('person_id', $personId)->where('address', $addressLine)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
if (! $map->target_field) {
continue;
}
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'address') continue;
$field = $parts[1] ?? null; if (!$field) continue;
if ($parts[0] !== 'address') {
continue;
}
$field = $parts[1] ?? null;
if (! $field) {
continue;
}
$val = $addrData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $val;
}
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No address updates'];
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No address updates'];
}
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'address' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No address fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No address fields for insert'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$data['person_id'] = $personId;
$data['country'] = $data['country'] ?? 'SLO';
$data['type_id'] = $data['type_id'] ?? $this->getDefaultAddressTypeId();
$created = PersonAddress::create($data);
return ['action' => 'inserted', 'address' => $created];
}
}
private function upsertPhone(int $personId, array $phoneData, $mappings): array
{
$nu = trim((string)($phoneData['nu'] ?? ''));
if ($nu === '') return ['action' => 'skipped', 'message' => 'No phone value'];
$nu = trim((string) ($phoneData['nu'] ?? ''));
if ($nu === '') {
return ['action' => 'skipped', 'message' => 'No phone value'];
}
$existing = PersonPhone::where('person_id', $personId)->where('nu', $nu)->first();
$applyInsert = [];
$applyUpdate = [];
foreach ($mappings as $map) {
if (!$map->target_field) continue;
if (! $map->target_field) {
continue;
}
$parts = explode('.', $map->target_field);
if ($parts[0] !== 'phone') continue;
$field = $parts[1] ?? null; if (!$field) continue;
if ($parts[0] !== 'phone') {
continue;
}
$field = $parts[1] ?? null;
if (! $field) {
continue;
}
$val = $phoneData[$field] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert','both'])) { $applyInsert[$field] = $val; }
if (in_array($mode, ['update','both'])) { $applyUpdate[$field] = $val; }
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $val;
}
}
if ($existing) {
$changes = array_filter($applyUpdate, fn($v) => !is_null($v));
if (empty($changes)) return ['action' => 'skipped', 'message' => 'No phone updates'];
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No phone updates'];
}
$existing->fill($changes);
$existing->save();
return ['action' => 'updated', 'phone' => $existing];
} else {
if (empty($applyInsert)) return ['action' => 'skipped', 'message' => 'No phone fields for insert'];
$data = array_filter($applyInsert, fn($v) => !is_null($v));
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No phone fields for insert'];
}
$data = array_filter($applyInsert, fn ($v) => ! is_null($v));
$data['person_id'] = $personId;
$data['type_id'] = $data['type_id'] ?? $this->getDefaultPhoneTypeId();
$created = PersonPhone::create($data);
return ['action' => 'inserted', 'phone' => $created];
}
}