diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index 49a85c09..b4ffb8c9 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -49,13 +49,16 @@ class InvestmentDiscoveryController extends Controller if (in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) { $query->whereNotNull($filters['dimension'].'_id'); } - foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'geo_location_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) { + if (! empty($filters['geo_location_id'])) { + app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id'])); + } + foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) { if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) { return $query->whereRaw('false')->get(); } } if (! empty($filters['q'])) { - $query->where('title', 'ilike', '%'.$filters['q'].'%'); + $query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%'])); } foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) { if (! empty($filters[$field])) { @@ -80,7 +83,17 @@ class InvestmentDiscoveryController extends Controller { $record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail(); - $data = $request->validate(['zone_id' => ['nullable', 'uuid']]); + $data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]); + $dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null; + if ($dossierOrigin) { + $linked = false; + foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { + if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) { + $linked = true; + } + } + abort_unless($linked, 404); + } $origin = null; if (! empty($data['zone_id'])) { $origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') @@ -89,9 +102,10 @@ class InvestmentDiscoveryController extends Controller } $returnFilters = $request->query(); - unset($returnFilters['zone_id']); + unset($returnFilters['zone_id'], $returnFilters['from_dossier']); return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin, + 'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null, 'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]); } diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php index 1f98c303..d127ee21 100644 --- a/app/Http/Controllers/InvestmentPortfolioController.php +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers; use App\Models\InvestmentDossier; +use App\Models\InvestmentSubmission; use App\Models\Opportunity; use App\Models\Project; -use App\Models\InvestmentSubmission; -use App\Services\InvestmentZones; 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; @@ -21,6 +22,12 @@ 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()) { @@ -60,7 +67,7 @@ class InvestmentPortfolioController extends Controller $query->whereNotNull($filters['kind'].'_id'); } if (! empty($filters['q'])) { - $query->where('title', 'ilike', '%'.$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 !== []) { @@ -125,6 +132,7 @@ class InvestmentPortfolioController extends Controller return Inertia::render('portfolio/show', ['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]); } diff --git a/app/Http/Controllers/InvestmentRelationshipRepairController.php b/app/Http/Controllers/InvestmentRelationshipRepairController.php new file mode 100644 index 00000000..891461db --- /dev/null +++ b/app/Http/Controllers/InvestmentRelationshipRepairController.php @@ -0,0 +1,27 @@ +preview()); + } + + public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse + { + $data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'], + 'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]); + $result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null); + + return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Este plano já foi aplicado; nenhum duplicado criado.' : 'Associações documentadas aplicadas. Registos de oportunidades permanecem preparados sem publicação automática.'); + } +} diff --git a/app/Http/Controllers/PartnershipExchangeController.php b/app/Http/Controllers/PartnershipExchangeController.php new file mode 100644 index 00000000..6334789d --- /dev/null +++ b/app/Http/Controllers/PartnershipExchangeController.php @@ -0,0 +1,401 @@ +json(['available' => false, 'items' => []]); + } + app(PartnershipSources::class)->resolve($type, $id, $request->user()); + + return response()->json(['available' => true, 'items' => DB::table('portal_partnerships as p')->join('partnership_origins as origin', 'origin.partnership_id', '=', 'p.id') + ->where('origin.type', $type)->where('origin.target_id', $id)->where('p.status', 'published')->orderByDesc('p.id')->get(['p.id', 'p.title'])])->header('Cache-Control', 'private, no-store'); + } + + public function index(Request $request): Response|JsonResponse + { + $filters = $request->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer', 'partnership_type' => 'nullable|string|max:40', + 'kind' => 'nullable|in:offering,seeking', 'territory_id' => 'nullable|uuid', 'origin_type' => 'nullable|string|max:40', 'origin_id' => 'nullable|string|max:100']); + $query = DB::table('portal_partnerships as p')->where('p.status', 'published'); + foreach (['sector_id', 'partnership_type', 'kind'] as $field) { + if (! empty($filters[$field])) { + $query->where('p.'.$field, $filters[$field]); + } + } + if (! empty($filters['q'])) { + $term = '%'.$filters['q'].'%'; + $query->where(fn ($q) => $q->where('p.title', 'ilike', $term)->orWhere('p.sector', 'ilike', $term) + ->orWhereRaw("p.payload->>'summary' ILIKE ?", [$term])->orWhere(fn ($details) => $details->whereRaw("COALESCE(p.payload->>'confidentiality','public')='public'")->where(fn ($body) => $body->where('p.offer', 'ilike', $term)->orWhere('p.seeking', 'ilike', $term)))); + } + if (! empty($filters['origin_type']) && ! empty($filters['origin_id'])) { + // Authenticate access to a private master before exposing a backlink query. + app(PartnershipSources::class)->resolve($filters['origin_type'], $filters['origin_id'], $request->user()); + $query->whereExists(fn ($q) => $q->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', $filters['origin_type'])->where('target_id', $filters['origin_id'])); + } + if (! empty($filters['territory_id'])) { + abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $filters['territory_id'])->exists(), 422); + $ids = app(InvestmentGeography::class)->descendants($filters['territory_id']); + $investmentIds = app(InvestmentCatalogue::class)->query(['geo_location_id' => $filters['territory_id']])->pluck('id')->map(fn ($id) => (string) $id)->all(); + $query->where(fn ($q) => $q->whereExists(fn ($places) => $places->selectRaw('1')->from('partnership_locations')->whereColumn('partnership_id', 'p.id')->whereIn('geo_location_id', $ids)) + ->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins as origin')->join('project_locations as pl', DB::raw('pl.project_id::text'), '=', 'origin.target_id') + ->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'project')->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids)) + ->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', 'investment')->whereIn('target_id', $investmentIds)) + ->orWhereExists(fn ($members) => $members->selectRaw('1')->from('partnership_origins as origin') + ->joinSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'member', DB::raw('member.cluster_id::text'), '=', 'origin.target_id') + ->join('project_locations as pl', 'pl.project_id', '=', 'member.project_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'cluster') + ->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids)) + ->orWhereExists(fn ($zones) => $zones->selectRaw('1')->from('partnership_origins as origin')->join('investment_zone_locations as zl', DB::raw('zl.zone_id::text'), '=', 'origin.target_id') + ->join('investment_zones as zone', 'zone.id', '=', 'zl.zone_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'zone')->where('zone.publication_status', 'published') + ->whereNull('zone.deleted_at')->where('zl.validation_status', 'validated')->whereIn('zl.geo_location_id', $ids))); + } + $results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at']) + ->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString(); + if ($request->expectsJson()) { + return response()->json($results); + } + + $mapPoints = []; + foreach ($results as $partnership) { + $locations = app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $partnership->id)->get() + ->map(fn ($location) => ['id' => $location->geo_location_id, 'role' => $location->role, 'source_reference' => $location->source_reference])->all())); + foreach ($locations as $location) { + if ($location['location_type'] !== 'site') { + continue; + } + foreach ($location['coordinates'] as $coordinate) { + $mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title, + 'location' => $location['name'].' · '.$location['role'].' · '.$location['source_reference'], 'url' => '/parcerias/'.$partnership->id]]; + } + } + foreach (app(PartnershipSources::class)->forPartnership($partnership->id) as $source) { + $context = $source['geography']['locations'] ?? []; + foreach ($source['geography']['members'] ?? [] as $member) { + $context = [...$context, ...$member['locations']]; + } + foreach ($context as $location) { + foreach ($location['coordinates'] as $coordinate) { + $mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title, + 'location' => 'Contexto da origem: '.$location['name'].'; implantação da parceria não confirmada', 'url' => '/parcerias/'.$partnership->id]]; + } + } + } + } + + return Inertia::render('partnerships/catalogue', ['results' => $results, 'filters' => $filters, 'mapPoints' => $mapPoints, ...$this->catalogues($request)]); + } + + public function dashboard(Request $request): Response + { + $actor = $request->user(); + $organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id'); + $mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get(); + $interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id') + ->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id') + ->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations)) + ->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get(); + $moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : []; + + return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation, + 'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]); + } + + public function edit(Request $request, ?int $id = null): Response + { + $record = $id ? app(PartnershipExchange::class)->find($id) : null; + abort_if($record && ! app(PartnershipExchange::class)->owns($request->user(), $record), 403); + $origins = $record ? DB::table('partnership_origins')->where('partnership_id', $id)->get() : []; + if (! $record && $request->filled('origin_type') && $request->filled('origin_id')) { + $source = app(PartnershipSources::class)->resolve($request->string('origin_type')->toString(), $request->string('origin_id')->toString(), $request->user()); + $origins = [['type' => $source['type'], 'target_id' => $source['target_id']]]; + } + + return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins, + 'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]); + } + + public function save(Request $request, ?int $id = null): RedirectResponse|JsonResponse + { + $record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id); + + return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.'); + } + + public function show(Request $request, int $id): Response|JsonResponse + { + $record = app(PartnershipExchange::class)->find($id); + $owner = $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record, false); + $moderator = $request->user() && Gate::forUser($request->user())->allows('manage-portal'); + abort_unless($record->status === 'published' || $owner || $moderator, 404); + $payload = json_decode($record->payload, true); + unset($payload['_creation_hash']); + $privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator; + $mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect(); + $interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine; + $interestData = $interests->map(function ($interest) use ($record) { + return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true), + 'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')]; + }); + $props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind, + 'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline, + 'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload], + 'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator, + 'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData, + 'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())), + 'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [], + ...$this->catalogues($request)]; + if ($request->user()) { + Inertia::encryptHistory(); + } + + return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props); + } + + public function transition(Request $request, int $id): RedirectResponse|JsonResponse + { + $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']); + $record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']); + + return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.'); + } + + public function interest(Request $request, int $id): RedirectResponse|JsonResponse + { + $interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all()); + + return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.'); + } + + public function decide(Request $request, int $id): RedirectResponse|JsonResponse + { + $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']); + $interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []); + + return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.'); + } + + public function room(Request $request, int $id): Response|JsonResponse + { + $context = app(PartnershipExchange::class)->room($request->user(), $id); + $props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null], + 'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']), + 'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id) + ->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id)) + ->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']), + 'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(), + 'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])]; + + return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props); + } + + public function message(Request $request, int $id): RedirectResponse|JsonResponse + { + $data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']); + DB::transaction(function () use ($request, $id, $data) { + $context = app(PartnershipExchange::class)->room($request->user(), $id, true); + $locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first(); + abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.'); + $key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']]; + $existing = DB::table('partnership_messages')->where($key)->first(); + if ($existing) { + abort_unless($existing->body === $data['body'], 409, 'Chave já utilizada com outro conteúdo.'); + + return; + } + DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]); + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]); + }); + + return $request->expectsJson() ? response()->json(['sent' => true]) : back()->with('success', 'Mensagem registada.'); + } + + public function agreement(Request $request, int $id): RedirectResponse|JsonResponse + { + $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200', + 'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']); + DB::transaction(function () use ($request, $id, $data) { + $context = app(PartnershipExchange::class)->room($request->user(), $id, true); + $room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first(); + abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.'); + $agreement = $room->agreement ? json_decode($room->agreement, true) : null; + $status = $room->status; + if ($data['action'] === 'record') { + abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409); + $previous = $agreement['previous_versions'] ?? []; + if ($agreement) { + $previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']]; + } + $agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [], + 'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous]; + $status = 'agreement_recorded'; + } elseif ($data['action'] === 'confirm') { + abort_unless($status === 'agreement_recorded' && $agreement, 409); + $party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate'; + $agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']]; + if (count($agreement['confirmations']) === 2) { + $status = 'agreed'; + } + } else { + $allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']]; + abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409); + if ($data['action'] === 'complete') { + abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.'); + } + $status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']]; + if ($data['action'] === 'renew') { + $agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']]; + $agreement['confirmations'] = []; + } + } + DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]); + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]); + }); + + return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.'); + } + + public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse + { + $data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed', + 'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']); + DB::transaction(function () use ($request, $id, $milestone, $data) { + $context = app(PartnershipExchange::class)->room($request->user(), $id, true); + $locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first(); + abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409); + $responsible = User::query()->whereKey($data['responsible_id'])->first(); + abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.'); + if ($milestone) { + $existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first(); + abort_unless($existing !== null, 404); + abort_unless((int) $existing->version === (int) $data['version'], 409); + DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]); + } else { + unset($data['version']); + $milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]); + } + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]); + }); + + return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.'); + } + + public function upload(Request $request, int $id): RedirectResponse|JsonResponse + { + $data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']); + $context = app(PartnershipExchange::class)->room($request->user(), $id, true); + $file = $request->file('file'); + $documentId = (string) Str::uuid(); + $stored = $file->storeAs('partnerships/'.$id, $documentId, 'local'); + abort_unless(is_string($stored) && $stored !== '', 503); + try { + DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) { + app(PartnershipExchange::class)->room($request->user(), $id, true); + $locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first(); + abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409); + DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted', + 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored, + 'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined']); + DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]); + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado'); + }); + } catch (\Throwable $e) { + Storage::disk('local')->delete($stored); + throw $e; + } + ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + + return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.'); + } + + public function download(Request $request, int $id, int $attachment): BinaryFileResponse + { + $context = app(PartnershipExchange::class)->room($request->user(), $id); + $document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment) + ->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at') + ->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.storage_key', 'd.file_name', 'd.sha256']); + abort_unless($document !== null, 404); + $root = realpath(Storage::disk('local')->path('')); + $path = realpath(Storage::disk('local')->path($document->storage_key)); + abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404); + $checksum = hash_file('sha256', $path); + abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum), 404); + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado'); + + return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); + } + + public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse + { + $data = $request->validate(['shared' => 'required|boolean']); + DB::transaction(function () use ($request, $id, $attachment, $data) { + $context = app(PartnershipExchange::class)->room($request->user(), $id, (bool) $data['shared']); + $link = DB::table('partnership_documents')->where('room_id', $id)->where('id', $attachment)->lockForUpdate()->first(); + abort_unless($link !== null && (int) $link->owner_id === $request->user()->id, 403, 'Só o autor pode autorizar ou retirar a partilha.'); + DB::table('partnership_documents')->where('id', $attachment)->update(['shared' => $data['shared']]); + app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_sharing_changed.v1', ['room_id' => $id, 'attachment_id' => $attachment, 'shared' => $data['shared']], 'Autorização documental atualizada'); + }); + + return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Autorização de partilha atualizada.'); + } + + public function notification(Request $request, int $id): RedirectResponse + { + abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404); + + return back(); + } + + /** @return array */ + private function catalogues(Request $request): array + { + $sources = []; + foreach (InvestmentPortfolioController::visible()->where(fn ($q) => $q->whereNotNull('project_id')->orWhereNotNull('cluster_id'))->get(['id', 'title', 'project_id', 'cluster_id']) as $dossier) { + $sources[] = ['type' => $dossier->project_id ? 'project' : 'cluster', 'target_id' => $dossier->project_id ?? $dossier->cluster_id, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id]; + } + foreach (DB::table('opportunities')->where('status', 'published')->whereNotNull('published_at')->get(['id', 'title', 'slug']) as $investment) { + $sources[] = ['type' => 'investment', 'target_id' => (string) $investment->id, 'title' => $investment->title, 'href' => '/oportunidades/'.$investment->slug]; + } + foreach (DB::table('investment_zones')->where('publication_status', 'published')->whereNull('deleted_at')->get(['id', 'name']) as $zone) { + $sources[] = ['type' => 'zone', 'target_id' => $zone->id, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id]; + } + foreach (ExistingBusiness::query()->published()->get(['id', 'public_title', 'public_slug']) as $business) { + $sources[] = ['type' => 'business', 'target_id' => $business->id, 'title' => $business->public_title, 'href' => '/negocios/'.$business->public_slug]; + } + if ($request->user()) { + foreach (DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->get(['id']) as $company) { + $sources[] = ['type' => 'company', 'target_id' => $company->id, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile']; + } + foreach (DB::table('simulation_form_drafts')->where('owner_user_id', $request->user()->id)->whereNull('deleted_at')->get(['id', 'created_at']) as $draft) { + $sources[] = ['type' => 'simulation', 'target_id' => $draft->id, 'title' => 'Ficha de projeto · '.$draft->created_at, 'href' => '/simulador-fiscal?draft='.$draft->id]; + } + } + + return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => DB::table('sectors')->where('is_active', true)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']), + 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']), + 'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id) + ->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []]; + } +} diff --git a/app/Http/Controllers/PartnershipInstallationController.php b/app/Http/Controllers/PartnershipInstallationController.php new file mode 100644 index 00000000..ad108be1 --- /dev/null +++ b/app/Http/Controllers/PartnershipInstallationController.php @@ -0,0 +1,25 @@ +preview()); + } + + public function apply(Request $request, PartnershipInstallation $installation): RedirectResponse + { + $data = $request->validate(['hash' => 'required|string|size:64']); + $installation->apply($data['hash'], $request->user()); + + return back()->with('success', 'Bolsa de Parcerias instalada. Registos antigos preservados.'); + } +} diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php index 44d737a3..6369d91b 100644 --- a/app/Http/Controllers/PortalServicesController.php +++ b/app/Http/Controllers/PortalServicesController.php @@ -11,6 +11,7 @@ use App\Services\Malware\MalwareScanner; use Illuminate\Database\Query\Builder; use Illuminate\Database\QueryException; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -46,9 +47,12 @@ class PortalServicesController extends Controller ->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'); } - public function page(Request $request): Response + public function page(Request $request): Response|JsonResponse { $page = $request->route()->defaults['page']; + if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) { + return app(PartnershipExchangeController::class)->index($request); + } $filters = $request->validate([ 'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'], 'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'], @@ -477,6 +481,9 @@ class PortalServicesController extends Controller public function moderate(Request $request, string $type, int $id): RedirectResponse { + if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) { + return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.'); + } $data = $this->validateManagement($request, ['published' => ['required', 'boolean']]); $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; DB::transaction(function () use ($request, $data, $table, $type, $id) { diff --git a/app/Http/Controllers/VirtualDeskController.php b/app/Http/Controllers/VirtualDeskController.php new file mode 100644 index 00000000..091d9732 --- /dev/null +++ b/app/Http/Controllers/VirtualDeskController.php @@ -0,0 +1,227 @@ +validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']); + $installed = $desk->installed(); + $services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect(); + $services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE)), mb_strtolower($filters['q']))) + && (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values(); + $page = max(1, (int) $request->query('page', 1)); + $data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters, + 'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')]; + return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data); + } + + public function panel(Request $request, VirtualDesk $desk) + { + $actor = $request->user(); + $organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id'); + $cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']); + $queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id)) + ->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']); + $data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(), + 'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->orderByDesc('created_at')->get(['reference', 'status', 'created_at']), + 'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']), + 'submissions' => DB::table('investment_submissions')->where('user_id', $actor->id)->get(['id', 'status']), + 'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')]; + return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data); + } + + public function createCase(Request $request, VirtualDesk $desk) + { + $case = $desk->createCase($request->user(), $request->all()); + return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id); + } + + public function showCase(Request $request, string $id, VirtualDesk $desk) + { + $case = $desk->case($request->user(), $id); $owner = $desk->owns($request->user(), $case, false); + $this->auditRead($request, 'desk_cases', $id); + $data = ['case' => [...(array) $case, 'payload' => \Illuminate\Support\Arr::except($desk->decode($case->payload), ['_creation_hash']), 'workflow' => json_decode($case->workflow, true)], + 'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), + 'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']), + 'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]), + 'documents' => $this->documents($id), 'events' => $this->events($id, $desk), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'), + 'links' => DB::table('desk_institutional_links')->where('status', 'active')->whereNotNull('verified_at')->get(['id', 'stage', 'entity', 'purpose', 'contact', 'verified_at']), 'source' => config('virtual-desk.source')]; + return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data); + } + + private function documents(string $case) + { + return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at') + ->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']); + } + + private function events(string $case, VirtualDesk $desk) + { + return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case)->orderBy('e.created_at')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => [...(array) $e, 'payload' => $desk->decode($e->payload)]); + } + + public function stage(Request $request, string $id, VirtualDesk $desk) + { + $case = $desk->stage($request->user(), $id, $request->all()); + return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.'); + } + + public function createRequest(Request $request, string $id, VirtualDesk $desk) + { + $data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid']); + $row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key']); + return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id); + } + + public function showRequest(Request $request, string $id, VirtualDesk $desk) + { + $row = $desk->request($request->user(), $id); $case = $desk->case($request->user(), $row->case_id); $payload = $desk->decode($row->payload); + $this->auditRead($request, 'desk_requests', $id); + $data = ['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], 'definition' => $desk->definition($row), + '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, + 'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []), + 'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id)->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(), + 'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => \Illuminate\Support\Facades\Crypt::decryptString($m->body)]), + 'events' => $this->events($case->id, $desk)->filter(fn ($e) => $e['request_id'] === $id)->values(), + 'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]), + 'payments' => DB::table('desk_payments')->where('request_id', $id)->get(), + 'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [], + 'territories' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])]; + return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data); + } + + public function save(Request $request, string $id, VirtualDesk $desk) + { + $row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit')); + return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.'); + } + + public function transition(Request $request, string $id, VirtualDesk $desk) + { + $row = $desk->transition($request->user(), $id, $request->all()); + return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.'); + } + + public function assign(Request $request, string $id, VirtualDesk $desk) + { + abort_unless($request->user()->can('manage-portal'), 403); + $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date']); + DB::transaction(function () use ($request, $id, $desk, $data) { + $row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id); + abort_unless((int) $row->version === (int) $data['version'] && ! in_array($row->status, ['draft', 'cancelled', 'closed'], true), 409); + $analyst = User::findOrFail($data['analyst_id']); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.'); + DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]); + $desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first()); + }); + return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.'); + } + + public function message(Request $request, string $id, VirtualDesk $desk) + { + $data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']); + DB::transaction(function () use ($request, $id, $desk, $data) { + $row = $desk->request($request->user(), $id, false, true); $case = $desk->case($request->user(), $row->case_id); + abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.'); + $existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first(); + if ($existing) { abort_unless(\Illuminate\Support\Facades\Crypt::decryptString($existing->body) === $data['body'], 409); return; } + DB::table('desk_messages')->insert(['id' => (string) Str::uuid(), 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => \Illuminate\Support\Facades\Crypt::encryptString($data['body'])]); + $desk->event($case, $request->user(), 'message_added', [], $row); + }); + return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.'); + } + + public function receipt(Request $request, string $id, VirtualDesk $desk) + { + $row = $desk->request($request->user(), $id); abort_unless($row->protocol && $row->submitted_snapshot, 404); + $snapshot = $desk->decode($row->submitted_snapshot); + $this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded'); + return response()->streamDownload(function () use ($row, $snapshot) { + echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL; + echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + }, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']); + } + + public function attach(Request $request, string $id, VirtualDesk $desk) + { + $data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240', + 'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']); + $key = null; $newId = null; + try { + DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) { + $case = $desk->case($request->user(), $id, true, true); abort_unless((int) $case->version === (int) $data['version'], 409); + if ($data['stage'] ?? null) abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422); + if ($data['request_id'] ?? null) { + $row = $desk->request($request->user(), $data['request_id'], true, true); + abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.'); + } + abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.'); + if ($request->hasFile('file')) { + $file = $request->file('file'); $hash = hash_file('sha256', $file->getRealPath()); + $duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first(); + if ($duplicate) return; + $newId = (string) Str::uuid(); $key = $file->storeAs('virtual-desk/'.$id, $newId, 'local'); abort_unless($key, 500); + DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key, + 'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']); + $documentId = $newId; + } else { + $documentId = $data['document_id']; + $document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first(); abort_unless($document, 404); + $owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists(); + abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.'); + if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) return; + } + DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null, + 'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]); + DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]); + $desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]); + }); + } catch (\Throwable $e) { if ($key) Storage::disk('local')->delete($key); throw $e; } + if ($newId) ScanDocumentForMalware::dispatch($newId)->afterCommit(); + return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.'); + } + + public function download(Request $request, string $id, string $document, VirtualDesk $desk) + { + $desk->case($request->user(), $id); + $doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*']); + abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404); + $path = realpath(Storage::disk('local')->path($doc->storage_key)); $root = realpath(Storage::disk('local')->path('')); + abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 404); + $this->auditRead($request, 'documents', $doc->id, 'document_downloaded'); + return response()->download($path, $doc->file_name, ['Cache-Control' => 'private, no-store']); + } + + public function notification(Request $request, int $id) + { + DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]); + return back(); + } + + public function redirectLink(Request $request, string $id, VirtualDesk $desk) + { + $case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']); + $link = DB::table('desk_institutional_links')->where('id', $id)->where('status', 'active')->whereNotNull('verified_at')->first(); abort_unless($link, 404); + abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422); + $desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]); + // No personal data, case ID or protocol appended to the external URL. + return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']); + } + + private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void + { + DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]); + } +} diff --git a/app/Http/Middleware/PartnershipInstalled.php b/app/Http/Middleware/PartnershipInstalled.php new file mode 100644 index 00000000..422d1bca --- /dev/null +++ b/app/Http/Middleware/PartnershipInstalled.php @@ -0,0 +1,25 @@ +expectsJson()) { + return response()->json(['code' => 'partnership_installation_pending', 'message' => 'A extensão da Bolsa aguarda aplicação autenticada; o catálogo existente continua disponível.'], 503); + } + + return redirect('/parcerias')->with('success', 'A extensão da Bolsa aguarda aplicação pela gestão. As propostas existentes continuam preservadas.'); + } + + return $next($request); + } +} diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 243b9224..494dabda 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Http\Controllers\InvestmentPortfolioController; +use App\Models\InvestmentDossier; use App\Models\Opportunity; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; @@ -54,6 +55,7 @@ class InvestmentCatalogue $territory->whereIn('opportunities.primary_territory_id', $ids) ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link') ->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids)); + app(InvestmentRelations::class)->opportunityTerritories($territory, $ids); if ($includeNational) { $territory->orWhere('opportunities.geography_scope', 'national'); } @@ -83,7 +85,13 @@ class InvestmentCatalogue if (! empty($filters['q'])) { $term = '%'.$filters['q'].'%'; $query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term) - ->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))); + ->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term)) + ->orWhereExists(fn ($d) => $d->selectRaw('1')->from('investment_dossiers as searchable_dossier') + ->whereNull('searchable_dossier.deleted_at')->where('searchable_dossier.publication_status', 'published') + ->where(fn ($text) => $text->where('searchable_dossier.title', 'ilike', $term)->orWhereRaw('searchable_dossier.content::text ILIKE ?', [$term])) + ->where(fn ($relation) => $relation + ->whereExists(fn ($p) => $p->selectRaw('1')->from('opportunity_projects as search_project')->whereColumn('search_project.opportunity_id', 'opportunities.id')->whereColumn('search_project.project_id', 'searchable_dossier.project_id')) + ->orWhereExists(fn ($c) => $c->selectRaw('1')->from('opportunity_clusters as search_cluster')->whereColumn('search_cluster.opportunity_id', 'opportunities.id')->whereColumn('search_cluster.cluster_id', 'searchable_dossier.cluster_id'))))); } if (! empty($filters['sector'])) { $query->whereHas('sector', fn ($s) => $s->where('slug', $filters['sector'])); @@ -99,11 +107,14 @@ class InvestmentCatalogue ? app(InvestmentGeography::class)->descendants((string) $filters[$foreign]) : null; if ($relation === 'locations') { - $query->where(fn ($matches) => $matches->whereIn('opportunities.primary_territory_id', $locationIds ?? []) - ->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link') - ->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id') - ->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? []) - ->whereNull('target.deleted_at')->where('target.data_classification', 'public'))); + $query->where(function ($matches) use ($locationIds) { + $matches->whereIn('opportunities.primary_territory_id', $locationIds ?? []) + ->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link') + ->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id') + ->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? []) + ->whereNull('target.deleted_at')->where('target.data_classification', 'public')); + app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []); + }); continue; } @@ -113,9 +124,8 @@ class InvestmentCatalogue ->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign))); } else { $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) - ->whereColumn('link.opportunity_id', 'opportunities.id')->when($relation === 'locations', fn ($q) => $q->whereIn('link.'.$foreign, $locationIds ?? []), fn ($q) => $q->where('link.'.$foreign, $filters[$foreign]))->whereNull('target.deleted_at') - ->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'), - fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)))); + ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at') + ->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign))); } } } @@ -149,7 +159,8 @@ class InvestmentCatalogue }; } - /** @return array */ + /** @param array $territoryFilters + * @return array */ public function references(bool $management = false, array $territoryFilters = []): array { $result = ['options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(), @@ -203,6 +214,15 @@ class InvestmentCatalogue } } $result[$relation] = $query->get(['t.id', 't.name']); + if (in_array($relation, ['projects', 'clusters'], true)) { + $result[$relation] = $result[$relation]->map(function ($entity) use ($foreign, $management) { + $dossiers = ($management ? InvestmentDossier::query() : InvestmentPortfolioController::visible())->where($foreign, $entity->id)->get(); + $entity->dossiers = $dossiers->map(fn ($dossier) => ['id' => $dossier->id, 'title' => $dossier->title, 'source' => $dossier->source]); + $entity->geography = $dossiers->first() ? app(InvestmentRelations::class)->dossier($dossiers->first(), $management) : null; + + return $entity; + }); + } } $result['zones'] = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') @@ -224,6 +244,13 @@ class InvestmentCatalogue $ids = $records->pluck('id'); $points = DB::table('opportunities as o')->join('geo_locations as g', function ($join) { $join->on('g.id', '=', 'o.primary_territory_id')->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_locations as l')->whereColumn('l.opportunity_id', 'o.id')->whereColumn('l.geo_location_id', 'g.id')); + $join->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_projects as map_project') + ->join('projects as p', 'p.id', '=', 'map_project.project_id')->whereColumn('map_project.opportunity_id', 'o.id')->whereNull('p.deleted_at') + ->whereNotIn('p.data_classification', ['confidential', 'restricted'])->whereIn('p.id', app(InvestmentRelations::class)->projectIds()) + ->where(fn ($site) => $site->whereColumn('p.primary_territory_id', 'g.id') + ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as map_location') + ->whereColumn('map_location.project_id', 'p.id')->whereColumn('map_location.geo_location_id', 'g.id') + ->whereNull('map_location.deleted_at')->where('map_location.data_classification', 'public')->where('map_location.role', '!=', 'impact')))); })->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id') ->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public') ->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point') @@ -234,7 +261,14 @@ class InvestmentCatalogue ) SELECT 1 FROM parents WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)") ->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326') ->orderBy('o.id')->orderByRaw("CASE WHEN g.source_system='investment-site-editor' THEN 0 ELSE 1 END")->orderBy('g.id')->orderBy('shape.id') - ->get(['o.id', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')])->unique('id')->keyBy('id'); + ->get(['o.id', 'g.name', 'g.id as location_id', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'), + DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'opportunity_site' ELSE 'linked_project_site' END as location_basis")]) + ->groupBy('id')->map(function ($candidates) { + $direct = $candidates->where('location_basis', 'opportunity_site'); + $selected = $direct->isNotEmpty() ? $direct : $candidates; + + return $selected->unique('location_id')->count() === 1 && $selected->unique('geometry')->count() === 1 ? $selected->first() : null; + })->filter(); $relations = []; foreach (['clusters', 'corridors', 'projects'] as $relation) { [$foreign, $table] = self::RELATIONS[$relation]; @@ -251,7 +285,8 @@ class InvestmentCatalogue $properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all(); } if ($point = $points->get($record->id)) { - $features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, 'location' => trim($properties['location'].' — '.$point->name)]]; + $features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, + 'location_basis' => $point->location_basis, 'location' => trim($properties['location'].' — '.$point->name).($point->location_basis === 'linked_project_site' ? ' (local documentado do projecto associado; implantação da oportunidade por confirmar)' : '')]]; } else { $missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates']; } diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php index 3d287e03..bf600145 100644 --- a/app/Services/InvestmentGeography.php +++ b/app/Services/InvestmentGeography.php @@ -2,8 +2,9 @@ namespace App\Services; -use Illuminate\Database\Query\Builder; +use App\Models\InvestmentDossier; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -13,14 +14,25 @@ final class InvestmentGeography /** * Restrict published dossier searches using direct territories and public, validated structure links. - * @param EloquentBuilder<\App\Models\InvestmentDossier> $query - * @param list $territoryIds - * @return EloquentBuilder<\App\Models\InvestmentDossier> + * + * @param EloquentBuilder $query + * @param list $territoryIds + * @return EloquentBuilder */ public function constrainDossiersToTerritories(EloquentBuilder $query, array $territoryIds): EloquentBuilder { return $query->where(function ($matches) use ($territoryIds): void { - $matches->whereIn('project_id', DB::table('projects')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id')) + $matches->orWhereExists(fn ($coverage) => $coverage->selectRaw('1')->from('economic_clusters as coverage_cluster') + ->whereColumn('coverage_cluster.id', 'investment_dossiers.cluster_id')->whereNull('coverage_cluster.deleted_at') + ->whereRaw(app(InvestmentRelations::class)->coverageSql('coverage_cluster.metadata', $territoryIds), $territoryIds)); + $matches->orWhereExists(fn ($member) => $member->selectRaw('1')->fromSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'documented_member') + ->join('projects as member_project', 'member_project.id', '=', 'documented_member.project_id') + ->whereColumn('documented_member.cluster_id', 'investment_dossiers.cluster_id') + ->where(fn ($area) => $area->whereIn('member_project.primary_territory_id', $territoryIds) + ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as member_location') + ->whereColumn('member_location.project_id', 'member_project.id')->whereNull('member_location.deleted_at') + ->where('member_location.data_classification', 'public')->whereIn('member_location.geo_location_id', $territoryIds)))); + $matches->orWhereIn('project_id', DB::table('projects')->whereNull('deleted_at')->whereIn('primary_territory_id', $territoryIds)->select('id')) ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as pl') ->whereColumn('pl.project_id', 'investment_dossiers.project_id')->whereNull('pl.deleted_at') ->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $territoryIds)) @@ -177,7 +189,18 @@ final class InvestmentGeography ? DB::table('geo_locations')->whereNull('deleted_at') : $this->publicReferences(); - return array_values($query->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all()); + $treeIds = array_map(fn ($row) => $row->id, $rows); + $ids = $query->whereIn('id', $treeIds)->pluck('id')->map(fn ($id) => (string) $id)->all(); + if (! $includeNonPublic) { + // Documented sites need not be administrative registry entries themselves. + // Their parent must belong to the same public, trusted administrative hierarchy. + $sites = DB::table('geo_locations')->whereIn('id', $treeIds)->where('location_type', 'site') + ->whereNull('deleted_at')->where('data_classification', 'public') + ->whereIn('parent_id', $this->publicReferences()->select('id'))->pluck('id')->all(); + $ids = array_merge($ids, $sites); + } + + return array_values(array_unique($ids)); } /** @return list<\stdClass> */ diff --git a/app/Services/InvestmentRelations.php b/app/Services/InvestmentRelations.php new file mode 100644 index 00000000..9ef35cc3 --- /dev/null +++ b/app/Services/InvestmentRelations.php @@ -0,0 +1,178 @@ +whereNotNull('project_id')->select('project_id')->toBase(); + } + + public function memberships(): Builder + { + // Legacy proposal links are consultable when both dossiers disclose the exact project code. + // Their pending/legal status remains independent from permission to consult the source. + return DB::table('cluster_projects as cp')->join('projects as member', 'member.id', '=', 'cp.project_id') + ->whereNull('cp.deleted_at')->whereNull('member.deleted_at')->whereNotIn('member.data_classification', ['confidential', 'restricted']) + ->where('cp.validation_status', '!=', 'revoked') + ->where(fn ($q) => $q->whereNull('cp.valid_from')->orWhereDate('cp.valid_from', '<=', today())) + ->where(fn ($q) => $q->whereNull('cp.valid_until')->orWhereDate('cp.valid_until', '>=', today())) + ->whereIn('cp.project_id', $this->projectIds()) + ->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_dossiers as disclosed_cluster') + ->whereColumn('disclosed_cluster.cluster_id', 'cp.cluster_id')->where('disclosed_cluster.publication_status', 'published')->whereNull('disclosed_cluster.deleted_at') + ->where(fn ($authorized) => $authorized->where('cp.data_classification', 'public') + ->orWhere(fn ($legacy) => $legacy->where('cp.source_system', 'upgrade-v4') + ->whereRaw("(disclosed_cluster.content->>'anchors') ~ ('\\m' || member.code || '\\M')")))); + } + + /** @return list> */ + public function projectLocations(string $id, bool $management = false): array + { + $project = DB::table('projects')->where('id', $id)->whereNull('deleted_at')->first(); + if (! $project || (! $management && (in_array($project->data_classification, ['confidential', 'restricted'], true) + || ! InvestmentPortfolioController::visible()->where('project_id', $id)->exists()))) { + return []; + } + $links = DB::table('project_locations')->where('project_id', $id)->whereNull('deleted_at') + ->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('role')->get(); + $items = []; + $metadata = json_decode($project->metadata, true); + foreach ($links as $link) { + $items[] = ['id' => $link->geo_location_id, 'role' => $link->role, 'relation_id' => $link->id, + 'source_reference' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_reference'] ?? $link->source_system, + 'source_quote' => $metadata['documented_location_sources'][$link->geo_location_id.':'.$link->role]['source_quote'] ?? null, + 'external_id' => $link->external_id]; + } + if ($project->primary_territory_id && ! collect($items)->contains(fn ($item) => $item['id'] === $project->primary_territory_id && $item['role'] === 'primary')) { + $items[] = ['id' => $project->primary_territory_id, 'role' => 'primary', 'source_reference' => $project->source_system, 'relation_id' => null]; + } + + return $this->locations($items, $management); + } + + /** @param list> $items + * @return list> */ + public function locations(array $items, bool $management = false): array + { + $result = []; + foreach ($items as $item) { + $path = app(InvestmentGeography::class)->path($item['id'], $management); + // Site nodes are not administrative references; use their documented parent path. + $site = DB::table('geo_locations')->where('id', $item['id'])->whereNull('deleted_at') + ->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->first(); + if (! $site) { + continue; + } + if ($site->location_type === 'site') { + $path = app(InvestmentGeography::class)->path($site->parent_id, $management); + if ($site->parent_id && ! $path) { + continue; + } + } elseif (! $path) { + continue; + } + $shapes = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at') + ->when(! $management, fn ($q) => $q->where('data_classification', 'public')) + ->where('geometry_type', 'point')->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326') + ->orderBy('id')->get(['id', 'source_system', DB::raw('ST_AsGeoJSON(geom) as geometry')]); + $coordinates = $shapes->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system, + 'geometry' => json_decode($shape->geometry, true)])->all(); + $result[] = [...$item, 'name' => $site->name, 'location_type' => $site->location_type, 'path' => $path, 'coordinates' => $coordinates]; + } + + return $result; + } + + /** @return array */ + public function dossier(InvestmentDossier $dossier, bool $management = false): array + { + $result = ['locations' => [], 'members' => [], 'clusters' => [], 'chainLinks' => [], 'chains' => []]; + if ($dossier->project_id) { + $result['locations'] = $this->projectLocations($dossier->project_id, $management); + $memberships = $this->memberships()->where('cp.project_id', $dossier->project_id)->get(['cp.cluster_id', 'cp.role', 'cp.validation_status', 'cp.source_reference']); + foreach ($memberships as $membership) { + foreach (InvestmentPortfolioController::visible()->where('cluster_id', $membership->cluster_id)->get() as $cluster) { + $result['clusters'][] = ['id' => $cluster->cluster_id, 'dossier_id' => $cluster->id, 'title' => $cluster->title, + 'role' => $membership->role, 'validation_status' => $membership->validation_status, 'source_reference' => $membership->source_reference ?? $cluster->source]; + } + } + } + if ($dossier->cluster_id) { + $result['chains'] = DB::table('value_chains')->where('cluster_id', $dossier->cluster_id)->whereNull('deleted_at') + ->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('code')->get(['id', 'code', 'name', 'stage'])->all(); + $cluster = DB::table('economic_clusters')->where('id', $dossier->cluster_id)->first(); + $metadata = json_decode($cluster->metadata ?? '{}', true); + $coverage = array_values(array_filter($metadata['documented_coverage'] ?? [], fn ($item) => isset($item['id'], $item['source_reference']) && ($management || ($item['data_classification'] ?? '') === 'public'))); + if ($cluster?->primary_territory_id) { + $coverage[] = ['id' => $cluster->primary_territory_id, 'role' => 'coverage', 'source_reference' => $cluster->source_system]; + } + $result['locations'] = $this->locations($coverage, $management); + foreach ($this->memberships()->where('cp.cluster_id', $dossier->cluster_id)->get(['cp.project_id', 'member.code', 'member.name', 'cp.role', 'cp.validation_status', 'cp.source_reference']) as $member) { + $projectDossier = InvestmentPortfolioController::visible()->where('project_id', $member->project_id)->first(); + if ($projectDossier) { + $result['members'][] = ['id' => $member->project_id, 'dossier_id' => $projectDossier->id, 'title' => $projectDossier->title, + 'code' => $member->code, 'role' => $member->role, 'validation_status' => $member->validation_status, + 'source_reference' => $member->source_reference ?? $dossier->source, 'locations' => $this->projectLocations($member->project_id, $management)]; + } + } + $result['chainLinks'] = DB::table('value_chain_links as link')->join('value_chains as origin', 'origin.id', '=', 'link.from_value_chain_id') + ->join('value_chains as destination', 'destination.id', '=', 'link.to_value_chain_id') + ->where('origin.cluster_id', $dossier->cluster_id)->where('destination.cluster_id', $dossier->cluster_id) + ->whereNull('link.deleted_at')->whereNull('origin.deleted_at')->whereNull('destination.deleted_at') + ->when(! $management, fn ($q) => $q->where('link.data_classification', 'public')->where('origin.data_classification', 'public')->where('destination.data_classification', 'public')) + ->get(['link.id', 'origin.name as origin', 'destination.name as destination', 'link.link_type'])->all(); + } + + return $result; + } + + /** Add explicitly linked project and cluster member territories without creating opportunity-project links. + * @param Builder|\Illuminate\Database\Eloquent\Builder $query + * @param list $ids */ + public function opportunityTerritories(Builder|\Illuminate\Database\Eloquent\Builder $query, array $ids): void + { + $query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_projects as geographic_project') + ->join('projects as documented_project', 'documented_project.id', '=', 'geographic_project.project_id') + ->whereColumn('geographic_project.opportunity_id', 'opportunities.id')->whereNull('documented_project.deleted_at') + ->whereIn('documented_project.id', $this->projectIds()) + ->where(fn ($area) => $area->whereIn('documented_project.primary_territory_id', $ids) + ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as documented_location') + ->whereColumn('documented_location.project_id', 'documented_project.id')->whereNull('documented_location.deleted_at') + ->where('documented_location.data_classification', 'public')->whereIn('documented_location.geo_location_id', $ids)))); + $query->orWhereExists(fn ($links) => $links->selectRaw('1')->from('opportunity_clusters as geographic_cluster') + ->join('economic_clusters as documented_cluster', 'documented_cluster.id', '=', 'geographic_cluster.cluster_id') + ->whereColumn('geographic_cluster.opportunity_id', 'opportunities.id')->whereNull('documented_cluster.deleted_at') + ->whereIn('documented_cluster.id', InvestmentPortfolioController::visible()->whereNotNull('cluster_id')->select('cluster_id')) + ->where(fn ($area) => $area->whereIn('documented_cluster.primary_territory_id', $ids) + ->orWhereRaw($this->coverageSql('documented_cluster.metadata', $ids), $ids) + ->orWhereExists(fn ($members) => $members->selectRaw('1')->fromSub($this->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'territorial_member') + ->join('projects as member_project', 'member_project.id', '=', 'territorial_member.project_id')->whereColumn('territorial_member.cluster_id', 'documented_cluster.id') + ->where(fn ($territory) => $territory->whereIn('member_project.primary_territory_id', $ids) + ->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('project_locations as member_location') + ->whereColumn('member_location.project_id', 'member_project.id')->whereNull('member_location.deleted_at') + ->where('member_location.data_classification', 'public')->whereIn('member_location.geo_location_id', $ids)))))); + } + + /** @param list $ids */ + public function coverageSql(string $column, array $ids): string + { + if (! in_array($column, ['documented_cluster.metadata', 'coverage_cluster.metadata'], true)) { + throw new \InvalidArgumentException('Unsupported coverage column'); + } + if (! $ids) { + return 'false'; + } + + return "EXISTS (SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof({$column}->'documented_coverage')='array' THEN {$column}->'documented_coverage' ELSE '[]'::jsonb END) AS coverage_entry + WHERE coverage_entry->>'data_classification'='public' AND NULLIF(coverage_entry->>'source_reference','') IS NOT NULL + AND coverage_entry->>'id' IN (".implode(',', array_fill(0, count($ids), '?')).'))'; + } +} diff --git a/app/Services/InvestmentRelationshipRepair.php b/app/Services/InvestmentRelationshipRepair.php new file mode 100644 index 00000000..a0da2901 --- /dev/null +++ b/app/Services/InvestmentRelationshipRepair.php @@ -0,0 +1,213 @@ +,locations:list>,memberships:list>,pending:list>,prepared_opportunities:list>} */ +class InvestmentRelationshipRepair +{ + public function directory(): string + { + return config('investment.repair_directory', storage_path('app/private/anchor-project-reconciliation')); + } + + /** @return array{hash:string,plan:RepairPlan,territorial:list>,history:list>,unproposed:list>} */ + public function preview(): array + { + $file = $this->directory().'/preview.json'; + abort_unless(is_file($file), 404, 'Pré-visualização não preparada.'); + $contents = file_get_contents($file); + abort_if($contents === false, 503, 'Pré-visualização não pode ser lida.'); + + $hash = hash('sha256', $contents); + $plan = $this->decodePlan($contents); + $history = $this->history($hash); + $validation = app(InvestmentTerritorialValidation::class); + $territorial = []; + foreach ($plan['locations'] as $row) { + $result = $validation->validate($row); + $result['applied'] = $this->wasApplied($history, $result['row_id']); + $result['validation_hash'] = hash('sha256', json_encode($result, JSON_THROW_ON_ERROR)); + $territorial[] = $result; + } + + $unproposed = []; + foreach (array_unique(array_column($plan['pending'], 'code')) as $code) { + if (! preg_match('/^A\d{2}$/', $code) || collect($plan['locations'])->contains('code', $code)) { + continue; + } + $project = DB::table('projects')->where('code', $code)->whereNull('deleted_at')->first(); + if (! $project) { + continue; + } + $dossier = DB::table('investment_dossiers')->where('project_id', $project->id)->whereNull('deleted_at')->first(); + $unproposed[] = ['code' => $code, 'current' => app(InvestmentRelations::class)->projectLocations($project->id, true), + 'source' => $dossier?->source, 'location_text' => $dossier ? (json_decode($dossier->content, true)['location'] ?? null) : null, + 'reasons' => collect($plan['pending'])->where('code', $code)->pluck('reason')->all()]; + } + + return ['hash' => $hash, 'plan' => $plan, 'territorial' => $territorial, 'history' => $history, 'unproposed' => $unproposed]; + } + + /** @return RepairPlan */ + private function decodePlan(string $contents): array + { + $plan = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + abort_unless(is_array($plan) && is_array($plan['target'] ?? null) && is_array($plan['locations'] ?? null) && is_array($plan['memberships'] ?? null), 422, 'Plano de associações inválido.'); + $target = []; + foreach ($plan['target'] as $key => $value) { + abort_unless(is_string($key), 422, 'Destino inválido.'); + $target[$key] = $value; + } + + return ['target' => $target, 'locations' => $this->decodeRows($plan['locations']), 'memberships' => $this->decodeRows($plan['memberships']), + 'pending' => $this->decodeRows($plan['pending'] ?? []), 'prepared_opportunities' => $this->decodeRows($plan['prepared_opportunities'] ?? [])]; + } + + /** @return list> */ + private function decodeRows(mixed $rows): array + { + abort_unless(is_array($rows), 422, 'Linhas do plano inválidas.'); + $result = []; + foreach ($rows as $row) { + abort_unless(is_array($row), 422, 'Linha do plano inválida.'); + $item = []; + foreach ($row as $key => $value) { + abort_unless(is_string($key), 422, 'Campo do plano inválido.'); + $item[$key] = $value; + } + $result[] = $item; + } + + return $result; + } + + /** @return list> */ + private function history(string $hash): array + { + return array_values(DB::table('audit_events as event')->leftJoin('users as actor', 'actor.id', '=', 'event.actor_user_id') + ->where('event.source_system', 'documented-relations-repair')->whereRaw("event.after_data->>'plan_hash' = ?", [$hash]) + ->orderBy('event.created_at')->orderBy('event.id')->get(['event.id', 'event.created_at', 'event.actor_user_id', 'actor.name as actor_name', 'event.after_data']) + ->map(fn ($event) => ['id' => $event->id, 'date' => $event->created_at, 'actor_id' => $event->actor_user_id, 'actor' => $event->actor_name, + 'data' => json_decode($event->after_data, true)])->all()); + } + + /** @param list> $history */ + private function wasApplied(array $history, string $rowId): bool + { + foreach ($history as $event) { + if (! isset($event['data']['location_row_ids']) || in_array($rowId, $event['data']['location_row_ids'], true)) { + return true; + } + } + + return false; + } + + /** @return array */ + public function apply(string $hash, int $actor, ?string $rowId = null, ?string $validationHash = null): array + { + $preview = $this->preview(); + abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada. Reveja as associações.'); + $proofFile = $this->directory().'/backup-proof.json'; + abort_unless(is_file($proofFile), 422, 'Backup e restauro isolado ainda não verificados.'); + $contents = file_get_contents($proofFile); + abort_if($contents === false, 503, 'Evidência de restauro não pode ser lida.'); + $proof = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + $dumpHash = is_file($proof['dump_path'] ?? '') ? hash_file('sha256', $proof['dump_path']) : false; + abort_unless(($proof['restore_verified'] ?? false) && is_file($proof['dump_path'] ?? '') + && $dumpHash !== false && hash_equals($proof['sha256'], $dumpHash), 422, 'Evidência de backup/restauro inválida.'); + $plan = $preview['plan']; + $validation = app(InvestmentTerritorialValidation::class); + if ($rowId !== null) { + abort_unless(collect($plan['locations'])->contains(fn ($row) => $validation->rowId($row) === $rowId), 422, 'Associação não pertence ao plano.'); + } + $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); + abort_unless($identity->database === $plan['target']['database'] && (int) $identity->port === (int) $plan['target']['port'] + && ($proof['target'] ?? null) === $plan['target'], 409, 'Base de destino diferente da pré-visualização e do backup.'); + if (app()->environment('testing')) { + abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base *_test isolada na porta 55441.'); + } + + return DB::transaction(function () use ($plan, $hash, $actor, $rowId, $validation, $validationHash): array { + DB::select('select pg_advisory_xact_lock(71026, 150000)'); + $history = $this->history($hash); + if (($rowId !== null && $this->wasApplied($history, $rowId)) || ($rowId === null && collect($history)->contains(fn ($event) => ! isset($event['data']['location_row_ids']) || ($event['data']['batch_complete'] ?? false)))) { + return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0]; + } + DB::statement('LOCK TABLE geo_locations, geo_geometries IN SHARE MODE'); + $created = $memberships = 0; + $projects = $appliedRows = $sources = $before = []; + foreach ($plan['locations'] as $row) { + $id = $validation->rowId($row); + if (($rowId !== null && $rowId !== $id) || $this->wasApplied($history, $id)) { + continue; + } + $territory = $validation->validate($row); + if ($rowId !== null) { + $territory['applied'] = false; + abort_unless($validationHash !== null && hash_equals(hash('sha256', json_encode($territory, JSON_THROW_ON_ERROR)), $validationHash), 409, 'Validação territorial alterada. Reveja a proposta.'); + } + if ($territory['status'] !== 'grounded') { + abort_if($rowId !== null, 409, 'Associação pendente ou contraditória; não pode ser aplicada.'); + + continue; + } + $project = $projects[$row['project_id']] ?? DB::table('projects')->where('id', $row['project_id'])->whereNull('deleted_at')->lockForUpdate()->first(); + $increments = collect($history)->filter(fn ($event) => in_array($row['project_id'], $event['data']['project_ids'] ?? [], true))->count(); + abort_unless($project && (int) $project->version_no === (int) $row['expected_project_version'] + $increments, 409, 'Projecto alterado desde a pré-visualização.'); + $dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first(); + abort_unless($dossier && $dossier->project_id === $project->id && (int) $dossier->version_no === (int) $row['dossier_version'], 409, 'Dossier alterado.'); + $content = json_decode($dossier->content, true); + abort_unless(($content['location'] ?? null) === $row['source_quote'] && $dossier->source === $row['source_reference'], 409, 'Citação documental alterada.'); + abort_unless($row['data_classification'] !== 'public' || $dossier->publication_status === 'published', 409, 'Autorização de divulgação alterada.'); + abort_unless(app(InvestmentGeography::class)->references(true)->where('id', $row['geo_location_id'])->exists(), 409, 'Referência territorial indisponível.'); + $appliedRows[] = $id; + $before[] = ['row_id' => $id, 'project_id' => $project->id, 'current' => $territory['current']]; + $sources[] = ['row_id' => $id, 'project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role'], + 'source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'reason' => $row['reason']]; + $key = ['project_id' => $project->id, 'geo_location_id' => $row['geo_location_id'], 'role' => $row['role']]; + if (! DB::table('project_locations')->where($key)->whereNull('deleted_at')->exists()) { + DB::table('project_locations')->insert([...$key, 'id' => (string) Str::uuid(), 'source_system' => 'documented-relations-repair', + 'external_id' => $row['dossier_id'].':'.$row['geo_location_id'].':'.$row['role'], 'data_classification' => $row['data_classification']]); + $metadata = json_decode($project->metadata, true); + $metadata['documented_location_sources'][$row['geo_location_id'].':'.$row['role']] = ['source_reference' => $row['source_reference'], 'source_quote' => $row['source_quote'], 'dossier_id' => $dossier->id, 'plan_hash' => $hash, 'reason' => $row['reason'], 'actor_user_id' => $actor, 'applied_at' => now()->toIso8601String()]; + $project->metadata = json_encode($metadata, JSON_THROW_ON_ERROR); + $projects[$project->id] = $project; + $created++; + } + } + foreach ($projects as $project) { + DB::table('projects')->where('id', $project->id)->update(['metadata' => $project->metadata, 'version_no' => $project->version_no + 1]); + } + foreach ($rowId === null ? $plan['memberships'] : [] as $row) { + if ($row['existing_id']) { + continue; + } + $key = ['cluster_id' => $row['cluster_id'], 'project_id' => $row['project_id']]; + $dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->lockForUpdate()->first(); + abort_unless($dossier && $dossier->cluster_id === $row['cluster_id'] && (int) $dossier->version_no === (int) $row['dossier_version'] + && (json_decode($dossier->content, true)['anchors'] ?? null) === $row['source_quote'], 409, 'Fonte da relação do cluster alterada.'); + abort_unless(DB::table('projects')->where('id', $row['project_id'])->where('code', $row['project_code'])->whereNull('deleted_at')->exists(), 409, 'Identidade do projecto alterada.'); + if (! DB::table('cluster_projects')->where($key)->whereNull('deleted_at')->exists()) { + DB::table('cluster_projects')->insert([...$key, 'id' => (string) Str::uuid(), 'role' => $row['role'], 'source_system' => 'documented-relations-repair', + 'source_reference' => $row['source_reference'], 'evidence_reference' => $row['dossier_id'], 'editor_id' => $actor, + 'data_classification' => $dossier->publication_status === 'published' ? 'public' : 'internal', 'validation_status' => 'pending', 'association_type' => 'potential']); + $memberships++; + } + } + if (! $appliedRows && ! $memberships) { + return ['replayed' => true, 'locations_created' => 0, 'memberships_created' => 0]; + } + DB::table('audit_events')->insert(['source_system' => 'documented-relations-repair', 'entity_type' => 'project_locations', + 'actor_user_id' => $actor, 'action' => 'documented_relations_applied', 'before_data' => json_encode($before, JSON_THROW_ON_ERROR), 'after_data' => json_encode(['plan_hash' => $hash, + 'locations_created' => $created, 'memberships_created' => $memberships, 'project_ids' => array_keys($projects), + 'location_row_ids' => $appliedRows, 'sources' => $sources, + 'batch_complete' => $rowId === null && count(array_filter($plan['locations'], fn ($row) => ! in_array($validation->rowId($row), $appliedRows, true) && ! $this->wasApplied($history, $validation->rowId($row)))) === 0], JSON_THROW_ON_ERROR)]); + + return ['replayed' => false, 'locations_created' => $created, 'memberships_created' => $memberships]; + }); + } +} diff --git a/app/Services/InvestmentTerritorialValidation.php b/app/Services/InvestmentTerritorialValidation.php new file mode 100644 index 00000000..d44ac021 --- /dev/null +++ b/app/Services/InvestmentTerritorialValidation.php @@ -0,0 +1,132 @@ + $row */ + public function rowId(array $row): string + { + return hash('sha256', json_encode($row, JSON_THROW_ON_ERROR)); + } + + /** @param array $row + * @return array + */ + public function validate(array $row): array + { + $issues = $warnings = $path = $visited = []; + $pending = false; + $project = DB::table('projects')->where('id', $row['project_id'])->whereNull('deleted_at')->first(); + $dossier = DB::table('investment_dossiers')->where('id', $row['dossier_id'])->whereNull('deleted_at')->first(); + if (! $project || ! $dossier || $dossier->project_id !== $row['project_id']) { + $issues[] = 'Projeto ou documento de origem indisponível ou de outra identidade.'; + } elseif (($row['source_reference'] ?? '') === '' || ($row['source_quote'] ?? '') === '') { + $pending = true; + $warnings[] = 'Falta referência ou citação documental da localização.'; + } elseif ($dossier->source !== $row['source_reference'] || (json_decode($dossier->content, true)['location'] ?? null) !== $row['source_quote'] + || (int) $dossier->version_no !== (int) $row['dossier_version']) { + $issues[] = 'Fonte, citação ou versão do dossier diverge da proposta.'; + } + if (($row['data_classification'] ?? null) === 'public' && $dossier && $dossier->publication_status !== 'published') { + $issues[] = 'A autorização de divulgação do dossier já não corresponde à proposta pública.'; + } + if (! in_array($row['role'] ?? null, ['primary', 'secondary', 'impact'], true)) { + $issues[] = 'Papel territorial inválido.'; + } + $id = $row['geo_location_id']; + while ($id) { + if (isset($visited[$id])) { + $issues[] = 'Ciclo na hierarquia territorial.'; + break; + } + $visited[$id] = true; + $node = DB::table('geo_locations')->where('id', $id)->first(); + if (! $node || $node->deleted_at || ! in_array($node->data_classification, ['public', 'internal'], true)) { + $issues[] = 'Território ou antepassado indisponível para esta associação.'; + break; + } + array_unshift($path, ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type]); + $id = $node->parent_id; + } + $levels = ['country' => 0, 'province' => 1, 'district' => 2, 'admin_post' => 3, 'locality' => 4, 'site' => 5]; + $previous = null; + foreach ($path as $node) { + $level = $levels[$node['type']] ?? null; + if ($level === null || ($previous !== null && $level !== $previous + 1 && $node['type'] !== 'site')) { + $issues[] = 'Hierarquia incompatível: província → distrito → posto administrativo → localidade.'; + } + $previous = $level; + } + if (! collect($path)->contains('type', 'province')) { + $pending = true; + $warnings[] = 'A hierarquia não identifica uma província.'; + } + foreach (InvestmentGeography::FIELDS as $field => $type) { + if (isset($row[$field]) && ! collect($path)->contains(fn ($node) => $node['type'] === $type && $node['id'] === $row[$field])) { + $issues[] = 'O nível indicado em '.$field.' não pertence à hierarquia proposta.'; + } + } + $points = DB::table('geo_geometries')->where('geo_location_id', $row['geo_location_id'])->whereNull('deleted_at') + ->whereIn('data_classification', ['public', 'internal'])->where('geometry_type', 'point') + ->get(['id', 'source_system', DB::raw('ST_AsGeoJSON(geom) as geometry'), DB::raw('ST_SRID(geom) as srid')]); + $coordinates = []; + foreach ($points as $point) { + $geometry = json_decode($point->geometry, true); + $coordinates[] = ['geometry_id' => $point->id, 'source' => $point->source_system, 'coordinates' => $geometry['coordinates'] ?? [], 'srid' => $point->srid]; + } + if (isset($row['coordinates'])) { + $coordinates[] = ['geometry_id' => null, 'source' => $row['source_reference'], 'coordinates' => $row['coordinates'], 'srid' => 4326]; + } + $checks = []; + foreach ($coordinates as $point) { + $xy = $point['coordinates']; + if (count($xy) < 2 || ! is_numeric($xy[0]) || ! is_numeric($xy[1]) || ! is_finite((float) $xy[0]) || ! is_finite((float) $xy[1]) + || abs((float) $xy[0]) > 180 || abs((float) $xy[1]) > 90 || (int) $point['srid'] !== 4326) { + $issues[] = 'Coordenadas inválidas ou sistema de referência incompatível com WGS84.'; + + continue; + } + $boundaries = DB::table('geo_geometries')->whereIn('geo_location_id', array_column($path, 'id'))->whereNull('deleted_at') + ->whereIn('data_classification', ['public', 'internal'])->whereIn('geometry_type', ['polygon', 'multipolygon']) + ->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326') + ->select(['id', 'geo_location_id', 'source_system']) + ->selectRaw('ST_Covers(geom, ST_SetSRID(ST_MakePoint(?, ?),4326)) as compatible', [(float) $xy[0], (float) $xy[1]])->get(); + if ($boundaries->isEmpty()) { + $warnings[] = 'Coordenadas com formato válido; sem limites cartográficos documentados para confirmar inclusão territorial.'; + } + foreach ($boundaries as $boundary) { + $checks[] = ['geometry_id' => $point['geometry_id'], 'territory_id' => $boundary->geo_location_id, 'source' => $boundary->source_system, 'compatible' => (bool) $boundary->compatible]; + if (! $boundary->compatible) { + $issues[] = 'Coordenadas fora do território '.(collect($path)->firstWhere('id', $boundary->geo_location_id)['name'] ?? $boundary->geo_location_id).'.'; + } + } + } + if (! $coordinates) { + $warnings[] = 'Sem coordenadas; a ausência não impede validar a localização documental.'; + } + + $current = []; + if ($project) { + $links = DB::table('project_locations')->where('project_id', $project->id)->whereNull('deleted_at')->get(); + foreach ($links as $link) { + $location = DB::table('geo_locations')->where('id', $link->geo_location_id)->first(); + $current[] = ['id' => $link->geo_location_id, 'name' => $location->name ?? 'Território indisponível', 'role' => $link->role, + 'path' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($link->geo_location_id, true))]; + } + if ($project->primary_territory_id && ! collect($current)->contains(fn ($item) => $item['id'] === $project->primary_territory_id && $item['role'] === 'primary')) { + $location = DB::table('geo_locations')->where('id', $project->primary_territory_id)->first(); + $current[] = ['id' => $project->primary_territory_id, 'name' => $location->name ?? 'Território indisponível', 'role' => 'primary', + 'path' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($project->primary_territory_id, true))]; + } + } + + return ['row_id' => $this->rowId($row), 'status' => $issues ? 'contradictory' : ($pending ? 'pending' : 'grounded'), + 'issues' => array_values(array_unique($issues)), 'warnings' => array_values(array_unique($warnings)), 'current' => $current, + 'proposed' => ['id' => $row['geo_location_id'], 'name' => collect($path)->last()['name'] ?? ($row['name'] ?? 'Território indisponível'), 'role' => $row['role'], 'path' => $path], + 'coordinates' => $coordinates, 'coordinate_checks' => $checks]; + } +} diff --git a/app/Services/PartnershipExchange.php b/app/Services/PartnershipExchange.php new file mode 100644 index 00000000..3358fb2b --- /dev/null +++ b/app/Services/PartnershipExchange.php @@ -0,0 +1,241 @@ +where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at') + ->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists() + && DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists(); + } + + public function owns(User $actor, stdClass $record, bool $write = true): bool + { + // An organization record follows its current representatives, including the original author. + return $record->organization_id ? $this->represents($actor, $record->organization_id, $write) : (int) $record->user_id === $actor->id; + } + + public function find(int $id, bool $lock = false): stdClass + { + $record = DB::table('portal_partnerships')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first(); + abort_unless($record !== null, 404); + + return $record; + } + + /** @param array $data */ + public function save(User $actor, array $data, ?int $id = null): stdClass + { + $data = Validator::make($data, [ + 'client_key' => [$id ? 'nullable' : 'required', 'uuid'], 'version' => [$id ? 'required' : 'nullable', 'integer', 'min:1'], + 'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'], + 'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))], + 'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')], + 'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:summary,requirements,mandatory,confidentiality,consent,availability'], + 'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'], + 'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))], + 'payload.confidentiality' => ['in:public,private'], 'payload.consent' => ['boolean'], 'payload.availability' => ['nullable', 'string', 'max:1000'], + 'locations' => ['array', 'max:30'], 'locations.*.geo_location_id' => ['required', 'uuid'], 'locations.*.role' => ['required', 'in:primary,operating,coverage'], + 'locations.*.source_reference' => ['required', 'string', 'max:2000'], + 'origins' => ['array', 'max:20'], 'origins.*.type' => ['required', 'string'], 'origins.*.target_id' => ['required', 'string', 'max:100'], + ])->validate(); + abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.'); + + $creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)); + + return DB::transaction(function () use ($actor, $data, $id, $creationHash) { + DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]); + if ($id) { + $record = $this->find($id, true); + abort_unless($this->owns($actor, $record), 403); + abort_unless((int) $record->version === (int) $data['version'], 409, 'Rascunho alterado; recarregue.'); + abort_unless(in_array($record->status, ['draft', 'returned', 'rejected', 'published'], true), 409, 'Estado não permite edição.'); + } else { + $existing = DB::table('portal_partnerships')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first(); + if ($existing) { + abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.'); + + return $existing; + } + $id = DB::table('portal_partnerships')->insertGetId(['user_id' => $actor->id, 'client_key' => $data['client_key'], 'title' => '', 'sector' => '', 'province' => '', 'offer' => '', 'seeking' => '', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]); + $record = $this->find($id, true); + } + $origins = $data['origins'] ?? []; + foreach ($origins as $origin) { + app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor); + } + $locations = $data['locations'] ?? []; + foreach ($locations as $location) { + abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location['geo_location_id'])->exists(), 422, 'Localização não pertence ao cadastro territorial público.'); + } + DB::table('portal_partnerships')->where('id', $id)->update(['title' => $data['title'] ?? '', 'offer' => $data['offer'] ?? '', 'seeking' => $data['seeking'] ?? '', + 'organization_id' => $data['organization_id'] ?? null, 'sector_id' => $data['sector_id'] ?? null, 'kind' => $data['kind'], 'partnership_type' => $data['partnership_type'] ?? null, + 'deadline' => $data['deadline'] ?? null, 'payload' => json_encode([...($data['payload'] ?? []), '_creation_hash' => json_decode($record->payload, true)['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR), + 'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]); + DB::table('partnership_origins')->where('partnership_id', $id)->delete(); + foreach ($origins as $origin) { + DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]); + } + DB::table('partnership_locations')->where('partnership_id', $id)->delete(); + foreach ($locations as $location) { + DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]); + } + $saved = $this->find($id); + $this->event($saved, $actor, 'partnership_opportunity.changed.v1', ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations], 'Rascunho guardado', [$record->user_id]); + + return $saved; + }); + } + + public function transition(User $actor, int $id, int $version, string $action, string $reason): stdClass + { + return DB::transaction(function () use ($actor, $id, $version, $action, $reason) { + $record = $this->find($id, true); + abort_unless((int) $record->version === $version, 409, 'Proposta alterada; recarregue.'); + $moderation = in_array($action, ['publish', 'return', 'reject', 'suspend'], true); + if ($moderation) { + Gate::forUser($actor)->authorize('manage-portal'); + abort_if($this->owns($actor, $record), 403, 'Não pode moderar a própria proposta.'); + } else { + abort_unless($this->owns($actor, $record), 403); + } + $from = $record->status; + $allowed = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'], + 'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']]; + abort_unless(isset($allowed[$action]) && in_array($from, $allowed[$action], true), 409, 'Transição inválida.'); + if ($action === 'submit') { + $payload = json_decode($record->payload, true); + Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false], + ['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate(); + } + $status = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'][$action]; + DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by, + 'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]); + $saved = $this->find($id); + $this->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', ['from' => $from, 'to' => $status, 'reason' => $reason], 'Proposta: '.$status, [$record->user_id]); + + return $saved; + }); + } + + /** @param array $data */ + public function interest(User $actor, int $id, array $data): stdClass + { + $data = Validator::make($data, ['version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'], + 'payload' => ['array:capacity,availability,contribution,conflicts,consent,sector_id,territory_id'], 'payload.consent' => ['accepted'], + 'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'], + 'payload.sector_id' => ['nullable', 'integer', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id']])->validate(); + abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403); + + return DB::transaction(function () use ($actor, $id, $data) { + $record = $this->find($id, true); + abort_unless($record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()), 409, 'Receção de interesses encerrada.'); + abort_if((int) $record->user_id === $actor->id || $this->owns($actor, $record, false) || ($data['organization_id'] ?? null) && $data['organization_id'] === $record->organization_id, 403, 'Auto-candidatura não permitida.'); + $existing = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id) + ->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->first(); + if ($data['organization_id'] ?? null) { + abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $data['organization_id'])->where('status', '!=', 'withdrawn') + ->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.'); + } + if ($existing) { + abort_unless($this->owns($actor, $existing), 403); + abort_unless(isset($data['version']) && (int) $existing->version === (int) $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.'); + abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409); + $interestId = $existing->id; + DB::table('partnership_interests')->where('id', $interestId)->update(['proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'organization_id' => $data['organization_id'] ?? null, + 'status' => 'submitted', 'assessment' => '{}', 'version' => $existing->version + 1, 'updated_at' => now()]); + } else { + $interestId = DB::table('partnership_interests')->insertGetId(['partnership_id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null, + 'proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'created_at' => now(), 'updated_at' => now()]); + } + $this->event($record, $actor, 'partnership_interest.submitted.v1', ['interest_id' => $interestId, 'interest_version' => $existing ? $existing->version + 1 : 1], 'Manifestação de interesse recebida', [$record->user_id, $actor->id]); + + return DB::table('partnership_interests')->where('id', $interestId)->first(); + }); + } + + /** @param array $assessment */ + public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = []): stdClass + { + return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment) { + $interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first(); + abort_unless($interest !== null, 404); + $record = $this->find($interest->partnership_id, true); + abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.'); + if ($action === 'withdraw') { + abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.'); + abort_unless($this->owns($actor, $interest), 403); + abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409); + } else { + abort_unless($this->owns($actor, $record), 403); + abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409); + } + $states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status]; + abort_unless(isset($states[$action]), 422); + foreach ($assessment as $code => $criterion) { + abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422); + Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate(); + $assessment[$code]['assessed_by'] = $actor->id; + $assessment[$code]['assessed_at'] = now()->toIso8601String(); + } + $assessed = array_replace(json_decode($interest->assessment, true), $assessment); + if (in_array($action, ['shortlist', 'connect'], true)) { + $match = app(PartnershipMatching::class)->explain($record, $interest, $assessed); + abort_if($match['eligibility'] === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.'); + } + if ($action === 'connect') { + abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.'); + DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]); + } + DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]); + $this->event($record, $actor, 'partnership_interest.changed.v1', ['interest_id' => $id, 'interest_version' => $version + 1, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed], 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]); + + return DB::table('partnership_interests')->where('id', $id)->first(); + }); + } + + /** @return array{room:stdClass,interest:stdClass,partnership:stdClass} */ + public function room(User $actor, int $id, bool $write = false): array + { + $room = DB::table('partnership_rooms')->where('id', $id)->first(); + abort_unless($room !== null, 404); + $interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first(); + $record = $this->find($interest->partnership_id); + abort_unless($this->owns($actor, $record, $write) || $this->owns($actor, $interest, $write), 403, 'Sala privada reservada aos participantes.'); + abort_if($write && in_array($room->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.'); + + return ['room' => $room, 'interest' => $interest, 'partnership' => $record]; + } + + /** @param array $payload + * @param list $recipients + */ + public function event(stdClass $record, User $actor, string $type, array $payload, string $title, array $recipients = []): void + { + DB::transaction(function () use ($record, $actor, $type, $payload, $title, $recipients): void { + DB::select('select pg_advisory_xact_lock(hashtextextended(?,0))', ['partnership-events:'.$record->id]); + $sequence = (DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version') ?? 0) + 1; + $eventId = (string) Str::uuid(); + $envelope = ['event_id' => $eventId, 'event_type' => $type, 'event_version' => 1, 'source' => 'investmoz.partnerships', 'subject_id' => $record->id, + 'occurred_at' => now()->toIso8601String(), 'producer_version' => '1.0', 'correlation_id' => $eventId, 'causation_id' => null, 'data_classification' => 'internal', + 'aggregate_id' => $record->id, 'aggregate_version' => $sequence, 'publication_version' => $record->version, 'payload' => $payload, 'external_delivery' => 'not_configured']; + DB::table('partnership_events')->insert(['id' => $eventId, 'partnership_id' => $record->id, 'actor_id' => $actor->id, 'event_type' => $type, 'aggregate_version' => $sequence, 'payload' => json_encode($envelope)]); + DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id, + 'action' => $type, 'after_data' => json_encode($envelope)]); + foreach (array_unique($recipients) as $recipient) { + DB::table('partnership_notifications')->insert(['event_id' => $eventId, 'user_id' => $recipient, 'title' => $title, 'href' => '/parcerias/'.$record->id]); + } + }); + } +} diff --git a/app/Services/PartnershipInstallation.php b/app/Services/PartnershipInstallation.php new file mode 100644 index 00000000..7cde167e --- /dev/null +++ b/app/Services/PartnershipInstallation.php @@ -0,0 +1,85 @@ + resource_path('js/pages/investment/'.$name.'.tsx'), ['business-opportunity', 'catalogue', 'detail', 'profile', 'zone'])]; + sort($files); + $hashes = []; + foreach ($files as $file) { + $hashes[str_replace(base_path().DIRECTORY_SEPARATOR, '', $file)] = hash_file('sha256', $file); + } + + return hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR)); + } + + /** @return array */ + public function preview(): array + { + $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); + $rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all(); + $hash = hash('sha256', json_encode(['target' => $identity, 'code' => $this->fingerprint(), 'records' => $rows], JSON_THROW_ON_ERROR)); + $proof = $this->read('backup-proof.json'); + $qa = $this->read('qa-evidence.json'); + $verified = ($proof['restore_verified'] ?? false) && ($proof['target']['database'] ?? null) === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port + && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null); + $qaVerified = ($qa['passed'] ?? false) && ($qa['code_hash'] ?? null) === $this->fingerprint() && str_ends_with($qa['database'] ?? '', '_test') && (int) ($qa['port'] ?? 0) === 55441; + + return ['hash' => $hash, 'installed' => Schema::hasTable('partnership_interests'), 'target' => $identity, 'records' => $rows, 'records_hash' => hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR)), 'backupVerified' => $verified, 'qaVerified' => $qaVerified, + 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Extensão de portal_partnerships com campos opcionais e versão, sem substituir os registos.', + 'Entidades próprias de candidaturas, salas, mensagens, referências documentais, marcos, eventos e notificações locais.', + 'Nenhuma cópia de contas, organizações, documentos, setores, territórios ou oportunidades de investimento.', 'Estados e autores antigos preservados; publicação nova continua moderada.']]; + } + + /** @return array */ + private function read(string $file): array + { + $path = config('partnerships.proof_directory', storage_path('app/private/partnership-exchange')).'/'.$file; + if (! is_file($path)) { + return []; + } + + $contents = file_get_contents($path); + abort_unless(is_string($contents), 503, 'Evidência privada indisponível.'); + + return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } + + public function apply(string $hash, User $actor): void + { + DB::transaction(function () use ($hash, $actor) { + DB::statement('LOCK TABLE portal_partnerships IN ACCESS EXCLUSIVE MODE'); + $preview = $this->preview(); + abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada; reveja a instalação.'); + if ($preview['installed']) { + return; + } + abort_unless($preview['backupVerified'] && $preview['qaVerified'], 422, 'Backup/restauro e QA desta versão ainda não estão comprovados.'); + config(['partnerships.installation_authorized' => true]); + try { + $migration = require database_path('migrations/'.self::MIGRATION.'.php'); + $migration->up(); + } finally { + config(['partnerships.installation_authorized' => false]); + } + $rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all(); + abort_unless(hash_equals($preview['records_hash'], hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR))), 409, 'Os registos antigos não foram preservados.'); + DB::table('migrations')->insert(['migration' => self::MIGRATION, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]); + DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id, + 'action' => 'partnership_module_installed', 'after_data' => json_encode(['preview_hash' => $hash, 'code_hash' => $this->fingerprint(), 'preserved_records' => count($rows), 'migration' => self::MIGRATION])]); + }); + } +} diff --git a/app/Services/PartnershipMatching.php b/app/Services/PartnershipMatching.php new file mode 100644 index 00000000..c292e839 --- /dev/null +++ b/app/Services/PartnershipMatching.php @@ -0,0 +1,42 @@ +|null $assessment + * @return array + */ + public function explain(stdClass $partnership, stdClass $interest, ?array $assessment = null): array + { + $assessment ??= json_decode($interest->assessment, true); + $payload = json_decode($partnership->payload, true); + $weights = config('partnerships.type_weights.'.$partnership->partnership_type, config('partnerships.weights')); + $criteria = []; + $known = $unknown = 0; + $mandatoryUnmet = $mandatoryUnknown = false; + foreach ($weights as $code => $weight) { + $entry = $assessment[$code] ?? []; + $state = $entry['state'] ?? 'unknown'; + if (in_array($state, ['met', 'unmet'], true) && empty($entry['evidence'])) { + $state = 'unknown'; + } + $mandatory = in_array($code, $payload['mandatory'] ?? [], true); + $known += $state === 'met' ? $weight : 0; + $unknown += $state === 'unknown' ? $weight : 0; + $mandatoryUnmet = $mandatoryUnmet || ($mandatory && $state === 'unmet'); + $mandatoryUnknown = $mandatoryUnknown || ($mandatory && $state === 'unknown'); + $criteria[] = ['code' => $code, 'label' => config('partnerships.criteria.'.$code), 'weight' => $weight, 'state' => $state, + 'mandatory' => $mandatory, 'evidence' => $entry['evidence'] ?? null, + 'explanation' => $state === 'unknown' ? 'Informação insuficiente; declarações sem avaliação documental não provam cumprimento ou incumprimento.' : 'Avaliação fundamentada do responsável; não equivale a certificação ou aprovação.']; + } + $total = array_sum($weights); + + return ['algorithm_version' => 'transparent-rules.v1', 'weights_hash' => hash('sha256', json_encode($weights, JSON_THROW_ON_ERROR)), 'score_min' => $total ? round(100 * $known / $total, 1) : null, + 'score_max' => $total ? round(100 * ($known + $unknown) / $total, 1) : null, + 'eligibility' => $mandatoryUnmet ? 'unmet' : ($mandatoryUnknown ? 'unknown' : 'met'), 'criteria' => $criteria, + 'unknown_weight' => $unknown, 'recommendation_is_approval' => false]; + } +} diff --git a/app/Services/PartnershipSources.php b/app/Services/PartnershipSources.php new file mode 100644 index 00000000..22cf3396 --- /dev/null +++ b/app/Services/PartnershipSources.php @@ -0,0 +1,81 @@ + */ + public function resolve(string $type, string $id, ?User $actor = null): array + { + $result = ['type' => $type, 'target_id' => $id, 'geography' => null, 'public' => true]; + if (in_array($type, ['project', 'cluster', 'dossier', 'zone', 'business', 'company', 'simulation'], true)) { + abort_unless(Str::isUuid($id), 422, 'Identificador da origem inválido.'); + } + if (in_array($type, ['project', 'cluster', 'dossier'], true)) { + $dossier = InvestmentPortfolioController::visible()->where($type === 'dossier' ? 'id' : $type.'_id', $id)->first(); + abort_unless($dossier !== null, 404, 'Dossier público de origem não disponível.'); + + return [...$result, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id, 'geography' => app(InvestmentRelations::class)->dossier($dossier)]; + } + if ($type === 'investment') { + abort_unless(ctype_digit($id), 422); + $record = Opportunity::query()->published()->whereKey((int) $id)->first(); + abort_unless($record !== null, 404); + + return [...$result, 'title' => $record->title, 'href' => '/oportunidades/'.$record->slug]; + } + if ($type === 'zone') { + $record = DB::table('investment_zones')->where('id', $id)->where('publication_status', 'published')->whereNull('deleted_at')->first(); + abort_unless($record !== null, 404); + + return [...$result, 'title' => $record->name, 'href' => '/zonas/'.$id]; + } + if ($type === 'business') { + $record = ExistingBusiness::query()->published()->whereKey($id)->first(); + abort_unless($record !== null, 404); + + return [...$result, 'title' => $record->public_title, 'href' => '/negocios/'.$record->public_slug]; + } + if ($type === 'company') { + abort_unless($actor !== null, 403); + $record = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $actor->id)->first(); + abort_unless($record !== null, 404); + + return [...$result, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile', 'public' => false]; + } + if ($type === 'simulation') { + abort_unless($actor !== null, 403); + $record = DB::table('simulation_form_drafts')->where('id', $id)->where('owner_user_id', $actor->id)->whereNull('deleted_at')->first(); + abort_unless($record !== null, 404); + + return [...$result, 'title' => 'Ficha de projeto do simulador', 'href' => '/simulador-fiscal?draft='.$id, 'public' => false]; + } + abort(422, 'Tipo de origem não suportado.'); + } + + /** @return list> */ + public function forPartnership(int $id, ?User $actor = null): array + { + $result = []; + foreach (DB::table('partnership_origins')->where('partnership_id', $id)->get() as $origin) { + try { + $result[] = $this->resolve($origin->type, $origin->target_id, $actor); + } catch (HttpException $e) { + if (! in_array($e->getStatusCode(), [403, 404], true)) { + throw $e; + } + // Private or withdrawn source identifiers are not disclosed by a public proposal. + } + } + + return $result; + } +} diff --git a/app/Services/VirtualDesk.php b/app/Services/VirtualDesk.php new file mode 100644 index 00000000..0d635a63 --- /dev/null +++ b/app/Services/VirtualDesk.php @@ -0,0 +1,254 @@ +where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at') + ->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists(); + } + + public function owns(User $actor, object $case, bool $write = true): bool + { + return $case->organization_id ? $this->represents($actor, $case->organization_id, $write) : (int) $case->user_id === $actor->id; + } + + public function case(User $actor, string $id, bool $write = false, bool $lock = false): object + { + $case = DB::table('desk_cases')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first(); + abort_unless($case, 404); + abort_unless($this->owns($actor, $case, $write) || (! $write && ($actor->can('manage-portal') || DB::table('desk_requests')->where('case_id', $id)->where('analyst_id', $actor->id)->exists())), 403); + return $case; + } + + public function request(User $actor, string $id, bool $ownerOnly = false, bool $lock = false): object + { + $request = DB::table('desk_requests')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first(); + abort_unless($request, 404); + $case = $this->case($actor, $request->case_id, $ownerOnly); + abort_unless($this->owns($actor, $case, $ownerOnly) || (! $ownerOnly && ($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id)), 403); + return $request; + } + + public function createCase(User $actor, array $input): object + { + $data = Validator::make($input, ['title' => 'required|string|max:200', 'client_key' => 'required|uuid', 'reuse_consent' => 'accepted', + 'organization_id' => 'nullable|uuid', 'company_id' => 'nullable|uuid', 'submission_id' => 'nullable|uuid', 'simulation_id' => 'nullable|uuid'])->validate(); + return DB::transaction(function () use ($actor, $data) { + DB::table('users')->where('id', $actor->id)->lockForUpdate()->first(); + $existing = DB::table('desk_cases')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first(); + $inputHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)); + if ($existing) { + abort_unless(($this->decode($existing->payload)['_creation_hash'] ?? '') === $inputHash, 409, 'Chave de criação usada com outro conteúdo.'); + abort_unless($this->owns($actor, $existing), 403); + return $existing; + } + abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403); + $payload = ['profile' => app(InvestmentWorkspace::class)->account($actor->id)['payload'], 'name' => $actor->name, 'email' => $actor->email, '_creation_hash' => $inputHash, 'reuse_consent' => ['actor_id' => $actor->id, 'date' => now()->toIso8601String()]]; + if ($data['company_id'] ?? null) { + $company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $actor->id)->first(); + abort_unless($company, 404); + $payload['company'] = $this->decode($company->payload); $payload['company_version'] = $company->version; + } + if ($data['submission_id'] ?? null) { + $submission = \App\Models\InvestmentSubmission::where('id', $data['submission_id'])->where('user_id', $actor->id)->firstOrFail(); + $payload['investment'] = $submission->payload; $payload['investment_version'] = $submission->version; + } + if ($data['simulation_id'] ?? null) app(PartnershipSources::class)->resolve('simulation', $data['simulation_id'], $actor); + // One case per explicitly reused investment; retries never create parallel dossiers. + if ($data['submission_id'] ?? null) { + $linked = DB::table('desk_cases')->where('submission_id', $data['submission_id'])->where('user_id', $actor->id)->first(); + if ($linked) { abort_unless($this->owns($actor, $linked), 403); return $linked; } + } + $id = (string) Str::uuid(); + DB::table('desk_cases')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null, 'company_id' => $data['company_id'] ?? null, + 'submission_id' => $data['submission_id'] ?? null, 'simulation_id' => $data['simulation_id'] ?? null, 'title' => $data['title'], 'client_key' => $data['client_key'], + 'payload' => $this->encode($payload), 'workflow' => json_encode(array_map(fn ($s) => [...$s, 'status' => 'not_started', 'evidence' => null], config('virtual-desk.steps'))), 'created_at' => now(), 'updated_at' => now()]); + $case = DB::table('desk_cases')->where('id', $id)->first(); + $this->event($case, $actor, 'case_created', ['reuse_consent' => true]); + return $case; + }); + } + + public function createRequest(User $actor, string $caseId, string $serviceId, string $clientKey): object + { + abort_unless(Str::isUuid($serviceId) && Str::isUuid($clientKey), 422); + return DB::transaction(function () use ($actor, $caseId, $serviceId, $clientKey) { + $case = $this->case($actor, $caseId, true, true); + abort_if($case->closed_at, 409, 'Dossier encerrado.'); + $service = DB::table('desk_service_versions')->where('id', $serviceId)->where('status', 'published')->first(); + abort_unless($service, 422, 'Versão do serviço indisponível para novos pedidos.'); + $existing = DB::table('desk_requests')->where('case_id', $caseId)->where('client_key', $clientKey)->first(); + if ($existing) { abort_unless($existing->service_version_id === $serviceId, 409); return $existing; } + $id = (string) Str::uuid(); $base = $this->decode($case->payload); + $payload = ['title' => $case->title, 'description' => $base['investment']['objective'] ?? '', 'regime' => 'unknown']; + DB::table('desk_requests')->insert(['id' => $id, 'case_id' => $caseId, 'service_version_id' => $serviceId, 'client_key' => $clientKey, 'payload' => $this->encode($payload), 'created_at' => now(), 'updated_at' => now()]); + $request = DB::table('desk_requests')->where('id', $id)->first(); $this->event($case, $actor, 'draft_created', [], $request); + return $request; + }); + } + + public function definition(object $request): array + { + return json_decode(DB::table('desk_service_versions')->where('id', $request->service_version_id)->value('definition'), true, 512, JSON_THROW_ON_ERROR); + } + + public function validateFields(array $payload, array $definition, bool $submitting): array + { + $rules = ['payload' => ['required', 'array:'.implode(',', array_keys($definition['fields']))]]; + foreach ($definition['fields'] as $key => $field) { + $active = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals']; + $required = $submitting && ($field['required'] ?? false) && $active; + $rule = [$required ? 'required' : 'nullable']; + $rule = [...$rule, ...match ($field['type']) { + 'number' => ['numeric', 'min:'.($field['min'] ?? 0), 'max:999999999999'], 'date' => ['date_format:Y-m-d'], + 'select' => [\Illuminate\Validation\Rule::in(array_keys($field['options']))], 'territory' => ['uuid'], + default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)], + }]; + $rules['payload.'.$key] = $rule; + } + $validated = Validator::make(['payload' => $payload], $rules)->validate()['payload']; + if ($validated['territory_id'] ?? null) { + abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $validated['territory_id'])->exists(), 422, 'Localização indisponível.'); + if ($submitting && trim($validated['location_source'] ?? '') === '') throw ValidationException::withMessages(['payload.location_source' => 'Indique a fonte da localização.']); + } + return $validated; + } + + public function findings(array $payload): array + { + $findings = []; + if (isset($payload['investment_amount'], $payload['own_finance'], $payload['other_finance']) && abs((float) $payload['investment_amount'] - (float) $payload['own_finance'] - (float) $payload['other_finance']) > 0.01) { + $findings[] = ['code' => 'finance_difference', 'severity' => 'error', 'field' => 'investment_amount', 'explanation' => 'A soma do financiamento difere do valor do investimento.', 'recommendation' => 'Confirme valores e moeda.', 'method' => 'deterministic', 'confidence' => null]; + } + if (! ($payload['territory_id'] ?? null)) $findings[] = ['code' => 'location_unknown', 'severity' => 'warning', 'field' => 'territory_id', 'explanation' => 'Localização ainda não documentada.', 'recommendation' => 'Complete quando houver fonte.', 'method' => 'deterministic', 'confidence' => null]; + $findings[] = ['code' => 'official_form_pending', 'severity' => 'warning', 'field' => null, 'explanation' => 'Adaptação digital do plano; não foi identificada uma versão oficial homologada do formulário APIEX no acervo.', 'recommendation' => 'A preparação e análise interna continuam disponíveis; confirme a versão exigida pela entidade para o ato oficial.', 'method' => 'documentary', 'confidence' => null]; + return $findings; + } + + public function save(User $actor, string $id, array $input, bool $submit = false): object + { + $data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'required|array', 'consent' => $submit ? 'accepted' : 'nullable|boolean', 'reason' => 'nullable|string|max:3000'])->validate(); + return DB::transaction(function () use ($actor, $id, $data, $submit) { + $request = $this->request($actor, $id, true, true); + if ($submit && $request->status === 'submitted' && $request->version === (int) $data['version'] + 1 && hash_equals(hash('sha256', json_encode(Arr::sortRecursive($this->decode($request->payload)))), hash('sha256', json_encode(Arr::sortRecursive($data['payload']))))) return $request; + abort_unless(in_array($request->status, ['draft', 'returned'], true) && $request->version === (int) $data['version'], 409, 'Pedido alterado ou já submetido.'); + $payload = $this->validateFields($data['payload'], $this->definition($request), $submit); + if ($request->status === 'returned') { + $editable = json_decode($request->editable_fields ?? '[]', true); $previous = $this->decode($request->payload); + foreach (array_unique([...array_keys($previous), ...array_keys($payload)]) as $key) { + if (! in_array($key, $editable, true) && ($previous[$key] ?? null) !== ($payload[$key] ?? null)) throw ValidationException::withMessages(['payload.'.$key => 'Campo não autorizado para esta correção.']); + } + abort_if($submit && trim($data['reason'] ?? '') === '', 422, 'Justifique a correção.'); + } + $findings = $this->findings($payload); + if ($submit && array_filter($findings, fn ($f) => $f['severity'] === 'error')) throw ValidationException::withMessages(['payload.investment_amount' => $findings[0]['explanation']]); + $case = $this->case($actor, $request->case_id, true); + $snapshot = ['payload' => $payload, 'service_version_id' => $request->service_version_id, 'documents' => DB::table('desk_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.case_id', $case->id)->where(fn ($q) => $q->whereNull('link.request_id')->orWhere('link.request_id', $id))->get(['link.document_id', 'd.sha256', 'd.version_no', 'link.source_reference'])->all(), 'findings' => $findings, 'consent' => $submit, 'source' => config('virtual-desk.source')]; + DB::table('desk_revisions')->insert(['request_id' => $id, 'version' => $request->version, 'snapshot' => $this->encode(['payload' => $this->decode($request->payload), 'status' => $request->status, 'submitted_snapshot' => $request->submitted_snapshot ? $this->decode($request->submitted_snapshot) : null])]); + $updates = ['payload' => $this->encode($payload), 'version' => $request->version + 1, 'updated_at' => now()]; + if ($submit) $updates = [...$updates, 'status' => 'submitted', 'protocol' => $request->protocol ?? 'BV-'.date('Y').'-'.strtoupper(str_replace('-', '', $id)), 'submitted_at' => now(), 'submitted_snapshot' => $this->encode($snapshot)]; + DB::table('desk_requests')->where('id', $id)->update($updates); $request = DB::table('desk_requests')->where('id', $id)->first(); + $this->event($case, $actor, $submit ? 'submitted' : 'draft_saved', ['reason' => $data['reason'] ?? '', 'findings' => $findings], $request); + return $request; + }); + } + + public function transition(User $actor, string $id, array $data): object + { + $data = Validator::make($data, ['version' => 'required|integer|min:1', 'status' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.request_states')))], 'reason' => 'required|string|min:3|max:5000', 'editable_fields' => 'nullable|array', 'editable_fields.*' => 'string|max:100'])->validate(); + return DB::transaction(function () use ($actor, $id, $data) { + $request = $this->request($actor, $id, false, true); $case = $this->case($actor, $request->case_id); + abort_unless($request->version === (int) $data['version'], 409); + $cancel = $data['status'] === 'cancelled'; + if ($cancel) { abort_unless($this->owns($actor, $case) && in_array($request->status, ['draft', 'returned'], true), 403); } + else { + abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.'); + abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403); + if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) abort_unless($actor->can('manage-portal'), 403); + abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.'); + } + $editable = $data['editable_fields'] ?? []; + abort_if($data['status'] === 'returned' && ! $editable, 422, 'Indique os campos autorizados para correção.'); + abort_if(array_diff($editable, array_keys($this->definition($request)['fields'])), 422); + DB::table('desk_requests')->where('id', $id)->update(['status' => $data['status'], 'version' => $request->version + 1, 'editable_fields' => json_encode($editable), 'closed_at' => in_array($data['status'], ['closed', 'cancelled'], true) ? now() : null, 'updated_at' => now()]); + $request = DB::table('desk_requests')->where('id', $id)->first(); $this->event($case, $actor, 'request_'.$data['status'], ['reason' => $data['reason'], 'editable_fields' => $editable], $request); + return $request; + }); + } + + public function stage(User $actor, string $id, array $data): object + { + $data = Validator::make($data, ['version' => 'required|integer|min:1', 'stage' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.steps')))], + 'status' => ['required', \Illuminate\Validation\Rule::in(array_keys(config('virtual-desk.step_states')))], 'reason' => 'required|string|min:3|max:3000', + 'evidence' => 'nullable|string|max:5000', 'document_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'exception' => 'nullable|boolean'])->validate(); + return DB::transaction(function () use ($actor, $id, $data) { + $case = $this->case($actor, $id, false, true); + abort_unless($this->owns($actor, $case) || $actor->can('manage-portal'), 403); + abort_unless((int) $case->version === (int) $data['version'], 409); + $workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR); $current = $workflow[$data['stage']]; + $decision = in_array($data['status'], ['completed', 'not_applicable'], true); + if ($decision) { + abort_unless($actor->can('manage-portal') && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.'); + abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.'); + foreach ($current['dependencies'] as $dependency) { + abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.'); + } + if ($data['stage'] !== 'E0') abort_unless($data['document_id'] ?? null, 422, 'Ligue o documento de suporte ou de não aplicabilidade.'); + } + if ($data['document_id'] ?? null) { + $document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.id', $data['document_id'])->whereNull('d.deleted_at')->first(['d.status', 'd.malware_scan_status', 'd.sha256', 'd.malware_scanned_sha256', 'l.valid_until']); + abort_unless($document, 422); + if ($decision) abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean' && trim($document->sha256) === $document->malware_scanned_sha256 && (! $document->valid_until || $document->valid_until >= now()->toDateString()), 422, 'Documento ainda não consultável ou expirado.'); + } + if (in_array($data['status'], ['referred', 'waiting'], true)) abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Registe referência e canal do encaminhamento.'); + // Reopening an upstream step must never leave dependent conclusions falsely valid. + if (! in_array($data['status'], ['completed', 'not_applicable'], true)) { + $affected = [$data['stage']]; + do { $changed = false; foreach ($workflow as $code => &$step) { if (! in_array($code, $affected, true) && array_intersect($step['dependencies'], $affected)) { $affected[] = $code; $changed = true; if ($step['status'] === 'completed') $step['status'] = 'pending'; } } unset($step); } while ($changed); + } + $workflow[$data['stage']] = [...$current, 'status' => $data['status'], 'evidence' => $data['evidence'] ?? null, 'document_id' => $data['document_id'] ?? null, + 'valid_until' => $data['valid_until'] ?? null, 'reason' => $data['reason'], 'reviewed_by' => $actor->id, 'updated_at' => now()->toIso8601String()]; + DB::table('desk_cases')->where('id', $id)->update(['workflow' => json_encode($workflow), 'version' => $case->version + 1, 'updated_at' => now()]); + $case = DB::table('desk_cases')->where('id', $id)->first(); $this->event($case, $actor, 'stage_updated', $data); + return $case; + }); + } + + public function event(object $case, User $actor, string $action, array $payload, ?object $request = null): void + { + $id = (string) Str::uuid(); $version = $request->version ?? $case->version; + DB::table('desk_events')->insert(['id' => $id, 'case_id' => $case->id, 'request_id' => $request->id ?? null, 'actor_id' => $actor->id, 'action' => $action, 'version' => $version, 'payload' => $this->encode($payload)]); + DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $request ? 'desk_requests' : 'desk_cases', 'entity_id' => $request->id ?? $case->id, 'actor_user_id' => $actor->id, 'action' => $action, + 'after_data' => json_encode(['event_id' => $id, 'event_version' => 1, 'version' => $version, 'case_id' => $case->id, 'source_hash' => config('virtual-desk.source.sha256')])]); + $recipients = [(int) $case->user_id]; + if ($case->organization_id) $recipients = [...$recipients, ...DB::table('memberships')->where('organization_id', $case->organization_id)->whereNull('deleted_at')->pluck('user_id')->all()]; + if ($request?->analyst_id) $recipients[] = (int) $request->analyst_id; + foreach (array_unique($recipients) as $user) DB::table('desk_notifications')->insert(['event_id' => $id, 'user_id' => $user, 'title' => config('virtual-desk.request_states.'.$request?->status, 'Dossier atualizado'), 'href' => $request ? '/balcao-virtual/pedidos/'.$request->id : '/balcao-virtual/dossiers/'.$case->id]); + } +} diff --git a/config/partnerships.php b/config/partnerships.php new file mode 100644 index 00000000..7ac3370b --- /dev/null +++ b/config/partnerships.php @@ -0,0 +1,20 @@ + [ + 'supply' => 'Fornecimento e subcontratação', + 'distribution' => 'Distribuição, representação comercial e franquia', + 'joint_venture' => 'Joint venture, consórcio ou co-investimento', + 'technology' => 'Transferência de tecnologia, licenciamento e inovação', + 'finance' => 'Financiamento, investimento e garantias', + 'training' => 'Formação, assistência técnica e certificação', + 'research' => 'Investigação, desenvolvimento e ligação academia–empresa', + 'logistics' => 'Logística, exportação, mercados e cadeias de valor', + 'public_impact' => 'Parcerias público-privadas e impacto social', + ], + 'weights' => ['sector' => 25, 'capacity' => 20, 'requirements' => 20, 'territory' => 10, 'experience' => 10, 'availability' => 10, 'impact' => 5], + 'type_weights' => [], + 'criteria' => ['sector' => 'Setor e tipo', 'capacity' => 'Capacidade técnica/produtiva', 'requirements' => 'Requisitos e certificações', + 'territory' => 'Localização e cobertura', 'experience' => 'Experiência comprovada', 'availability' => 'Disponibilidade e prazo', 'impact' => 'Impacto e conteúdo local'], + 'installation_authorized' => false, +]; diff --git a/config/virtual-desk.php b/config/virtual-desk.php new file mode 100644 index 00000000..98df7cd3 --- /dev/null +++ b/config/virtual-desk.php @@ -0,0 +1,45 @@ + 'Plano de Incorporação e Optimização do Módulo Balcão Virtual — Portal do Investidor', + 'sha256' => 'f167d9156c2572c0253949a6f0ed88aafc28450cce902e8f445e3d979119aa37', 'edition' => 'Plano fornecido em 7 outubro 2026', + 'legal_status' => 'not_a_legal_instrument', 'form_status' => 'digital_preparation', 'rule_use' => 'internal_workflow_only']; +$steps = [ + 'E0' => ['Perfil e preparação', [], 'Portal do Investidor', 'Dados mínimos do promotor e do projeto; reutilização consentida.'], + 'E1' => ['Reserva do nome', ['E0'], 'SARN', 'Nome aprovado, identificação do pedido, comprovativo e validade.'], + 'E2' => ['Constituição e registo', ['E1'], 'SARN / Conservatória', 'Certidão, NUEL ou comprovativo de registo; reserva válida.'], + 'E3' => ['Beneficiários efectivos', ['E2'], 'SARN / Conservatória', 'Declaração ou recibo; aplicabilidade documentada.'], + 'E4' => ['NUIT colectivo', ['E2'], 'Autoridade Tributária', 'NUIT e carta; Modelo M01C quando aplicável.'], + 'E5' => ['Dossier societário', ['E2', 'E3', 'E4'], 'Representante legal', 'Índice de documentos, versões e validade.'], + 'E6' => ['Licenciamento da actividade', ['E5'], 'BAU / entidade sectorial', 'Licença, alvará, registo ou fundamento de não aplicabilidade.'], + 'E7' => ['Início de actividade fiscal', ['E4', 'E6'], 'Autoridade Tributária', 'Modelo M02, data efectiva e comprovativo.'], + 'E8' => ['Inscrição da empresa no INSS', ['E7'], 'INSS', 'Número de contribuinte e comprovativo. Prazo documental por confirmar no caso.'], + 'E9' => ['Trabalhadores e contribuições', ['E8'], 'INSS', 'Inscrições, calendário e comprovativos quando aplicável.'], + 'E10' => ['Projeto de investimento', ['E0'], 'APIEX', 'Preparação paralela; formulário oficial, evidências e decisão competente tratados separadamente.'], + 'E11' => ['Pós-estabelecimento', ['E7'], 'Entidades competentes', 'Licenças, obrigações e marcos aplicáveis; não presume aprovação de E10.'], +]; +$field = fn ($label, $type = 'text', $required = false, $extra = []) => ['label' => $label, 'type' => $type, 'required' => $required, ...$extra]; +$common = ['title' => $field('Nome do projeto', 'text', true), 'description' => $field('Descrição e objetivo', 'textarea', true), + 'territory_id' => $field('Localização documentada', 'territory'), 'location_source' => $field('Fonte da localização'), + 'regime' => $field('Regime declarado', 'select', false, ['options' => ['unknown' => 'Por determinar', 'registration' => 'Mero registo declarado', 'authorization' => 'Autorização declarada', 'special' => 'Regime especial declarado']])]; +return [ + 'installation_authorized' => false, + 'source' => $source, + 'steps' => array_map(fn ($s) => ['title' => $s[0], 'dependencies' => $s[1], 'entity' => $s[2], 'requirements' => $s[3], 'cost' => null, 'legal_deadline' => null], $steps), + 'step_states' => ['not_started' => 'Não iniciado', 'preparing' => 'Em preparação', 'ready' => 'Pronto para encaminhamento', 'referred' => 'Encaminhado ao sector', 'waiting' => 'A aguardar resposta', 'pending' => 'Com pendência', 'completed' => 'Concluído documentalmente', 'not_applicable' => 'Não aplicável', 'expired' => 'Expirado'], + 'request_states' => ['draft' => 'Rascunho', 'submitted' => 'Submetido', 'reviewing' => 'Em análise', 'returned' => 'Devolvido para correção', 'opinion' => 'Com parecer', 'accepted' => 'Aceite internamente', 'rejected' => 'Rejeitado internamente', 'closed' => 'Encerrado', 'cancelled' => 'Cancelado'], + 'transitions' => ['submitted' => ['reviewing', 'returned', 'rejected'], 'reviewing' => ['returned', 'opinion', 'rejected'], 'opinion' => ['accepted', 'returned', 'rejected'], 'accepted' => ['closed'], 'rejected' => ['closed']], + 'services' => [ + 'project-preparation' => ['title' => 'Preparar e submeter dossier de investimento', 'theme' => 'Investimento', 'profile' => 'Investidor ou representante', 'stage' => 'E10', 'entity' => 'APIEX — preparação e análise interna', + 'result' => 'Protocolo e apreciação interna do dossier; não substitui o ato da entidade competente.', 'fields' => [...$common, + 'investment_amount' => $field('Valor do investimento', 'number', true, ['min' => 0.01]), 'currency' => $field('Moeda', 'select', true, ['options' => ['MZN' => 'MZN', 'USD' => 'USD', 'EUR' => 'EUR']]), + 'own_finance' => $field('Financiamento próprio', 'number'), 'other_finance' => $field('Outro financiamento', 'number'), 'jobs' => $field('Empregos previstos', 'number'), + 'foreign_capital' => $field('Origem do capital', 'select', false, ['options' => ['unknown' => 'Por determinar', 'national' => 'Nacional', 'foreign' => 'Estrangeiro', 'mixed' => 'Misto']]), + 'schedule' => $field('Cronograma', 'textarea'), 'special_regime_details' => $field('Fundamento do regime especial', 'textarea', true, ['when' => ['field' => 'regime', 'equals' => 'special']])]], + 'project-change' => ['title' => 'Comunicar alteração do projeto', 'theme' => 'Alterações', 'profile' => 'Investidor ou representante', 'stage' => 'E10', 'entity' => 'APIEX — análise interna', + 'result' => 'Registo versionado da alteração e parecer interno.', 'fields' => [...$common, 'original_reference' => $field('Referência original', 'text', true), 'change_reason' => $field('Motivo e alcance da alteração', 'textarea', true)]], + 'project-follow-up' => ['title' => 'Acompanhar investimento e pós-estabelecimento', 'theme' => 'Acompanhamento', 'profile' => 'Investidor ou representante', 'stage' => 'E11', 'entity' => 'Portal / entidades competentes', + 'result' => 'Relatório interno de execução, pendências e marcos.', 'fields' => [...$common, 'original_reference' => $field('Referência do investimento', 'text', true), 'progress' => $field('Progresso e evidências', 'textarea', true), 'next_milestone' => $field('Próximo marco'), 'due_date' => $field('Data prevista', 'date')]], + ], + 'ai' => ['available' => false, 'reason' => 'Modelo de análise documental não configurado; a revisão determinística e humana funciona internamente.'], + 'external_channels' => ['email' => 'not_configured', 'sms' => 'not_configured', 'payments' => 'documentary_manual_only'], +]; diff --git a/database/migrations/2026_10_07_160000_extend_internal_partnership_exchange.php b/database/migrations/2026_10_07_160000_extend_internal_partnership_exchange.php new file mode 100644 index 00000000..60d15b55 --- /dev/null +++ b/database/migrations/2026_10_07_160000_extend_internal_partnership_exchange.php @@ -0,0 +1,138 @@ +database, '_test') && (int) $identity->port === 55441) && ! config('partnerships.installation_authorized')) { + throw new RuntimeException('Use isolated QA, or the authenticated partnership installation with verified backup/restore.'); + } + Schema::table('portal_partnerships', function (Blueprint $t) { + $t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete(); + $t->foreignId('sector_id')->nullable()->constrained('sectors')->restrictOnDelete(); + $t->unsignedInteger('version')->default(1); + $t->string('kind')->default('seeking'); + $t->string('partnership_type')->nullable(); + $t->jsonb('payload')->default('{}'); + $t->uuid('client_key')->nullable(); + $t->date('deadline')->nullable(); + $t->timestampTz('published_at')->nullable(); + $t->unique(['user_id', 'client_key']); + $t->index(['status', 'sector_id', 'partnership_type']); + }); + Schema::create('partnership_origins', function (Blueprint $t) { + $t->id(); + $t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete(); + $t->string('type'); + $t->string('target_id'); + $t->unique(['partnership_id', 'type', 'target_id']); + $t->index(['type', 'target_id']); + }); + Schema::create('partnership_locations', function (Blueprint $t) { + $t->id(); + $t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete(); + $t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete(); + $t->string('role'); + $t->text('source_reference'); + $t->unique(['partnership_id', 'geo_location_id', 'role']); + }); + Schema::create('partnership_interests', function (Blueprint $t) { + $t->id(); + $t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete(); + $t->text('proposal'); + $t->string('status')->default('submitted'); + $t->unsignedInteger('version')->default(1); + $t->jsonb('payload')->default('{}'); + $t->jsonb('assessment')->default('{}'); + $t->text('decision_reason')->nullable(); + $t->timestampsTz(); + $t->unique(['partnership_id', 'user_id']); + }); + Schema::create('partnership_rooms', function (Blueprint $t) { + $t->id(); + $t->foreignId('interest_id')->unique()->constrained('partnership_interests')->restrictOnDelete(); + $t->string('status')->default('negotiation'); + $t->unsignedInteger('version')->default(1); + $t->jsonb('agreement')->nullable(); + $t->timestampsTz(); + }); + Schema::create('partnership_messages', function (Blueprint $t) { + $t->id(); + $t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete(); + $t->foreignId('author_id')->constrained('users')->restrictOnDelete(); + $t->uuid('client_key'); + $t->text('body'); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['room_id', 'author_id', 'client_key']); + }); + Schema::create('partnership_documents', function (Blueprint $t) { + $t->id(); + $t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete(); + $t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); + $t->foreignId('owner_id')->constrained('users')->restrictOnDelete(); + $t->boolean('shared')->default(false); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['room_id', 'document_id']); + }); + Schema::create('partnership_milestones', function (Blueprint $t) { + $t->id(); + $t->foreignId('room_id')->constrained('partnership_rooms')->restrictOnDelete(); + $t->foreignId('responsible_id')->constrained('users')->restrictOnDelete(); + $t->string('title'); + $t->date('due_date')->nullable(); + $t->string('status')->default('pending'); + $t->text('evidence')->nullable(); + $t->unsignedInteger('version')->default(1); + $t->timestampsTz(); + }); + Schema::create('partnership_events', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignId('partnership_id')->constrained('portal_partnerships')->restrictOnDelete(); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->string('event_type'); + $t->unsignedInteger('aggregate_version'); + $t->jsonb('payload'); + $t->timestampTz('occurred_at')->useCurrent(); + }); + Schema::create('partnership_notifications', function (Blueprint $t) { + $t->id(); + $t->foreignUuid('event_id')->constrained('partnership_events')->restrictOnDelete(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->string('title'); + $t->string('href'); + $t->timestampTz('read_at')->nullable(); + $t->unique(['event_id', 'user_id']); + }); + } + + public function down(): void + { + $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); + if (! (str_ends_with($identity->database, '_test') && (int) $identity->port === 55441)) { + throw new RuntimeException('Rollback is restricted to isolated QA; preserve active records and restore a verified backup instead.'); + } + foreach (['partnership_events', 'partnership_interests', 'partnership_origins', 'partnership_locations'] as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Preserve partnership data and history before rollback.'); + } + } + foreach (['partnership_notifications', 'partnership_events', 'partnership_milestones', 'partnership_documents', 'partnership_messages', 'partnership_rooms', 'partnership_interests', 'partnership_locations', 'partnership_origins'] as $table) { + Schema::drop($table); + } + Schema::table('portal_partnerships', function (Blueprint $t) { + $t->dropForeign(['organization_id']); + $t->dropForeign(['sector_id']); + $t->dropUnique(['user_id', 'client_key']); + $t->dropIndex(['status', 'sector_id', 'partnership_type']); + $t->dropColumn(['organization_id', 'sector_id', 'version', 'kind', 'partnership_type', 'payload', 'client_key', 'deadline', 'published_at']); + }); + } +}; diff --git a/database/migrations/2026_10_07_190000_create_internal_virtual_desk.php b/database/migrations/2026_10_07_190000_create_internal_virtual_desk.php new file mode 100644 index 00000000..6a67c5c0 --- /dev/null +++ b/database/migrations/2026_10_07_190000_create_internal_virtual_desk.php @@ -0,0 +1,90 @@ +database, '_test') && (int) $identity->port === 55441) && ! ($install && config('virtual-desk.installation_authorized'))) { + throw new RuntimeException('Use isolated QA or the authenticated virtual desk installation with verified restore.'); + } + } + + public function up(): void + { + $this->guard(true); + Schema::create('desk_service_versions', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->string('code'); $t->unsignedInteger('version'); $t->string('status')->default('draft'); + $t->jsonb('definition'); $t->jsonb('source'); $t->foreignId('author_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('published_by')->nullable()->constrained('users')->restrictOnDelete(); $t->timestampTz('published_at')->nullable(); $t->timestampsTz(); $t->unique(['code', 'version']); + }); + Schema::create('desk_cases', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete(); + $t->foreignUuid('company_id')->nullable()->constrained('investment_company_declarations')->restrictOnDelete(); + $t->foreignUuid('submission_id')->nullable()->constrained('investment_submissions')->restrictOnDelete(); + $t->uuid('simulation_id')->nullable(); $t->string('title'); $t->uuid('client_key'); $t->unsignedInteger('version')->default(1); + $t->text('payload'); $t->jsonb('workflow'); $t->timestampTz('closed_at')->nullable(); $t->timestampsTz(); $t->unique(['user_id', 'client_key']); + }); + Schema::create('desk_requests', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete(); + $t->foreignUuid('service_version_id')->constrained('desk_service_versions')->restrictOnDelete(); + $t->foreignId('analyst_id')->nullable()->constrained('users')->restrictOnDelete(); $t->string('status')->default('draft'); + $t->unsignedInteger('version')->default(1); $t->uuid('client_key'); $t->text('payload'); $t->string('protocol')->nullable()->unique(); + $t->text('submitted_snapshot')->nullable(); $t->jsonb('editable_fields')->nullable(); $t->timestampTz('submitted_at')->nullable(); + $t->timestampTz('due_at')->nullable(); $t->timestampTz('closed_at')->nullable(); $t->timestampsTz(); $t->unique(['case_id', 'client_key']); + }); + Schema::create('desk_documents', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete(); + $t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); $t->foreignUuid('request_id')->nullable()->constrained('desk_requests')->restrictOnDelete(); + $t->string('category'); $t->string('stage')->nullable(); $t->date('valid_until')->nullable(); $t->text('source_reference'); + $t->foreignId('linked_by')->constrained('users')->restrictOnDelete(); $t->timestampTz('created_at')->useCurrent(); $t->unique(['case_id', 'document_id']); + }); + Schema::create('desk_events', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignUuid('case_id')->constrained('desk_cases')->restrictOnDelete(); + $t->foreignUuid('request_id')->nullable()->constrained('desk_requests')->restrictOnDelete(); $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->string('action'); $t->unsignedInteger('version'); $t->text('payload'); $t->timestampTz('created_at')->useCurrent(); + }); + Schema::create('desk_revisions', function (Blueprint $t) { + $t->id(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete(); $t->unsignedInteger('version'); + $t->text('snapshot'); $t->timestampTz('created_at')->useCurrent(); $t->unique(['request_id', 'version']); + }); + Schema::create('desk_messages', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete(); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); $t->uuid('client_key'); $t->text('body'); $t->timestampTz('created_at')->useCurrent(); + $t->unique(['request_id', 'actor_id', 'client_key']); + }); + Schema::create('desk_notifications', function (Blueprint $t) { + $t->id(); $t->foreignUuid('event_id')->constrained('desk_events')->restrictOnDelete(); $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->string('title'); $t->string('href'); $t->timestampTz('read_at')->nullable(); $t->unique(['event_id', 'user_id']); + }); + Schema::create('desk_institutional_links', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->string('stage'); $t->string('entity'); $t->string('purpose'); $t->text('url'); $t->string('contact')->nullable(); + $t->string('status')->default('pending'); $t->unsignedInteger('version')->default(1); $t->text('source_reference'); + $t->timestampTz('verified_at')->nullable(); $t->foreignId('verified_by')->nullable()->constrained('users')->restrictOnDelete(); $t->timestampsTz(); + }); + Schema::create('desk_payments', function (Blueprint $t) { + $t->uuid('id')->primary(); $t->foreignUuid('request_id')->constrained('desk_requests')->restrictOnDelete(); + $t->string('reference')->unique(); $t->decimal('amount', 18, 2); $t->string('currency', 3); $t->text('source_reference'); + $t->string('status')->default('pending'); $t->foreignUuid('document_id')->nullable()->constrained('documents')->restrictOnDelete(); + $t->foreignId('verified_by')->nullable()->constrained('users')->restrictOnDelete(); $t->text('reason')->nullable(); $t->unsignedInteger('version')->default(1); $t->timestampsTz(); + }); + DB::statement("ALTER TABLE desk_requests ADD CONSTRAINT desk_request_status CHECK (status IN ('draft','submitted','reviewing','returned','opinion','accepted','rejected','closed','cancelled'))"); + DB::statement("ALTER TABLE desk_requests ADD CONSTRAINT desk_request_receipt CHECK (status IN ('draft','cancelled') OR (protocol IS NOT NULL AND submitted_snapshot IS NOT NULL AND submitted_at IS NOT NULL))"); + DB::statement('ALTER TABLE desk_payments ADD CONSTRAINT desk_payment_amount CHECK(amount>0)'); + } + + public function down(): void + { + $this->guard(); + foreach (['desk_cases', 'desk_service_versions', 'desk_institutional_links'] as $table) { + if (DB::table($table)->exists()) throw new RuntimeException('Preserve virtual desk data before rollback.'); + } + foreach (['desk_payments', 'desk_institutional_links', 'desk_notifications', 'desk_messages', 'desk_revisions', 'desk_events', 'desk_documents', 'desk_requests', 'desk_cases', 'desk_service_versions'] as $table) Schema::drop($table); + } +}; diff --git a/docs/investmoz/partnerships/README.md b/docs/investmoz/partnerships/README.md new file mode 100644 index 00000000..c892415c --- /dev/null +++ b/docs/investmoz/partnerships/README.md @@ -0,0 +1,28 @@ +# Entrega da Bolsa de Parcerias + +O percurso local foi implementado sobre `portal_partnerships`, preservando IDs, autores e estados antigos. A matriz está em [requirements.md](requirements.md), e os contratos internos e estados em [contracts.md](contracts.md). A especificação original, inventário do acervo e hashes permanecem privados; não foram publicados automaticamente. + +## Implementado e verificado em QA + +- Catálogo, pesquisa, filtros territoriais hierárquicos, paginação, detalhe e mapa com coordenadas existentes. Locais documentados da origem são contexto identificado, não implantação presumida da parceria. +- Nove tipos de parceria; oferta/procura pessoal ou por representante autorizado; rascunhos persistidos, recuperação, edição, pré-visualização, submissão e moderação por outro utilizador. +- Referências aos IDs de empresa, investimento, projeto, cluster, zona e dossier, com ligações nos dois sentidos. ZEE, ZFI e parques reutilizam o cadastro de zonas. O simulador abre o mesmo rascunho autorizado pelo seu ID; não cria um cadastro paralelo. O roteiro continua ligado a `/parcerias`. +- Interesse, atualização/retirada, esclarecimentos, comparação e lista curta. Regras explicáveis com pesos configuráveis: informação insuficiente produz intervalo de pontuação e lacunas; só incumprimento obrigatório comprovado impede a decisão correspondente. A avaliação inicial é documental e declarada pelos participantes, sem certificação automática de perfis. +- Sala privada, mensagens idempotentes, documentos com consentimento/antimalware/integridade, acordo com confirmação separada das partes, responsáveis, marcos, execução e encerramento. Histórico, notificações locais e eventos versionados. +- Proteção de organizações e candidatos, versões concorrentes, transições, isolamento de salas e preservação das propostas antigas na instalação transacional autenticada. + +Passaram 46 testes relevantes, com 701 asserções, mais 23 testes de continuidade/privacidade dos rascunhos do simulador, com 176 asserções: 69 testes e 877 asserções nas bases isoladas da porta 55441. TypeScript, compilação e PHPStan dos serviços/controladores revistos também passaram. `browser-qa.json` regista o percurso realizado por três contas sintéticas em QA, do rascunho ao acordo e marco, e ligação de regresso ao projeto. Capturas a 1440 e 390 px verificam computador e telemóvel. As contas e declarações sintéticas servem apenas a verificação; não são propostas reais publicadas na base ativa. Documentos limpos simulados existem somente em testes de permissões; nenhum documento real recebeu confirmação de scan sem evidência. + +## Base ativa e aplicação + +O código e a compilação estão na cópia local; a extensão da base ativa ainda não foi aplicada. A página `/management/partnerships-install` apresenta o alvo, registos preservados, alterações, prova de restauro e evidência QA da versão do código. A aplicação exige a conta de gestão já autorizada, compara a pré-visualização, bloqueia os registos, migra transacionalmente e regista autor/data/versão. Repetir a instalação não duplica dados. `/parcerias` mantém o comportamento anterior enquanto a extensão não estiver instalada. + +O backup atualizado foi restaurado em `partnership_restore_20261007_172202_test:55441`, com comparação integral do conteúdo de 145 tabelas. Inclui as 28 associações territoriais que a leitura final encontrou na base ativa, auditadas pelo utilizador 13 às 12:59:36 UTC. A prova e o dump ficam privados. A ação autenticada para instalar a Bolsa continua necessária por instrução expressa do AGENTS.md: «Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária». Não se criou uma aprovação adicional nem uma conta artificial na base ativa. + +## Pendências concretas + +- Executar a instalação com uma sessão existente autorizada na base ativa e verificar aí o percurso com utilizadores reais. A sessão QA não autoriza escrever na base ativa. +- CRM/SSO/APIs externos, entrega de emails, IA externa, consulta de registos públicos e assinatura jurídica certificada não estão configurados. O fluxo local não depende deles e não afirma sincronização, certificação ou entrega. +- Fontes das âncoras e clusters continuam com as pendências discriminadas em [../relationships/README.md](../relationships/README.md), incluindo coordenadas e abrangência própria dos clusters. A Bolsa reutiliza relações documentadas e não completa essas lacunas por proximidade. +- O scanner real necessita do serviço antimalware disponível no ambiente para libertar novos ficheiros. Um scan pendente conserva a consulta da proposta e bloqueia apenas o download do ficheiro em questão. +- O teste geral preexistente de download com scan pendente e os erros estáticos globais fora do âmbito continuam discriminados na entrega territorial. A proteção documental não foi reduzida para os fazer passar. diff --git a/docs/investmoz/partnerships/browser-qa.json b/docs/investmoz/partnerships/browser-qa.json new file mode 100644 index 00000000..7f5d6ed9 --- /dev/null +++ b/docs/investmoz/partnerships/browser-qa.json @@ -0,0 +1,20 @@ +{ + "target": "anchor_restore_20261007_test:55441", + "passed": true, + "partnershipId": 3, + "roomId": 3, + "draftRecovered": true, + "sourceBacklink": true, + "moderation": true, + "interest": true, + "shortlist": true, + "privateMessage": true, + "bilateralAgreement": true, + "milestoneCompleted": true, + "roomClosed": true, + "viewports": [ + 1440, + 390 + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/investmoz/partnerships/contracts.md b/docs/investmoz/partnerships/contracts.md new file mode 100644 index 00000000..5ed6c35b --- /dev/null +++ b/docs/investmoz/partnerships/contracts.md @@ -0,0 +1,15 @@ +# Contratos internos v1 + +Não existe entrega externa configurada. Os endpoints públicos são `GET /api/v1/partnerships` e `GET /api/v1/partnerships/source/{type}/{id}`. O primeiro aceita `q`, `sector_id`, `kind`, `partnership_type`, `territory_id`, `origin_type`, `origin_id` e `page`; devolve paginação Laravel de 12 elementos. O segundo devolve apenas propostas publicadas e origens que o visitante pode consultar. Uma origem privada não é divulgada pelo catálogo. + +As escritas usam a sessão existente, CSRF e autorização por recurso. Os contratos das rotas estão em `routes/partnerships.php`. Alterações enviam `version`; uma versão desatualizada resulta em 409. Criação de rascunho e mensagens usam `client_key` UUID: repetir a mesma operação devolve o resultado existente; reutilizar a chave com outro conteúdo resulta em 409. + +| Agregado | Estados persistidos | Separação | +| --- | --- | --- | +| Publicação | draft, submitted, returned, rejected, published, suspended, archived; pending legado | Renovar recupera o rascunho e exige nova submissão. Editar uma proposta publicada não publica a alteração automaticamente. | +| Candidatura | submitted, clarification, shortlisted, rejected, withdrawn, negotiation | Negociação de um candidato não encerra outros. A retirada pertence ao candidato; avaliação e decisão ao titular da proposta. | +| Sala/acordo | negotiation, agreement_recorded, agreed, execution, closed, cancelled | Registo de termos não confirma as partes. Só a confirmação de ambas permite acompanhar a execução. Encerramento exige os marcos concluídos. Renovação devolve a negociação sem apagar o histórico dos termos. | + +Os eventos persistidos em `partnership_events` e `audit_events` têm envelope v1: `event_id`, `event_type`, `event_version`, `source`, `subject_id`, `occurred_at`, `producer_version`, `correlation_id`, `causation_id`, `data_classification`, `aggregate_id`, `aggregate_version`, `publication_version`, `payload`, `external_delivery`. A sequência do agregado é atribuída com bloqueio transacional; não confunde a versão de publicação com a de cada candidatura ou sala. O autor fica no registo do evento e da auditoria. `external_delivery=not_configured` não afirma transmissão ao CRM. Estes envelopes são internos e não constituem um endpoint público para informação privada. + +As salas usam documentos do cadastro existente, armazenamento privado, consentimento explícito de partilha e scanner existente. Download exige acesso atual à sala, autorização de partilha, resultado antimalware limpo e integridade do ficheiro. O autor pode revogar a partilha. Gestores de publicação não recebem acesso automático às mensagens ou aos documentos de negociação. diff --git a/docs/investmoz/partnerships/requirements.md b/docs/investmoz/partnerships/requirements.md new file mode 100644 index 00000000..514b2b9c --- /dev/null +++ b/docs/investmoz/partnerships/requirements.md @@ -0,0 +1,22 @@ +# Bolsa de Parcerias — matriz de implementação + +Especificação: «Bolsas de pareceria Bolsa de Parcerias.docx», versão 1.0, 7 outubro 2026, no acervo de Relatórios. Original, hash e inventário permanecem privados em `storage/app/private/partnership-exchange/`. As instruções do responsável prevalecem sobre as dependências externas e a exigência geral de organização verificada do documento. + +| Requisito | Existente | Alteração necessária | Verificação | +| --- | --- | --- | --- | +| Identidade e representação | users, organizations, memberships | Reutilizar sessão; escrita pela conta titular ou representante autorizado; rascunho pessoal permitido | Conta alheia, organizações e papéis | +| Catálogo e propostas antigas | /parcerias, portal_partnerships, moderação | Estender os registos, manter IDs/autores/estados; pesquisa, detalhe, tipos e filtros | Preservação e paginação | +| Dados mestres e origens | opportunities, dossiers, projects, economic_clusters, investment_zones, declarações empresariais | Referências por ID; entradas e ligações nos dois sentidos | Origem pública/privada e identidade | +| Territórios | geo_locations, project_locations, relações de zonas | Várias localizações com fonte e papel; contexto da origem separado de implantação declarada | Hierarquia e ausência de inferência | +| Percurso de publicação | Proposta pending → published | Rascunho, edição, pré-visualização, submissão, devolução/rejeição fundamentada | Concorrência e transições | +| Candidaturas | Pedidos gerais de interesse | Candidaturas próprias da parceria, atualização/retirada, comparação, esclarecimentos e lista curta | Unicidade e isolamento entre candidatos | +| Correspondências | Sem motor da Bolsa | Regras e pesos configuráveis do documento; informação desconhecida separada de incumprimento comprovado | Dados completos/incompletos e explicação | +| Colaboração | investment_messages ligados obrigatoriamente a investment_submissions | Mensagens da sala de parceria, com o mesmo padrão de isolamento e idempotência; documents mestre e scanner existente | Participantes, armazenamento privado e scan | +| Notificações | investment_notifications ligadas a investment_submissions | Caixa local da Bolsa e eventos versionados; sem correio externo obrigatório | Destinatários e leitura | +| Auditoria e CRM | audit_events e CRM interno | Reutilizar auditoria; preparar eventos sem simular sincronização externa | Autor/data/motivo/versão | +| Acordo e acompanhamento | Sem processo próprio | Registo de instrumento/partes/responsáveis/marcos; confirmação das partes distinta de assinatura certificada | Fluxo completo e permissões | +| QA e aplicação | PostgreSQL QA 55441; gestão autenticada | Migração protegida; pré-visualização, backup/restauro e aplicação autenticada | Base ativa intacta e restauro | + +As tabelas de mensagens/notificações existentes têm chaves obrigatórias para candidaturas de investimento. Não se criam candidaturas fictícias de investimento para contornar essa integridade. A Bolsa tem entidades transacionais próprias, sem novos cadastros de contas, organizações, documentos ou oportunidades de investimento. + +Consulta e preparação de rascunhos não exigem NUIT, empresa constituída, anexos ou verificação. Autenticação serve a propriedade e privacidade; representação exige a membership existente; moderação serve a divulgação pública. Pendências permanecem visíveis. Não há SSO/CRM/IA externos bloqueantes nem emails reais nos testes. diff --git a/docs/investmoz/partnerships/room-1440.png b/docs/investmoz/partnerships/room-1440.png new file mode 100644 index 00000000..2517c41b Binary files /dev/null and b/docs/investmoz/partnerships/room-1440.png differ diff --git a/docs/investmoz/partnerships/room-390.png b/docs/investmoz/partnerships/room-390.png new file mode 100644 index 00000000..6c0dabb9 Binary files /dev/null and b/docs/investmoz/partnerships/room-390.png differ diff --git a/docs/investmoz/relationships/README.md b/docs/investmoz/relationships/README.md new file mode 100644 index 00000000..6c6c15f1 --- /dev/null +++ b/docs/investmoz/relationships/README.md @@ -0,0 +1,80 @@ +# InvestMoz: projetos âncora e clusters — 7 outubro 2026 + +Código integrado e compilado, preservando as alterações locais. As 28 associações territoriais de 13 projetos foram verificadas em QA. A leitura final também confirmou essas 28 associações na base ativa, com auditoria de aplicação autenticada pelo utilizador 13 às 12:59:36 UTC de 7 outubro 2026. A nova validação individual foi exercitada exclusivamente em QA. A página /management/relationship-repair apresenta o plano, fontes, motivos, oportunidades preparadas e pendências, usando as permissões de gestão existentes. + +## Inventário + +Inventariados 66 ficheiros, incluindo 35 DOCX e anexos incorporados, com SHA256 e proveniência. Há 15 âncoras reais, um projeto fictício excluído, oito clusters, 23 dossiers publicados de projetos/clusters, 13 cluster_projects e seis oportunidades genéricas. Não há cadeias nem elos cadastrados. Inventários e fontes extraídas permanecem em storage/app/private/anchor-project-reconciliation/. + +O rótulo original dos dossiers refere programação v4, cujo ficheiro não foi encontrado; a programação v5 disponível corrobora as linhas/localizações. O rótulo original foi preservado. projectos.docx fundamenta os componentes preparados. Não foi afirmada autenticidade, vigência jurídica ou análise antimalware. Nenhum documento foi importado ou publicado. + +## Projetos: integração das fichas corrigida; associações confirmadas em QA e na base ativa + +| Código | Projeto | Localização documentada e papel | +| --- | --- | --- | +| A01 | MozParks — 20 Parques Industriais | Âmbito nacional; sem implantação individual | +| A02 | Linha Ferroviária Norte–Sul | Âmbito nacional; sem traçado documentado | +| A03 | Desenvolvimento Integrado do Corredor de Nacala | Nampula, Cabo Delgado, Niassa, Tete: secundárias; norte da Zambézia: impacto parcial | +| A04 | Crusse Jamali — ZEE de Turismo | Nampula: principal | +| A05 | Linha de Transmissão Namialo–Metoro | Nampula, Cabo Delgado: secundárias | +| A06 | Cadeia de Valor do Arroz por Projecto de Processamento | Tete, Manica, Zambézia, Sofala: secundárias | +| A07 | Parque Têxtil Integrado | Sofala: principal; Manica, Tete: secundárias | +| A08 | ZEE de Mocuba | Zambézia: principal | +| A09 | Cidadela Parlamentar de Mocuba | Zambézia: principal | +| A10 | Mphanda Nkuwa HPP | Tete: principal | +| A11 | Corredor Ferroviário e Portuário Macuse–Moatize | Tete, Zambézia e distrito de Nicoadala: secundárias | +| A12 | Conectividade e Resiliência Costeira — Compacto II | Zambézia: principal | +| A13 | Vilanculos — Região Integrada de Turismo | Inhambane: principal | +| A14 | ZEE de Agronegócio do Limpopo | Gaza: principal | +| A15 | Regadio na Bacia Hidrográfica do Rio Maputo | Maputo Província: principal; distritos Boane, Moamba, Namaacha: impacto | + +Para todos faltam coordenadas, posto administrativo e localidade com fonte exata. Os distritos foram associados apenas quando identificados explicitamente; as cidades não foram convertidas em distritos. Não foram inventados pontos. Os estados dos projetos/dossiers foram preservados. + +## Clusters: relações existentes apresentadas com navegação nos dois sentidos + +| Código | Cluster | Âncoras documentadas | +| --- | --- | --- | +| CLU-01 | Agro-indústria e segurança alimentar | A06, A14, A15 | +| CLU-02 | Energia e industrialização | A01, A05, A08, A10 | +| CLU-03 | Logística, corredores e exportação | A02, A03, A11 | +| CLU-04 | Turismo costeiro e economia azul | A04, A13 | +| CLU-05 | Têxtil, vestuário e algodão | A07 | +| CLU-06 | Mineração e transformação mineral | Sem códigos individuais na fonte | +| CLU-07 | Tecnologia e serviços digitais | Sem códigos individuais na fonte | +| CLU-08 | Financiamento do investimento | Sem códigos individuais na fonte | + +As 13 relações reutilizam identificadores existentes e permanecem relações documentais de planeamento pendentes, sem confirmação jurídica. Para todos os clusters falta fonte da área própria de abrangência e elos individualizados de cadeias de valor. Os locais dos membros não definem o perímetro do cluster; nenhum ponto único foi atribuído. As atividades/lacunas continuam consultáveis no dossier. Pertença ao cluster não cria automaticamente associações entre todos os projetos e todas as oportunidades. + +Pesquisa, filtros e mapa usam as relações explícitas e a hierarquia territorial. Várias localizações possíveis não produzem um marcador arbitrário da oportunidade. A localização de um projeto ligado é identificada como contexto, sem confirmar implantação da oportunidade. + +## Oportunidades preparadas e pendências + +158 componentes preparados em privado, com identificador original, texto integral das células, localização textual, custos/horizonte originais e proveniência SHA256/tabela/linha. Não foram cadastrados nem publicados automaticamente. + +86 têm códigos explícitos consistentes em A06, A07, A08, A09, A10, A11, A13, A14 e A15. Os restantes 72 têm conflitos nas três primeiras tabelas: legenda A03/âncora A02; legenda A04/âncora A02; legenda A05/âncora A03. Precisam de correção documental; não foram associados ou renumerados. + +As seis oportunidades existentes não têm código documental que fundamente associação às âncoras. Nenhuma associação foi inferida por nome/setor. Nos componentes preparados continuam desconhecidos sector_id, promotor confirmado, maturidade, coordenadas, títulos/disponibilidade e interpretação monetária. Estas lacunas não impedem consultar os projetos. + +## Backup e situação da aplicação ativa + +Backup restaurado em PostgreSQL isolado, porta 55441. Comparadas contagens de 145 tabelas e hashes do conteúdo de 12 tabelas relevantes antes de alterar QA. Prova e dump: storage/app/private/anchor-project-reconciliation/backup-proof.json e backup-20261007/database.dump. +SHA256 do dump: C52865525BE0B227D9EA9B96FD74E563CE7AA547F6BDE31572F9D0FC539E16A6. + +A aplicação verifica hash do plano, backup, identidade da base, versões e fontes. Usa transação, bloqueio e auditoria do utilizador autenticado; falhas causam rollback e repetições não criam duplicados. + +## Validação territorial individual + +`/management/relationship-repair` mostra localização atual e proposta, hierarquia completa disponível, papel, documento, citação e motivo. Valida província → distrito → posto administrativo → localidade, incluindo conflitos de ascendência e registos indisponíveis. Localização principal, secundária e área de impacto são independentes e podem coexistir. Sem coordenadas, uma localização documental continua fundamentada; coordenadas inválidas ou incompatíveis com um polígono territorial disponível são contraditórias. Sem polígonos disponíveis, a inclusão territorial das coordenadas fica explicitamente por verificar. + +As linhas são separadas em fundamentadas, pendentes e contraditórias. É possível aplicar uma linha fundamentada individualmente; as restantes conservam os conflitos. Cada aplicação revalida a pré-visualização sob bloqueio e regista utilizador, data, fonte e estado anterior. O histórico permanece visível. Não se deduz a localização de um projeto pela pertença a um cluster ou proximidade a uma zona. + +Em QA, duas linhas sintéticas fundamentadas foram aplicadas individualmente; uma pendente e uma contraditória ficaram intactas. O navegador verificou autor/data/fonte e ausência de coordenadas sem bloqueio, em 1440 e 390 px. Evidência: `territorial-browser-qa.json`. Esses locais sintéticos não fazem parte das propostas de correção da base ativa. + +A ação autenticada continua necessária para novas correções por regra expressa do AGENTS.md. Iniciar sessão com uma conta existente autorizada em http://localhost:8000/management/relationship-repair; as linhas já aplicadas aparecem no histórico e não devem ser duplicadas. Não publica oportunidades nem promove estados. Inventário ativo final: project_locations=28, opportunity_projects=0, opportunity_clusters=0, cluster_projects=13. Um segundo backup atualizado foi restaurado em `partnership_restore_20261007_172202_test:55441`, comparando integralmente as 145 tabelas, e inclui as 28 associações; prova em `storage/app/private/partnership-exchange/backup-proof.json`. + +## Verificação e limites + +- 46 testes relevantes passaram, com 701 asserções, exclusivamente em investmoz_test:55441, incluindo a Bolsa: relações, validação territorial individual, quatro níveis territoriais, pesquisa, filtros, mapas, privacidade, ambiguidades, rollback e idempotência. Mais 23 testes do simulador passaram, com 176 asserções. +- TypeScript, compilação de produção e PHPStan dos ficheiros PHP alterados passaram. +- Navegador em QA verificou um projeto sintético completo com localização, coordenadas, mapa, oportunidade e regresso; cluster com dois locais de membros e dois elos; filtros; aplicação autenticada das 28 associações e repetição sem duplicados. Evidência: browser-qa.json e capturas a 1440/390 px. Os mosaicos externos foram bloqueados no ensaio; a disponibilidade do fundo cartográfico externo não foi validada. +- O teste geral preexistente InvestmentUnificationTest::test_attachments_are_private_and_content_changes_are_blocked continua a falhar ao esperar download com análise antimalware pendente. A proteção de downloads foi preservada. A análise estática global contém erros fora dos ficheiros alterados. diff --git a/docs/investmoz/relationships/browser-qa.json b/docs/investmoz/relationships/browser-qa.json new file mode 100644 index 00000000..dda909f2 --- /dev/null +++ b/docs/investmoz/relationships/browser-qa.json @@ -0,0 +1,19 @@ +{ + "target": "anchor_restore_20261007_test:55441", + "project": "970d942e-329a-48fa-9426-39828b65be6c", + "cluster": "e563883e-4353-4e07-8969-c774fe2d6d5f", + "passed": true, + "publicNavigation": true, + "administrativeHierarchy": true, + "clusterMemberSites": 2, + "clusterSinglePoint": false, + "territorialMap": true, + "chainLinks": true, + "authenticatedPlanApplied": true, + "replayWithoutDuplicates": true, + "viewports": [ + 1440, + 390 + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/investmoz/relationships/cluster-1440.png b/docs/investmoz/relationships/cluster-1440.png new file mode 100644 index 00000000..dd8f78f4 Binary files /dev/null and b/docs/investmoz/relationships/cluster-1440.png differ diff --git a/docs/investmoz/relationships/cluster-390.png b/docs/investmoz/relationships/cluster-390.png new file mode 100644 index 00000000..7461ad4f Binary files /dev/null and b/docs/investmoz/relationships/cluster-390.png differ diff --git a/docs/investmoz/relationships/territorial-1440.png b/docs/investmoz/relationships/territorial-1440.png new file mode 100644 index 00000000..bb429abd Binary files /dev/null and b/docs/investmoz/relationships/territorial-1440.png differ diff --git a/docs/investmoz/relationships/territorial-390.png b/docs/investmoz/relationships/territorial-390.png new file mode 100644 index 00000000..3d1e7853 Binary files /dev/null and b/docs/investmoz/relationships/territorial-390.png differ diff --git a/docs/investmoz/relationships/territorial-browser-qa.json b/docs/investmoz/relationships/territorial-browser-qa.json new file mode 100644 index 00000000..c85ff370 --- /dev/null +++ b/docs/investmoz/relationships/territorial-browser-qa.json @@ -0,0 +1,14 @@ +{ + "target": "anchor_restore_20261007_test:55441", + "passed": true, + "individualApplications": 2, + "pendingRetained": 1, + "contradictoryRetained": 1, + "noCoordinatesAccepted": true, + "auditAuthorDateSource": true, + "viewports": [ + 1440, + 390 + ], + "errors": [] +} \ No newline at end of file diff --git a/resources/js/components/partnership-search-results.tsx b/resources/js/components/partnership-search-results.tsx new file mode 100644 index 00000000..2d5d6df5 --- /dev/null +++ b/resources/js/components/partnership-search-results.tsx @@ -0,0 +1,39 @@ +import { Link } from "@inertiajs/react"; +import { useEffect, useState } from "react"; +export default function PartnershipSearchResults({ query }: { query: string }) { + const [items, setItems] = useState<{ id: number; title: string }[]>([]); + useEffect(() => { + if (!query.trim()) { + setItems([]); + return; + } + const controller = new AbortController(); + fetch("/api/v1/partnerships?q=" + encodeURIComponent(query), { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => setItems(data?.data ?? [])) + .catch(() => {}); + return () => controller.abort(); + }, [query]); + if (!items.length) return null; + return ( +
+

