Files
2026-11-11 08:21:56 +02:00

258 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\EducationInvestmentProposal;
use App\Models\LabourDemand;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Sector;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationAccess;
use App\Services\EducationDeclarationDrafts;
use App\Services\EducationDeclarationRules;
use App\Services\EducationGapCalculator;
use App\Services\EducationReviews;
use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class EducationController extends Controller
{
public function index(Request $request): \Symfony\Component\HttpFoundation\Response
{
app(EducationAccess::class)->authorize($request->user());
$filters = $this->filters($request);
if (($filters['scope'] ?? 'all') === 'review') {
unset($filters['status']);
}
$draftService = app(EducationDeclarationDrafts::class);
$draftsAvailable = $draftService->installed();
$selectedDraft = null;
if (! empty($filters['draft'])) {
abort_unless($draftsAvailable, 404);
$kind = DB::table('education_declaration_drafts')->where('id', $filters['draft'])->where('owner_user_id', $request->user()->id)->value('kind');
abort_unless($kind !== null, 404);
$selectedDraft = $draftService->show($request->user(), $kind, $filters['draft']);
}
Inertia::encryptHistory();
$demands = LabourDemand::query();
$capacities = TrainingCapacity::query();
$territoryIds = [];
foreach ([$demands, $capacities] as $query) {
if ($term = trim($filters['q'] ?? '')) {
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
}
$path = app(InvestmentGeography::class)->selection($filters);
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
if ($territory) {
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
$query->whereIn('geo_location_id', $territoryIds);
}
if (($filters['status'] ?? null) === 'expired') {
$query->where('valid_until', '<', now()->toDateString());
} elseif ($status = $filters['status'] ?? null) {
$query->where('status', $status)->where('valid_until', '>=', now()->toDateString());
}
}
// Viewing one's work must not remove existing territorial catalogue evidence.
$territorialCapacities = clone $capacities;
foreach ([$demands, $capacities] as $query) {
if (($filters['scope'] ?? 'all') === 'mine') {
$query->where('submitted_by', $request->user()->id);
} elseif (($filters['scope'] ?? 'all') === 'review') {
$query->where('submitted_by', '<>', $request->user()->id)->where('status', 'submitted')->where('valid_until', '>=', now()->toDateString());
}
}
$providers = TrainingProvider::where('status', '!=', 'inactive');
$courses = Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
if ($territoryIds !== []) {
$courses->whereIn('id', $territorialCapacities->select('course_id'));
$providers->whereIn('id', (clone $courses)->select('provider_id'));
}
return tap(Inertia::render('education/index', [
'workQueue' => app(EducationWorkQueue::class)->summary($request->user()),
'draftsAvailable' => $draftsAvailable, 'selectedDraft' => $selectedDraft,
'ownDrafts' => $draftsAvailable ? DB::table('education_declaration_drafts')->where('owner_user_id', $request->user()->id)
->orderByDesc('updated_at')->orderBy('id')->paginate(20, ['id', 'kind', 'version_no', 'state', 'updated_at', 'payload'], 'draft_page')->withQueryString()
->through(function ($row) {
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'kind' => $row->kind, 'version_no' => (int) $row->version_no, 'state' => $row->state,
'updated_at' => CarbonImmutable::parse($row->updated_at)->toIso8601String(), 'label' => $payload['qualification'] ?? null];
}) : null,
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString()->through(fn (LabourDemand $row) => $this->listing($row, 'demands', $request, $filters)),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString()->through(fn (TrainingCapacity $row) => $this->listing($row, 'capacities', $request, $filters)),
'filters' => $filters,
'indicators' => [
'demand_records' => LabourDemand::count(),
'capacity_records' => TrainingCapacity::count(),
'expired_demands' => LabourDemand::where('valid_until', '<', now()->toDateString())->count(),
'pending_demands' => LabourDemand::where('status', 'submitted')->where('valid_until', '>=', now()->toDateString())->count(),
],
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
'providers' => $providers->orderBy('name')->get(['id', 'name']),
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
'today' => now()->toDateString(),
])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
return $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
'draft' => ['nullable', 'uuid'], 'draft_page' => ['nullable', 'integer', 'min:1'],
'scope' => ['nullable', Rule::in(['all', 'mine', 'review'])],
]);
}
/** @param array<string,mixed> $filters
* @return array<string,mixed> */
private function listing(LabourDemand|TrainingCapacity $record, string $kind, Request $request, array $filters): array
{
$unavailable = $record->getAttribute('valid_until') < now()->toDateString() ? 'expired' : ((int) $record->getAttribute('submitted_by') === $request->user()->id ? 'self' : null);
return [...$record->toArray(), 'can_review' => $unavailable === null, 'review_unavailable' => $unavailable,
'details_url' => route('management.education.declaration', ['kind' => $kind, 'id' => $record->id, ...$filters], false)];
}
public function declaration(Request $request, string $kind, string $id): \Symfony\Component\HttpFoundation\Response
{
app(EducationAccess::class)->authorize($request->user());
$model = match ($kind) {
'demands' => LabourDemand::class,'capacities' => TrainingCapacity::class,default => abort(404)
};
$record = $model::findOrFail($id);
$filters = $this->filters($request);
$request->validate(['history_page' => ['nullable', 'integer', 'min:1']]);
$row = $this->listing($record, $kind, $request, $filters);
$row['review_context'] = app(EducationReviews::class)->context($request->user(), $record);
$row['submitted_by_name'] = User::whereKey($record->getAttribute('submitted_by'))->value('name');
$row['reviewed_by_name'] = $record->getAttribute('reviewed_by') ? User::whereKey($record->getAttribute('reviewed_by'))->value('name') : null;
$references = ['occupation' => Occupation::whereKey($record->getAttribute('occupation_id'))->value('name'),
'skill' => Skill::whereKey($record->getAttribute('skill_id'))->value('name'),
'location' => collect(app(InvestmentGeography::class)->path($record->getAttribute('geo_location_id'), true))->pluck('name')->implode(' › ')];
if ($kind === 'demands') {
$references['project'] = Project::whereKey($record->getAttribute('project_id'))->value('name');
} else {
$references['course'] = Course::whereKey($record->getAttribute('course_id'))->value('title');
}
Inertia::encryptHistory();
return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(),
'preparation' => app(EducationReviews::class)->preparation($request->user(), $record),
'history' => app(EducationReviews::class)->history($request->user(), $record),
'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function catalog(Request $request, string $kind): RedirectResponse
{
$model = match ($kind) {
'skills' => Skill::class, 'occupations' => Occupation::class,
'providers' => TrainingProvider::class, 'courses' => Course::class,
default => abort(404),
};
$record = new $model;
$rules = [$kind === 'courses' ? 'title' : 'name' => ['required', 'string', 'max:200']];
if ($kind !== 'providers') {
$rules['code'] = ['required', 'string', 'max:80', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')];
}
if ($kind === 'courses') {
$rules['provider_id'] = ['required', 'uuid', Rule::exists('training_providers', 'id')->whereNull('deleted_at')->whereNot('status', 'inactive')];
}
$data = $request->validate($rules);
PortalAudit::save($record, [...$data, ...match ($kind) {
'providers' => ['status' => 'candidate'], 'courses' => ['status' => 'draft'], default => [],
}], $request->user()->id);
return back()->with('success', 'Cadastro interno criado. Não constitui acreditação ou certificação.');
}
public function store(Request $request, string $kind): RedirectResponse
{
$demand = $kind === 'demands';
$data = $request->validate(EducationDeclarationRules::forKind($kind));
PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
return back()->with('success', 'Declaração registada para revisão independente.');
}
public function reviewPreparation(Request $request, string $kind, string $id): JsonResponse
{
$service = app(EducationReviews::class);
app(EducationAccess::class)->authorize($request->user());
if ($request->isMethod('POST')) {
// Like CRM preparations, retain partial text before business validation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
$view = $service->savePreparation($request->user(), $kind, $id, $input);
} else {
$expected = $request->validate(['actor_id' => 'sometimes|required|integer|min:1']);
abort_if(isset($expected['actor_id']) && (int) $expected['actor_id'] !== $request->user()->id, 409, 'A conta mudou. Actualize a entrada antes de recuperar.');
$record = match ($kind) {
'demands' => LabourDemand::findOrFail($id), 'capacities' => TrainingCapacity::findOrFail($id), default => abort(404)
};
$view = $service->preparation($request->user(), $record);
}
return response()->json($view)->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, string $kind, string $id): RedirectResponse
{
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all());
return back()->with('success', $result['replayed'] ? 'A revisão já tinha sido registada.' : 'Revisão interna registada; não substitui decisão institucional.');
}
public function analysis(string $id, EducationGapCalculator $calculator): Response
{
$demand = LabourDemand::findOrFail($id);
$current = $demand->getAttribute('valid_until') >= now()->toDateString();
$eligible = $current && $demand->getAttribute('status') === 'reviewed';
$candidates = $eligible ? TrainingCapacity::where('status', 'reviewed')->where('valid_until', '>=', now()->toDateString())
->whereIn('course_id', Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->select('id'))
->where('occupation_id', $demand->getAttribute('occupation_id'))->where('skill_id', $demand->getAttribute('skill_id'))
->where('geo_location_id', $demand->getAttribute('geo_location_id'))->where('qualification', $demand->getAttribute('qualification'))
->where('period_end', '<=', $demand->getAttribute('period_start'))->orderBy('period_end')->get() : collect();
return Inertia::render('education/analysis', ['demand' => $demand, 'candidates' => $candidates,
'proposal' => EducationInvestmentProposal::where('labour_demand_id', $id)->first(),
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territoryPath' => app(InvestmentGeography::class)->path($demand->getAttribute('geo_location_id'), true),
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
}
}