175 lines
10 KiB
PHP
175 lines
10 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\Services\EducationGapCalculator;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\PortalAudit;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class EducationController extends Controller
|
|
{
|
|
public function index(Request $request): Response
|
|
{
|
|
$filters = $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'],
|
|
]);
|
|
$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());
|
|
}
|
|
}
|
|
|
|
$territorialCapacities = clone $capacities;
|
|
$providers = TrainingProvider::where('status', '!=', 'inactive');
|
|
$courses = Course::where('status', '!=', 'retired')
|
|
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
|
|
if ($territoryIds !== []) {
|
|
// Provider and course location is evidenced by their submitted training capacity.
|
|
// Do not infer locations from provider names, addresses, or unrelated records.
|
|
$courses->whereIn('id', $territorialCapacities->select('course_id'));
|
|
$providers->whereIn('id', (clone $courses)->select('provider_id'));
|
|
}
|
|
|
|
return Inertia::render('education/index', [
|
|
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
|
|
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
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([
|
|
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
|
|
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
|
|
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
|
|
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
|
|
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
|
|
? Rule::exists('projects', 'id')->whereNull('deleted_at')
|
|
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
|
|
->where(fn ($query) => $query->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')))],
|
|
'employer' => [$demand ? 'required' : 'exclude', 'string', 'max:200'],
|
|
'qualification' => ['required', 'string', 'max:150'],
|
|
'quantity' => ['required', 'integer', 'min:1', 'max:10000000'],
|
|
'period_start' => ['required', 'date_format:Y-m-d'],
|
|
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
|
|
'source' => ['required', 'string', 'min:10', 'max:5000'],
|
|
'reference_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
|
|
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:reference_date'],
|
|
]);
|
|
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 review(Request $request, string $kind, string $id): RedirectResponse
|
|
{
|
|
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'],
|
|
'status' => ['required', 'in:reviewed,rejected'], 'review_reason' => ['required', 'string', 'min:15', 'max:3000']]);
|
|
$model = $kind === 'demands' ? LabourDemand::class : TrainingCapacity::class;
|
|
DB::transaction(function () use ($model, $id, $data, $request) {
|
|
$record = $model::whereKey($id)->lockForUpdate()->firstOrFail();
|
|
abort_if((int) $record->getAttribute('submitted_by') === $request->user()->id, 403, 'Outro gestor deve efectuar a revisão.');
|
|
abort_if((int) $record->getAttribute('version_no') !== $data['version_no'], 409, 'A versão foi alterada. Recarregue antes de decidir.');
|
|
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
|
|
PortalAudit::save($record, [...$data, 'version_no' => $data['version_no'] + 1,
|
|
'reviewed_by' => $request->user()->id], $request->user()->id);
|
|
});
|
|
|
|
return back()->with('success', '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)]);
|
|
}
|
|
}
|