oportunidades 6
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-10 21:17:46 +02:00
parent 01405cbf8a
commit 20971d523f
126 changed files with 44362 additions and 3496 deletions
@@ -3,9 +3,11 @@
namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -21,7 +23,20 @@ class InvestorPathController extends Controller
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{
$id = $path->create($request->user(), $request->all());
$source = $request->input('content_resource_id');
if ($source !== null) {
abort_unless(filter_var($source, FILTER_VALIDATE_INT), 422);
app(LegacyContent::class)->articleForResource((int) $source);
}
$id = DB::transaction(function () use ($path, $request, $source): string {
$id = $path->create($request->user(), $request->all());
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
}
return $id;
});
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class LegacyContentController extends Controller
{
public function landing(Request $request, LegacyContent $legacy, string $sectorSlug = 'agronegocio'): Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120']);
$articles = $legacy->articlesForGroup($sectorSlug);
$root = config('legacy_content.groups.'.$sectorSlug.'.root');
$article = $legacy->article($root);
$q = $filters['q'] ?? '';
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$match = $articles !== [];
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
}
public function article(Request $request, LegacyContent $legacy, string $legacySlug = 'agronegocio'): Response
{
return Inertia::render('portal/legacy', ['article' => $legacy->article($legacySlug), 'view' => 'article', 'contentContext' => $this->context($request)]);
}
public function start(Request $request, LegacyContent $legacy, InvestorPath $path, string $legacySlug = 'agronegocio'): Response
{
abort_unless($path->available(), 404);
$article = $legacy->article($legacySlug);
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
}
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
{
$legacy->attach($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
}
/** @return array{id: string, title: string}|null */
private function context(Request $request): ?array
{
$data = $request->validate(['context' => 'nullable|uuid']);
if (! $request->user() || empty($data['context'])) {
return null;
}
try {
$case = app(VirtualDesk::class)->case($request->user(), $data['context'], true);
return $case->closed_at ? null : ['id' => $case->id, 'title' => $case->title];
} catch (HttpException $error) {
if (! in_array($error->getStatusCode(), [403, 404], true)) {
throw $error;
}
return null; // Consultation stays public; an unauthorized private context is never returned.
}
}
}
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
@@ -126,6 +127,7 @@ class OpportunityManagementController extends Controller
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
'workflowState' => $record ? $access->state($record) : null,
'publicationState' => $record?->publicationPresentation(),
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
@@ -151,6 +153,12 @@ class OpportunityManagementController extends Controller
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
}
public function canonicalResponse(Request $request, int $id, OpportunityCanonicalResponse $response): JsonResponse
{
return response()->json($response->read($request->user(), Opportunity::withTrashed()->findOrFail($id)))
->header('Cache-Control', 'private, no-store');
}
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
{
abort_unless($service->installed(), 503);
@@ -138,13 +138,20 @@ class OpportunityResourceController extends Controller
$districts = [];
foreach ($locations as $location) {
$p = $location->payload;
if (empty($p['province_id']) || empty($p['district_id']) || empty($p['role'])) {
$issues[] = 'Uma localização tem província, distrito ou papel pendente.';
$mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ';
if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) {
$issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.';
}
if (! empty($p['district_id'])) {
if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
$issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.';
}
if (empty($p['role'])) {
$issues[] = 'Uma localização tem papel pendente.';
}
if ($mozambican && ! empty($p['district_id'])) {
$districts[$p['district_id']] = true;
}
if (! empty($p['province_id'])) {
if ($mozambican && ! empty($p['province_id'])) {
$provinces[$p['province_id']] = true;
}
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
@@ -164,7 +171,7 @@ class OpportunityResourceController extends Controller
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
}
if ($locations) {
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : 'district')).'. Sem conversão de contexto em implantação.';
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.';
}
foreach (['images', 'documents'] as $kind) {
foreach ($resources[$kind] as $r) {
@@ -8,6 +8,7 @@ use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
@@ -366,7 +367,7 @@ class PortalServicesController extends Controller
public function upload(Request $request): RedirectResponse
{
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'],
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
$file = $request->file('file');
$checksum = hash_file('sha256', $file->getRealPath());
@@ -635,7 +636,9 @@ class PortalServicesController extends Controller
$data = $this->validateManagement($request, [
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
'topic' => ['required', 'string', 'max:80'], 'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
'topic' => [app(\App\Services\LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
@@ -656,6 +659,7 @@ class PortalServicesController extends Controller
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
]);
$data['topic'] = $data['topic'] ?? '';
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
}
@@ -144,7 +144,7 @@ class VirtualDeskController extends Controller
}
}
$this->auditRead($request, 'desk_requests', $id);
$data = ['countries' => app(CountryCatalogue::class)->all(), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
$data = ['countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id,