diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php index 54a592ae..e6a82817 100644 --- a/app/Http/Controllers/OpportunityManagementController.php +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -103,6 +103,7 @@ class OpportunityManagementController extends Controller $records = $query->orderByDesc('id')->paginate(20)->withQueryString(); $records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) { $r->setAttribute('can_edit', $access->canEdit($request->user(), $r)); + $r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r)); $r->setAttribute('working_status', $access->state($r)); $r->setAttribute('publication', $r->publicationPresentation()); $r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title); @@ -130,6 +131,7 @@ class OpportunityManagementController extends Controller 'profileReauthenticationRequired' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false, 'publicationState' => $record?->publicationPresentation(), + 'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null, 'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null, 'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false, 'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []], diff --git a/app/Http/Controllers/OpportunityPromotionController.php b/app/Http/Controllers/OpportunityPromotionController.php new file mode 100644 index 00000000..d183be6c --- /dev/null +++ b/app/Http/Controllers/OpportunityPromotionController.php @@ -0,0 +1,26 @@ +editor($request->user(), Opportunity::findOrFail($id))); + } + + public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse + { + return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all())); + } + + public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse + { + return response()->json($service->confirm($request->user(), $id, $request->all())); + } +} diff --git a/app/Http/Middleware/ProtectProfileOpportunity.php b/app/Http/Middleware/ProtectProfileOpportunity.php index ca097c59..f7ba25ac 100644 --- a/app/Http/Middleware/ProtectProfileOpportunity.php +++ b/app/Http/Middleware/ProtectProfileOpportunity.php @@ -29,7 +29,9 @@ class ProtectProfileOpportunity if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) { abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.'); $access = app(PortalProfileAccess::class); - $access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review') ? 'view' : 'edit', $record); + // The promotional controller checks its own scoped disclosure capability, + // like workflow decisions; publication does not grant draft editing. + $access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review', 'minhas-oportunidades/*/divulgacao/previa', 'minhas-oportunidades/*/divulgacao/confirmar') ? 'view' : 'edit', $record); abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.'); abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.'); } diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index 6f55cc23..5f18b14e 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -68,7 +68,8 @@ class Opportunity extends Model $scope = $public ? 'public' : ($visible && $this->owner_user_id !== null ? 'restricted' : 'unpublished'); return ['scope' => $scope, 'public_catalog' => $public, 'label' => match ($scope) { - 'public' => 'Publicada no catálogo público', + 'public' => data_get($this->workflow_context, 'publication.purpose') === 'promotional_preparation' + ? 'Resumo divulgado — Em preparação; não autoriza execução' : 'Publicada no catálogo público', 'restricted' => 'Disponibilizada com acesso restrito — perfis e organização autorizados', default => 'Sem versão disponibilizada; aprovação interna é uma etapa distinta', }]; diff --git a/app/Services/CorridorAgribusiness.php b/app/Services/CorridorAgribusiness.php index 015813cd..cf3df43b 100644 --- a/app/Services/CorridorAgribusiness.php +++ b/app/Services/CorridorAgribusiness.php @@ -8,7 +8,7 @@ use DOMElement; /** Supplement the six named agribusiness sections without changing corridor masters or source documents. */ final class CorridorAgribusiness { - public function enrich(string $html): string + public function enrich(string $html, array $links = []): string { $data = json_decode(file_get_contents(__DIR__.'/../../database/data/corridor-investment-opportunities.json'), true, 512, JSON_THROW_ON_ERROR); $doc = new DOMDocument; @@ -49,12 +49,19 @@ final class CorridorAgribusiness $fragment->appendChild($label); $list = $doc->createElement('ul'); $list->setAttribute('id', $marker); - foreach ($group['opportunities'] as $opportunity) { + foreach ($group['opportunities'] as $itemIndex => $opportunity) { $item = $doc->createElement('li'); $chain = $doc->createElement('strong'); $chain->appendChild($doc->createTextNode($opportunity['chain'].': ')); $item->appendChild($chain); $item->appendChild($doc->createTextNode($opportunity['description'])); + if (isset($links[$index][$itemIndex])) { + $item->appendChild($doc->createTextNode(' ')); + $link = $doc->createElement('a'); + $link->setAttribute('href', $links[$index][$itemIndex]); + $link->appendChild($doc->createTextNode('Conhecer a oportunidade e iniciar o roteiro')); + $item->appendChild($link); + } $list->appendChild($item); } $fragment->appendChild($list); diff --git a/app/Services/CorridorAgribusinessOpportunities.php b/app/Services/CorridorAgribusinessOpportunities.php new file mode 100644 index 00000000..1b6138f2 --- /dev/null +++ b/app/Services/CorridorAgribusinessOpportunities.php @@ -0,0 +1,86 @@ +where('is_active', true)->whereNull('deleted_at')->firstOrFail(); + $entries = []; + foreach ($data['groups'] as $g => $group) { + foreach ($group['opportunities'] as $i => $item) { + $key = (string) Uuid::uuid5(Uuid::NAMESPACE_URL, 'investmoz:agronegocio:'.self::SOURCE_HASH.':'.$g.':'.$i); + $title = Str::limit($item['chain'].' — '.$group['name'], 150, '…'); + $description = $item['description']."\n\nEnquadramento documental: ".$group['name'].".\n\nFonte: ".$data['source'].'; SHA-256 '.self::SOURCE_HASH.".\n\n".self::LIMITATIONS; + $entries[] = ['key' => $key, 'group' => $g, 'index' => $i, 'corridor' => $group['name'], 'chain' => $item['chain'], + 'source' => $data['source'], 'source_sha256' => self::SOURCE_HASH, 'title' => $title, + 'summary' => $item['description'], 'description' => $description, 'sector_id' => $sector->id]; + } + } + if (count($entries) !== 34) throw new \RuntimeException('O lote deve conter as 34 entradas da fonte.'); + return $entries; + } + + public function register(User $actor, string $organization): array + { + // The caller uses the ordinary profile creation route and its approved scope. + abort_unless(str_starts_with(request()->path(), 'minhas-oportunidades'), 422); + return DB::transaction(function () use ($actor, $organization) { + DB::select('SELECT pg_advisory_xact_lock(231026, 34)'); + $result = []; + foreach ($this->entries() as $entry) { + $record = Opportunity::withTrashed()->where('management_creation_key', $entry['key'])->first(); + if ($record) { + abort_unless(app(PortalProfileAccess::class)->canView($actor, $record), 403); + abort_unless(data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') === self::SOURCE_HASH, 409); + // Recovery never overwrites editorial changes, withdrawal or archive. + $result[] = ['id' => $record->id, 'created' => false, 'key' => $entry['key']]; + continue; + } + $record = app(OpportunityManagement::class)->save($actor, ['creation_key' => $entry['key'], 'organization_id' => $organization, + 'opportunity_category' => 'investment_project', 'sector_id' => $entry['sector_id'], + 'title' => $entry['title'], 'summary' => $entry['summary'], 'description' => $entry['description'], + 'canonical' => ['opportunity_category' => 'investment_project', 'official_title' => $entry['title'], + 'executive_summary' => $entry['summary'], 'demand_statement' => $entry['description'], 'sector_id' => $entry['sector_id']], + 'reason' => 'Cadastro privado das 34 oportunidades de agronegócio solicitado pela Tempo em 23/10/2026; não constitui publicação nem aprovação de execução.']); + $context = $record->workflow_context ?? []; + $context['corridor_agribusiness'] = $entry; + $context['promotional_summary'] = ['input' => ['title' => $entry['title'], 'summary' => $entry['summary'], + 'description' => $entry['description'], 'source_reference' => $entry['source'].'; SHA-256 '.self::SOURCE_HASH, + 'limitations' => self::LIMITATIONS, 'edition' => '', 'source_date' => '', 'territory_ids' => [], 'sector_id' => $entry['sector_id']]]; + $record->workflow_context = $context; + $record->maturity_label = 'Em preparação'; + $record->save(); + DB::table('audit_events')->insert(['source_system' => 'corridor-agribusiness-registration', 'entity_type' => 'opportunity', + 'action' => 'private_source_registered', 'actor_user_id' => $actor->id, 'data_classification' => 'restricted', + 'after_data' => json_encode(['record_id' => $record->id, 'source' => $entry, 'no_publication' => true, 'no_master_relation_inferred' => true], JSON_THROW_ON_ERROR)]); + $result[] = ['id' => $record->id, 'created' => true, 'key' => $entry['key']]; + } + return $result; + }); + } + + public function articleLinks(): array + { + $records = Opportunity::published()->whereIn('management_creation_key', array_column($this->entries(), 'key'))->get()->keyBy('management_creation_key'); + $links = []; + foreach ($this->entries() as $entry) { + if ($record = $records->get($entry['key'])) $links[$entry['group']][$entry['index']] = '/oportunidades/'.$record->slug; + } + return $links; + } +} diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 6e351ff1..4b73e684 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -308,6 +308,18 @@ class InvestmentCatalogue /** @return array */ public function context(int $id, bool $management = false): array { + $opportunity = Opportunity::findOrFail($id); + if (! $management && data_get($opportunity->workflow_context, 'publication.purpose') === OpportunityPromotion::PURPOSE) { + // Promotional disclosure includes only its explicit public coverage. + // Private profiles, source evidence and other dossier relations are not auto-published. + $public = ['profile' => null, 'facets' => collect(), 'zones' => collect(), 'classification' => [], 'source_evidence' => [], + 'primary_territory_path' => app(InvestmentGeography::class)->path($opportunity->primary_territory_id, true), + 'opportunity_locations' => app(InvestmentRelations::class)->opportunityLocations($id)]; + foreach (self::RELATIONS as $relation => $_) $public[$relation] = collect(); + $public['locations'] = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id') + ->where('l.opportunity_id', $id)->where('g.data_classification', 'public')->whereNull('g.deleted_at')->get(['g.id', 'g.name']); + return $public; + } $result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(), 'facets' => DB::table('opportunity_facets as f')->join('investment_reference_options as o', 'o.id', '=', 'f.option_id') ->where('f.opportunity_id', $id)->get(['o.id', 'o.group', 'o.label'])]; diff --git a/app/Services/LegacyContent.php b/app/Services/LegacyContent.php index f20e3640..7a390457 100644 --- a/app/Services/LegacyContent.php +++ b/app/Services/LegacyContent.php @@ -79,7 +79,7 @@ final class LegacyContent } } if ($slug === 'agronegocio') { - $localHtml = app(CorridorAgribusiness::class)->enrich($localHtml); + $localHtml = app(CorridorAgribusiness::class)->enrich($localHtml, app(CorridorAgribusinessOpportunities::class)->articleLinks()); } preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks); $alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]); diff --git a/app/Services/OpportunityManagement.php b/app/Services/OpportunityManagement.php index e1bc4c1a..8cebfc24 100644 --- a/app/Services/OpportunityManagement.php +++ b/app/Services/OpportunityManagement.php @@ -27,6 +27,9 @@ class OpportunityManagement public function values(Opportunity $record): array { $base = $record->only(self::FIELDS); + if (data_get($record->workflow_context, 'publication.purpose') === OpportunityPromotion::PURPOSE) { + $base = [...$base, ...($record->workflow_context['promotional_private_base'] ?? [])]; + } $draft = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : []; return [...$base, ...($this->closed($record) ? $draft : array_intersect_key($draft, array_flip(self::PRIVATE_DRAFT_FIELDS)))]; diff --git a/app/Services/OpportunityPromotion.php b/app/Services/OpportunityPromotion.php new file mode 100644 index 00000000..623a46d5 --- /dev/null +++ b/app/Services/OpportunityPromotion.php @@ -0,0 +1,167 @@ +trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record) + || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) return false; + // Never overwrite a normal approved publication or its workflow decisions. + if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) return false; + $internal = $access->internalAssignment($actor, $record->opportunity_category); + return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id)) + || ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) + && $access->operational($actor, 'publish', $record->id)); + } + + private function authorize(User $actor, Opportunity $record): void + { + abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.'); + if (! app(PortalProfileAccess::class)->mfa($actor)) { + throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required')); + } + } + + public function editor(User $actor, Opportunity $record): array + { + abort_unless($this->allowed($actor, $record), 403); + $saved = data_get($record->workflow_context, 'promotional_summary', []); + // Only previously public text is recovered automatically. No private contact/document is copied. + $existing = data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE; + $form = $saved['input'] ?? ['title' => $existing ? $record->title : '', 'summary' => $existing ? $record->summary : '', + 'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '', + 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [], + 'sector_id' => $record->sector_id]; + return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record), + 'initial' => $form, 'publicNow' => $existing && $record->status === 'published', + 'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor), + 'sectors' => \App\Models\Sector::catalog()->orderBy('name')->get(['id', 'name']), + 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']), + 'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') + ->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])]; + } + + public function preview(User $actor, Opportunity $record, array $input): array + { + $this->authorize($actor, $record); + $data = Validator::make($input, ['etag' => 'required|string|size:64', 'action' => 'required|in:disclose,withdraw', + 'title' => 'required_if:action,disclose|nullable|string|max:255', 'summary' => 'required_if:action,disclose|nullable|string|max:499', + 'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500', + 'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d', + 'sector_id' => ['nullable', 'integer', Rule::in(\App\Models\Sector::catalog()->pluck('id')->all())], + 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct', + ])->validate(); + abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.'); + if ($data['action'] === 'withdraw') abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409); + $paths = []; + foreach ($data['territory_ids'] ?? [] as $id) { + $path = app(InvestmentGeography::class)->path($id, true); + abort_unless(count($path) > 0 && collect($path)->contains('id', $id), 422, 'Seleccione apenas territórios públicos existentes.'); + $paths[] = $path; + } + $text = fn ($key) => trim(strip_tags($data[$key] ?? '')); + $body = implode("\n\n", array_filter([$text('description'), self::NOTICE, + 'Limitações e lacunas: '.($text('limitations') ?: 'Informação ainda por completar.'), + 'Fonte: '.($text('source_reference') ?: 'Não indicada.'), 'Edição: '.($text('edition') ?: 'Não indicada.'), + 'Data-base: '.($data['source_date'] ?? 'Não indicada.')])); + $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body, + 'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE, + 'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)]; + if ($data['action'] === 'disclose') abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); + if ($data['action'] === 'withdraw') $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; + $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'], + 'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp]; + return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), + 'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false]; + } + + public function confirm(User $actor, int $id, array $input): array + { + $data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid', + 'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate(); + try { $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); } + catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); } + abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403); + return DB::transaction(function () use ($actor, $id, $data, $preview) { + DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]); + $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); + $this->authorize($actor, $record); + $digest = hash('sha256', $data['token'].'|'.$data['reason']); + $replay = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') + ->where('after_data->operation_key', $data['operation_key'])->first(); + if ($replay) { + $prior = json_decode($replay->after_data, true); + abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.'); + return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.']; + } + abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.'); + abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $preview['etag']), 409, 'Alteração concorrente: a ficha foi preservada. Reabra e reveja a nova versão.'); + $before = $record->getRawOriginal(); + $oldLocations = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')->all(); + $context = $record->workflow_context ?? []; + if ($preview['action'] === 'withdraw') { + $record->status = 'suspended'; + $record->published_at = null; + $context['publication']['visibility'] = 'withdrawn'; + $action = 'promotional_summary_withdrawn'; + } else { + $p = $preview['projection']; + if ($p['sector_id'] !== null) abort_unless(\App\Models\Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.'); + $paths = []; + foreach ($preview['input']['territory_ids'] ?? [] as $territory) { + $path = app(InvestmentGeography::class)->path($territory, true); + abort_unless(count($path) && collect($path)->contains('id', $territory), 409, 'Um território deixou de estar disponível. Prepare outra prévia.'); + $paths[] = $path; + } + $currentTerritories = array_map(fn ($path) => ['id' => end($path)->id, 'label' => implode(' / ', array_column($path, 'name'))], $paths); + abort_unless($currentTerritories === $p['territories'], 409, 'O enquadramento territorial mudou. Prepare outra prévia.'); + // Preserve the original private draft bytes; retain its former base + // columns in the existing hidden context before projecting public text. + $context['promotional_private_base'] ??= $record->only(OpportunityManagement::FIELDS); + $context['working_state'] ??= app(PortalProfileAccess::class)->state($record); + $context['promotional_summary'] = ['input' => $preview['input'], 'projection' => $p]; + $context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id, + 'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true]; + $action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published'; + foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) $record->{$field} = $p[$field]; + foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) $record->{$field} = null; + $main = $paths[0] ?? []; + $record->primary_territory_id = $main ? end($main)->id : null; + $record->province = collect($main)->firstWhere('location_type', 'province')?->name; + $record->district = collect($main)->firstWhere('location_type', 'district')?->name; + $record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified'); + DB::table('opportunity_locations')->where('opportunity_id', $id)->delete(); + foreach ($preview['input']['territory_ids'] ?? [] as $territory) DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]); + $record->status = 'published'; + $record->is_featured = false; + $record->last_validated_at = null; + $record->published_at = now(); + } + $record->workflow_context = $context; + $record->management_version++; + $record->save(); + DB::table('audit_events')->insert(['source_system' => 'tempo-preparation-publication', 'entity_type' => 'opportunity', + 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(), + 'before_data' => json_encode(['record_id' => $id, 'record' => $before, 'territories' => $oldLocations], JSON_THROW_ON_ERROR), + 'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest, + 'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false, + 'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]); + return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.']; + }); + } +} diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index f062f3b3..418f03f2 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -4,6 +4,8 @@ Data: 2026-10-03. Âmbito: prompt master recebido em `Pasted text.txt`, document ## Estado inicial e ambiente +**Correcção de âmbito, 23/10/2026 — agronegócio e percurso existente:** a lista informativa instalada não encerrava o cadastro. Preparadas e efectivamente cadastradas 34 oportunidades privadas em QA, com as seis designações documentais intactas, sector, fonte/hash e lacunas explícitas. Reutilizada a candidata de divulgação promocional `9d3bfa89` no fluxo necessário, sem alterações independentes de administração de contas nem novos papéis. Equipa edita/recupera e divulga/actualiza/retira pela interface; público acede à ficha individual, manifestação e roteiro com referência confirmada no mesmo dossier. Três testes específicos, 553 asserções; tipos/build concluídos; tabelas/linhas preexistentes comparadas e preservadas. As divulgações dos testes foram revertidas; as 34 fichas permanecem privadas. Sem alteração da homologação nesta correcção. Carregamento inicial técnico QA já executado; não é dependência das operações correntes. Guia, limites e recibo na [secção de correcção do manual](unification/traceability-and-manual.md#correcção-de-âmbito--34-oportunidades-cadastradas-e-roteiro-em-qa-23102026). Não encerra viabilidade, implantação, financiamento ou execução das oportunidades. + | Item | Evidência observada | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Branch / commit | `main` / `4f35313d06ccd597a4ebf9ed043964d898c95813` | diff --git a/docs/investmoz/unification/agribusiness-opportunities-qa-20261023.json b/docs/investmoz/unification/agribusiness-opportunities-qa-20261023.json new file mode 100644 index 00000000..053a5b3b --- /dev/null +++ b/docs/investmoz/unification/agribusiness-opportunities-qa-20261023.json @@ -0,0 +1,214 @@ +{ + "target": { + "name": "simulator_fiscal_delta_20261021_test", + "port": 55441 + }, + "source_sha256": "1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055", + "records": [ + { + "id": 269, + "created": true, + "key": "80858ef1-42ad-5bc6-88ee-0a4a6253f9e2" + }, + { + "id": 270, + "created": true, + "key": "29990fe3-2453-5308-8a23-27622209ab02" + }, + { + "id": 271, + "created": true, + "key": "9e79c4ac-01d5-5d20-abf3-b49df6ef8206" + }, + { + "id": 272, + "created": true, + "key": "ccde9a38-0a27-5b46-a158-b2013128baaf" + }, + { + "id": 273, + "created": true, + "key": "1748aee6-cf7a-5e16-acde-d37777578d3a" + }, + { + "id": 274, + "created": true, + "key": "4be5f275-3504-5a7e-8ba9-d51a51efd534" + }, + { + "id": 275, + "created": true, + "key": "7887e378-6d59-5acc-94e1-41f320e690f6" + }, + { + "id": 276, + "created": true, + "key": "b337b1cf-01ae-5c35-9949-90704ea68f0f" + }, + { + "id": 277, + "created": true, + "key": "1e9d0e7a-9c63-58cb-9951-349c9cc236c6" + }, + { + "id": 278, + "created": true, + "key": "ebd761cb-e793-5d11-809a-dc04e4baeb9d" + }, + { + "id": 279, + "created": true, + "key": "69d7e89a-bd72-574c-8ce1-104f4b8448d4" + }, + { + "id": 280, + "created": true, + "key": "405f0e8a-973f-5a2f-bbe8-f923944bcde1" + }, + { + "id": 281, + "created": true, + "key": "7002d77b-2df6-5373-91b0-a67abed64378" + }, + { + "id": 282, + "created": true, + "key": "4bfe5b57-ea81-564e-a6ec-8867de5b79f7" + }, + { + "id": 283, + "created": true, + "key": "40c73a69-8724-5552-9612-ecd9ec50d6d8" + }, + { + "id": 284, + "created": true, + "key": "d8ab3e05-a180-5a0a-877e-c9b4fab34931" + }, + { + "id": 285, + "created": true, + "key": "8a493e37-5d26-5cc7-9937-aab3e7ad0f40" + }, + { + "id": 286, + "created": true, + "key": "a80dc6b8-1c9b-53ef-bf31-ec37fa22561f" + }, + { + "id": 287, + "created": true, + "key": "59726827-4fe9-5256-af12-10bd56dab0e3" + }, + { + "id": 288, + "created": true, + "key": "2d0fb383-6d02-5df6-a24a-57474ab15ad0" + }, + { + "id": 289, + "created": true, + "key": "791e2921-92cf-5e08-9ff7-99a275bd6f34" + }, + { + "id": 290, + "created": true, + "key": "bc1ee2df-33f9-5262-9ad4-1af961393c88" + }, + { + "id": 291, + "created": true, + "key": "aaf5661e-0a45-561a-b74e-c5cbfaad7721" + }, + { + "id": 292, + "created": true, + "key": "45423b00-4673-5595-b3ac-cc4ab8e1dc8a" + }, + { + "id": 293, + "created": true, + "key": "0ebbae53-7331-5360-b704-3060f2f52548" + }, + { + "id": 294, + "created": true, + "key": "5ead8334-a1ea-5710-b5ae-751e1a97bc18" + }, + { + "id": 295, + "created": true, + "key": "fbbb0961-e5b0-5900-ad33-4efb5fc7bfc5" + }, + { + "id": 296, + "created": true, + "key": "7b4070c4-59f9-59f3-ae3a-ef897998e5ae" + }, + { + "id": 297, + "created": true, + "key": "09fa3da3-96c0-562c-8b3d-1010841fac16" + }, + { + "id": 298, + "created": true, + "key": "8b3cd51b-d51a-5a60-9ec3-9222db943bbb" + }, + { + "id": 299, + "created": true, + "key": "14b76956-6afa-5c52-8d56-db402dbbd099" + }, + { + "id": 300, + "created": true, + "key": "bd07739d-e0b8-5561-8baa-14f67571ecf5" + }, + { + "id": 301, + "created": true, + "key": "a15d68d3-02c8-50d9-a03b-63f0478733ff" + }, + { + "id": 302, + "created": true, + "key": "eb5bad23-5ea2-5f81-bb2e-85609853ae1b" + } + ], + "total": 34, + "new": 34, + "published": 0, + "active_writes": 0, + "status": "QA private registration complete; homologation unchanged", + "preservation": { + "target": { + "name": "simulator_fiscal_delta_20261021_test", + "port": 55441 + }, + "records": 34, + "private_drafts": 34, + "preserved": { + "opportunities": true, + "audit_events": true, + "development_corridors": true, + "portal_role_assignments": true, + "portal_resources": true, + "documents": true + }, + "active_writes": 0 + }, + "checks": { + "feature_tests": 3, + "assertions": 553, + "types": "passed", + "frontend_build": "passed", + "test_disclosures_rolled_back": true + }, + "management_url": "http://127.0.0.1:18574/gestao-conteudos", + "individual_editor_example": "http://127.0.0.1:18574/minhas-oportunidades/269/edit", + "reused_promotional_candidate": "9d3bfa89", + "schema_changes": 0, + "new_roles": 0, + "new_dependencies": 0 +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 36ff19ef..0fe52232 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,5 +1,23 @@ # Rastreabilidade, utilização e formação +## Correcção de âmbito — 34 oportunidades cadastradas e roteiro em QA, 23/10/2026 + +A lista informativa anteriormente instalada no artigo não cumpria o cadastro pedido pela Tempo. Esta correcção cria **34 fichas privadas reais na base QA isolada**, recuperáveis pela gestão existente, com título, resumo e descrição da entrada, sector Agronegócio, nome exacto do corredor documental, fonte e hash do DOCX. Conserva as seis secções e os totais 5/6/6/7/5/5. Não modifica a homologação, os corredores mestres, dossiers anteriores ou o original do artigo. Não transforma a organização operadora no promotor dos investimentos. Implantação, área, montantes, promotores, viabilidade e licenças ausentes ficam explicitamente por completar; nenhuma correspondência com corredores de nomes mais amplos foi inferida. + +Reutilizada a implementação QA de divulgação promocional da candidata `9d3bfa89`, limitada ao fluxo funcional necessário. Não transportadas as alterações independentes da administração de contas. A prévia, divulgação, actualização e retirada usam as designações, âmbito e MFA existentes, sem novos papéis ou aprovadores. O resumo público é seleccionado explicitamente; preparação, contactos e documentos privados não são copiados automaticamente. Divulgação identifica **Em preparação** e não autoriza execução. Publicação documental e aprovação/publicação de uma ficha completa continuam operações distintas. + +**Guia curto da equipa em QA:** + +1. Abrir a [gestão única QA](http://127.0.0.1:18574/gestao-conteudos), entrar normalmente com a conta designada e abrir [Minhas oportunidades](http://127.0.0.1:18574/minhas-oportunidades). Pesquisar o título ou corredor; as fichas iniciais são privadas. +2. Abrir **Editar**, completar/corrigir os dados disponíveis, guardar e reabrir. A origem documental aparece separadamente; seleccionar localização apenas quando houver informação suficiente. A referência textual de corredor não é uma parcela. +3. Abrir **Divulgação promocional**, rever o texto público e a fonte, preparar a prévia e confirmar com motivo e MFA recente. A prévia conserva as lacunas e o estado **Em preparação**. Alterar a ficha privada não copia automaticamente dados para o resumo público: rever a selecção na divulgação. +4. Depois de divulgar, a entrada correspondente no artigo aponta para a ficha individual. Nela, o interessado pode preparar uma manifestação de interesse ou escolher **Iniciar ou continuar o roteiro deste investimento**. O percurso existente permite seleccionar um dossier ou criar um novo, comparar e confirmar a referência; preserva o título que o interessado já deu ao seu investimento. +5. Para corrigir o resumo divulgado ou retirá-lo, usar a mesma área de divulgação, com prévia e auditoria. A retirada remove a consulta pública e a ligação do artigo, preservando cadastro, preparação e histórico. + +**Provas do delta:** dois testes sobre as 34 fichas, 534 asserções: abrir/editar, prévia, divulgação, ficha pública, origem de roteiro, correspondência das seis listas, actualização/retirada e repetição sem duplicados nem perda da edição. Teste adicional de interessado: 19 asserções, criação de dossier, comparação/confirmação da oportunidade, recuperação de título próprio/resumo/sector, abertura do roteiro e preparação da manifestação. Escritas desses testes revertidas por transacção: as **34 fichas permanecem privadas**, não divulgadas como efeito do cadastro. Conferidos por comparação os registos preexistentes de oportunidades/auditoria, corredores, permissões, biblioteca e documentos. Verificação de tipos e build passaram. Trata-se de prova HTTP dos percursos usados pela interface, sem afirmar uma nova sessão manual de navegador ou repetir as suites gerais aceites. + +Carregamento inicial executado uma vez por `tools/register-corridor-agribusiness-qa.php`, com guarda exacta de base/porta QA, actor e organização já designados, snapshot privado e chaves estáveis por entrada. A repetição recupera os mesmos IDs sem sobrescrever edições posteriores ou reactivar uma retirada. Esse carregamento técnico de instalação não é uma operação corrente da equipa. **Editar, guardar/recuperar, pré-visualizar, divulgar, actualizar, retirar e iniciar/continuar o roteiro não exigem comandos nem Codex.** Não há migration ou nova dependência. Disponibilização na homologação e transporte das 34 fichas continuam separados desta entrega QA; não foram executados nem autorizados por esta correcção. [Recibo QA](agribusiness-opportunities-qa-20261023.json). + ## Agronegócio nos corredores — complemento de apresentação, 23/10/2026 Entrega QA recebida em `entrega-agronegocio-corredores-20261023`: cinco ficheiros conferidos pelos hashes do manifesto. Complementa exclusivamente `/agronegocio`, nas seis secções existentes: Pemba–Lichinga (5), Nacala (6), Vale do Zambeze (6), Beira (7), Limpopo (5) e Maputo (5), total 34 entradas. Reutilizadas as provas QA entregues (dois testes, 63 asserções e verificador offline do exemplar preservado); não constituem prova HTTP do destino. diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx index 95468ad5..7bbd55f0 100644 --- a/resources/js/pages/investment/detail.tsx +++ b/resources/js/pages/investment/detail.tsx @@ -32,6 +32,7 @@ export default function Detail({ const [sharing, setSharing] = useState(''); return ( + {r.maturity_label === 'Em preparação' &&

Em preparação — divulgação promocional. A divulgação não autoriza a execução do investimento.

} )}
+ + Iniciar ou continuar o roteiro deste investimento + {auth.user ? (

} + {Object.entries(errors).map(([key,value]) =>

{key}: {value}

)} +
{e.preventDefault(); void prepare('disclose');}}> + change('title',e.target.value)}/> +