Register 34 private agribusiness opportunities with promotional management and investor paths in QA
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'])];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)))];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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']))));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user