feat: consolidar desenvolvimentos do portal InvestMoz

This commit is contained in:
2026-11-03 23:32:32 +02:00
parent 1da273f1f3
commit 36c8cadb49
345 changed files with 110714 additions and 2799 deletions
+101 -44
View File
@@ -11,11 +11,19 @@ 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\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
@@ -23,16 +31,23 @@ use Inertia\Response;
class EducationController extends Controller
{
public function index(Request $request): Response
public function index(Request $request): \Symfony\Component\HttpFoundation\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'],
]);
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 = [];
@@ -53,20 +68,36 @@ class EducationController extends Controller
}
}
// 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 !== []) {
// 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(),
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(),
@@ -83,9 +114,62 @@ class EducationController extends Controller
->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(),
'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) {
@@ -112,24 +196,7 @@ class EducationController extends Controller
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'],
]);
$data = $request->validate(EducationDeclarationRules::forKind($kind));
PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
@@ -138,19 +205,9 @@ class EducationController extends Controller
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);
});
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all());
return back()->with('success', 'Revisão interna registada; não substitui decisão institucional.');
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