169 lines
10 KiB
PHP
169 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\InvestmentSubmission;
|
|
use App\Models\Opportunity;
|
|
use App\Models\Project;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\InvestmentRelations;
|
|
use App\Services\InvestmentZones;
|
|
use App\Services\PortalAudit;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class InvestmentPortfolioController extends Controller
|
|
{
|
|
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
|
|
{
|
|
foreach ([[$first, $second], [$second, $first]] as [$cluster, $project]) {
|
|
if ($cluster->cluster_id && $project->project_id && app(InvestmentRelations::class)->memberships()
|
|
->where('cp.cluster_id', $cluster->cluster_id)->where('cp.project_id', $project->project_id)->exists()) {
|
|
return true;
|
|
}
|
|
}
|
|
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
|
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
|
|
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function currentRelation(QueryBuilder $query): QueryBuilder
|
|
{
|
|
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
|
|
->whereDate('valid_from', '<=', today()->toDateString())
|
|
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
|
|
}
|
|
|
|
/** @return Builder<InvestmentDossier> */
|
|
public static function visible(): Builder
|
|
{
|
|
return InvestmentDossier::select(['id', 'title', 'content', 'source', 'project_id', 'cluster_id', 'corridor_id', 'publication_status', 'published_at'])->where('publication_status', 'published')->where(function ($q) {
|
|
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
|
|
$q->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from($table)
|
|
->whereColumn($table.'.id', 'investment_dossiers.'.$foreign)->whereNull($table.'.deleted_at'));
|
|
}
|
|
});
|
|
}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$management = $request->routeIs('management.portfolio*');
|
|
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
|
|
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
|
$query = $management ? InvestmentDossier::query() : self::visible();
|
|
if (! empty($filters['kind'])) {
|
|
$query->whereNotNull($filters['kind'].'_id');
|
|
}
|
|
if (! empty($filters['q'])) {
|
|
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
|
|
}
|
|
$territorySelection = app(InvestmentGeography::class)->selection($filters);
|
|
if ($territorySelection !== []) {
|
|
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
|
|
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
|
|
}
|
|
|
|
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
|
|
'management' => $management, 'filters' => $filters, 'territorySelection' => $territorySelection]);
|
|
}
|
|
|
|
public function show(Request $request, string $id): Response
|
|
{
|
|
$management = $request->routeIs('management.portfolio*');
|
|
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
|
|
$relatedIds = [];
|
|
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
|
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
|
|
if ($record->$origin) {
|
|
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
|
|
->where($origin, $record->$origin))->pluck($destination)->all());
|
|
}
|
|
}
|
|
}
|
|
$related = ($management ? InvestmentDossier::query() : self::visible())->where(function ($q) use ($relatedIds) {
|
|
$q->whereRaw('false');
|
|
foreach ($relatedIds as $column => $ids) {
|
|
$q->orWhereIn($column, $ids);
|
|
}
|
|
})->orderBy('title')->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id']);
|
|
|
|
$context = $request->validate(['from' => ['nullable', 'uuid']]);
|
|
$origin = isset($context['from']) ? self::visible()->whereKey($context['from'])->firstOrFail() : null;
|
|
abort_if($origin && ! self::related($record, $origin), 422, 'Contexto sem relação com esta ficha.');
|
|
|
|
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
|
|
$query->whereRaw('false');
|
|
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
|
|
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
|
|
if ($record->$foreign) {
|
|
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
|
|
}
|
|
}
|
|
})->orderBy('title')->get(['id', 'title', 'slug', 'summary']);
|
|
$chains = $record->cluster_id ? DB::table('value_chains')->where('cluster_id', $record->cluster_id)->whereNull('deleted_at')
|
|
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('name')->get(['id', 'name', 'stage']) : collect();
|
|
|
|
$zones = collect();
|
|
foreach (['cluster_id', 'corridor_id', 'project_id'] as $foreign) {
|
|
if ($record->$foreign) {
|
|
$zones = $zones->merge(app(InvestmentZones::class)->links(true)->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')
|
|
->where('zl.'.$foreign, $record->$foreign)->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference']));
|
|
}
|
|
}
|
|
|
|
$anchorProject = $record->project_id
|
|
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
|
|
: null;
|
|
$existingSubmissions = ! $management && $request->user()
|
|
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
|
|
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
|
: collect();
|
|
|
|
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
|
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
|
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
|
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
|
}
|
|
|
|
public function save(Request $request, InvestmentDossier $dossier): RedirectResponse
|
|
{
|
|
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
|
|
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
|
|
'content.*' => ['nullable', 'string', 'max:15000']]);
|
|
DB::transaction(function () use ($request, $dossier, $data) {
|
|
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
|
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
|
|
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'editor_id' => $request->user()->id, 'reviewer_id' => null,
|
|
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
|
|
});
|
|
|
|
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
|
|
}
|
|
|
|
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
|
|
{
|
|
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
|
|
DB::transaction(function () use ($request, $dossier, $data) {
|
|
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
|
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
|
|
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
|
|
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
|
|
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
|
|
});
|
|
|
|
return back()->with('success', 'Ficha publicada com evidência de revisão.');
|
|
}
|
|
}
|