This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Models\PeopleProcess;
use App\Models\User;
use App\Services\PeopleAccess;
use App\Services\PeopleApplicationReport;
use App\Services\PeopleDeadlineReport;
use App\Services\PeopleHiring;
use App\Services\PeopleSelectionReport;
use App\Services\PeopleWorkdesk;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class PeopleReportController extends Controller
{
public function index(Request $request): Response
{
return DB::transaction(function () use ($request) {
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
$kinds = app(PeopleHiring::class)->kinds($actor);
abort_unless($kinds !== [], 403);
$filters = $request->validate([
'view' => 'nullable|in:processes,applications,selections,deadlines',
'deadline_window' => 'nullable|in:all,overdue,today,future,undated',
'deadline_page' => 'nullable|integer|min:1|max:100000',
'kind' => ['nullable', Rule::in($kinds)], 'status' => ['nullable', Rule::in(PeopleWorkdesk::STATES)],
'search' => 'nullable|string|max:100', 'from' => 'nullable|date_format:Y-m-d',
'to' => ['nullable', 'date_format:Y-m-d', Rule::when($request->filled('from'), 'after_or_equal:from')],
]);
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
foreach (['kind', 'status'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->whereLike('title', '%'.$search.'%');
if (preg_match('/^(?:PES-)?([1-9][0-9]*)$/D', $search, $matches)) {
$id = filter_var($matches[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id !== false) {
$q->orWhere('id', $id);
}
}
});
}
if (! empty($filters['from'])) {
$query->whereDate('created_at', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$query->whereDate('created_at', '<=', $filters['to']);
}
$columns = ['id', 'title', 'kind', 'status', 'closes_on', 'created_at'];
$page = $request->routeIs('people.reports.csv') ? null : (clone $query)->latest('id')->paginate(25, $columns)->withQueryString();
$applications = ($filters['view'] ?? 'processes') === 'applications';
$selections = ($filters['view'] ?? 'processes') === 'selections';
$deadlines = ($filters['view'] ?? 'processes') === 'deadlines';
$deadlineReport = $deadlines ? app(PeopleDeadlineReport::class)->build(clone $query, $actor, $filters['deadline_window'] ?? 'all') : null;
if ($deadlineReport !== null && ! $request->routeIs('people.reports.csv')) {
$number = (int) ($filters['deadline_page'] ?? 1);
$pager = (new LengthAwarePaginator(array_slice($deadlineReport['items'], ($number - 1) * 25, 25), $deadlineReport['total_items'], 25, $number,
['path' => $request->url(), 'pageName' => 'deadline_page']))->appends($request->except('deadline_page'));
$deadlineReport['items'] = $pager->items();
$deadlineReport['pagination'] = ['current_page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'prev_page_url' => $pager->previousPageUrl(), 'next_page_url' => $pager->nextPageUrl()];
}
$metrics = [];
if ($applications || $selections) {
$metricQuery = clone $query;
if ($page !== null) {
$metricQuery->whereIn('id', $page->getCollection()->modelKeys());
}
foreach ($metricQuery->orderBy('id')->lazyById(100) as $process) {
$metrics[$process->id] = $applications ? app(PeopleApplicationReport::class)->counts($process, $actor) : app(PeopleSelectionReport::class)->facts($process, $actor);
}
}
if ($request->routeIs('people.reports.csv') && $deadlineReport !== null) {
$response = $this->deadlineCsv($deadlineReport);
} elseif ($request->routeIs('people.reports.csv')) {
// Materialise within the read-only snapshot; streaming runs after the transaction.
$rows = (clone $query)->orderBy('id')->get($columns);
$response = response()->streamDownload(function () use ($rows, $applications, $selections, $metrics) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
$extra = ['Registadas para avaliação', 'Avaliação completa', 'Por concluir avaliação', 'Sem avaliação', 'Avaliação em curso', 'Base da avaliação', 'Recebidas no portal', 'Admitidas para análise no portal', 'Não admitidas no portal', 'Triagem pendente'];
$selectionColumns = ['Situação da selecção', 'Proposta em', 'Decisão em', 'Pendência', 'Acesso à minuta', 'Referência da minuta', 'Minuta preparada em', 'Início previsto na minuta'];
fputcsv($file, ['Referência', 'Título', 'Percurso', 'Estado', 'Fecho de candidaturas', 'Criado em', ...($applications ? $extra : ($selections ? $selectionColumns : []))], ';', '"', '');
foreach ($rows as $row) {
$title = preg_match('/^[\s]*[=+@-]/u', $row->title) ? "'".$row->title : $row->title;
$counts = $metrics[$row->id] ?? [];
$values = $applications ? array_values(array_map(fn ($value) => $value === null ? 'Sem acesso' : ($value === 'selection_snapshot' ? 'Selecção preservada' : ($value === 'current_eligibility' ? 'Elegibilidade actual' : $value)), $counts)) : [];
if ($selections) {
$values = array_values(array_map(fn ($value) => $value === null ? 'Não registado' : (is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value), $counts));
}
fputcsv($file, ['PES-'.$row->id, $title, $row->kind === 'service' ? 'Serviços individuais' : 'Recrutamento e selecção', self::states()[$row->status] ?? $row->status, $row->closes_on, $row->created_at?->toDateString(), ...$values], ';', '"', '');
}
fclose($file);
}, 'relatorio-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
} else {
$summary = (clone $query)->selectRaw('kind, status, count(*) as total')->groupBy('kind', 'status')->get();
Inertia::encryptHistory();
$response = Inertia::render('people/reports', [
'filters' => $filters, 'kinds' => $kinds, 'summary' => $summary,
'applicationMetrics' => $applications ? $metrics : [],
'selectionMetrics' => $selections ? $metrics : [],
'deadlineReport' => $deadlineReport,
'generatedAt' => now()->toIso8601String(),
'processes' => $page,
])->toResponse($request);
}
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
});
}
/** @param array<string,mixed> $report */
private function deadlineCsv(array $report): Response
{
return response()->streamDownload(function () use ($report) {
$file = fopen('php://output', 'w');
if ($file === false) {
throw new \RuntimeException('Não foi possível abrir a exportação.');
}
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['Referência', 'Processo', 'Categoria', 'Intervenção', 'Responsabilidade', 'Prazo', 'Dias de atraso', 'Consulta em', 'Filtro de prazo'], ';', '"', '');
$windows = ['all' => 'Todos', 'overdue' => 'Vencidos', 'today' => 'Hoje', 'future' => 'Futuros', 'undated' => 'Sem prazo definido'];
foreach ($report['items'] as $item) {
$values = [$item['reference'], $item['title'], $item['category'], $item['label'], $item['responsibility'], $item['due_on'] ?? 'Sem prazo definido', $item['days_overdue'] ?? 'Não determinável', $report['as_of'], $windows[$report['window']]];
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[\s]*[=+@-]/u', $value) ? "'".$value : $value, $values);
fputcsv($file, $values, ';', '"', '');
}
fclose($file);
}, 'relatorio-prazos-pessoas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/** @return array<string,string> */
private static function states(): array
{
return ['draft' => 'Abertura por conferir', 'open' => 'Recepção de candidaturas', 'assessment' => 'Avaliação',
'selection_review' => 'Selecção por conferir', 'selected' => 'Selecção aprovada', 'contract_draft' => 'Minuta contratual preparada'];
}
}