Parcerias nesta pesquisa

+ {items.slice(0, 5).map((item) => ( +

+ + {item.title} + +

+ ))} + + Ver todas as parcerias correspondentes + +
+ ); +} diff --git a/resources/js/components/partnership-source-links.tsx b/resources/js/components/partnership-source-links.tsx new file mode 100644 index 00000000..e1f3b03f --- /dev/null +++ b/resources/js/components/partnership-source-links.tsx @@ -0,0 +1,52 @@ +import { Link } from "@inertiajs/react"; +import { useEffect, useState } from "react"; +export default function PartnershipSourceLinks({ + type, + id, +}: { + type: string; + id: string | number; +}) { + const [data, setData] = useState<{ + available: boolean; + items: { id: number; title: string }[]; + } | null>(null); + useEffect(() => { + const controller = new AbortController(); + fetch( + `/api/v1/partnerships/source/${encodeURIComponent(type)}/${encodeURIComponent(String(id))}`, + { + signal: controller.signal, + headers: { Accept: "application/json" }, + }, + ) + .then((r) => (r.ok ? r.json() : null)) + .then(setData) + .catch(() => {}); + return () => controller.abort(); + }, [type, id]); + if (!data?.available) return null; + return ( +
+

+ Parcerias ligadas a este registo +

+ {data.items.map((item) => ( +

+ + {item.title} + +

+ ))} + {!data.items.length && ( +

Ainda não há parcerias publicadas com esta origem.

+ )} + + Propor parceria a partir deste registo + +
+ ); +} diff --git a/resources/js/components/simulation-form.tsx b/resources/js/components/simulation-form.tsx index 78084076..65212f0f 100644 --- a/resources/js/components/simulation-form.tsx +++ b/resources/js/components/simulation-form.tsx @@ -6,6 +6,7 @@ useRef, useState, } from 'react'; +import PartnershipSourceLinks from './partnership-source-links'; import { getAutosaveSnapshot, partitionSimulationDraft, @@ -1053,6 +1054,7 @@ export function SimulationForm() { let cancelled = false; void (async () => { let local: SimulationDraft = {}; + const requestedDraftId = new URLSearchParams(window.location.search).get('draft'); try { const stored = sessionStorage.getItem(draftKey); if (stored) local = JSON.parse(stored) as SimulationDraft; @@ -1063,7 +1065,7 @@ export function SimulationForm() { } try { const response = await fetch( - '/api/v1/simulation-drafts/current', + requestedDraftId ? `/api/v1/simulation-drafts/${encodeURIComponent(requestedDraftId)}` : '/api/v1/simulation-drafts/current', { credentials: 'same-origin', headers: { @@ -1110,7 +1112,7 @@ export function SimulationForm() { setSelectedAccountDraftId( listPayload.data[0]?.id ?? '', ); - if (previewPayload.data) { + if (previewPayload.data && !requestedDraftId) { setPendingAnonymousDraft(previewPayload.data); setShowDraftChoice(true); setSaveStatus( @@ -2104,6 +2106,7 @@ export function SimulationForm() { className="space-y-6 rounded-xl border bg-white p-4 sm:p-6" aria-labelledby="simulator-step-title" > + {canSavePersonalData && draftVersionNo && draftIdRef.current && }

= { primary: 'Localização principal', secondary: 'Localização secundária', impact: 'Área de impacto', coverage: 'Área de abrangência documentada' }; +export function LocationList({ locations }: { locations: DocumentedLocation[] }) { + return
{locations.map((location, index) =>
+

{roles[location.role] ?? location.role}: {location.name}

+
{[['province','Província'],['district','Distrito'],['admin_post','Posto administrativo'],['locality','Localidade']].map(([type,label]) =>
{label}
{location.path.find(node => node.location_type===type)?.name ?? 'Não documentado'}
)}
+ {location.coordinates.map(point =>

Coordenadas documentadas ({location.location_type === 'site' ? 'local do projecto' : 'referência territorial; não representa a implantação'}): {point.geometry.coordinates[1]}, {point.geometry.coordinates[0]} · Fonte: {point.source_reference}

)} + {!location.coordinates.length &&

Coordenadas não documentadas.

} +

Fonte da relação: {location.source_reference ?? 'Não indicada'}

+ Pesquisar oportunidades neste território +
)}
; +} + +export default function StructureGeographyPanel({ geography, cluster = false, dossierId }: { geography: StructureGeography; cluster?: boolean; dossierId: string }) { + const items = cluster ? geography.members.flatMap(member=>member.locations.map(location=>({location,title:member.title,url:`/carteira/${member.dossier_id}`}))) : geography.locations.map(location=>({location,title:location.name,url:`/carteira/${dossierId}`})); + const points = items.flatMap(item=>item.location.location_type === 'site' ? item.location.coordinates.map(point=>({geometry:point.geometry,properties:{id:0,title:item.title,location:`${roles[item.location.role] ?? item.location.role} · ${item.location.name}`,url:item.url}})) : []).map((point,index)=>({...point,properties:{...point.properties,id:index+1}})); + return
+

{cluster ? 'Abrangência do cluster e localizações dos membros' : 'Localização documentada do projecto'}

+ + {!geography.locations.length &&

{cluster ? 'A área própria de abrangência do cluster não está documentada em referências territoriais. As localizações dos membros abaixo não definem o seu perímetro.' : 'Sem associação territorial estruturada. Consulte a descrição de localização no dossier; os níveis desconhecidos permanecem não documentados.'}

} + {cluster &&

O mapa apresenta locais documentados dos membros. Não existe um ponto único atribuído ao cluster.

} + + {!points.length &&

Sem coordenadas documentadas dos locais. Nenhum ponto foi estimado.

} + {geography.members.map(member=>

{member.code} · {member.title}

+

Papel: {member.role} · Relação {member.validation_status === 'validated' ? 'validada no cadastro' : 'documental de planeamento; validação pendente'} · Fonte: {member.source_reference}

{!member.locations.length &&

Localização estruturada do membro não documentada.

}
)} + {geography.clusters.map(item=>

Cluster: {item.title} · {item.validation_status === 'validated' ? 'validada no cadastro' : 'relação documental de planeamento; validação pendente'} · Fonte: {item.source_reference}

)} + {cluster &&

Elos da cadeia de valor cadastrados

{geography.chains?.map(chain=>

{chain.code} · {chain.name} · {({input:'Insumos',production:'Produção',processing:'Transformação',logistics:'Logística',market:'Mercado'} as Record)[chain.stage] ?? chain.stage}

)}{!geography.chains?.length &&

Sem elos estruturados cadastrados. As actividades e lacunas descritas no dossier continuam disponíveis.

}
} + {!!geography.chainLinks.length &&

Relações entre elos da cadeia de valor

{geography.chainLinks.map(link=>

{link.origin} → {link.destination} · {link.link_type}

)}
} +
; +} diff --git a/resources/js/pages/investment/business-opportunity.tsx b/resources/js/pages/investment/business-opportunity.tsx index 0e001fe2..ffa9f90d 100644 --- a/resources/js/pages/investment/business-opportunity.tsx +++ b/resources/js/pages/investment/business-opportunity.tsx @@ -1,9 +1,10 @@ import { Link } from "@inertiajs/react"; import { Frame, button, card } from "./shared"; import { businessModalities } from "./business-types"; +import PartnershipSourceLinks from '@/components/partnership-source-links'; type Business = { - slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; activity_code?: string | null; + id: string; slug: string; title: string; modality: string; objective?: string | null; stage?: string | null; activity_code?: string | null; amount_min?: string | null; amount_max?: string | null; available_percentage?: string | null; currency?: string | null; province?: string | null; district?: string | null; locality?: string | null; sector?: { name: string; slug: string } | null; }; @@ -11,6 +12,7 @@ type Business = { export default function BusinessOpportunity({ record, returnUrl = "/negocios", interestUrl = `/negocios/${record.slug}/manifestar-interesse` }: { record: Business; returnUrl?: string; interestUrl?: string }) { return ( + Voltar às oportunidades

{businessModalities[record.modality] ?? record.modality}

diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index 6e8039ef..befd4108 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import Map from "@/components/interactive-investment-map"; import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory"; import { MATURITY_LEVELS } from "@/lib/investment-maturity"; +import PartnershipSearchResults from '@/components/partnership-search-results'; import { Frame, Field, input, button, card, money, api, groupLabels } from "./shared"; import type { Opportunity, References } from "./shared"; @@ -189,6 +190,7 @@ export default function Catalogue({ +

{c[key].map((entity) => (

+ {entity.dossiers?.map(dossier => Consultar dossier: {dossier.title}
)} ))} + {(['projects','clusters'] as const).flatMap(key => c[key].filter(entity => entity.geography && entity.dossiers?.length).map(entity => ))}

Infraestruturas, competências, educação e apoios diff --git a/resources/js/pages/investment/profile.tsx b/resources/js/pages/investment/profile.tsx index c10c629f..b82dfaf2 100644 --- a/resources/js/pages/investment/profile.tsx +++ b/resources/js/pages/investment/profile.tsx @@ -1,5 +1,6 @@ import { Link, router, useForm } from "@inertiajs/react"; import { Frame, Field, input, button, card } from "./shared"; +import PartnershipSourceLinks from '@/components/partnership-source-links'; type RecordData = { version: number; payload: Record }; type Company = RecordData & { id: string }; @@ -47,6 +48,7 @@ export default function Profile({

{companies.map((company) => (
+

{company.payload.name}

+ {!management&&}

{z.code} · {z.category.toUpperCase()} · {z.validation_status} · {z.province}{" "} {z.district} diff --git a/resources/js/pages/management/relationship-repair.tsx b/resources/js/pages/management/relationship-repair.tsx new file mode 100644 index 00000000..661bbca4 --- /dev/null +++ b/resources/js/pages/management/relationship-repair.tsx @@ -0,0 +1,445 @@ +import { Link, useForm } from "@inertiajs/react"; +import { Management } from "./shared"; + +type Plan = { + target: { database: string; port: number }; + locations: { + code: string; + project_id: string; + name: string; + role: string; + source_reference: string; + source_quote: string; + reason: string; + existing: boolean; + }[]; + memberships: { + cluster_code: string; + project_code: string; + source_reference: string; + source_quote: string; + action: string; + }[]; + prepared_opportunities: { + reference: string; + title: string; + project_code: string | null; + location_text: string; + source_reference: string; + identity_status: string; + unknown: string[]; + reason: string; + }[]; + pending: { code: string; reason: string; name?: string }[]; +}; +type Territory = { + row_id: string; + validation_hash: string; + status: "grounded" | "pending" | "contradictory"; + applied: boolean; + issues: string[]; + warnings: string[]; + current: { + id: string; + name: string; + role: string; + path: { name: string; type: string }[]; + }[]; + proposed: { + name: string; + role: string; + path: { name: string; type: string }[]; + }; + coordinates: { source: string; coordinates: number[]; srid: number }[]; + coordinate_checks: { + territory_id: string; + source: string; + compatible: boolean; + }[]; +}; +type History = { + id: string; + date: string; + actor_id: number; + actor: string | null; + data: { + sources?: { + row_id: string; + source_reference: string; + reason: string; + }[]; + }; +}; +const roles: Record = { + primary: "Principal", + secondary: "Secundária", + impact: "Área de impacto", +}; +const levels: Record = { + country: "País", + province: "Província", + district: "Distrito", + admin_post: "Posto administrativo", + locality: "Localidade", + site: "Implantação", +}; +function pathText(path: { name: string; type: string }[]) { + return ( + path + .map((node) => `${levels[node.type] ?? node.type}: ${node.name}`) + .join(" → ") || "Hierarquia não indicada" + ); +} +function IndividualAssociation({ + hash, + item, +}: { + hash: string; + item: Territory; +}) { + const form = useForm({ + hash, + row_id: item.row_id, + validation_hash: item.validation_hash, + }); + return ( +

{ + event.preventDefault(); + form.transform(() => ({ + hash, + row_id: item.row_id, + validation_hash: item.validation_hash, + })); + form.post("/management/relationship-repair", { + preserveScroll: true, + }); + }} + > + + {Object.values(form.errors).map((error) => ( +

+ {error} +

+ ))} +
+ ); +} +type Unproposed = { + code: string; + current: { name: string; role: string }[]; + source: string | null; + location_text: string | null; + reasons: string[]; +}; +export default function RelationshipRepair({ + hash, + plan, + territorial, + history, + unproposed, +}: { + hash: string; + plan: Plan; + territorial: Territory[]; + history: History[]; + unproposed: Unproposed[]; +}) { + const form = useForm({ hash }); + return ( + +

+ Pré-visualização para {plan.target.database}:{plan.target.port}. + A execução verifica o backup e o restauro, a versão dos dossiers + e os identificadores. As associações existentes são preservadas. +

+

+ O plano corrige localizações documentadas. As oportunidades + abaixo ficam preparadas para cadastro; não são publicadas nem + ligadas a clusters por pertencerem ao mesmo projecto. +

+
{ + event.preventDefault(); + form.post("/management/relationship-repair"); + }} + > + + {Object.values(form.errors).map((error) => ( +

+ {error} +

+ ))} +
+
+

+ Localizações: {plan.locations.length} +

+

+ As propostas acrescentam localizações documentadas e + preservam as atuais. Cada projeto pode ter várias + localizações principais, secundárias e de impacto. A + localização não é deduzida de clusters ou da proximidade a + zonas. +

+ {(["grounded", "pending", "contradictory"] as const).map( + (status) => ( +
+

+ {status === "grounded" + ? "Fundamentadas" + : status === "pending" + ? "Pendentes" + : "Contraditórias"} + :{" "} + {territorial.filter( + (item) => item.status === status, + ).length + + (status === "pending" + ? unproposed.length + : 0)} +

+ {status === "pending" && + unproposed.map((item) => ( +
+

+ {item.code} · Sem associação + territorial individualizada +

+

+ Localização atual:{" "} + {item.current + .map( + (location) => + `${location.name} · ${roles[location.role]}`, + ) + .join("; ") || + "Sem localização cadastrada"} +

+

+ Localização documental proposta:{" "} + {item.location_text ?? + "Não indicada"} +

+

+ Documento de origem:{" "} + {item.source ?? "Não indicado"} +

+ {item.reasons.map((reason) => ( +

{reason}

+ ))} +
+ ))} + {territorial.map((validation, index) => + validation.status !== status ? null : ( +
+

+ {plan.locations[index].code} →{" "} + {validation.proposed.name} ·{" "} + {roles[validation.proposed.role] ?? + validation.proposed.role} +

+
+ Localização atual + {validation.current.length ? ( +
    + {validation.current.map( + (current, i) => ( +
  • + {current.name} ·{" "} + {roles[ + current.role + ] ?? + current.role}{" "} + —{" "} + {pathText( + current.path, + )} +
  • + ), + )} +
+ ) : ( +

+ Sem localização cadastrada. +

+ )} +
+

+ + Localização proposta: + {" "} + {pathText(validation.proposed.path)}{" "} + · {roles[validation.proposed.role]} +

+

+ Documento de origem:{" "} + { + plan.locations[index] + .source_reference + } +

+
+ {plan.locations[index].source_quote} +
+

+ Motivo:{" "} + {plan.locations[index].reason} +

+ {validation.coordinates.map( + (point, i) => ( +

+ Coordenadas (longitude, + latitude):{" "} + {point.coordinates.join( + ", ", + )}{" "} + · SRID {point.srid} · Fonte:{" "} + {point.source} +

+ ), + )} + {validation.coordinate_checks.map( + (check, i) => ( +

+ {check.compatible + ? "Coordenadas compatíveis com o limite territorial" + : "Coordenadas incompatíveis com o limite territorial"}{" "} + · Fonte cartográfica:{" "} + {check.source} +

+ ), + )} + {validation.issues.map((issue) => ( +

+ {issue} +

+ ))} + {validation.warnings.map((warning) => ( +

{warning}

+ ))} + +
+ ), + )} +
+ ), + )} +
+
+

Histórico de aplicação

+ {history.length ? ( + history.map((event) => ( +
+

+ Autor: {event.actor ?? "Utilizador"} (ID{" "} + {event.actor_id}) · Data: {event.date} +

+ {event.data.sources?.map((source) => ( +

+ Fonte: {source.source_reference} · Motivo:{" "} + {source.reason} +

+ ))} +
+ )) + ) : ( +

Nenhuma associação aplicada por este plano.

+ )} +
+
+

+ Projectos nos clusters: {plan.memberships.length} +

+ {plan.memberships.map((item, index) => ( +
+

+ {item.cluster_code} ↔ {item.project_code} +

+

+ {item.action === "present_existing" + ? "Relação existente, agora apresentada como proposta documental pendente" + : "Relação ausente"} +

+

Fonte: {item.source_reference}

+
{item.source_quote}
+
+ ))} +
+
+

+ Registos de oportunidades preparados:{" "} + {plan.prepared_opportunities.length} +

+ {plan.prepared_opportunities.map((item, index) => ( +
+ + {item.reference} · {item.title} + +

+ Projecto:{" "} + {item.project_code ?? + "Identidade em conflito; associação pendente"} +

+

{item.location_text}

+

Fonte: {item.source_reference}

+

{item.reason}

+

Dados por completar: {item.unknown.join(", ")}.

+
+ ))} +
+
+

Pendências concretas

+
    + {plan.pending.map((item, index) => ( +
  • + {item.code}: {item.reason} {item.name} +
  • + ))} +
+
+ + Consultar carteira + +
+ ); +} diff --git a/resources/js/pages/management/shared.tsx b/resources/js/pages/management/shared.tsx index 00fdd6c3..e648f973 100644 --- a/resources/js/pages/management/shared.tsx +++ b/resources/js/pages/management/shared.tsx @@ -53,6 +53,8 @@ export function Management({ title, children }: { title: string; children: React Investidores Projectos Clusters + Revisão das associações documentadas + Aplicação da Bolsa de Parcerias Corredores Localizações dos investimentos diff --git a/resources/js/pages/partnerships/catalogue.tsx b/resources/js/pages/partnerships/catalogue.tsx new file mode 100644 index 00000000..19c070c3 --- /dev/null +++ b/resources/js/pages/partnerships/catalogue.tsx @@ -0,0 +1,179 @@ +import { Link, router } from "@inertiajs/react"; +import { useState } from "react"; +import InteractiveInvestmentMap, { + type InvestmentMapFeature, +} from "@/components/interactive-investment-map"; +import { + Frame, + Field, + control, + button, + type Catalogues, + type Partnership, +} from "./shared"; +type Result = Pick< + Partnership, + "id" | "title" | "kind" | "partnership_type" | "deadline" +> & { summary: string | null; province: string; sector: string }; +export default function Catalogue( + p: Catalogues & { + results: { + data: Result[]; + total: number; + prev_page_url: string | null; + next_page_url: string | null; + }; + filters: Record; + mapPoints: InvestmentMapFeature[]; + }, +) { + const [filters, setFilters] = useState(p.filters); + const set = (key: string, value: string) => + setFilters({ ...filters, [key]: value }); + return ( + +

+ Explore ofertas e procuras de colaboração. Pode preparar uma + ideia com os dados disponíveis; a publicação passa por + moderação. +

+
{ + e.preventDefault(); + router.get("/parcerias", filters); + }} + > + + set("q", e.target.value)} + /> + + + + + + + + + + + + + +
+ +
+
+

{p.results.total} parcerias publicadas

+
+ {p.results.data.map((item) => ( +
+

+ + {item.title} + +

+

+ {item.partnership_type + ? p.types[item.partnership_type] + : "Tipo por completar"}{" "} + · {item.kind === "offering" ? "Oferta" : "Procura"} +

+

{item.summary}

+

+ {item.sector || "Setor por completar"} ·{" "} + {item.province || "Território na ficha"} +

+ {item.deadline &&

Prazo: {item.deadline}

} + + Consultar parceria + +
+ ))} +
+ {!p.results.data.length && ( +

+ Nenhuma parceria corresponde aos filtros. +

+ )} +
+

+ Mapa dos resultados desta página +

+

+ Localizações próprias e contexto documental das origens são + identificados separadamente. Um cluster pode apresentar + vários locais dos membros; estes pontos não confirmam a + implantação da parceria. +

+ + {!p.mapPoints.length && ( +

+ Sem coordenadas documentadas nos resultados. As fichas e + os filtros territoriais continuam disponíveis. +

+ )} +
+ + + ); +} diff --git a/resources/js/pages/partnerships/dashboard.tsx b/resources/js/pages/partnerships/dashboard.tsx new file mode 100644 index 00000000..a6daafb6 --- /dev/null +++ b/resources/js/pages/partnerships/dashboard.tsx @@ -0,0 +1,124 @@ +import { Link, router } from "@inertiajs/react"; +import { Frame, labels, button, type Partnership } from "./shared"; +type Props = { + mine: Pick[]; + interests: { + id: number; + partnership_id: number; + title: string; + status: string; + decision_reason: string | null; + room_id: number | null; + }[]; + moderation: Pick[]; + notifications: { + id: number; + title: string; + href: string; + read_at: string | null; + }[]; +}; +export default function Dashboard(p: Props) { + return ( + +
+
+

+ Ideias e propostas +

+ + Preparar uma parceria + + {p.mine.map((x) => ( +
+ + {x.title || "Ideia sem título"} + +

{labels[x.status] ?? x.status}

+

+ Próxima ação:{" "} + {["draft", "returned", "rejected"].includes( + x.status, + ) + ? "Completar e pré-visualizar para submissão" + : x.status === "published" + ? "Consultar candidatos e esclarecer pendências" + : "Acompanhar a decisão"} +

+
+ ))} +
+
+

+ As minhas candidaturas +

+ {p.interests.map((x) => ( +
+ + {x.title} + +

+ {labels[x.status]} · {x.decision_reason} +

+ {x.room_id && ( + + Abrir conexão privada + + )} +
+ ))} +
+ {p.moderation.length > 0 && ( +
+

Fila de moderação

+ {p.moderation.map((x) => ( +

+ + {x.title} · {labels[x.status]} + +

+ ))} +
+ )} +
+

+ Notificações no portal +

+ {p.notifications.map((n) => ( +
+ + {n.title} + + {!n.read_at && ( + + )} +
+ ))} +
+
+ + ); +} diff --git a/resources/js/pages/partnerships/detail.tsx b/resources/js/pages/partnerships/detail.tsx new file mode 100644 index 00000000..a544eaa4 --- /dev/null +++ b/resources/js/pages/partnerships/detail.tsx @@ -0,0 +1,628 @@ +import { Link, useForm, usePage } from "@inertiajs/react"; +import StructureGeographyPanel, { + type StructureGeography, +} from "@/components/structure-geography"; +import { + Frame, + Errors, + Field, + control, + button, + labels, + type Catalogues, + type Interest, + type Partnership, + type Source, +} from "./shared"; +type Props = Catalogues & { + record: Partnership; + owner: boolean; + canEdit: boolean; + moderator: boolean; + origins: (Source & { geography: StructureGeography | null })[]; + locations: { + id: string; + name: string; + role: string; + source_reference: string; + path: { name: string }[]; + }[]; + interests: Interest[]; + history: { + event_type: string; + actor_id: number; + occurred_at: string; + payload: string; + }[]; +}; +function Publication({ p }: { p: Props }) { + const form = useForm({ version: p.record.version, action: "", reason: "" }); + const action = (value: string) => { + form.transform((data) => ({ ...data, action: value })); + form.post("/parcerias/" + p.record.id + "/estado", { + preserveScroll: true, + }); + }; + return ( +
+

Pré-visualização e publicação

+

+ Estado: {labels[p.record.status] ?? p.record.status}. + Verificação da organização, setor e localização permanecem por + confirmar quando não há evidência. +

+ +