Register 34 private agribusiness opportunities with promotional management and investor paths in QA

This commit is contained in:
2026-10-23 10:15:41 +02:00
parent 761035aefb
commit 7d08c2c664
24 changed files with 1019 additions and 5 deletions
@@ -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' => []],
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
{
return Inertia::render('management/opportunity-promotion', $service->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()));
}
}
@@ -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.');
}
+2 -1
View File
@@ -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',
}];
+9 -2
View File
@@ -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);
@@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
/** Source-defined opportunities; never infer a master corridor, site or promoter. */
final class CorridorAgribusinessOpportunities
{
public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055';
public const LIMITATIONS = 'Promotor, implantação exacta, área, montante, financiamento, viabilidade e licenças não indicados nesta fonte. O corredor é um enquadramento documental; não identifica uma parcela nem confirma disponibilidade.';
public function entries(): array
{
$data = json_decode(file_get_contents(database_path('data/corridor-investment-opportunities.json')), true, 512, JSON_THROW_ON_ERROR);
if ($data['source_sha256'] !== self::SOURCE_HASH) throw new \RuntimeException('Fonte divergente; reconciliar antes de cadastrar.');
$sector = Sector::where('slug', 'agronegocio')->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;
}
}
+12
View File
@@ -308,6 +308,18 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
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'])];
+1 -1
View File
@@ -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]);
+3
View File
@@ -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)))];
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/** Tempo's promotional disclosure, independent of investment execution approval. */
final class OpportunityPromotion
{
public const PURPOSE = 'promotional_preparation';
public const NOTICE = 'Em preparação. A divulgação não confirma financiamento, disponibilidade de terreno, licenças ou autorização de execução do investimento.';
public function allowed(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
if ($record->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.'];
});
}
}
@@ -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` |
@@ -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
}
@@ -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.
+4
View File
@@ -32,6 +32,7 @@ export default function Detail({
const [sharing, setSharing] = useState('');
return (
<Frame title={r.title}>
{r.maturity_label === 'Em preparação' && <p className="rounded border border-amber-700 bg-amber-50 p-4"><strong>Em preparação — divulgação promocional.</strong> A divulgação não autoriza a execução do investimento.</p>}
<Link
className={button}
href={'/investimentos/iniciar?source=opportunity:' + r.id}
@@ -97,6 +98,9 @@ export default function Detail({
</p>
)}
<div className="flex flex-wrap gap-4">
<Link className={button} href={`/investimentos/iniciar?source=opportunity:${r.id}`}>
Iniciar ou continuar o roteiro deste investimento
</Link>
{auth.user ? (
<button
className={button}
@@ -18,6 +18,7 @@ export default function ContentHub({access, counts}: {access: Access; counts: Re
<p>Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.</p>
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
<p>Para divulgar um resumo ainda em preparação, abra a ficha e escolha <strong>Divulgação promocional</strong>: prepare o texto público, reveja a prévia e confirme divulgar, actualizar ou retirar. Esta operação não conclui a validação do investimento.</p>
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
{!access.opportunities.profileWorkflow && <p>O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.</p>}
@@ -17,6 +17,7 @@ type Entry = {
updated_at: string;
deleted_at: string | null;
can_edit?: boolean;
can_promote?: boolean;
working_status?: string;
draft_title?: string;
publication?: { scope: string; label: string; public_catalog: boolean };
@@ -232,6 +233,7 @@ export default function Opportunities({
<p>Atualizado: {r.updated_at}</p>
<div className="flex flex-wrap gap-4">
<Link href={`${basePath}/${r.id}/view`}>Ver</Link>
{r.can_promote && <Link href={`/minhas-oportunidades/${r.id}/divulgacao`}>Divulgação promocional</Link>}
{r.can_edit !== false && (
<Link href={`${basePath}/${r.id}/edit`}>
{r.deleted_at || r.status === 'archived'
@@ -75,9 +75,11 @@ export default function OpportunityDraft({
workflowParticipants,
publicationPreview,
publicationState,
promotionHref,
auth,
canEditDraft = false,
} = usePage<{
promotionHref?: string | null;
basePath?: string;
profileMode?: boolean;
profileOrganization?: string | null;
@@ -246,6 +248,15 @@ export default function OpportunityDraft({
<Link href={basePath}>
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
</Link>
{promotionHref && <Link className="ml-3 underline" href={promotionHref}>Divulgação promocional — Em preparação</Link>}
{!!profileContext?.corridor_agribusiness && (
<section className="my-4 space-y-2 rounded border p-4">
<h2 className="font-semibold">Origem e enquadramento da oportunidade</h2>
<p>{valueText((profileContext.corridor_agribusiness as Record<string, unknown>).corridor)}</p>
<p>Fonte: {valueText((profileContext.corridor_agribusiness as Record<string, unknown>).source)}</p>
<p>Em preparação. O corredor indicado na fonte não confirma implantação, promotor, viabilidade ou financiamento. Complete a ficha e reveja a prévia antes de divulgar.</p>
</section>
)}
{record && (
<Link
href={`/investimentos/iniciar?source=opportunity:${record.id}`}
@@ -0,0 +1,77 @@
import { Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { Management, Field, control, button } from './shared';
type Draft = {title: string; summary: string; description: string; limitations: string; source_reference: string; edition: string; source_date: string; sector_id: number | null; territory_ids: string[]};
type Projection = {title: string; summary: string; description: string; sector_id: number | null; maturity_label: string; territories: {id: string; label: string}[]};
type Preview = {token: string; action: 'disclose' | 'withdraw'; projection: Projection; current: {title: string; summary: string; description: string; status: string}};
export default function OpportunityPromotion({record, initial, etag, publicNow, reauthenticationRequired, sectors, territories, history}: {
record: {id: number; reference: string}; initial: Draft; etag: string; publicNow: boolean; reauthenticationRequired: boolean;
sectors: {id: number; name: string}[]; territories: {id: string; name: string; location_type: string}[];
history: {id: string; action: string; actor_user_id: number; occurred_at: string}[];
}) {
const [draft, setDraft] = useState<Draft>({...initial, source_date: initial.source_date ?? ''});
const [preview, setPreview] = useState<Preview | null>(null);
const [operationKey, setOperationKey] = useState(createOperationKey);
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string,string>>({});
const [needsMfa, setNeedsMfa] = useState(reauthenticationRequired);
function change<K extends keyof Draft>(key: K, value: Draft[K]) { setDraft(current => ({...current, [key]:value})); setPreview(null); setConfirmed(false); }
function report(error: unknown) {setMessage(error instanceof Error ? error.message : 'Não foi possível concluir. A ficha anterior foi preservada.'); setErrors(error instanceof ProfileValidationError ? error.fields : {}); if(error instanceof ProfileValidationError && error.reauthenticate)setNeedsMfa(true);}
function renewAuthentication() { router.post('/logout', {}, {onSuccess:()=>router.visit(`/minhas-oportunidades/${record.id}/divulgacao`)}); }
async function prepare(action: 'disclose' | 'withdraw') {
setBusy(true); setMessage(''); setErrors({}); setPreview(null); setConfirmed(false);
try { const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/previa`, {...draft, etag, action}); setPreview(result); setOperationKey(createOperationKey()); }
catch (error) { report(error); } finally { setBusy(false); }
}
async function publish() {
if (!preview || !confirmed) return;
setBusy(true); setErrors({});
try {const result = await profileJson(`/minhas-oportunidades/${record.id}/divulgacao/confirmar`, {token:preview.token, operation_key:operationKey, reason, confirm:confirmed});
setMessage(result.message); setPreview(null); setConfirmed(false); router.reload();
} catch (error) { report(error); } finally {setBusy(false);}
}
const labels: Record<string,string> = {promotional_summary_published:'Resumo divulgado', promotional_summary_updated:'Resumo actualizado', promotional_summary_withdrawn:'Resumo retirado'};
return <Management title={`Divulgação promocional — ${record.reference}`}>
<Link className="underline" href={`/minhas-oportunidades/${record.id}/view`}>Voltar à oportunidade</Link>
<p>Prepare o resumo público e reveja a prévia antes de confirmar. A divulgação fica identificada como <strong>Em preparação</strong> e não autoriza a execução do investimento. Documentos e contactos do cadastro continuam privados.</p>
<p role="status">{publicNow ? 'Resumo actualmente divulgado.' : 'Resumo não divulgado.'} {message}</p>
{needsMfa && <p role="alert">Entre novamente com a sua conta e MFA para confirmar esta operação. O cadastro guardado permanece disponível; a prévia ainda não confirmada terá de ser preparada novamente. <button className="underline" onClick={renewAuthentication}>Renovar entrada com MFA</button></p>}
{Object.entries(errors).map(([key,value]) => <p role="alert" key={key}>{key}: {value}</p>)}
<form className="space-y-4 rounded border p-5" onSubmit={e=>{e.preventDefault(); void prepare('disclose');}}>
<Field label="Título público"><input required className={control} maxLength={255} value={draft.title} onChange={e=>change('title',e.target.value)}/></Field>
<Field label="Resumo público"><textarea required className={control} maxLength={499} value={draft.summary} onChange={e=>change('summary',e.target.value)}/></Field>
<Field label="Descrição pública"><textarea className={control} maxLength={6000} value={draft.description} onChange={e=>change('description',e.target.value)}/></Field>
<Field label="Limitações e lacunas conhecidas"><textarea className={control} value={draft.limitations} onChange={e=>change('limitations',e.target.value)}/></Field>
<Field label="Fonte do resumo"><textarea className={control} value={draft.source_reference} onChange={e=>change('source_reference',e.target.value)}/></Field>
<Field label="Edição"><input className={control} value={draft.edition} onChange={e=>change('edition',e.target.value)}/></Field>
<Field label="Data-base"><input className={control} type="date" value={draft.source_date} onChange={e=>change('source_date',e.target.value)}/></Field>
<Field label="Sector divulgado"><select className={control} value={draft.sector_id ?? ''} onChange={e=>change('sector_id',e.target.value ? Number(e.target.value):null)}><option value="">Por indicar</option>{sectors.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select></Field>
<Field label="Acrescentar abrangência territorial documentada"><select className={control} value="" onChange={e=>{if(e.target.value && !draft.territory_ids.includes(e.target.value))change('territory_ids',[...draft.territory_ids,e.target.value]);}}><option value="">Seleccionar território existente</option>{territories.map(t=><option key={t.id} value={t.id}>{t.name} · {t.location_type}</option>)}</select></Field>
<p>Seleccione apenas a abrangência que pretende divulgar e cuja origem está identificada. Abrangência não comprova implantação ou disponibilidade de terreno. Sem selecção, a localização fica por indicar.</p>
<ul>{draft.territory_ids.map(id=><li key={id}>{territories.find(t=>t.id===id)?.name ?? 'Referência anterior indisponível'} <button type="button" className="underline" onClick={()=>change('territory_ids',draft.territory_ids.filter(t=>t!==id))}>Remover abrangência</button></li>)}</ul>
<button className={button} disabled={busy || needsMfa}>{publicNow ? 'Rever actualização do resumo' : 'Preparar prévia de divulgação'}</button>
{publicNow && <button type="button" className={`${button} ml-3`} disabled={busy || needsMfa} onClick={()=>void prepare('withdraw')}>Preparar retirada do resumo</button>}
</form>
{preview && <section className="space-y-4 rounded border p-5" aria-label="Prévia da divulgação">
<h2 className="text-xl font-semibold">{preview.action === 'withdraw' ? 'Rever retirada do catálogo' : 'Prévia do conteúdo público'}</h2>
<p><strong>{preview.projection.maturity_label}</strong></p>
<h3>{preview.projection.title}</h3><p>{preview.projection.summary}</p><p className="whitespace-pre-wrap">{preview.projection.description}</p>
<p>Sector: {sectors.find(s=>s.id===preview.projection.sector_id)?.name ?? 'Por indicar'}</p>
<ul>{preview.projection.territories.map(t=><li key={t.id}>{t.label}</li>)}</ul>
{!preview.projection.territories.length && <p>Abrangência territorial por indicar.</p>}
<p>Esta prévia ainda não alterou o portal público. {preview.action === 'withdraw' ? 'A confirmação retira o resumo da consulta pública e preserva o cadastro.' : 'Só este texto e o enquadramento apresentado serão divulgados; os anexos e contactos não são incluídos.'}</p>
<Field label="Motivo da divulgação, alteração ou retirada"><textarea required className={control} minLength={10} maxLength={1000} value={reason} onChange={e=>setReason(e.target.value)}/></Field>
<label className="block"><input type="checkbox" checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/> Confirmo o conteúdo público desta prévia e a operação indicada.</label>
<button className={button} disabled={busy || !confirmed || reason.trim().length<10} onClick={()=>void publish()}>{preview.action === 'withdraw' ? 'Confirmar retirada do resumo' : 'Confirmar divulgação do resumo'}</button>
<button className="ml-3 underline" disabled={busy} onClick={()=>{setPreview(null);setConfirmed(false);}}>Cancelar prévia</button>
</section>}
<section aria-label="Histórico da divulgação"><h2 className="text-xl font-semibold">Histórico da divulgação</h2><ul>{history.map(h=><li key={h.id}>{labels[h.action] ?? h.action} · {h.occurred_at} · responsável {h.actor_user_id}</li>)}</ul></section>
</Management>;
}
+3
View File
@@ -177,6 +177,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/classification/review', [OpportunityClassificationController::class, 'review'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/workflow', [$profiles, 'workflow'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/divulgacao', [\App\Http\Controllers\OpportunityPromotionController::class, 'show'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/divulgacao/previa', [\App\Http\Controllers\OpportunityPromotionController::class, 'preview'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/divulgacao/confirmar', [\App\Http\Controllers\OpportunityPromotionController::class, 'confirm'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/contacts', [$profiles, 'contact'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/resources', [OpportunityResourceController::class, 'index'])->whereNumber('id');
Route::post('/minhas-oportunidades/{id}/library-support', [OpportunityResourceController::class, 'reuseLibrary'])->whereNumber('id');
@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class CorridorAgribusinessInvestorPathTest extends TestCase
{
use DatabaseTransactions;
public function test_interested_investor_recovers_the_opportunity_in_the_same_individual_path_and_interest_draft(): void
{
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true,
'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$actor = User::findOrFail(295);
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
$entry = app(CorridorAgribusinessOpportunities::class)->entries()[0];
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
$base = '/minhas-oportunidades/'.$record->id.'/divulgacao';
$preview = $this->postJson($base.'/previa', [...$record->workflow_context['promotional_summary']['input'],
'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
$this->postJson($base.'/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
'reason' => 'QA: conferir o roteiro de um interessado sem atribuir qualquer autorização.'])->assertOk();
$investor = User::factory()->create();
$this->actingAs($investor);
$this->get('/oportunidades/'.$record->slug)->assertOk();
$id = $this->postJson('/investimentos', ['title' => 'QA: investimento a partir de agronegócio', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => ['opportunity' => 'opportunity:'.$record->id]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'],
'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()
->assertJsonPath('context.selection.opportunity.key', 'opportunity:'.$record->id)
->assertJsonPath('context.fields.project_title', 'QA: investimento a partir de agronegócio')
->assertJsonPath('context.fields.project_summary', $record->summary)
->assertJsonPath('context.fields.sector_id', '1');
$this->get('/investimentos/'.$id)->assertOk();
$interestId = $this->postJson('/investidor/manifestacoes', ['opportunity_id' => $record->id])->assertCreated()->json('id');
$this->getJson('/investidor/manifestacoes/'.$interestId)->assertOk();
$this->assertSame(1, DB::table('desk_cases')->where('id', $id)->count());
$this->assertSame('opportunity:'.$record->id, app(\App\Services\InvestorContext::class)->view($investor, $id)['context']['selection']['opportunity']['key']);
// All synthetic investor/path/disclosure writes roll back with this test.
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class CorridorAgribusinessOpportunitiesTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'investor-path.enabled' => true]);
$actor = User::findOrFail(295); // Existing designated QA operator; no new role or account.
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
public function test_all_34_records_have_editable_drafts_and_individual_disclosure_and_path(): void
{
$service = app(CorridorAgribusinessOpportunities::class);
$entries = $service->entries();
$this->assertCount(34, $entries);
$masters = DB::table('development_corridors')->orderBy('id')->get()->toJson();
$this->assertSame([], $service->articleLinks());
foreach ($entries as $entry) {
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
$this->assertSame('draft', $record->status);
$this->assertNull($record->primary_territory_id);
$this->assertNull($record->promoter_name);
$this->assertNull($record->estimated_investment_amount);
$this->assertSame($entry['corridor'], $record->workflow_context['corridor_agribusiness']['corridor']);
$base = '/minhas-oportunidades/'.$record->id;
$this->get($base.'/edit')->assertOk();
$this->get($base.'/divulgacao')->assertOk();
$input = $record->workflow_context['promotional_summary']['input'];
$preview = $this->postJson($base.'/divulgacao/previa', [...$input, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
$this->assertStringContainsString($entry['corridor'], $preview['projection']['description']);
$this->postJson($base.'/divulgacao/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
'reason' => 'QA: conferir divulgação individual da fonte e ligação ao roteiro.'])->assertOk();
$this->getJson('/api/v1/opportunities/'.$record->slug)->assertOk()->assertJsonPath('data.maturity_label', 'Em preparação');
$this->get('/oportunidades/'.$record->slug)->assertOk();
$this->get('/investimentos/iniciar?source=opportunity:'.$record->id)->assertOk();
$this->assertSame($record->id, (int) app(\App\Services\InvestorContext::class)->reference('opportunity:'.$record->id)['id']);
}
$links = $service->articleLinks();
$this->assertSame([5, 6, 6, 7, 5, 5], array_map('count', $links));
$this->assertSame($masters, DB::table('development_corridors')->orderBy('id')->get()->toJson());
// DatabaseTransactions rolls back these QA disclosures; the 34 real-source drafts stay private.
}
public function test_edit_recover_preview_update_withdraw_and_idempotent_loading_preserve_editorial_work(): void
{
$record = Opportunity::where('management_creation_key', app(CorridorAgribusinessOpportunities::class)->entries()[0]['key'])->firstOrFail();
$base = '/minhas-oportunidades/'.$record->id;
$canonical = app(OpportunityManagement::class)->values($record)['canonical'];
$canonical['executive_summary'] .= ' Correcção editorial QA.';
$this->putJson($base.'/draft', ['etag' => app(OpportunityManagement::class)->etag($record), 'canonical' => $canonical,
'reason' => 'QA: corrigir e recuperar os dados através da gestão.'])->assertOk();
$this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
$this->get($base.'/edit')->assertOk();
$input = $record->fresh()->workflow_context['promotional_summary']['input'];
foreach (['disclose', 'disclose', 'withdraw'] as $index => $action) {
$record->refresh();
if ($index === 1) $input['summary'] .= ' Actualização pública QA.';
$preview = $this->postJson($base.'/divulgacao/previa', [...$input, 'action' => $action, 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk()->json();
$this->postJson($base.'/divulgacao/confirmar', ['token' => $preview['token'], 'operation_key' => (string) Str::uuid(), 'confirm' => true,
'reason' => 'QA: conferir actualização ou retirada sem perder a preparação.'])->assertOk();
}
$this->getJson('/api/v1/opportunities/'.$record->slug)->assertNotFound();
$this->assertSame([], app(CorridorAgribusinessOpportunities::class)->articleLinks());
$this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
$request = \Illuminate\Http\Request::create('/minhas-oportunidades/drafts', 'POST');
app()->instance('request', $request);
$repeat = app(CorridorAgribusinessOpportunities::class)->register(User::findOrFail(295), $record->organization_id);
$this->assertCount(34, $repeat);
$this->assertSame(0, count(array_filter($repeat, fn ($r) => $r['created'])));
$this->assertSame('suspended', $record->fresh()->status);
$this->assertSame($canonical['executive_summary'], app(OpportunityManagement::class)->values($record->fresh())['canonical']['executive_summary']);
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAdministration;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class OpportunityPromotionTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private Opportunity $record;
private string $org;
protected function setUp(): void
{
parent::setUp();
$target = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.managers' => []]);
$this->actor = User::factory()->create();
$this->actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
$this->org = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $this->org, 'name' => 'SYNTHETIC QA Tempo operator', 'external_id' => '000000000', 'organization_type' => 'private']);
DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'action' => 'operator_organization_registered', 'data_classification' => 'restricted',
'after_data' => json_encode(['organization_id' => $this->org, 'organization' => ['name' => 'SYNTHETIC QA Tempo operator', 'nuit' => '000000000'], 'source_reference' => 'Synthetic QA designation only'])]);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), ['user_id' => $this->actor->id,
'role' => 'promoter', 'organization_id' => $this->org, 'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'internal-portal-designation']);
$this->record = new Opportunity;
$this->record->forceFill(['title' => 'PRIVATE TITLE', 'slug' => 'qa-promo-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'status' => 'draft',
'opportunity_category' => 'investment_project', 'owner_user_id' => $this->actor->id, 'organization_id' => $this->org, 'management_version' => 1,
'promoter_name' => 'PRIVATE CONTACT', 'description' => 'PRIVATE CONTENT', 'estimated_investment_amount' => '123456.00',
'workflow_context' => ['operator_organization_id' => $this->org, 'contributors' => [$this->actor->id]],
'management_draft' => json_encode(['canonical' => ['official_title' => 'PRIVATE TITLE', 'contact' => 'PRIVATE CONTACT', 'documents' => ['PRIVATE DOCUMENT']]])])->save();
$this->login($this->actor);
}
private function login(User $actor): void
{
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
private function base(): string { return '/minhas-oportunidades/'.$this->record->id.'/divulgacao'; }
private function data(array $overrides = []): array
{
return [...['etag' => app(OpportunityManagement::class)->etag($this->record), 'action' => 'disclose', 'title' => 'SYNTHETIC QA public summary',
'summary' => 'Synthetic QA opportunity in preparation, no real investment.', 'description' => 'Only this deliberately selected public text.',
'source_reference' => 'Synthetic source provided for QA', 'edition' => 'QA-1', 'source_date' => '2026-10-22',
'limitations' => 'Financing and implementation not established.', 'territory_ids' => []], ...$overrides];
}
private function preview(array $overrides = []): array { return $this->postJson($this->base().'/previa', $this->data($overrides))->assertOk()->json(); }
private function confirm(array $preview, ?string $key = null): array
{
$data = ['token' => $preview['token'], 'operation_key' => $key ?? (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA operation reviewed in preview'];
$this->postJson($this->base().'/confirmar', $data)->assertOk();
return $data;
}
public function test_preview_publish_update_withdraw_and_redisclose_preserve_private_draft_and_audit(): void
{
$file = (string) Str::uuid();
$attachment = (string) Str::uuid();
DB::table('documents')->insert(['id' => $file, 'file_name' => 'PRIVATE-QA-DOCUMENT.txt', 'storage_key' => 'private-qa/'.$file,
'media_type' => 'text/plain', 'sha256' => hash('sha256', 'private QA only'), 'size_bytes' => 15,
'uploaded_by' => $this->actor->id, 'status' => 'quarantined', 'data_classification' => 'internal']);
DB::table('opportunity_documents')->insert(['id' => $attachment, 'opportunity_id' => $this->record->id, 'document_id' => $file,
'version' => 1, 'created_by' => $this->actor->id, 'payload' => json_encode(['title' => 'PRIVATE DOCUMENT', 'visibility' => 'internal'])]);
$documentBefore = DB::table('documents')->where('id', $file)->first();
$attachmentBefore = DB::table('opportunity_documents')->where('id', $attachment)->first();
DB::table('opportunity_profiles')->insert(['opportunity_id' => $this->record->id, 'business_model' => 'PRIVATE PROFILE', 'source_reference' => 'PRIVATE SOURCE']);
$before = $this->record->refresh()->getRawOriginal();
$private = $before['management_draft'];
$preview = $this->preview();
$this->assertSame($before, $this->record->fresh()->getRawOriginal());
$this->assertFalse($preview['writes']);
$this->assertStringContainsString('Em preparação', $preview['projection']['description']);
$this->assertStringNotContainsString('PRIVATE', json_encode($preview['projection']));
$key = (string) Str::uuid();
$request = $this->confirm($preview, $key);
$this->postJson($this->base().'/confirmar', $request)->assertOk()->assertJsonPath('replayed', true);
$this->record->refresh();
$this->assertSame('draft', $this->record->workflow_context['working_state']);
$this->assertSame($private, $this->record->management_draft);
$this->assertSame('PRIVATE CONTACT', app(OpportunityManagement::class)->values($this->record)['promoter_name']);
$public = $this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$this->assertStringNotContainsString('PRIVATE', $public->getContent());
$public->assertJsonPath('data.maturity_label', 'Em preparação')->assertJsonPath('data.estimated_investment_amount', null);
$context = app(\App\Services\InvestmentCatalogue::class)->context($this->record->id);
$this->assertNull($context['profile']);
$this->assertSame([], $context['source_evidence']);
$this->assertStringNotContainsString('PRIVATE', json_encode($context));
$this->confirm($this->preview(['summary' => 'Updated synthetic QA summary.']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk()->assertJsonPath('data.summary', 'Updated synthetic QA summary.');
$this->get($this->base())->assertOk();
$this->confirm($this->preview(['action' => 'withdraw']));
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertNotFound();
$this->get($this->base())->assertOk();
$this->confirm($this->preview());
$this->getJson('/api/v1/opportunities/'.$this->record->slug)->assertOk();
$events = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication')->where('after_data->record_id', $this->record->id)->get();
$this->assertCount(4, $events);
$this->assertContains('promotional_summary_withdrawn', $events->pluck('action')->all());
foreach ($events as $event) { $this->assertSame($this->actor->id, (int) $event->actor_user_id); $this->assertFalse(json_decode($event->after_data, true)['execution_approved']); }
$this->assertEquals($documentBefore, DB::table('documents')->where('id', $file)->first());
$this->assertEquals($attachmentBefore, DB::table('opportunity_documents')->where('id', $attachment)->first());
}
public function test_stale_tampered_expired_and_foreign_preview_do_not_write(): void
{
$preview = $this->preview();
$payload = ['token' => $preview['token'].'broken', 'operation_key' => (string) Str::uuid(), 'confirm' => true, 'reason' => 'Synthetic QA invalid confirmation'];
$this->postJson($this->base().'/confirmar', $payload)->assertUnprocessable();
$this->record->title = 'Concurrent private edit'; $this->record->save();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $preview['token']])->assertConflict();
$fresh = $this->preview();
$this->travel(16)->minutes();
$this->withSession(['portal.mfa_at' => time()]);
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertConflict();
$this->travelBack();
$this->login(User::factory()->create());
$this->get($this->base())->assertForbidden();
$this->postJson($this->base().'/confirmar', [...$payload, 'token' => $fresh['token']])->assertForbidden();
$this->assertSame('draft', $this->record->fresh()->status);
}
public function test_external_promoter_and_unrelated_internal_actor_cannot_disclose_and_no_ordinary_publication_is_overwritten(): void
{
$other = User::factory()->create();
$this->login($other);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'organization_id' => $this->org, 'user_id' => $this->actor->id, 'role' => 'officer']);
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'approved-external-promoter']);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
DB::table('portal_role_assignments')->where('user_id', $this->actor->id)->update(['source' => 'internal-portal-designation']);
$this->withSession(['portal.mfa_at' => time() - 901]);
$this->postJson($this->base().'/previa', $this->data())->assertForbidden();
$this->login($this->actor);
$this->record->forceFill(['status' => 'published', 'published_at' => now(), 'workflow_context' => [...$this->record->workflow_context, 'publication' => ['visibility' => 'public']]])->save();
$this->get($this->base())->assertForbidden();
}
public function test_existing_publisher_scope_can_use_the_same_action_without_new_role_or_approval(): void
{
$publisher = User::factory()->create();
$publisher->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor, ['user_id' => $publisher->id,
'role' => 'publisher', 'organization_id' => $this->org, 'opportunity_types' => ['investment_project'], 'responsibilities' => [], 'source' => 'synthetic-approved-designation']);
$roles = DB::table('portal_role_assignments')->count();
$this->login($publisher);
$access = app(\App\Services\PortalProfileAccess::class);
$this->assertTrue($access->canView($publisher, $this->record), 'Publisher read scope');
$this->assertTrue($access->scoped($publisher, 'publisher', $this->org, 'investment_project'), 'Publisher scope');
$this->assertTrue(app(\App\Services\OpportunityPromotion::class)->allowed($publisher, $this->record), 'Promotional action scope');
$this->confirm($this->preview());
$this->assertSame($roles, DB::table('portal_role_assignments')->count());
$this->assertArrayNotHasKey('approval', $this->record->fresh()->workflow_context);
}
public function test_profile_directory_uses_existing_accounts_without_exposing_authentication_secrets(): void
{
$other = User::factory()->create();
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
$this->get('/perfis/gestao')->assertOk()->assertInertia(fn (\Inertia\Testing\AssertableInertia $page) => $page
->has('staffAccounts')->where('staffAccounts', fn ($rows) => collect($rows)->contains(fn ($row) => $row['id'] === $this->actor->id && $row['email'] === $this->actor->email && $row['verified'] && $row['mfa'])
&& collect($rows)->every(fn ($row) => !isset($row['password']) && !isset($row['two_factor_secret']))));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// Local QA entry, ordinary login/MFA; never maps to homologation or the active DB.
foreach (['APP_ENV' => 'testing', 'APP_URL' => 'http://127.0.0.1:18574', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1',
'DB_PORT' => '55441', 'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '',
'PORTAL_PROFILE_ACCESS_ENABLED' => 'true', 'PORTAL_OPPORTUNITY_SPECIALIZED_DRAFTS_ENABLED' => 'true',
'INVESTOR_PATH_ENABLED' => 'true', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'agribusiness_opportunities_qa',
'CACHE_STORE' => 'array', 'MAIL_MAILER' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $k => $v) {
putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$public = realpath(__DIR__.'/../public');
$asset = realpath($public.rawurldecode($path));
if ($asset && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset) && pathinfo($asset, PATHINFO_EXTENSION) !== 'php') return false;
define('LARAVEL_START', microtime(true));
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Http\Kernel::class)->bootstrap();
$target = Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
if (! $app->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) {
http_response_code(503); exit('QA target rejected');
}
$app->handleRequest(Illuminate\Http\Request::capture());
@@ -0,0 +1,34 @@
<?php
// Initial source loading only. Everyday editing and disclosure use the portal.
$options = getopt('', ['apply', 'actor:', 'organization:']);
foreach (['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441',
'DB_DATABASE' => 'simulator_fiscal_delta_20261021_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => ''] as $k => $v) {
putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v;
}
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use App\Services\CorridorAgribusinessOpportunities;
$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
if (! app()->environment('testing') || $target->name !== 'simulator_fiscal_delta_20261021_test' || (int) $target->port !== 55441) throw new RuntimeException('Refusing non-QA target.');
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true]);
$service = app(CorridorAgribusinessOpportunities::class);
if (! isset($options['apply'])) {echo json_encode(['target' => $target, 'entries' => $service->entries(), 'writes' => 0], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit;}
$actor = App\Models\User::findOrFail($options['actor'] ?? 0);
$organization = $options['organization'] ?? '';
$request = Illuminate\Http\Request::create('/minhas-oportunidades/drafts', 'POST');
$request->setUserResolver(fn () => $actor); $app->instance('request', $request);
$dir = storage_path('app/private/agribusiness-opportunities-20261023');
if (! is_dir($dir)) mkdir($dir, 0700, true);
if (! file_exists($dir.'/before.json')) {
$before = [];
foreach (['opportunities', 'audit_events', 'development_corridors', 'portal_role_assignments', 'portal_resources', 'documents'] as $table) $before[$table] = DB::table($table)->orderBy('id')->get()->all();
file_put_contents($dir.'/before.json', json_encode($before, JSON_THROW_ON_ERROR));
}
$records = $service->register($actor, $organization);
$receipt = ['target' => $target, 'source_sha256' => $service::SOURCE_HASH, 'records' => $records, 'total' => count($records),
'new' => count(array_filter($records, fn ($r) => $r['created'])), 'published' => App\Models\Opportunity::whereIn('id', array_column($records, 'id'))->published()->count(), 'active_writes' => 0];
file_put_contents($dir.'/receipt.json', json_encode($receipt, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($receipt, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE).PHP_EOL;