Entrega dashboard e correccoes transversais preservando Ofertas instalado
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-15 18:56:39 +02:00
parent 1349789226
commit fb076c174f
117 changed files with 10380 additions and 1905 deletions
@@ -20,11 +20,14 @@ final class DemographyController extends Controller
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid', 'investment_context' => 'nullable|boolean']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if (! empty($data['investment_context']) && count($data['territories'] ?? []) === 1) {
$evidence = $service->investmentContext($data['territories'][0], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
}
if ($request->expectsJson()) {
return response()->json($evidence);
}
@@ -150,31 +150,37 @@ final class FundingProgrammeController extends Controller
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid',
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
$explicitLocation = $request->hasAny(['territory', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
if ($dossier && ! $explicitLocation && empty($filters['territory']) && $dossier['territory']) {
$filters['territory'] = $dossier['territory'];
}
$query = $service->catalogueQuery($filters);
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
$geography = app(InvestmentGeography::class);
$criteriaErrors = [];
$territorySelection = [];
try {
$filters = $geography->interestSelection($filters);
$territorySelection = ! empty($filters['territory']) ? $geography->path($filters['territory']) : [];
$query = $service->catalogueQuery($filters);
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
} catch (\Illuminate\Validation\ValidationException $error) {
if ($request->expectsJson()) throw $error;
$criteriaErrors = $error->errors();
$items = new \Illuminate\Pagination\LengthAwarePaginator([], 0, 20, 1, ['path' => '/fundos', 'query' => $request->query()]);
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
}
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
$items->through(function ($row) use ($service, $filters) {
$record = $service->present($row);
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
});
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
return tap(Inertia::render('funding/index', ['criteriaErrors' => $criteriaErrors, 'territorySelection' => $territorySelection, 'categoryTypes' => FundingInstitutionClassification::CATEGORY_TYPES, 'items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => $territorySelection,
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -183,7 +189,7 @@ final class FundingProgrammeController extends Controller
{
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
if ($dossier && ! $request->has('territory') && $dossier['territory']) {
$request->merge(['territory' => $dossier['territory']]);
}
$record = $service->published($id);
@@ -204,7 +210,7 @@ final class FundingProgrammeController extends Controller
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['q', 'kind', 'sector', 'certainty', 'state', 'include_historical', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'page', 'need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -12,6 +12,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentProfileCommands;
use App\Services\InvestmentWorkspace;
use App\Services\VirtualDesk;
use App\Services\OpportunityManagement;
use App\Services\OrganizationCountry;
use App\Services\Procurement;
@@ -28,6 +29,8 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): \Symfony\Component\HttpFoundation\Response
{
$entry = $request->validate(['dossier' => 'nullable|uuid']);
$continuation = ! empty($entry['dossier']) ? app(VirtualDesk::class)->case($request->user(), $entry['dossier']) : null;
$data = ['actorId' => $request->user()->id, 'organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
@@ -36,6 +39,13 @@ class InvestmentWorkspaceController extends Controller
return $company;
})];
$data['continuation'] = $continuation ? ['id' => $continuation->id, 'title' => $continuation->title, 'href' => '/investimentos/'.$continuation->id.'#entidade-promotora'] : null;
$forms = config('company-comparison.forms');
abort_unless(is_array($forms), 503, 'As formas jurídicas estão temporariamente indisponíveis.');
$data['legalForms'] = [];
foreach ($forms as $code => $form) {
$data['legalForms'][] = ['value' => $code, 'label' => $form['title']];
}
Inertia::encryptHistory();
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
@@ -77,7 +87,8 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,legal_form,sector,role,description'],
'payload.legal_form' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('company-comparison.forms')))],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\Sector;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
@@ -19,6 +20,27 @@ use Inertia\Response;
final class InvestorContextController extends Controller
{
/** Explicit choices only; a title never assigns an activity or amount.
* @return array<string,mixed>
*/
private function ideaEntry(Request $request): array
{
$entry = $request->validate([
'project_title' => 'nullable|string|max:200',
'sector_id' => 'nullable|integer|exists:sectors,id',
'sector' => 'nullable|string|max:100|exists:sectors,slug',
'investment_budget_min' => 'nullable|numeric|min:0',
'investment_budget_max' => ['nullable', 'numeric', 'min:0', Rule::when($request->filled('investment_budget_min'), 'gte:investment_budget_min')],
'currency' => 'nullable|regex:/^[A-Z]{3}$/D',
]);
if (empty($entry['sector_id']) && ! empty($entry['sector'])) {
$entry['sector_id'] = (string) Sector::where('slug', $entry['sector'])->value('id');
}
unset($entry['sector']);
return $entry;
}
/**
* @return array<array-key, mixed>
*/
@@ -82,17 +104,21 @@ final class InvestorContextController extends Controller
public function options(Request $r, InvestorContext $context): JsonResponse
{
$d = $r->validate(['q' => 'nullable|string|max:120']);
$d = $r->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer|exists:sectors,id', 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
$filters = array_intersect_key($d, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id']));
if (! empty($d['sector_id'])) {
$filters['sector'] = Sector::whereKey($d['sector_id'])->firstOrFail()->slug;
}
return response()->json($context->options($d['q'] ?? ''));
return response()->json($context->options($d['q'] ?? '', $filters));
}
public function corridorOptions(Request $r): JsonResponse
{
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid', 'include_corridors' => 'nullable|boolean']);
$service = app(InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(),
return response()->json(['corridors' => ($data['include_corridors'] ?? true) ? collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values() : [],
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
}
@@ -101,6 +127,16 @@ final class InvestorContextController extends Controller
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
}
public function supplyAggregate(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($context->saveSupplyAggregate($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
}
public function promoter(Request $r, string $id, InvestorContext $context): JsonResponse
{
return response()->json($r->isMethod('GET') ? $context->promoter($r->user(), $id) : $context->setPromoter($r->user(), $id, $r->all()))->header('Cache-Control', 'private, no-store');
}
public function edit(Request $r, string $id, InvestorContext $context): Response
{
$data = $context->view($r->user(), $id);
@@ -133,8 +169,8 @@ final class InvestorContextController extends Controller
}
}
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') {
$entryTerritory = ($source['kind'] ?? '') === 'territory' ? $source : null;
if ($r->hasAny(['province_id', 'district_id', 'admin_post_id', 'locality_id'])) {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) {
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
@@ -148,7 +184,7 @@ final class InvestorContextController extends Controller
}
}
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
return Inertia::render('investor-path/context', ['entryIdea' => $this->ideaEntry($r), 'entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
}
public function preview(Request $r, string $id, InvestorContext $context): JsonResponse
@@ -175,7 +211,7 @@ final class InvestorContextController extends Controller
$choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => [...$this->ideaEntry($r), ...$r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id'])], 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response