258 lines
15 KiB
PHP
258 lines
15 KiB
PHP
<?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)]);
|
||
}
|
||
}
|