Dev branch
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshMaterializedViews extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh configured Postgres materialized views for reporting';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (empty($views)) {
|
||||
$this->info('No materialized views configured.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
|
||||
|
||||
foreach ($views as $view) {
|
||||
$name = trim((string) $view);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
|
||||
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
|
||||
// Use a safe fallback: wrap with " if not already quoted
|
||||
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
|
||||
try {
|
||||
DB::statement($safe);
|
||||
$this->info("Refreshed: {$name}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to refresh {$name}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -15,11 +15,22 @@ protected function schedule(Schedule $schedule): void
|
||||
// Optionally prune old previews daily
|
||||
if (config('files.enable_preview_prune', true)) {
|
||||
$days = (int) config('files.preview_retention_days', 90);
|
||||
if ($days < 1) { $days = 90; }
|
||||
if ($days < 1) {
|
||||
$days = 90;
|
||||
}
|
||||
$schedule->command('documents:prune-previews', [
|
||||
'--days' => $days,
|
||||
])->dailyAt('02:00');
|
||||
}
|
||||
|
||||
// Optional: refresh configured materialized views for reporting
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (! empty($views)) {
|
||||
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
|
||||
$schedule->command('reports:refresh-mviews', [
|
||||
'--concurrently' => true,
|
||||
])->dailyAt($time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,6 +280,20 @@ public function cancel(Package $package): RedirectResponse
|
||||
return back()->with('success', 'Package canceled');
|
||||
}
|
||||
|
||||
public function destroy(Package $package): RedirectResponse
|
||||
{
|
||||
// Allow deletion only for drafts (not yet dispatched)
|
||||
if ($package->status !== Package::STATUS_DRAFT) {
|
||||
return back()->with('error', 'Package not in a deletable state.');
|
||||
}
|
||||
|
||||
// Remove items first to avoid FK issues
|
||||
$package->items()->delete();
|
||||
$package->delete();
|
||||
|
||||
return back()->with('success', 'Package deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts for a given segment and include selected phone per person.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use App\Services\Documents\DocumentStreamService;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Exception;
|
||||
use Illuminate\Database\QueryException;
|
||||
@@ -16,45 +18,45 @@
|
||||
|
||||
class ClientCaseContoller extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ReferenceDataCache $referenceCache,
|
||||
protected DocumentStreamService $documentStream
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(ClientCase $clientCase, Request $request)
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
$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.'%');
|
||||
});
|
||||
->select('client_cases.*')
|
||||
->when($search, function ($que) use ($search) {
|
||||
$que->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('client_cases.id');
|
||||
})
|
||||
->where('client_cases.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->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);
|
||||
}),
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
// 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);
|
||||
}),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->orderByDesc('created_at');
|
||||
->with(['person.client', 'client.person'])
|
||||
->orderByDesc('client_cases.created_at');
|
||||
|
||||
return Inertia::render('Cases/Index', [
|
||||
'client_cases' => $query
|
||||
@@ -609,188 +611,7 @@ public function viewDocument(ClientCase $clientCase, Document $document, Request
|
||||
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',
|
||||
]);
|
||||
return $this->documentStream->stream($document, inline: true);
|
||||
}
|
||||
|
||||
public function downloadDocument(ClientCase $clientCase, Document $document, Request $request)
|
||||
@@ -814,163 +635,8 @@ public function downloadDocument(ClientCase $clientCase, Document $document, Req
|
||||
]);
|
||||
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',
|
||||
]);
|
||||
return $this->documentStream->stream($document, inline: false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,8 +650,7 @@ public function viewContractDocument(Contract $contract, Document $document, Req
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Reuse the existing logic by delegating to a small helper
|
||||
return $this->streamDocumentForDisk($document, inline: true);
|
||||
return $this->documentStream->stream($document, inline: true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -998,138 +663,7 @@ public function downloadContractDocument(Contract $contract, Document $document,
|
||||
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);
|
||||
return $this->documentStream->stream($document, inline: false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1142,8 +676,8 @@ public function show(ClientCase $clientCase)
|
||||
])->where('active', 1)->findOrFail($clientCase->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
// $active = false;
|
||||
@@ -1210,10 +744,10 @@ public function show(ClientCase $clientCase)
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Use pagination for contracts to avoid loading too many at once
|
||||
// Default to 50 per page, but allow frontend to request more
|
||||
$perPage = request()->integer('contracts_per_page', 50);
|
||||
$contracts = $contractsQuery->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
||||
|
||||
// TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved)
|
||||
try {
|
||||
@@ -1234,49 +768,19 @@ public function show(ClientCase $clientCase)
|
||||
// swallow
|
||||
}
|
||||
|
||||
// Prepare contract reference and UUID maps from paginated contracts
|
||||
$contractItems = $contracts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
? $contracts->items()
|
||||
: $contracts->all();
|
||||
|
||||
$contractRefMap = [];
|
||||
foreach ($contracts as $c) {
|
||||
$contractUuidMap = [];
|
||||
foreach ($contractItems as $c) {
|
||||
$contractRefMap[$c->id] = $c->reference;
|
||||
$contractUuidMap[$c->id] = $c->uuid;
|
||||
}
|
||||
|
||||
// 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', 'is_public'])
|
||||
->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', 'is_public'])
|
||||
->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();
|
||||
|
||||
$contractIds = collect($contractItems)->pluck('id');
|
||||
|
||||
// Resolve current segment for display when filtered
|
||||
$currentSegment = null;
|
||||
@@ -1284,10 +788,55 @@ public function show(ClientCase $clientCase)
|
||||
$currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId);
|
||||
}
|
||||
|
||||
// Load initial batch of documents (limit to reduce payload size)
|
||||
$contractDocs = collect();
|
||||
if ($contractIds->isNotEmpty()) {
|
||||
// Build UUID map for all contracts (including trashed) to avoid N+1 queries
|
||||
$allContractUuids = Contract::withTrashed()
|
||||
->whereIn('id', $contractIds->all())
|
||||
->pluck('uuid', 'id')
|
||||
->toArray();
|
||||
|
||||
// Merge with contracts already loaded
|
||||
$contractUuidMap = array_merge($contractUuidMap, $allContractUuids);
|
||||
|
||||
$contractDocs = Document::query()
|
||||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
|
||||
->where('documentable_type', Contract::class)
|
||||
->whereIn('documentable_id', $contractIds->all())
|
||||
->orderByDesc('created_at')
|
||||
->limit(50) // Initial batch - frontend can request more via separate endpoint if needed
|
||||
->get()
|
||||
->map(function ($d) use ($contractRefMap, $contractUuidMap) {
|
||||
$arr = $d->toArray();
|
||||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
||||
$arr['contract_uuid'] = $contractUuidMap[$d->documentable_id] ?? null;
|
||||
return $arr;
|
||||
});
|
||||
}
|
||||
|
||||
$caseDocs = $case->documents()
|
||||
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(50) // Initial batch
|
||||
->get()
|
||||
->map(function ($d) use ($case) {
|
||||
$arr = $d->toArray();
|
||||
$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', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
|
||||
'client_case' => $case,
|
||||
'contracts' => $contracts,
|
||||
'contracts' => $contracts, // Now paginated
|
||||
'documents' => $mergedDocs,
|
||||
])->with([
|
||||
// Active document templates for contracts (latest version per slug)
|
||||
'contract_doc_templates' => \App\Models\DocumentTemplate::query()
|
||||
->where('active', true)
|
||||
@@ -1326,9 +875,8 @@ function ($p) {
|
||||
});
|
||||
}
|
||||
),
|
||||
'documents' => $mergedDocs,
|
||||
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
|
||||
'account_types' => \App\Models\AccountType::all(),
|
||||
'contract_types' => $this->referenceCache->getContractTypes(),
|
||||
'account_types' => $this->referenceCache->getAccountTypes(),
|
||||
// Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
|
||||
'actions' => \App\Models\Action::query()
|
||||
->with([
|
||||
|
||||
@@ -3,57 +3,51 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
|
||||
public function index(Client $client, Request $request)
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
$query = $client::query()
|
||||
->with('person')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->whereHas('person', function ($q) use ($search) {
|
||||
$q->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
->select('clients.*')
|
||||
->when($search, function ($que) use ($search) {
|
||||
$que->join('person', 'person.id', '=', 'clients.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('active', 1)
|
||||
->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
'cases_with_active_contracts_count' => DB::query()
|
||||
->from('client_cases')
|
||||
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->selectRaw('COUNT(DISTINCT client_cases.id)')
|
||||
->whereColumn('client_cases.client_id', 'clients.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 account balances for active contracts that belong to this client's cases
|
||||
'active_contracts_balance_sum' => DB::query()
|
||||
->from('contracts')
|
||||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('client_cases')
|
||||
->whereColumn('client_cases.id', 'contracts.client_case_id')
|
||||
->whereColumn('client_cases.client_id', 'clients.id');
|
||||
})
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
||||
// Sum of account balances for active contracts
|
||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->orderByDesc('created_at');
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
@@ -67,44 +61,37 @@ public function show(Client $client, Request $request)
|
||||
->findOrFail($client->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'client_cases' => $data->clientCases()
|
||||
->with(['person', 'client.person'])
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
))
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('client_cases.id');
|
||||
})
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
'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);
|
||||
}),
|
||||
'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);
|
||||
}),
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->withQueryString(),
|
||||
'types' => $types,
|
||||
'filters' => $request->only(['search']),
|
||||
@@ -121,8 +108,31 @@ public function contracts(Client $client, Request $request)
|
||||
$segmentId = $request->input('segment');
|
||||
|
||||
$contractsQuery = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
|
||||
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $client->id)
|
||||
->whereNull('contracts.deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('contracts.start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('contracts.start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where(function ($inner) use ($search) {
|
||||
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
|
||||
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->when($segmentId, function ($q) use ($segmentId) {
|
||||
$q->join('contract_segment', function ($join) use ($segmentId) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.segment_id', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
@@ -132,42 +142,18 @@ public function contracts(Client $client, Request $request)
|
||||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentId, function ($q) use ($segmentId) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentId) {
|
||||
$s->where('segments.id', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
->orderByDesc('contracts.start_date');
|
||||
|
||||
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
|
||||
'contracts' => $contractsQuery->paginate($request->integer('per_page', 20))->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
||||
'segments' => $segments,
|
||||
'types' => $types,
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
@@ -168,8 +170,8 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||
|
||||
// Provide minimal types for PersonInfoGrid
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
// Case activities (compact for phone): latest 20 with relations
|
||||
@@ -235,7 +237,7 @@ function ($q) {
|
||||
'contracts' => $contracts,
|
||||
'documents' => $documents,
|
||||
'types' => $types,
|
||||
'account_types' => \App\Models\AccountType::all(),
|
||||
'account_types' => $this->referenceCache->getAccountTypes(),
|
||||
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
|
||||
'actions' => $actions,
|
||||
'activities' => $activities,
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Reports\ReportRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// facades referenced with fully-qualified names below to satisfy static analysis
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(protected ReportRegistry $registry) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$reports = collect($this->registry->all())
|
||||
->map(fn ($r) => [
|
||||
'slug' => $r->slug(),
|
||||
'name' => $r->name(),
|
||||
'description' => $r->description(),
|
||||
])
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Index', [
|
||||
'reports' => $reports,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
// Accept filters & pagination from query and return initial data for server-driven table
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Show', [
|
||||
'slug' => $report->slug(),
|
||||
'name' => $report->name(),
|
||||
'description' => $report->description(),
|
||||
'inputs' => $report->inputs(),
|
||||
'columns' => $report->columns(),
|
||||
'rows' => $rows,
|
||||
'meta' => [
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
|
||||
$paginator = $report->paginate($filters, $perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows,
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(string $slug, Request $request)
|
||||
{
|
||||
$report = $this->registry->findBySlug($slug);
|
||||
abort_if(! $report, 404);
|
||||
$report->authorize($request);
|
||||
|
||||
$filters = $this->validateFilters($report->inputs(), $request);
|
||||
$format = strtolower((string) $request->get('format', 'csv'));
|
||||
|
||||
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||
$columns = $report->columns();
|
||||
$filename = $report->slug().'-'.now()->format('Ymd_His');
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
||||
'name' => $report->name(),
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
||||
return $pdf->download($filename.'.pdf');
|
||||
}
|
||||
|
||||
if ($format === 'xlsx') {
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
// Convert values for correct Excel rendering (dates, numbers, text)
|
||||
$array = $this->prepareXlsxArray($rows, $keys);
|
||||
|
||||
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
|
||||
$columnFormats = [];
|
||||
$textColumns = [];
|
||||
$dateColumns = [];
|
||||
foreach ($keys as $i => $key) {
|
||||
$letter = $this->excelColumnLetter($i + 1);
|
||||
if ($key === 'contract_reference') {
|
||||
$columnFormats[$letter] = '@';
|
||||
$textColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($key, '_at')) {
|
||||
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
|
||||
$dateColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous export with custom value binder to force text where needed
|
||||
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
|
||||
{
|
||||
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->array;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return $this->headings;
|
||||
}
|
||||
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return $this->formats;
|
||||
}
|
||||
|
||||
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
|
||||
{
|
||||
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
|
||||
// Force text for configured columns or very long digit-only strings (>15)
|
||||
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
|
||||
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
// Data starts at row 2 (row 1 is headings)
|
||||
$rowIndex = 2;
|
||||
foreach ($this->array as $row) {
|
||||
foreach (array_values($row) as $i => $val) {
|
||||
$colLetter = $this->colLetter($i + 1);
|
||||
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
|
||||
continue; // already handled via columnFormats or binder
|
||||
}
|
||||
$coord = $colLetter.$rowIndex;
|
||||
$fmt = null;
|
||||
if (is_int($val)) {
|
||||
// Integer: thousands separator, no decimals
|
||||
$fmt = '#,##0';
|
||||
} elseif (is_float($val)) {
|
||||
// Float: show decimals only if fractional part exists
|
||||
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
|
||||
}
|
||||
if ($fmt) {
|
||||
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
|
||||
}
|
||||
}
|
||||
$rowIndex++;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private function colLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
};
|
||||
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
|
||||
}
|
||||
|
||||
// Default CSV export
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
$csv = fopen('php://temp', 'r+');
|
||||
fputcsv($csv, $headings);
|
||||
foreach ($rows as $r) {
|
||||
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
|
||||
fputcsv($csv, $line);
|
||||
}
|
||||
rewind($csv);
|
||||
$content = stream_get_contents($csv) ?: '';
|
||||
fclose($csv);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight users lookup for filters: id + name, optional search and limit.
|
||||
*/
|
||||
public function users(Request $request)
|
||||
{
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
$limit = (int) ($request->integer('limit') ?: 10);
|
||||
|
||||
$q = \App\Models\User::query()->orderBy('name');
|
||||
if ($search !== '') {
|
||||
$like = '%'.mb_strtolower($search).'%';
|
||||
$q->where(function ($qq) use ($like) {
|
||||
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
|
||||
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
|
||||
});
|
||||
}
|
||||
|
||||
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
|
||||
*/
|
||||
public function clients(Request $request)
|
||||
{
|
||||
$clients = \App\Models\Client::query()
|
||||
->with('person:id,full_name')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
return response()->json($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules based on inputs descriptor and validate.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $inputs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validateFilters(array $inputs, Request $request): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach ($inputs as $inp) {
|
||||
$key = $inp['key'];
|
||||
$type = $inp['type'] ?? 'string';
|
||||
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
|
||||
$rules[$key] = match ($type) {
|
||||
'date' => [$nullable, 'date'],
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure derived export/display fields exist on row objects.
|
||||
*/
|
||||
protected function normalizeRow(object $row): object
|
||||
{
|
||||
if (isset($row->contract) && ! isset($row->contract_reference)) {
|
||||
$row->contract_reference = $row->contract->reference ?? null;
|
||||
}
|
||||
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
|
||||
$row->assigned_user_name = $row->assignedUser->name ?? null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
|
||||
*
|
||||
* @param iterable<int, object|array> $rows
|
||||
* @param array<int, string> $keys
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
protected function prepareXlsxArray(iterable $rows, array $keys): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$line = [];
|
||||
foreach ($keys as $k) {
|
||||
$v = data_get($r, $k);
|
||||
if ($k === 'contract_reference') {
|
||||
$line[] = (string) $v;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($k, '_at')) {
|
||||
if (empty($v)) {
|
||||
$line[] = null;
|
||||
} else {
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($v);
|
||||
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
|
||||
} catch (\Throwable $e) {
|
||||
$line[] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
$line[] = $v;
|
||||
} elseif (is_numeric($v) && is_string($v)) {
|
||||
// cast numeric-like strings unless they are identifiers that we want as text
|
||||
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
|
||||
} else {
|
||||
$line[] = $v;
|
||||
}
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 1-based index to Excel column letter.
|
||||
*/
|
||||
protected function excelColumnLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Reports\ActionsDecisionsCountReport;
|
||||
use App\Reports\ActivitiesPerPeriodReport;
|
||||
use App\Reports\ActiveContractsReport;
|
||||
use App\Reports\FieldJobsCompletedReport;
|
||||
use App\Reports\DecisionsCountReport;
|
||||
use App\Reports\ReportRegistry;
|
||||
use App\Reports\SegmentActivityCountsReport;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ReportServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ReportRegistry::class, function () {
|
||||
$registry = new ReportRegistry;
|
||||
// Register built-in reports here
|
||||
$registry->register(new FieldJobsCompletedReport);
|
||||
$registry->register(new SegmentActivityCountsReport);
|
||||
$registry->register(new ActionsDecisionsCountReport);
|
||||
$registry->register(new ActivitiesPerPeriodReport);
|
||||
$registry->register(new DecisionsCountReport);
|
||||
$registry->register(new ActiveContractsReport);
|
||||
|
||||
return $registry;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'actions-decisions-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Dejanja / Odločitve – štetje';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'action_name', 'label' => 'Dejanje'],
|
||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
return Activity::query()
|
||||
->leftJoin('actions', 'activities.action_id', '=', 'actions.id')
|
||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('actions.name', 'decisions.name')
|
||||
->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActiveContractsReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'active-contracts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivne pogodbe';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
||||
['key' => 'client_name', 'label' => 'Stranka'],
|
||||
['key' => 'person_name', 'label' => 'Zadeva (oseba)'],
|
||||
['key' => 'start_date', 'label' => 'Začetek'],
|
||||
['key' => 'end_date', 'label' => 'Konec'],
|
||||
['key' => 'balance_amount', 'label' => 'Saldo'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$asOf = now()->toDateString();
|
||||
|
||||
return Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id')
|
||||
->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id')
|
||||
->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id')
|
||||
->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid']))
|
||||
// Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of)
|
||||
->where(function ($q) use ($asOf) {
|
||||
$q->whereNull('contracts.start_date')
|
||||
->orWhereDate('contracts.start_date', '<=', $asOf);
|
||||
})
|
||||
->where(function ($q) use ($asOf) {
|
||||
$q->whereNull('contracts.end_date')
|
||||
->orWhereDate('contracts.end_date', '>=', $asOf);
|
||||
})
|
||||
->select([
|
||||
'contracts.id',
|
||||
'contracts.start_date',
|
||||
'contracts.end_date',
|
||||
])
|
||||
->addSelect([
|
||||
\DB::raw('contracts.reference as contract_reference'),
|
||||
\DB::raw('client_people.full_name as client_name'),
|
||||
\DB::raw('subject_people.full_name as person_name'),
|
||||
\DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'),
|
||||
])
|
||||
->orderBy('contracts.start_date', 'asc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'activities-per-period';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivnosti po obdobjih';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'period', 'label' => 'Obdobje'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$periodRaw = $filters['period'] ?? 'day';
|
||||
$period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day';
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
// Build database-compatible period expressions
|
||||
if ($driver === 'sqlite') {
|
||||
if ($period === 'day') {
|
||||
// Use string slice to avoid timezone conversion differences in SQLite
|
||||
$selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period');
|
||||
$groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
||||
$orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
||||
} elseif ($period === 'month') {
|
||||
$selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
||||
$orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
||||
} else { // week
|
||||
$selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
||||
$orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
||||
}
|
||||
} elseif ($driver === 'mysql') {
|
||||
if ($period === 'day') {
|
||||
$selectExpr = DB::raw('DATE(activities.created_at) as period');
|
||||
$groupExpr = DB::raw('DATE(activities.created_at)');
|
||||
$orderExpr = DB::raw('DATE(activities.created_at)');
|
||||
} elseif ($period === 'month') {
|
||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period");
|
||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
||||
} else { // week
|
||||
// ISO week-year-week number for grouping; adequate for summary grouping
|
||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period");
|
||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
||||
}
|
||||
} else { // postgres and others supporting date_trunc
|
||||
$selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period");
|
||||
$groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
||||
$orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
||||
}
|
||||
|
||||
return Activity::query()
|
||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy($groupExpr)
|
||||
->orderBy($orderExpr)
|
||||
->select($selectExpr)
|
||||
->selectRaw('COUNT(*) as activities_count');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
abstract class BaseEloquentReport implements Report
|
||||
{
|
||||
public function description(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function authorize(Request $request): void
|
||||
{
|
||||
// Default: no extra checks. Controllers can gate via middleware.
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
|
||||
{
|
||||
/** @var EloquentBuilder|QueryBuilder $query */
|
||||
$query = $this->query($filters);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
interface Report
|
||||
{
|
||||
public function slug(): string;
|
||||
|
||||
public function name(): string;
|
||||
|
||||
public function description(): ?string;
|
||||
|
||||
/**
|
||||
* Return an array describing input filters (type, label, default, options) for UI.
|
||||
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function inputs(): array;
|
||||
|
||||
/**
|
||||
* Return column definitions for the table and exports.
|
||||
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function columns(): array;
|
||||
|
||||
/**
|
||||
* Build the data source query for the report based on validated filters.
|
||||
* Should return an Eloquent or Query builder.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return EloquentBuilder|QueryBuilder
|
||||
*/
|
||||
public function query(array $filters);
|
||||
|
||||
/**
|
||||
* Optional per-report authorization logic.
|
||||
*/
|
||||
public function authorize(Request $request): void;
|
||||
|
||||
/**
|
||||
* Execute the report and return a paginator for UI.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DecisionsCountReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'decisions-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Odločitve – štetje';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
return Activity::query()
|
||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
||||
->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('decisions.name')
|
||||
->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
|
||||
class FieldJobsCompletedReport extends BaseEloquentReport
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'field-jobs-completed';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Zaključeni tereni';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Pregled zaključenih terenov po datumu in uporabniku.';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()],
|
||||
['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'id', 'label' => '#'],
|
||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
||||
['key' => 'assigned_user_name', 'label' => 'Terenski'],
|
||||
['key' => 'completed_at', 'label' => 'Zaključeno'],
|
||||
['key' => 'notes', 'label' => 'Opombe'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function query(array $filters): EloquentBuilder
|
||||
{
|
||||
$from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth();
|
||||
$to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay();
|
||||
|
||||
return FieldJob::query()
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$from, $to])
|
||||
->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id']))
|
||||
->with(['assignedUser:id,name', 'contract:id,reference'])
|
||||
->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Reports\Contracts\Report;
|
||||
|
||||
class ReportRegistry
|
||||
{
|
||||
/** @var array<string, Report> */
|
||||
protected array $reports = [];
|
||||
|
||||
public function register(Report $report): void
|
||||
{
|
||||
$this->reports[$report->slug()] = $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Report>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->reports;
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Report
|
||||
{
|
||||
return $this->reports[$slug] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Reports;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Reports\Contracts\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function slug(): string
|
||||
{
|
||||
return 'segment-activity-counts';
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'Aktivnosti po segmentih';
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
|
||||
}
|
||||
|
||||
public function inputs(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'segment_name', 'label' => 'Segment'],
|
||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
||||
];
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$q = Activity::query()
|
||||
->join('actions', 'activities.action_id', '=', 'actions.id')
|
||||
->leftJoin('segments', 'actions.segment_id', '=', 'segments.id')
|
||||
->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from']))
|
||||
->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to']))
|
||||
->groupBy('segments.name')
|
||||
->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count");
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Documents;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DocumentStreamService
|
||||
{
|
||||
/**
|
||||
* Stream a document either inline or as attachment with all Windows/public fallbacks.
|
||||
*/
|
||||
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
|
||||
{
|
||||
$disk = $document->disk ?: 'public';
|
||||
$relPath = $this->normalizePath($document->path ?? '');
|
||||
|
||||
// Handle DOC/DOCX previews for inline viewing
|
||||
if ($inline) {
|
||||
$previewResponse = $this->tryPreview($document);
|
||||
if ($previewResponse) {
|
||||
return $previewResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the file using multiple path candidates
|
||||
$found = $this->findFile($disk, $relPath);
|
||||
|
||||
if (! $found) {
|
||||
// Try public/ fallback
|
||||
$found = $this->tryPublicFallback($relPath);
|
||||
if (! $found) {
|
||||
abort(404, 'Document file not found');
|
||||
}
|
||||
}
|
||||
|
||||
$headers = $this->buildHeaders($document, $inline);
|
||||
|
||||
// Try streaming first
|
||||
$stream = Storage::disk($disk)->readStream($found);
|
||||
if ($stream !== false) {
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallbacks on readStream failure
|
||||
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for Windows and legacy prefixes.
|
||||
*/
|
||||
protected function normalizePath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = ltrim($path, '/');
|
||||
if (str_starts_with($path, 'public/')) {
|
||||
$path = substr($path, 7);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build path candidates to try.
|
||||
*/
|
||||
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
|
||||
{
|
||||
$candidates = [$relPath];
|
||||
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : 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);
|
||||
}
|
||||
|
||||
return array_unique($candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find file using path candidates.
|
||||
*/
|
||||
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
|
||||
{
|
||||
$candidates = $this->buildPathCandidates($relPath, $documentPath);
|
||||
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try public/ fallback path.
|
||||
*/
|
||||
protected function tryPublicFallback(string $relPath): ?string
|
||||
{
|
||||
$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)) {
|
||||
return $real;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to stream preview for DOC/DOCX files.
|
||||
*/
|
||||
protected function tryPreview(Document $document): StreamedResponse|Response|null
|
||||
{
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
if (! in_array($ext, ['doc', 'docx'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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) {
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue preview generation if not available
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||||
|
||||
return response('Preview is being generated. Please try again shortly.', 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response headers.
|
||||
*/
|
||||
protected function buildHeaders(Document $document, bool $inline): array
|
||||
{
|
||||
$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;
|
||||
|
||||
return [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback streaming methods when readStream fails.
|
||||
*/
|
||||
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
|
||||
{
|
||||
// Fallback 1: get() the bytes directly
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($found);
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Continue to next fallback
|
||||
}
|
||||
|
||||
// Fallback 2: open via absolute storage path
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: serve from public path if available
|
||||
$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, 'Document file could not be streamed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AccountType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ReferenceDataCache
|
||||
{
|
||||
private const TTL = 3600; // 1 hour
|
||||
|
||||
public function getAddressTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
|
||||
}
|
||||
|
||||
public function getPhoneTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
|
||||
}
|
||||
|
||||
public function getAccountTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
|
||||
}
|
||||
|
||||
public function getContractTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reference data cache.
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
Cache::forget('reference_data:address_types');
|
||||
Cache::forget('reference_data:phone_types');
|
||||
Cache::forget('reference_data:account_types');
|
||||
Cache::forget('reference_data:contract_types');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific reference data cache.
|
||||
*/
|
||||
public function clear(string $type): void
|
||||
{
|
||||
Cache::forget("reference_data:{$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all types as an array for convenience.
|
||||
*/
|
||||
public function getAllTypes(): array
|
||||
{
|
||||
return [
|
||||
'address_types' => $this->getAddressTypes(),
|
||||
'phone_types' => $this->getPhoneTypes(),
|
||||
'account_types' => $this->getAccountTypes(),
|
||||
'contract_types' => $this->getContractTypes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user