1568 lines
68 KiB
PHP
1568 lines
68 KiB
PHP
<?php
|
||
|
||
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 Exception;
|
||
use Illuminate\Database\QueryException;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Inertia\Inertia;
|
||
|
||
class ClientCaseContoller extends Controller
|
||
{
|
||
/**
|
||
* Display a listing of the resource.
|
||
*/
|
||
public function index(ClientCase $clientCase, Request $request)
|
||
{
|
||
$query = $clientCase::query()
|
||
->with(['person.client', 'client.person'])
|
||
->where('active', 1)
|
||
->when($request->input('search'), function ($que, $search) {
|
||
$que->whereHas('person', function ($q) use ($search) {
|
||
$q->where('full_name', 'ilike', '%'.$search.'%');
|
||
});
|
||
})
|
||
->addSelect([
|
||
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
|
||
'active_contracts_count' => \DB::query()
|
||
->from('contracts')
|
||
->selectRaw('COUNT(*)')
|
||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||
->whereNull('contracts.deleted_at')
|
||
->whereExists(function ($q) {
|
||
$q->from('contract_segment')
|
||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||
->where('contract_segment.active', true);
|
||
}),
|
||
// Sum of balances for accounts of active contracts
|
||
'active_contracts_balance_sum' => \DB::query()
|
||
->from('contracts')
|
||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||
->whereNull('contracts.deleted_at')
|
||
->whereExists(function ($q) {
|
||
$q->from('contract_segment')
|
||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||
->where('contract_segment.active', true);
|
||
}),
|
||
])
|
||
->orderByDesc('created_at');
|
||
|
||
return Inertia::render('Cases/Index', [
|
||
'client_cases' => $query
|
||
->paginate(15, ['*'], 'client-cases-page')
|
||
->withQueryString(),
|
||
'filters' => $request->only(['search']),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Show the form for creating a new resource.
|
||
*/
|
||
public function create()
|
||
{
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Store a newly created resource in storage.
|
||
*/
|
||
public function store(Request $request)
|
||
{
|
||
//
|
||
$cuuid = $request->input('client_uuid');
|
||
|
||
$client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail();
|
||
|
||
if (isset($client->id)) {
|
||
|
||
\DB::transaction(function () use ($request, $client) {
|
||
$pq = $request->input('person');
|
||
|
||
$person = $client->person()->create([
|
||
'nu' => rand(100000, 200000),
|
||
'first_name' => $pq['first_name'],
|
||
'last_name' => $pq['last_name'],
|
||
'full_name' => $pq['full_name'],
|
||
'gender' => null,
|
||
'birthday' => null,
|
||
'tax_number' => $pq['tax_number'],
|
||
'social_security_number' => $pq['social_security_number'],
|
||
'description' => $pq['description'],
|
||
'group_id' => 2,
|
||
'type_id' => 1,
|
||
]);
|
||
|
||
$person->addresses()->create([
|
||
'address' => $pq['address']['address'],
|
||
'country' => $pq['address']['country'],
|
||
'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'],
|
||
]);
|
||
|
||
$person->clientCase()->create([
|
||
'client_id' => $client->id,
|
||
]);
|
||
});
|
||
}
|
||
|
||
return to_route('client.show', $client);
|
||
}
|
||
|
||
public function storeContract(ClientCase $clientCase, StoreContractRequest $request)
|
||
{
|
||
|
||
\DB::transaction(function () use ($request, $clientCase) {
|
||
|
||
// Create contract
|
||
$contract = $clientCase->contracts()->create([
|
||
'reference' => $request->input('reference'),
|
||
'start_date' => date('Y-m-d', strtotime($request->input('start_date'))),
|
||
'type_id' => $request->input('type_id'),
|
||
'description' => $request->input('description'),
|
||
]);
|
||
|
||
// Note: Contract config auto-application is handled in Contract model created hook.
|
||
|
||
// Optionally create related account with amounts and/or type
|
||
$initial = $request->input('initial_amount');
|
||
$balance = $request->input('balance_amount');
|
||
$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,
|
||
]);
|
||
}
|
||
|
||
});
|
||
|
||
// Preserve segment filter if present
|
||
$segment = request('segment');
|
||
|
||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||
}
|
||
|
||
public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request)
|
||
{
|
||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||
if (! $contract->active) {
|
||
return back()->with('warning', __('contracts.edit_not_allowed_archived'));
|
||
}
|
||
|
||
\DB::transaction(function () use ($request, $contract) {
|
||
$contract->update([
|
||
'reference' => $request->input('reference'),
|
||
'type_id' => $request->input('type_id'),
|
||
'description' => $request->input('description'),
|
||
'start_date' => $request->filled('start_date') ? date('Y-m-d', strtotime($request->input('start_date'))) : $contract->start_date,
|
||
]);
|
||
|
||
$initial = $request->input('initial_amount');
|
||
// Use has() to distinguish between an omitted field and an explicit 0 / null intent
|
||
$balanceFieldPresent = $request->has('balance_amount');
|
||
$balance = $balanceFieldPresent ? $request->input('balance_amount') : null;
|
||
// Always allow updating existing account even if only balance set to 0 (or unchanged) so user can correct it.
|
||
$hasType = $request->has('account_type_id');
|
||
$shouldUpsertAccount = ($contract->account()->exists()) || (! is_null($initial)) || $balanceFieldPresent || $hasType;
|
||
if ($shouldUpsertAccount) {
|
||
$accountData = [];
|
||
// Track old balance before applying changes
|
||
$currentAccount = $contract->account; // newest (latestOfMany)
|
||
if (! is_null($initial)) {
|
||
$accountData['initial_amount'] = $initial;
|
||
}
|
||
// If the balance field was present in the request payload we always apply it (allow setting to 0)
|
||
if ($balanceFieldPresent) {
|
||
// Allow explicitly setting to 0, fallback to 0 if null provided
|
||
$accountData['balance_amount'] = $balance ?? 0;
|
||
}
|
||
if ($request->has('account_type_id')) {
|
||
$accountData['type_id'] = $request->input('account_type_id');
|
||
}
|
||
if ($currentAccount) {
|
||
$currentAccount->update($accountData);
|
||
if (array_key_exists('balance_amount', $accountData)) {
|
||
$currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save();
|
||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||
if ((float) $freshBal !== (float) $accountData['balance_amount']) {
|
||
\DB::table('accounts')
|
||
->where('id', $currentAccount->id)
|
||
->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]);
|
||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||
}
|
||
} else {
|
||
$freshBal = (float) optional($currentAccount->fresh())->balance_amount;
|
||
}
|
||
} else {
|
||
$accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData);
|
||
$created = $contract->account()->create($accountData);
|
||
$freshBal = (float) optional($created->fresh())->balance_amount;
|
||
}
|
||
}
|
||
|
||
});
|
||
|
||
// Preserve segment filter if present
|
||
$segment = request('segment');
|
||
|
||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||
}
|
||
|
||
/**
|
||
* Debug endpoint: list all account rows for a contract (only in debug mode).
|
||
*/
|
||
public function debugContractAccounts(ClientCase $clientCase, string $uuid, Request $request)
|
||
{
|
||
abort_unless(config('app.debug'), 404);
|
||
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(['id', 'uuid', 'reference']);
|
||
$accounts = \DB::table('accounts')
|
||
->where('contract_id', $contract->id)
|
||
->orderBy('id')
|
||
->get(['id', 'contract_id', 'initial_amount', 'balance_amount', 'type_id', 'created_at', 'updated_at']);
|
||
|
||
return response()->json([
|
||
'contract' => $contract,
|
||
'accounts' => $accounts,
|
||
'count' => $accounts->count(),
|
||
]);
|
||
}
|
||
|
||
public function storeActivity(ClientCase $clientCase, Request $request)
|
||
{
|
||
try {
|
||
$attributes = $request->validate([
|
||
'due_date' => 'nullable|date',
|
||
'amount' => 'nullable|decimal:0,4',
|
||
'note' => 'nullable|string',
|
||
'action_id' => 'exists:\App\Models\Action,id',
|
||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||
'contract_uuid' => 'nullable|uuid',
|
||
'send_auto_mail' => 'sometimes|boolean',
|
||
]);
|
||
|
||
// Map contract_uuid to contract_id within the same client case, if provided
|
||
$contractId = null;
|
||
if (! empty($attributes['contract_uuid'])) {
|
||
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail(['id']);
|
||
if ($contract) {
|
||
// Archived contracts are now allowed: link activity regardless of active flag
|
||
$contractId = $contract->id;
|
||
}
|
||
}
|
||
|
||
// Create activity
|
||
$row = $clientCase->activities()->create([
|
||
'due_date' => $attributes['due_date'] ?? null,
|
||
'amount' => $attributes['amount'] ?? null,
|
||
'note' => $attributes['note'] ?? null,
|
||
'action_id' => $attributes['action_id'],
|
||
'decision_id' => $attributes['decision_id'],
|
||
'contract_id' => $contractId,
|
||
]);
|
||
/*foreach ($activity->decision->events as $e) {
|
||
$class = '\\App\\Events\\' . $e->name;
|
||
event(new $class($clientCase));
|
||
}*/
|
||
|
||
logger()->info('Activity successfully inserted', $attributes);
|
||
|
||
// Auto mail dispatch (best-effort)
|
||
try {
|
||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag);
|
||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||
// If template requires contract and user attempted to send, surface a validation message
|
||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||
}
|
||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// Do not fail activity creation due to mailing issues
|
||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||
}
|
||
|
||
// 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());
|
||
} 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 deleteActivity(ClientCase $clientCase, \App\Models\Activity $activity, Request $request)
|
||
{
|
||
// Ensure activity belongs to this case
|
||
if ($activity->client_case_id !== $clientCase->id) {
|
||
abort(404);
|
||
}
|
||
|
||
\DB::transaction(function () use ($activity) {
|
||
$activity->delete();
|
||
});
|
||
|
||
return back()->with('success', 'Activity deleted.');
|
||
}
|
||
|
||
public function deleteContract(ClientCase $clientCase, string $uuid, Request $request)
|
||
{
|
||
$contract = Contract::where('uuid', $uuid)->firstOrFail();
|
||
|
||
\DB::transaction(function () use ($contract) {
|
||
$contract->delete();
|
||
});
|
||
|
||
// Preserve segment filter if present
|
||
$segment = request('segment');
|
||
|
||
return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]);
|
||
}
|
||
|
||
public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request)
|
||
{
|
||
$validated = $request->validate([
|
||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||
]);
|
||
|
||
$contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail();
|
||
|
||
// Safety: Disallow segment change if contract archived (inactive)
|
||
if (! $contract->active) {
|
||
return back()->with('warning', __('contracts.segment_change_not_allowed_archived'));
|
||
}
|
||
|
||
\DB::transaction(function () use ($contract, $validated) {
|
||
// Deactivate current active relation(s)
|
||
\DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('active', true)
|
||
->update(['active' => false]);
|
||
|
||
// Attach or update the selected segment as active
|
||
$existing = \DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('segment_id', $validated['segment_id'])
|
||
->first();
|
||
|
||
if ($existing) {
|
||
\DB::table('contract_segment')
|
||
->where('id', $existing->id)
|
||
->update(['active' => true, 'updated_at' => now()]);
|
||
} else {
|
||
$contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]);
|
||
}
|
||
});
|
||
|
||
return back()->with('success', 'Contract segment updated.');
|
||
}
|
||
|
||
public function attachSegment(ClientCase $clientCase, Request $request)
|
||
{
|
||
$validated = $request->validate([
|
||
'segment_id' => ['required', 'integer', 'exists:segments,id'],
|
||
'contract_uuid' => ['nullable', 'uuid'],
|
||
'make_active_for_contract' => ['sometimes', 'boolean'],
|
||
]);
|
||
|
||
\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) {
|
||
$clientCase->segments()->attach($validated['segment_id'], ['active' => true]);
|
||
} 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)) {
|
||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail();
|
||
if (! $contract->active) {
|
||
// Prevent segment activation for archived contract
|
||
return; // Silent; we still attach to case but do not alter archived contract
|
||
}
|
||
\DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('active', true)
|
||
->update(['active' => false]);
|
||
|
||
$existing = \DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('segment_id', $validated['segment_id'])
|
||
->first();
|
||
if ($existing) {
|
||
\DB::table('contract_segment')
|
||
->where('id', $existing->id)
|
||
->update(['active' => true, 'updated_at' => now()]);
|
||
} else {
|
||
$contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]);
|
||
}
|
||
}
|
||
});
|
||
|
||
return back()->with('success', 'Segment attached to case.');
|
||
}
|
||
|
||
public function storeDocument(ClientCase $clientCase, Request $request)
|
||
{
|
||
$validated = $request->validate([
|
||
'file' => 'required|file|max:25600|mimes:doc,docx,pdf,txt,csv,xls,xlsx,jpeg,png', // 25MB and allowed types
|
||
'name' => 'nullable|string|max:255',
|
||
'description' => 'nullable|string',
|
||
'is_public' => 'sometimes|boolean',
|
||
'contract_uuid' => 'nullable|uuid',
|
||
]);
|
||
|
||
$file = $validated['file'];
|
||
$disk = 'public';
|
||
$contract = null;
|
||
if (! empty($validated['contract_uuid'])) {
|
||
$contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first();
|
||
if ($contract && ! $contract->active) {
|
||
return back()->with('warning', __('contracts.document_not_allowed_archived'));
|
||
}
|
||
}
|
||
$directory = $contract
|
||
? ('contracts/'.$contract->uuid.'/documents')
|
||
: ('cases/'.$clientCase->uuid.'/documents');
|
||
$path = $file->store($directory, $disk);
|
||
|
||
$doc = new Document([
|
||
'name' => $validated['name'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
|
||
'description' => $validated['description'] ?? null,
|
||
'user_id' => optional($request->user())->id,
|
||
'disk' => $disk,
|
||
'path' => $path,
|
||
'file_name' => basename($path),
|
||
'original_name' => $file->getClientOriginalName(),
|
||
'extension' => $file->getClientOriginalExtension(),
|
||
'mime_type' => $file->getMimeType(),
|
||
'size' => $file->getSize(),
|
||
'checksum' => null,
|
||
'is_public' => (bool) ($validated['is_public'] ?? false),
|
||
]);
|
||
|
||
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'])) {
|
||
\App\Jobs\GenerateDocumentPreview::dispatch($doc->id);
|
||
}
|
||
|
||
return back()->with('success', 'Document uploaded.');
|
||
}
|
||
|
||
public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
|
||
{
|
||
// Ensure the document belongs to this client case 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, [
|
||
'Content-Type' => $document->preview_mime ?: 'application/pdf',
|
||
'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"',
|
||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||
'Pragma' => 'no-cache',
|
||
]);
|
||
}
|
||
|
||
// If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted
|
||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||
if (in_array($ext, ['doc', 'docx'])) {
|
||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||
|
||
return response('Preview is being generated. Please try again shortly.', 202);
|
||
}
|
||
|
||
// 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($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);
|
||
}
|
||
|
||
return response()->stream(function () use ($stream) {
|
||
fpassthru($stream);
|
||
}, 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',
|
||
]);
|
||
}
|
||
|
||
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
|
||
{
|
||
$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';
|
||
// 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);
|
||
}
|
||
$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).'"',
|
||
'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', 'emails', 'bankAccounts', 'client']),
|
||
])->where('active', 1)->findOrFail($clientCase->id);
|
||
|
||
$types = [
|
||
'address_types' => \App\Models\Person\AddressType::all(),
|
||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||
];
|
||
|
||
// $active = false;
|
||
|
||
// Optional segment filter from query string
|
||
$segmentId = request()->integer('segment');
|
||
|
||
// Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables
|
||
$latestArchiveSetting = \App\Models\ArchiveSetting::query()
|
||
->where('enabled', true)
|
||
->where(function ($q) {
|
||
$q->whereNull('reactivate')->orWhere('reactivate', false);
|
||
})
|
||
->orderByDesc('id')
|
||
->first();
|
||
$archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null
|
||
$relatedArchiveTables = [];
|
||
if ($latestArchiveSetting) {
|
||
$entities = (array) $latestArchiveSetting->entities;
|
||
foreach ($entities as $edef) {
|
||
if (isset($edef['related']) && is_array($edef['related'])) {
|
||
foreach ($edef['related'] as $rel) {
|
||
$relatedArchiveTables[] = $rel;
|
||
}
|
||
}
|
||
}
|
||
$relatedArchiveTables = array_values(array_unique($relatedArchiveTables));
|
||
}
|
||
|
||
// Prepare contracts and a reference map.
|
||
// Only apply active/inactive filtering IF a segment filter is provided.
|
||
$contractsQuery = $case->contracts()
|
||
// Only select lean columns to avoid oversize JSON / headers (include description for UI display)
|
||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||
->with([
|
||
'type:id,name',
|
||
// Use closure for account to avoid ambiguous column names with latestOfMany join
|
||
'account' => function ($q) {
|
||
$q->select([
|
||
'accounts.id',
|
||
'accounts.contract_id',
|
||
'accounts.type_id',
|
||
'accounts.initial_amount',
|
||
'accounts.balance_amount',
|
||
'accounts.promise_date',
|
||
'accounts.created_at',
|
||
'accounts.updated_at', // include updated_at so FE can detect changes & for debugging
|
||
])->orderByDesc('accounts.id');
|
||
},
|
||
'segments:id,name',
|
||
// Eager load objects so newly created objects appear without full reload logic issues
|
||
'objects:id,contract_id,reference,name,description,type,created_at',
|
||
]);
|
||
|
||
$contractsQuery->orderByDesc('created_at');
|
||
|
||
if (! empty($segmentId)) {
|
||
// Filter to contracts that are in the provided segment and active on pivot
|
||
if ($archiveSegmentId && $segmentId === $archiveSegmentId) {
|
||
// Viewing the archive segment: only archived (inactive) contracts
|
||
$contractsQuery->where('active', 0);
|
||
} else {
|
||
// Any other specific segment: only active contracts
|
||
$contractsQuery->where('active', 1);
|
||
}
|
||
$contractsQuery->whereExists(function ($q) use ($segmentId) {
|
||
$q->from('contract_segment')
|
||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||
->where('contract_segment.segment_id', $segmentId)
|
||
->where('contract_segment.active', true);
|
||
});
|
||
}
|
||
|
||
// NOTE: If a case has an extremely large number of contracts this can still be heavy.
|
||
// Consider pagination or deferred (Inertia lazy) loading. For now, hard-cap to 500 to prevent
|
||
// pathological memory / header growth. Frontend can request more via future endpoint.
|
||
$contracts = $contractsQuery->limit(500)->get();
|
||
|
||
// TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved)
|
||
try {
|
||
logger()->info('Show contracts balances', [
|
||
'case_id' => $case->id,
|
||
'contract_count' => $contracts->count(),
|
||
'contracts' => $contracts->map(fn ($c) => [
|
||
'id' => $c->id,
|
||
'uuid' => $c->uuid,
|
||
'reference' => $c->reference,
|
||
'account_id' => optional($c->account)->id,
|
||
'initial_amount' => optional($c->account)->initial_amount,
|
||
'balance_amount' => optional($c->account)->balance_amount,
|
||
'account_updated_at' => optional($c->account)->updated_at,
|
||
])->toArray(),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
// swallow
|
||
}
|
||
|
||
$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');
|
||
// Include 'uuid' so frontend can build document routes (was causing missing 'document' param error)
|
||
// IMPORTANT: If there are no contracts for this case we must NOT return all contract documents from other cases.
|
||
if ($contractIds->isEmpty()) {
|
||
$contractDocs = collect();
|
||
} else {
|
||
$contractDocs = Document::query()
|
||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at'])
|
||
->where('documentable_type', Contract::class)
|
||
->whereIn('documentable_id', $contractIds)
|
||
->orderByDesc('created_at')
|
||
->limit(300) // cap to prevent excessive payload; add pagination later if needed
|
||
->get()
|
||
->map(function ($d) use ($contractRefMap) {
|
||
$arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
|
||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
||
$arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid;
|
||
|
||
return $arr;
|
||
});
|
||
}
|
||
|
||
$caseDocs = $case->documents()
|
||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at'])
|
||
->orderByDesc('created_at')
|
||
->limit(200)
|
||
->get()
|
||
->map(function ($d) use ($case) {
|
||
$arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d;
|
||
$arr['client_case_uuid'] = $case->uuid;
|
||
|
||
return $arr;
|
||
});
|
||
$mergedDocs = $caseDocs
|
||
->concat($contractDocs)
|
||
->sortByDesc('created_at')
|
||
->values();
|
||
|
||
// Resolve current segment for display when filtered
|
||
$currentSegment = null;
|
||
if (! empty($segmentId)) {
|
||
$currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId);
|
||
}
|
||
|
||
return Inertia::render('Cases/Show', [
|
||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
|
||
'client_case' => $case,
|
||
'contracts' => $contracts,
|
||
'archive_meta' => [
|
||
'archive_segment_id' => $archiveSegmentId,
|
||
'related_tables' => $relatedArchiveTables,
|
||
],
|
||
'activities' => tap(
|
||
(function () use ($case, $segmentId, $contractIds) {
|
||
$q = $case->activities()
|
||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||
->orderByDesc('created_at');
|
||
if (! empty($segmentId)) {
|
||
// Only activities for filtered contracts or unlinked (contract_id null)
|
||
$q->where(function ($qq) use ($contractIds) {
|
||
$qq->whereNull('contract_id');
|
||
if ($contractIds->isNotEmpty()) {
|
||
$qq->orWhereIn('contract_id', $contractIds);
|
||
}
|
||
});
|
||
}
|
||
|
||
return $q->paginate(20, ['*'], 'activities')->withQueryString();
|
||
})(),
|
||
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(),
|
||
// Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
|
||
'actions' => \App\Models\Action::query()
|
||
->with([
|
||
'decisions' => function ($q) {
|
||
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
||
},
|
||
'decisions.emailTemplate' => function ($q) {
|
||
$q->select('id', 'name', 'entity_types');
|
||
},
|
||
])
|
||
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||
'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']),
|
||
'current_segment' => $currentSegment,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Show the form for editing the specified resource.
|
||
*/
|
||
public function edit(string $id)
|
||
{
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Update the specified resource in storage.
|
||
*/
|
||
public function update(Request $request, string $id)
|
||
{
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Remove the specified resource from storage.
|
||
*/
|
||
public function destroy(string $id)
|
||
{
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts.
|
||
*/
|
||
public function deleteDocument(ClientCase $clientCase, Document $document, Request $request)
|
||
{
|
||
// Ownership check: direct case document?
|
||
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
|
||
|
||
// Or document of a contract that belongs to this case (include trashed contracts)
|
||
$belongsToContractOfCase = false;
|
||
if ($document->documentable_type === Contract::class) {
|
||
$belongsToContractOfCase = Contract::withTrashed()
|
||
->where('id', $document->documentable_id)
|
||
->where('client_case_id', $clientCase->id)
|
||
->exists();
|
||
}
|
||
|
||
if (! ($belongsToCase || $belongsToContractOfCase)) {
|
||
abort(404);
|
||
}
|
||
|
||
// (Optional future) $this->authorize('delete', $document);
|
||
|
||
$document->delete(); // soft delete
|
||
|
||
return $request->wantsJson()
|
||
? response()->json(['status' => 'ok'])
|
||
: back()->with('success', 'Document deleted.');
|
||
}
|
||
|
||
/**
|
||
* Delete a document accessed through a contract route binding.
|
||
*/
|
||
public function deleteContractDocument(Contract $contract, Document $document, Request $request)
|
||
{
|
||
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
|
||
if (! $belongs) {
|
||
abort(404);
|
||
}
|
||
|
||
// (Optional future) $this->authorize('delete', $document);
|
||
|
||
$document->delete();
|
||
|
||
return $request->wantsJson()
|
||
? response()->json(['status' => 'ok'])
|
||
: back()->with('success', 'Document deleted.');
|
||
}
|
||
|
||
/**
|
||
* Manually archive a contract (flag active=0) and optionally its immediate financial relations.
|
||
*/
|
||
public function archiveContract(ClientCase $clientCase, string $uuid, Request $request)
|
||
{
|
||
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
|
||
if ($contract->client_case_id !== $clientCase->id) {
|
||
abort(404);
|
||
}
|
||
$reactivateRequested = (bool) $request->boolean('reactivate');
|
||
// Determine applicable settings based on intent (archive vs reactivate)
|
||
if ($reactivateRequested) {
|
||
$latestReactivate = \App\Models\ArchiveSetting::query()
|
||
->where('enabled', true)
|
||
->where('reactivate', true)
|
||
->whereIn('strategy', ['immediate', 'manual'])
|
||
->orderByDesc('id')
|
||
->first();
|
||
if (! $latestReactivate) {
|
||
return back()->with('warning', __('contracts.reactivate_not_allowed'));
|
||
}
|
||
$settings = collect([$latestReactivate]);
|
||
$hasReactivateRule = true;
|
||
} else {
|
||
$settings = \App\Models\ArchiveSetting::query()
|
||
->where('enabled', true)
|
||
->whereIn('strategy', ['immediate', 'manual'])
|
||
->where(function ($q) { // exclude reactivate-only rules from archive run
|
||
$q->whereNull('reactivate')->orWhere('reactivate', false);
|
||
})
|
||
->get();
|
||
if ($settings->isEmpty()) {
|
||
return back()->with('warning', __('contracts.no_archive_settings'));
|
||
}
|
||
$hasReactivateRule = false;
|
||
}
|
||
|
||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||
$context = [
|
||
'contract_id' => $contract->id,
|
||
'client_case_id' => $clientCase->id,
|
||
];
|
||
if ($contract->account) {
|
||
$context['account_id'] = $contract->account->id;
|
||
}
|
||
|
||
$overall = [];
|
||
$hadAnyEffect = false;
|
||
foreach ($settings as $setting) {
|
||
|
||
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
|
||
foreach ($res as $table => $count) {
|
||
$overall[$table] = ($overall[$table] ?? 0) + $count;
|
||
if ($count > 0) {
|
||
$hadAnyEffect = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($reactivateRequested && $hasReactivateRule) {
|
||
// Reactivation path: ensure contract becomes active and soft-delete cleared.
|
||
if ($contract->active == 0 || $contract->deleted_at) {
|
||
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
|
||
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
|
||
$hadAnyEffect = true;
|
||
}
|
||
} else {
|
||
// Ensure the contract itself is archived even if rule conditions would have excluded it
|
||
if (! empty($contract->getAttributes()) && $contract->active) {
|
||
if (! array_key_exists('contracts', $overall)) {
|
||
$contract->update(['active' => 0]);
|
||
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
|
||
} else {
|
||
$contract->refresh();
|
||
}
|
||
$hadAnyEffect = true;
|
||
}
|
||
}
|
||
|
||
// Create an Activity record logging this archive if an action or decision is tied to any setting
|
||
if ($hadAnyEffect) {
|
||
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
|
||
if ($activitySetting) {
|
||
try {
|
||
if ($reactivateRequested) {
|
||
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
|
||
} else {
|
||
$noteKey = 'contracts.archived_activity_note';
|
||
$note = __($noteKey, ['reference' => $contract->reference]);
|
||
if ($note === $noteKey) {
|
||
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
|
||
}
|
||
}
|
||
$activityData = [
|
||
'client_case_id' => $clientCase->id,
|
||
'action_id' => $activitySetting->action_id,
|
||
'decision_id' => $activitySetting->decision_id,
|
||
'note' => $note,
|
||
'active' => 1,
|
||
'user_id' => optional($request->user())->id,
|
||
];
|
||
if ($reactivateRequested) {
|
||
// Attach the contract_id when reactivated as per requirement
|
||
$activityData['contract_id'] = $contract->id;
|
||
}
|
||
\App\Models\Activity::create($activityData);
|
||
} catch (\Throwable $e) {
|
||
logger()->warning('Failed to create archive/reactivate activity', [
|
||
'error' => $e->getMessage(),
|
||
'contract_id' => $contract->id,
|
||
'setting_id' => optional($activitySetting)->id,
|
||
'reactivate' => $reactivateRequested,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
|
||
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
|
||
if ($segmentSetting && $segmentSetting->segment_id) {
|
||
try {
|
||
$segmentId = $segmentSetting->segment_id;
|
||
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
|
||
// Ensure the segment is attached to the client case (activate if previously inactive)
|
||
$casePivot = \DB::table('client_case_segment')
|
||
->where('client_case_id', $clientCase->id)
|
||
->where('segment_id', $segmentId)
|
||
->first();
|
||
if (! $casePivot) {
|
||
\DB::table('client_case_segment')->insert([
|
||
'client_case_id' => $clientCase->id,
|
||
'segment_id' => $segmentId,
|
||
'active' => true,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
} elseif (! $casePivot->active) {
|
||
\DB::table('client_case_segment')
|
||
->where('id', $casePivot->id)
|
||
->update(['active' => true, 'updated_at' => now()]);
|
||
}
|
||
|
||
// Deactivate all current active contract segments
|
||
\DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('active', true)
|
||
->update(['active' => false, 'updated_at' => now()]);
|
||
|
||
// Attach or activate the archive segment for this contract
|
||
$existing = \DB::table('contract_segment')
|
||
->where('contract_id', $contract->id)
|
||
->where('segment_id', $segmentId)
|
||
->first();
|
||
if ($existing) {
|
||
\DB::table('contract_segment')
|
||
->where('id', $existing->id)
|
||
->update(['active' => true, 'updated_at' => now()]);
|
||
} else {
|
||
\DB::table('contract_segment')->insert([
|
||
'contract_id' => $contract->id,
|
||
'segment_id' => $segmentId,
|
||
'active' => true,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
});
|
||
} catch (\Throwable $e) {
|
||
logger()->warning('Failed to move contract to archive segment', [
|
||
'error' => $e->getMessage(),
|
||
'contract_id' => $contract->id,
|
||
'segment_id' => $segmentSetting->segment_id,
|
||
'setting_id' => $segmentSetting->id,
|
||
]);
|
||
}
|
||
}
|
||
|
||
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
|
||
|
||
return back()->with('success', $message);
|
||
}
|
||
|
||
/**
|
||
* Emergency: recreate a missing / soft-deleted person for a client case and re-link related data.
|
||
*/
|
||
public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
|
||
{
|
||
$oldPersonId = $clientCase->person_id;
|
||
/** @var \App\Models\Person\Person|null $existing */
|
||
$existing = \App\Models\Person\Person::withTrashed()->find($oldPersonId);
|
||
if ($existing && ! $existing->trashed()) {
|
||
return back()->with('flash', [
|
||
'type' => 'info',
|
||
'message' => 'Person already exists – emergency creation not needed.',
|
||
]);
|
||
}
|
||
|
||
$data = $request->validate([
|
||
'full_name' => ['nullable', 'string', 'max:255'],
|
||
'first_name' => ['nullable', 'string', 'max:255'],
|
||
'last_name' => ['nullable', 'string', 'max:255'],
|
||
'tax_number' => ['nullable', 'string', 'max:99'],
|
||
'social_security_number' => ['nullable', 'string', 'max:99'],
|
||
'description' => ['nullable', 'string', 'max:500'],
|
||
]);
|
||
|
||
$fullName = $data['full_name'] ?? trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? ''));
|
||
if ($fullName === '') {
|
||
$fullName = 'Unknown Person';
|
||
}
|
||
|
||
$newPerson = null;
|
||
|
||
\DB::transaction(function () use ($oldPersonId, $clientCase, $fullName, $data, &$newPerson) {
|
||
$newPerson = \App\Models\Person\Person::create([
|
||
'nu' => null,
|
||
'first_name' => $data['first_name'] ?? null,
|
||
'last_name' => $data['last_name'] ?? null,
|
||
'full_name' => $fullName,
|
||
'gender' => null,
|
||
'birthday' => null,
|
||
'tax_number' => $data['tax_number'] ?? null,
|
||
'social_security_number' => $data['social_security_number'] ?? null,
|
||
'description' => $data['description'] ?? 'Emergency recreated person (case)',
|
||
'group_id' => 2,
|
||
'type_id' => 1,
|
||
]);
|
||
|
||
// Re-point related data referencing old person
|
||
$tables = [
|
||
'emails', 'person_phones', 'person_addresses', 'bank_accounts',
|
||
];
|
||
foreach ($tables as $table) {
|
||
\DB::table($table)->where('person_id', $oldPersonId)->update(['person_id' => $newPerson->id]);
|
||
}
|
||
|
||
// Update the client case
|
||
$clientCase->person_id = $newPerson->id;
|
||
$clientCase->save();
|
||
});
|
||
|
||
return back()->with('flash', [
|
||
'type' => 'success',
|
||
'message' => 'New person created and case re-linked.',
|
||
'person_uuid' => $newPerson?->uuid,
|
||
]);
|
||
}
|
||
}
|