feat: integrate opportunity draft management with audited lifecycle

This commit is contained in:
2026-10-09 08:16:51 +02:00
parent ebfc188240
commit 7ecfe3d543
20 changed files with 1459 additions and 22 deletions
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Opportunities\Enums\OpportunityStatus;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityManagementController extends Controller
{
public function installation(Request $request, OpportunityManagementInstallation $service): JsonResponse|Response
{
return $request->expectsJson() ? response()->json($service->preview()) : Inertia::render('management/opportunity-install', $service->preview());
}
public function applyInstallation(Request $request, OpportunityManagementInstallation $service): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Gestão instalada; nenhuma oportunidade foi submetida ou publicada.');
}
public function index(Request $request, OpportunityManagement $service): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->catalog($request, 'opportunities');
}
$filters = $request->validate(['q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
$query = Opportunity::query();
if ($filters['deleted'] ?? false) {
$query->onlyTrashed();
}
if ($filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%'));
}
if ($filters['status'] ?? null) {
$query->where('status', $filters['status']);
}
if ($filters['category'] ?? null) {
$query->where('opportunity_category', $filters['category']);
}
if ($filters['sector_id'] ?? null) {
$query->where('sector_id', $filters['sector_id']);
}
if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']);
}
return Inertia::render('management/opportunities', ['records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'counts' => Opportunity::selectRaw('status,count(*) AS total')->groupBy('status')->pluck('total', 'status'), 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => Opportunity::where('geography_scope', 'unspecified')->count(), 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
}
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->edit('opportunities', $id);
}
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
$values = $record ? ($record->management_draft ? json_decode($record->management_draft, true) : $record->only(OpportunityManagement::FIELDS)) : [];
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
return Inertia::render('management/opportunity-draft', ['record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
}
public function save(Request $request, OpportunityManagement $service, ?int $id = null): JsonResponse
{
$record = $service->save($request->user(), $request->all(), $id);
if (! $id) {
$request->session()->flash('success', 'Rascunho guardado no servidor. Pode continuar o preenchimento; não foi submetido.');
}
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
}
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
{
abort_unless($service->installed(), 503);
$record = $service->lifecycle($request->user(), $id, $request->all());
return response()->json(['id' => $record->id, 'message' => $request->input('action') === 'restore' ? 'Restaurado como rascunho; nova revisão necessária antes de publicar.' : 'Exclusão lógica/arquivo registado; histórico e relações preservados.']);
}
}
@@ -10,6 +10,7 @@ use App\Models\User;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
@@ -257,6 +258,9 @@ class PortalManagementController extends Controller
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
}
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
+9
View File
@@ -11,11 +11,20 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property string|null $primary_territory_id
* @property int $territory_version
* @property int $management_version
* @property string|null $management_draft
* @property string|null $management_creation_key
* @property string|null $first_submitted_at
* @property bool $management_closed_once
* @property string|null $opportunity_category
* @property string|null $estimated_investment_currency
*/
class Opportunity extends Model
{
use SoftDeletes;
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once'];
protected $fillable = [
'sector_id', 'reference', 'title', 'slug', 'summary', 'description',
'province', 'district', 'promoter_name', 'promoter_type',
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class OpportunityManagement
{
public const CATEGORIES = ['investment_project', 'real_estate', 'land_rights', 'business_sale', 'business_partnership', 'franchise', 'concession', 'financing', 'supply', 'other'];
public const FIELDS = ['title', 'summary', 'description', 'sector_id', 'promoter_name', 'promoter_type', 'maturity_level', 'maturity_label', 'financing_modality', 'expected_impact', 'estimated_investment_amount', 'estimated_investment_currency', 'opportunity_category', 'primary_territory_id', 'province', 'district', 'geography_scope'];
public function installed(): bool
{
return Schema::hasColumn('opportunities', 'management_version');
}
public function etag(Opportunity $record): string
{
return hash('sha256', json_encode($record->fresh()->getRawOriginal(), JSON_THROW_ON_ERROR));
}
public function closed(Opportunity $record): bool
{
return $record->first_submitted_at !== null || $record->management_closed_once || ! in_array($record->status, ['draft'], true);
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?int $id = null): Opportunity
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless($this->installed(), 503, 'Instalação autenticada ainda necessária.');
$data = Validator::make($input, [
'etag' => [$id ? 'required' : 'nullable', 'string', 'size:64'],
'creation_key' => [$id ? 'nullable' : 'required', 'uuid'],
'title' => ['nullable', 'string', 'max:150'], 'summary' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:50000'], 'promoter_name' => ['nullable', 'string', 'max:200'],
'promoter_type' => ['nullable', Rule::in(['public', 'private', 'ppp', 'cooperative', 'association', 'community', 'other', $id ? Opportunity::withTrashed()->findOrFail($id)->promoter_type : null])],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'financing_modality' => ['nullable', 'string', 'max:120'],
'expected_impact' => ['nullable', 'string', 'max:10000'],
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
'opportunity_category' => ['nullable', Rule::in(self::CATEGORIES)],
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'estimated_investment_currency' => ['nullable', 'in:MZN,USD,EUR,ZAR'],
'reason' => ['required', 'string', 'min:20', 'max:1000'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
])->validate();
return DB::transaction(function () use ($actor, $data, $id) {
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
$snapshot = $existing->management_draft ? json_decode($existing->management_draft, true) : $existing->only(self::FIELDS);
foreach (array_intersect_key($data, array_flip(self::FIELDS)) as $key => $value) {
if (($snapshot[$key] ?? null) != $value) {
throw ValidationException::withMessages(['creation_key' => 'Esta chave já foi utilizada com dados diferentes.']);
}
}
return $existing;
}
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
if ($id) {
$this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
}
$before = $record->getAttributes();
$changes = array_intersect_key($data, array_flip(self::FIELDS));
if (array_key_exists('maturity_level', $changes)) {
$labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
$changes['maturity_label'] = $labels[$changes['maturity_level'] ?? 0] ?? null;
}
if (array_intersect_key($data, InvestmentGeography::FIELDS) !== []) {
$path = app(InvestmentGeography::class)->selection($data, true);
$changes['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$changes['province'] = $path[0]->name ?? null;
$changes['district'] = $path[1]->name ?? null;
$changes['geography_scope'] = $path === [] ? 'unspecified' : 'specific';
}
if (! $id) {
$record->reference = 'INV-MZ-GEN-'.random_int(1000000000000000, 9999999999999999);
$record->slug = strtolower($record->reference);
$record->status = 'draft';
$record->management_creation_key = $data['creation_key'];
$record->management_version = 1;
$record->estimated_investment_currency = null;
} else {
$record->management_version++;
}
if ($id && $this->closed($record)) {
$record->management_closed_once = true;
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS);
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
} else {
foreach ($changes as $key => $value) {
$record->{$key} = $value;
}
}
$record->save();
$this->audit($record, $actor, $before, $id ? 'draft_updated' : 'draft_created', $data['reason']);
return $record;
});
}
/** @param array<string,mixed> $input */
public function lifecycle(User $actor, int $id, array $input): Opportunity
{
abort_unless($actor->can('manage-portal'), 403);
$data = Validator::make($input, ['etag' => ['required', 'string', 'size:64'], 'action' => ['required', 'in:delete,restore'], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'confirm' => ['required', 'accepted']])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
$this->check($record, $data['etag']);
$before = $record->getAttributes();
if ($data['action'] === 'restore') {
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
if ($record->sector_id) {
Validator::make(['sector_id' => $record->sector_id], ['sector_id' => [Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)]])->validate();
}
$record->deleted_at = null;
$record->status = 'draft';
$record->published_at = null;
if ($this->closed($record) && ! $record->management_draft) {
$record->management_draft = json_encode($record->only(self::FIELDS), JSON_THROW_ON_ERROR);
}
} else {
abort_if($record->trashed() || $record->status === 'archived', 409, 'Registo já eliminado/arquivado.');
if ($this->closed($record)) {
$record->management_closed_once = true;
$record->status = 'archived';
} else {
$record->deleted_at = now();
}
}
$record->management_version++;
$record->save();
$this->audit($record, $actor, $before, $data['action'], $data['reason']);
return $record;
});
}
private function check(Opportunity $record, string $etag): void
{
if (! hash_equals($this->etag($record), $etag)) {
throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['O registo mudou. Os dados preenchidos foram preservados; abra a versão atual noutra aba antes de reconciliar.']]], 409));
}
}
/** @param array<string,mixed> $before */
private function audit(Opportunity $record, User $actor, array $before, string $action, string $reason): void
{
DB::table('audit_events')->insert(['source_system' => 'opportunity-management', 'entity_type' => 'opportunities', 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['record_id' => $record->id, 'snapshot' => Crypt::encryptString(json_encode($before, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['record_id' => $record->id, 'version' => $record->management_version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($record->getAttributes(), JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class OpportunityManagementInstallation
{
public const MIGRATION = '2026_10_09_020000_extend_opportunity_management';
public function fingerprint(): string
{
$files = [__FILE__, app_path('Models/Opportunity.php'), app_path('Services/OpportunityManagement.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Http/Controllers/PortalManagementController.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunities.tsx')];
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function preview(): array
{
$path = storage_path('app/private/investmoz-v2/opportunity-management-activation-proof.json');
$proof = is_file($path) ? json_decode(file_get_contents($path) ?: '{}', true) : [];
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$ready = ($proof['restore_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['qa_passed'] ?? false) && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null);
$installed = app(OpportunityManagement::class)->installed();
return ['installed' => $installed, 'ready' => $ready, 'target' => $identity, 'code_hash' => $code, 'hash' => hash('sha256', json_encode([$identity, $code, $ready, $installed], JSON_THROW_ON_ERROR)), 'migration' => self::MIGRATION, 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Permitir setor, título e moeda ausentes em rascunhos; nenhum preenchimento fictício.', 'Acrescentar versão, categoria opcional, rascunho de revisão, chave idempotente e marca de primeira submissão à entidade existente.', 'Impedir eliminação física de versões submetidas; preservar relações e classificações.', 'Nenhuma submissão, aprovação ou publicação automática.'], 'rollback' => 'Down apenas antes de novos eventos/rascunhos incompletos. Depois preservar e reconciliar dados; restauro integral requer janela autorizada e reconciliação de alterações posteriores.'];
}
public function apply(User $actor, string $hash): void
{
abort_unless($actor->can('manage-portal'), 403);
DB::transaction(function () use ($actor, $hash) {
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
$preview = $this->preview();
abort_unless(hash_equals($preview['hash'], $hash), 409, 'Plano mudou; reveja.');
if ($preview['installed']) {
return;
}
abort_unless($preview['ready'], 422, 'Backup/restauro, QA, compatibilidade e reversão desta versão necessários.');
config(['opportunity_management.authenticated_installation' => true]);
try {
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]);
abort_unless($exit === 0, 500, 'Migração falhou.');
} finally {
config(['opportunity_management.authenticated_installation' => false]);
}
DB::table('audit_events')->insert(['source_system' => 'opportunity-management-install', 'entity_type' => 'installation', 'action' => 'opportunity_management_installed', 'actor_user_id' => $actor->id, 'after_data' => json_encode(['code_hash' => $preview['code_hash'], 'migration' => self::MIGRATION], JSON_THROW_ON_ERROR)]);
});
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing')) {
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
throw_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, RuntimeException::class, 'Isolated QA required');
} else {
throw_unless(config('opportunity_management.authenticated_installation') === true, RuntimeException::class, 'Authenticated installation required');
}
DB::statement('ALTER TABLE opportunities ALTER COLUMN sector_id DROP NOT NULL, ALTER COLUMN title DROP NOT NULL, ALTER COLUMN estimated_investment_currency DROP NOT NULL');
DB::statement('ALTER TABLE opportunities ADD COLUMN management_version bigint NOT NULL DEFAULT 1 CHECK(management_version>0), ADD COLUMN opportunity_category varchar(40), ADD COLUMN management_draft jsonb, ADD COLUMN management_creation_key uuid UNIQUE, ADD COLUMN first_submitted_at timestamptz, ADD COLUMN management_closed_once boolean NOT NULL DEFAULT false');
DB::unprepared("CREATE OR REPLACE FUNCTION guard_opportunity_physical_delete() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF OLD.first_submitted_at IS NOT NULL OR OLD.management_closed_once OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Physical deletion forbidden after submission'; END IF; RETURN OLD; END; \$\$; CREATE TRIGGER opportunity_no_physical_delete BEFORE DELETE ON opportunities FOR EACH ROW EXECUTE FUNCTION guard_opportunity_physical_delete()");
}
public function down(): void
{
throw_if(DB::table('audit_events')->where('source_system', 'opportunity-management')->exists(), RuntimeException::class, 'Preserve management history; rollback requires reconciled restore');
throw_if(DB::table('opportunities')->whereNull('sector_id')->orWhereNull('title')->orWhereNull('estimated_investment_currency')->exists(), RuntimeException::class, 'Incomplete drafts must be preserved');
DB::unprepared('DROP TRIGGER opportunity_no_physical_delete ON opportunities; DROP FUNCTION guard_opportunity_physical_delete()');
DB::statement('ALTER TABLE opportunities DROP COLUMN management_version, DROP COLUMN opportunity_category, DROP COLUMN management_draft, DROP COLUMN management_creation_key, DROP COLUMN first_submitted_at, DROP COLUMN management_closed_once, ALTER COLUMN sector_id SET NOT NULL, ALTER COLUMN title SET NOT NULL, ALTER COLUMN estimated_investment_currency SET NOT NULL');
}
};
+10
View File
@@ -207,3 +207,13 @@ Todos mantêm estado editorial `published` e inclusão no filtro público do cat
**Preservação:** hashes das dez tabelas comparadas com o backup coincidem, incluindo IDs/dados das seis oportunidades, pedidos, recibos, mensagens e relações. Os **118 eventos históricos anteriores** permanecem presentes e inalterados; foram acrescentados seis eventos de classificação. Os dois eventos do ensaio QA são excluídos da comparação histórica, pois não pertencem à base ativa.
**Associações separadas:** zero relações oportunidade–projeto e oportunidade–cluster continuam presentes, tal como antes. As **28 localizações de projetos e 13 relações cluster–projeto** permanecem iguais ao backup. Classificar origem não aplica as correspondências ambíguas. Evidência privada: `opportunity-origin-active-confirmation.json`; leitor `tools/opportunity-origin-active-confirmation.php`. Nenhuma escrita ativa nesta confirmação. As restantes pendências independentes mantêm-se.
### Gestão de Oportunidades no Dashboard — primeiro incremento em QA
[Fonte, matriz reconciliada e plano de ativação](gestao-oportunidades-dashboard.md). DOCX libertado normalmente e lido integralmente; SHA-256 `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`, 14 958 parágrafos e esquema JSON com 110 definições. Consulta interna autorizada, especificação técnica, nenhum uso em cálculos ou publicação documental. `AGENTS.md` preservado.
**Implementado/QA:** gestão integrada, lista/pesquisa/filtros/contagens, criar/ver/editar, rascunho incompleto no servidor e recuperação em nova sessão, erros por campo, exclusão lógica/arquivo/restauro com motivo e confirmação, ETag/versão, replay sem duplicação, snapshots cifrados anterior/novo. Revisão de versão fechada preserva valores públicos; publicação pelo editor genérico fica bloqueada após instalação. SQL documental não executado; IDs, seis classificações reais, relações e histórico preservados.
**Evidência:** 72 testes/1 164 asserções, TypeScript/PHPStan/build e percurso desktop/móvel passaram. Backup/restauro de 288 tabelas/cinco ficheiros e reversão antes de uso comprovados em `desk_restore_20261009_060826_test:55441`. Plano único `/management/opportunity-management-install`, válido até 10/10/2026 06:08:36 UTC; aplicação exclusivamente autenticada. Leitura ativa em 09/10/2026 06:13:34 UTC: migration ausente, zero eventos desta gestão e seis classificações reais mantidas. **Preparado, não aplicado na base ativa.**
**Pendentes da especificação:** catálogos completos/dropdowns pesquisáveis, maturidade de oito etapas, wizard/autosave, imagens/documentos/multilocalização completa, validação final, segregação de papéis, revisão/submissão/aprovação/publicação e perfis especializados de investimento/imobiliário/direitos fundiários/transações. Matriz por secção no relatório; CRUD não certifica REQ01–REQ17/DT01–DT15 integrais. Cobertura do inventário, questões jurídicas, ClamD real e primeiro pedido real continuam separados.
@@ -0,0 +1,56 @@
# Gestão de Oportunidades integrada no Dashboard
Entrega de 9 de outubro de 2026. **Primeiro incremento implementado e validado em QA; não instalado na base ativa. A especificação integral não está concluída.**
## Fonte e reconciliação
Documento indicado: `C:\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\prompt\Formularios\novos formularios\Especificação do Formulário de Cadastro de Oportunidades de Investimento — InvestMoz.docx`. Título interno: «Especificação do Formulário de Cadastro de Oportunidades — InvestMoz». SHA-256: `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`; 320 004 bytes; modificação observada em 09/10/2026 05:31:13 UTC.
O bloqueio inicial, com a janela correspondente aberta no Word, foi libertado normalmente pelo utilizador. A leitura posterior foi bem-sucedida, sem terminar processos ou contornar o bloqueio. Extração de todos os 14 958 elementos `w:p`, incluindo tabelas: 14 849 com texto e 2 950 textos distintos por igualdade exata. Não há objetos incorporados ou media no pacote. A extração integral mantém todas as ocorrências; o índice único é auxiliar. O esquema JSON da secção 26 foi analisado e parseado, com 110 definições. Texto, índice e metadados privados em `storage/app/private/investmoz-v2/opportunity-form-*`.
Disponibilidade: consulta interna autorizada pelo pedido. Situação documental: especificação de software, não diploma jurídico. Utilização em cálculos: nenhuma. Não houve importação/publicação do DOCX nem declaração de scan. `AGENTS.md` foi lido integralmente; aceitação das fontes oficiais e seis classificações reais preservadas.
O cadastro anterior usava `Opportunity`, setores, territórios e relações existentes, mas exigia título/setor/moeda para guardar e permitia publicação pelo editor genérico. O incremento reutiliza essas entidades, dashboard, gate `manage-portal`, `audit_events` e geografia. Não executa SQL documental: mantém IDs bigint existentes em vez dos UUID dos exemplos. Referências técnicas novas são geradas pelo servidor; título, setor e moeda desconhecidos permanecem nulos.
## Matriz de cobertura
| Fonte / requisito | Estado | Implementação e lacuna |
| --- | --- | --- |
| §8/8.1 — rascunho vs exigências finais | Implementado para guardar; parcial global | Rascunho incompleto persistido; não declara validação final, submissão, aprovação ou publicação. Contrato JSON final não impede guardar. |
| §9 — gestão integrada | Implementado no escopo autorizado | Lista, pesquisa, filtros por estado/categoria/setor/âmbito, paginação e contagens; Nova, Ver, Editar, Excluir/Arquivar, Restaurar. Indicadores avançados e papéis adicionais pendentes. |
| §10 — núcleo/catálogos | Parcial | Campos legados preservados, categoria opcional e seleção territorial. Valor histórico `privado` conservado. Catálogos completos, dropdowns pesquisáveis e maturidade de oito etapas pendentes; não remapear automaticamente as seis etapas antigas. |
| §11 — multilocalização | Parcial existente | Território principal e hierarquia reutilizados; contexto consultável. Formulário de até 50 localizações, papéis, distribuição e geometrias/evidência por localização pendente. Não inventa coordenadas. |
| §12 — mercado/financiamento/calendário | Parcial | Montante decimal validado, campos existentes conservados. Estruturas completas e coerência cruzada por categoria pendentes; não calcula resultados financeiros/fiscais. |
| §13 — documentos/visibilidade | Parcial existente | Infraestrutura preservada, sem publicação automática. Integração completa de documentos, idiomas e pré-visualização exata de visibilidade pendente. |
| §15 — guardar/recuperar/editar | Implementado no incremento | Confirmação visível com foco, erros por campo, preservação e bloqueio durante operação, recuperação numa nova sessão pelo servidor. Wizard, autosave, validação final e comparação de duplicados documentais pendentes. |
| §15.2 — ciclo de exclusão | Implementado para oportunidade | Motivo 20–1000 caracteres e confirmação; draft eliminado logicamente, versão fechada arquivada, restauração autorizada como draft sem republicar. Trigger proíbe eliminação física após fecho/submissão. Ciclo individual de coleções/anexos pendente. |
| §15.3 — concorrência/idempotência | Implementado nos endpoints novos | Versão crescente, ETag do estado completo, bloqueio transacional e conflito HTTP 409; UUID de criação evita duplicação por replay. Token no payload e cabeçalho ETag na resposta. Contrato integral de todos os endpoints documentais pendente. |
| §16 — imagens | Pendente | Pipeline completo, direitos, MIME, EXIF, capa e versões. ClamD real separado; scanner simulado não valida ficheiros reais. |
| §17/19 — persistência/versões | Parcial | Mesmos IDs; alterações de versão fechada em `management_draft`, sem alterar ficha pública. Snapshots anterior/novo cifrados na auditoria existente. Fluxo completo de revisão pendente. |
| §20 — permissões/workflow | Parcial | Gate existente no servidor, sem conceder novos papéis. Editor genérico deixa de publicar após instalação. Segregação de papéis e workflow integral pendentes. Revisão interna não é aprovação institucional. |
| §21 — DT01–DT15 | Parcial | QA cobre rascunho, concorrência, permissões, replay, exclusão/arquivo/restauro e preservação. Critérios de imagens, validação final, publicação e coleções completas continuam abertos. |
| §22 — REQ01–REQ17 | Parcial global | Cobertura por grupos acima; requisitos dos módulos finais continuam pendentes. Não declarar Go/No-Go integral por CRUD. |
| §24–28 — categorias especializadas | Pendente além da seleção | Investimento, imobiliário, direitos fundiários, venda de negócio, parceria, franquia, concessão, financiamento, fornecimento e outros selecionáveis. Perfis, coleções e validações específicas exigem incrementos com reutilização dos módulos existentes. |
| Relações e fontes oficiais | Preservado | Consulta do contexto existente; nenhum par inferido por nome, setor ou proximidade. Editor de evidência por ligação permanece na frente territorial. |
## QA e preservação
Base isolada `desk_restore_20261009_060826_test:55441`, restaurada do backup ativo. Guardas recusam CLI de testes/migration na base ativa. Restauro verificado de **288 tabelas e cinco ficheiros**. Migração e reversão antes de novos eventos ensaiadas; hashes das 288 tabelas coincidem após reversão.
**72 testes, 1 164 asserções passaram**, cobrindo gestão, origem, geografia, relações, zonas, portfólio e unificação. TypeScript, PHPStan e build passaram. Navegador: dashboard → gestão → novo rascunho incompleto → guardar → erro de montante junto ao campo → correção → nova sessão móvel sem armazenamento local → recuperação no servidor → exclusão lógica → restauração. Desktop e largura 390 px sem overflow; sem erros de execução observados. Dados de teste apenas na cópia QA.
Casos automatizados adicionais: revisão publicada preserva valores públicos; API não expõe proposta privada; arquivo/restauro sem republicação; eliminação física rejeitada; conflito de versão; acessos negados; replay sem duplicação; instalação recusada sem prova válida.
Após navegador: oportunidades originais, relações, favoritos, pedidos, recibos, mensagens, tarefas, localizações e auditoria anterior preservados; seis classificações reais conservadas. Evidência privada: `opportunity-management-compatibility.json`, `opportunity-management-preservation.json`, `opportunity-management-browser.json`, `opportunity-management-backup-proof.json` e capturas desktop/móvel. Histórico visível mostra autor/data/ação; snapshots anterior/novo e motivos permanecem cifrados na auditoria restrita. Interface detalhada de comparação desses snapshots pendente.
## Plano único de ativação
Abrir com conta autorizada [a página de instalação](http://127.0.0.1:8000/management/opportunity-management-install), rever plano/backup/destino, assinalar confirmação e executar **Aplicar plano de gestão**. **Não executado pelo Codex.** Até instalar, lista/editor serve o cadastro anterior, pois o esquema novo está ausente.
Migration única `2026_10_09_020000_extend_opportunity_management`: permitir nulos em título/setor/moeda para drafts; acrescentar versão, categoria, proposta, chave idempotente, primeira submissão e marca histórica de fecho; instalar guarda de eliminação física. Não altera valores de negócio, classificações ou relações existentes. A página confere hash do plano/código, identidade, integridade do backup/restauro e QA desta versão, validade de 24 h; exige autenticação/permissão e audita aplicação.
Backup verificado em **09/10/2026 06:08:36 UTC**, válido para esta pré-visualização até **10/10/2026 06:08:36 UTC**. Expiração ou mudança de código exige nova preparação. Leitura ativa em **06:13:34 UTC**: migration ausente, zero eventos desta gestão, IDs 1–6 `real`; nenhuma escrita ativa nesta entrega.
Reversão direta `down` apenas antes de novos eventos ou drafts incompletos. Depois de uso, preservar/reconciliar dados e preparar reversão autorizada. Restauro integral exige janela e reconciliação de escritas posteriores; ensaio em cópia não autoriza sobrescrever a base ativa.
Pendências separadas: cobertura do inventário, questões jurídicas específicas, ClamD real, referências institucionais concretas e primeiro pedido real no Balcão. Nenhuma foi concluída por estes testes.
+5 -1
View File
@@ -52,7 +52,11 @@ export default function Dashboard({ summary }: { summary: Summary | null }) {
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Catálogo</h2>
<h2 className="text-lg font-semibold">
<Link href="/management/opportunities">
Gestão de Oportunidades
</Link>
</h2>
<div className="grid gap-4 sm:grid-cols-3">
{[
{
@@ -0,0 +1,203 @@
import { Link, useForm } from '@inertiajs/react';
import {
Management,
Pagination,
button,
control,
states,
type Paginated,
} from './shared';
type Entry = {
id: number;
reference: string;
title: string | null;
status: string;
opportunity_category: string | null;
updated_at: string;
deleted_at: string | null;
};
export default function Opportunities({
records,
filters,
counts,
categories,
sectors,
geographyReviewCount,
}: {
records: Paginated<Entry>;
filters: {
q?: string;
status?: string;
category?: string;
deleted?: boolean;
sector_id?: string;
geography_scope?: string;
};
counts: Record<string, number>;
categories: string[];
sectors: { id: number; name: string }[];
geographyReviewCount: number;
}) {
const form = useForm({
q: filters.q ?? '',
status: filters.status ?? '',
category: filters.category ?? '',
deleted: Boolean(filters.deleted),
sector_id: String(filters.sector_id ?? ''),
geography_scope: filters.geography_scope ?? '',
});
return (
<Management title="Gestão de Oportunidades">
<Link className={button} href="/management/opportunities/create">
Nova oportunidade
</Link>
<p>
{geographyReviewCount} registos com âmbito por indicar;
referências existentes permanecem aceites.
</p>
<section
aria-label="Indicadores autorizados"
className="grid gap-3 sm:grid-cols-3"
>
{[
'draft',
'submitted',
'returned',
'approved',
'published',
'suspended',
'archived',
].map((s) => (
<Link
className="rounded border p-3"
key={s}
href={`/management/opportunities?status=${s}`}
>
{states[s] ?? s}: {counts[s] ?? 0}
</Link>
))}
</section>
<form
className="flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
form.get('/management/opportunities');
}}
>
<input
aria-label="Pesquisa global"
placeholder="Título ou referência"
className={control}
value={form.data.q}
onChange={(e) => form.setData('q', e.target.value)}
/>
<select
aria-label="Estado"
className={control}
value={form.data.status}
onChange={(e) => form.setData('status', e.target.value)}
>
<option value="">Todos os estados</option>
{Object.keys(counts).map((s) => (
<option key={s} value={s}>
{states[s] ?? s}
</option>
))}
</select>
<select
aria-label="Categoria"
className={control}
value={form.data.category}
onChange={(e) => form.setData('category', e.target.value)}
>
<option value="">Todas as categorias</option>
{categories.map((s) => (
<option key={s}>{s}</option>
))}
</select>
<label>
<input
type="checkbox"
checked={form.data.deleted}
onChange={(e) =>
form.setData('deleted', e.target.checked)
}
/>{' '}
Rascunhos eliminados logicamente
</label>
<select
aria-label="Sector"
className={control}
value={form.data.sector_id}
onChange={(e) => form.setData('sector_id', e.target.value)}
>
<option value="">Todos os sectores</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
aria-label="Âmbito geográfico"
className={control}
value={form.data.geography_scope}
onChange={(e) =>
form.setData('geography_scope', e.target.value)
}
>
<option value="">Todos os âmbitos</option>
{[
'unspecified',
'specific',
'multiple',
'national',
'digital',
].map((s) => (
<option key={s}>{s}</option>
))}
</select>
<button className={button} disabled={form.processing}>
Pesquisar
</button>
</form>
<div className="space-y-3">
{records.data.map((r) => (
<article key={r.id} className="rounded border p-4">
<h2 className="font-semibold">
{r.title ?? 'Rascunho sem título'}
</h2>
<p>
ID {r.id} · {r.reference} ·{' '}
{r.opportunity_category ?? 'Categoria por indicar'}{' '}
·{' '}
{r.deleted_at
? 'Eliminado logicamente'
: (states[r.status] ?? r.status)}
</p>
<p>Atualizado: {r.updated_at}</p>
<div className="flex flex-wrap gap-4">
<Link
href={`/management/opportunities/${r.id}/view`}
>
Ver
</Link>
<Link
href={`/management/opportunities/${r.id}/edit`}
>
{r.deleted_at || r.status === 'archived'
? 'Restaurar'
: r.status === 'draft'
? 'Continuar preenchimento'
: 'Editar nova versão'}
</Link>
</div>
</article>
))}
</div>
{!records.data.length && <p>Nenhum registo encontrado.</p>}
<Pagination page={records} />
</Management>
);
}
@@ -0,0 +1,488 @@
import { Link, router } from '@inertiajs/react';
import { useState, useEffect } from 'react';
import {
TerritoryLevel,
type TerritoryOption,
} from '@/components/investment-territory';
import { Management, Field, button, control } from './shared';
type Values = Record<string, string | number | null>;
type Entry = {
id: number;
reference: string;
status: string;
deleted_at: string | null;
management_version: number;
};
export default function OpportunityDraft({
record,
values,
etag,
readOnly,
closedVersion,
classification,
sectors,
categories,
history,
territoryPath,
}: {
record: Entry | null;
values: Values;
etag: string | null;
readOnly: boolean;
closedVersion: boolean;
classification: string;
sectors: { id: number; name: string }[];
categories: string[];
territoryPath: TerritoryOption[];
history: {
id: string;
action: string;
actor_user_id: number;
occurred_at: string;
}[];
}) {
const [data, setData] = useState<Values>({
...values,
creation_key: crypto.randomUUID(),
});
const [busy, setBusy] = useState(false),
[errors, setErrors] = useState<Record<string, string>>({}),
[message, setMessage] = useState(''),
[reason, setReason] = useState(''),
[confirm, setConfirm] = useState(false),
[token, setToken] = useState(etag);
useEffect(() => setToken(etag), [etag]);
const [path, setPath] = useState(territoryPath);
const unavailable = Boolean(
record?.deleted_at || record?.status === 'archived',
);
const set = (key: string, value: string) =>
setData((d) => ({ ...d, [key]: value }));
async function send(action: 'save' | 'delete' | 'restore') {
if (busy) return;
setBusy(true);
setErrors({});
setMessage('');
try {
const cookie = document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='));
const url =
action === 'save'
? record
? `/management/opportunities/${record.id}/draft`
: '/management/opportunities/drafts'
: `/management/opportunities/${record!.id}/lifecycle`;
const response = await fetch(url, {
method: action === 'save' && record ? 'PUT' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': cookie
? decodeURIComponent(cookie.slice(11))
: '',
},
body: JSON.stringify(
action === 'save'
? { ...data, etag: token, reason }
: { etag: token, action, reason, confirm },
),
});
const result = await response.json();
if (!response.ok) {
setErrors(
Object.fromEntries(
Object.entries(result.errors ?? {}).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(' ') : String(v),
]),
),
);
setMessage(
result.message ??
'Operação falhou; os dados preenchidos foram preservados.',
);
return;
}
setMessage(result.message);
if (result.etag) setToken(result.etag);
if (!record)
router.visit(`/management/opportunities/${result.id}/edit`);
else if (action !== 'save') router.reload();
} catch {
setMessage(
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
);
} finally {
setBusy(false);
requestAnimationFrame(() =>
document.getElementById('opportunity-result')?.focus(),
);
}
}
return (
<Management
title={
record
? `${readOnly ? 'Ver' : 'Editar'} oportunidade ${record.reference}`
: 'Nova oportunidade'
}
>
<Link href="/management/opportunities">Voltar à gestão</Link>
<p>
Classificação de origem: {classification}. Esta classificação
não cria associações territoriais.
</p>
{closedVersion && (
<p>
Versão anterior fechada. As alterações são guardadas numa
nova versão de rascunho; a ficha publicada permanece
intacta. Este incremento não submete, aprova ou publica.
</p>
)}
<div
id="opportunity-result"
tabIndex={-1}
role={Object.keys(errors).length ? 'alert' : 'status'}
className="rounded border p-3"
>
{message}
{errors.etag && (
<p>
{errors.etag}{' '}
<a
target="_blank"
rel="noreferrer"
href={`/management/opportunities/${record?.id}/view`}
>
Abrir versão atual
</a>
</p>
)}
</div>
<form
className="max-w-3xl space-y-4"
onSubmit={(e) => {
e.preventDefault();
void send('save');
}}
>
<fieldset
disabled={busy || readOnly || unavailable}
className="space-y-4"
>
<section aria-label="Localização registada">
<p>
Hierarquia do cadastro territorial existente.
Alterar esta seleção não cria relações com
projetos/clusters.
</p>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/management/territory-options"
fieldError={errors[key]}
onChange={(value) => {
const next = [
...path.slice(0, index),
...(value ? [value] : []),
];
setPath(next);
setData((previous) => ({
...previous,
province_id: next[0]?.id ?? '',
district_id: next[1]?.id ?? '',
admin_post_id: next[2]?.id ?? '',
locality_id: next[3]?.id ?? '',
}));
}}
/>
))}
</section>
<Field
label="Categoria"
error={errors.opportunity_category}
>
<select
className={control}
value={String(data.opportunity_category ?? '')}
onChange={(e) =>
set('opportunity_category', e.target.value)
}
>
<option value="">Por indicar</option>
{categories.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
{Object.entries({
title: 'Título oficial',
summary: 'Resumo',
description: 'Descrição',
promoter_name: 'Promotor',
}).map(([key, label]) => (
<Field key={key} label={label} error={errors[key]}>
{key === 'description' || key === 'summary' ? (
<textarea
className={control}
value={String(data[key] ?? '')}
onChange={(e) => set(key, e.target.value)}
/>
) : (
<input
className={control}
value={String(data[key] ?? '')}
onChange={(e) => set(key, e.target.value)}
/>
)}
</Field>
))}
<Field label="Sector" error={errors.sector_id}>
<select
className={control}
value={String(data.sector_id ?? '')}
onChange={(e) => set('sector_id', e.target.value)}
>
<option value="">Por indicar</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
<Field
label="Tipo de promotor"
error={errors.promoter_type}
>
<select
className={control}
value={String(data.promoter_type ?? '')}
onChange={(e) =>
set('promoter_type', e.target.value)
}
>
<option value="">Por indicar</option>
{values.promoter_type &&
![
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
].includes(String(values.promoter_type)) && (
<option
value={String(values.promoter_type)}
>
{String(values.promoter_type)} (valor
existente)
</option>
)}
{[
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
].map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field
label="Maturidade existente"
error={errors.maturity_level}
>
<select
className={control}
value={String(data.maturity_level ?? '')}
onChange={(e) =>
set('maturity_level', e.target.value)
}
>
<option value="">Por indicar</option>
{[
'Ideia identificada',
'Pré-estudo disponível',
'Estudo de viabilidade em curso',
'Projecto estruturado',
'Pronto para investimento',
'Em negociação ou implementação',
].map((c, index) => (
<option key={c} value={index + 1}>
{c}
</option>
))}
</select>
</Field>
<Field
label="Modalidade de financiamento existente"
error={errors.financing_modality}
>
<input
className={control}
value={String(data.financing_modality ?? '')}
onChange={(e) =>
set('financing_modality', e.target.value)
}
/>
</Field>
<Field
label="Impacto esperado"
error={errors.expected_impact}
>
<textarea
className={control}
value={String(data.expected_impact ?? '')}
onChange={(e) =>
set('expected_impact', e.target.value)
}
/>
</Field>
{(data.opportunity_category === 'investment_project' ||
(!data.opportunity_category &&
data.estimated_investment_amount != null)) && (
<>
<Field
label="Investimento estimado"
error={errors.estimated_investment_amount}
>
<input
className={control}
value={String(
data.estimated_investment_amount ?? '',
)}
onChange={(e) =>
set(
'estimated_investment_amount',
e.target.value,
)
}
/>
</Field>
<Field
label="Moeda"
error={errors.estimated_investment_currency}
>
<select
className={control}
value={String(
data.estimated_investment_currency ??
'',
)}
onChange={(e) =>
set(
'estimated_investment_currency',
e.target.value,
)
}
>
<option value="">Por indicar</option>
{['MZN', 'USD', 'EUR', 'ZAR'].map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
</>
)}
{data.opportunity_category &&
data.opportunity_category !== 'investment_project' && (
<p>
O módulo especializado desta categoria permanece
pendente. Pode guardar a identificação
incompleta; não será submetida nem publicada.
</p>
)}
</fieldset>
{!readOnly && (
<>
<Field
label="Motivo da alteração"
error={errors.reason}
>
<textarea
className={control}
value={reason}
onChange={(e) => setReason(e.target.value)}
minLength={20}
maxLength={1000}
/>
</Field>
{!unavailable && (
<button className={button} disabled={busy}>
Guardar rascunho
</button>
)}
{record && (
<>
<label className="block">
<input
type="checkbox"
checked={confirm}
onChange={(e) =>
setConfirm(e.target.checked)
}
/>{' '}
Confirmo a{' '}
{unavailable
? 'restauração'
: 'exclusão lógica/arquivo'}
, preservando histórico e relações
</label>
<button
type="button"
className={button}
disabled={busy || !confirm}
onClick={() =>
void send(
unavailable ? 'restore' : 'delete',
)
}
>
{unavailable
? 'Restaurar'
: closedVersion
? 'Arquivar'
: 'Excluir rascunho'}
</button>
</>
)}
</>
)}
</form>
{record && (
<Link href={`/management/opportunities/${record.id}/context`}>
Consultar projetos, clusters e relações existentes
</Link>
)}
<section aria-label="Histórico de gestão">
<h2>Histórico</h2>
{history.length ? (
history.map((e) => (
<p key={e.id}>
{e.action} · conta {e.actor_user_id} ·{' '}
{e.occurred_at}
</p>
))
) : (
<p>Sem eventos deste incremento.</p>
)}
</section>
</Management>
);
}
@@ -0,0 +1,84 @@
import { useForm } from '@inertiajs/react';
import { Management, button } from './shared';
export default function Install({
installed,
ready,
target,
hash,
changes,
migration,
backupDate,
rollback,
}: {
installed: boolean;
ready: boolean;
target: { database: string; port: number };
hash: string;
changes: string[];
migration: string;
backupDate: string | null;
rollback: string;
}) {
const form = useForm({ hash, confirm: false });
return (
<Management title="Ativação da Gestão de Oportunidades">
<p>
Base: {target.database}:{target.port} ·{' '}
{installed ? 'Instalado' : 'Ainda não aplicado'}
</p>
<ul>
{changes.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
<p>Migração: {migration}</p>
<p>Backup/restauro: {backupDate ?? 'Prova ainda por preparar'}</p>
<p>{rollback}</p>
<p>
Este plano ativa apenas o primeiro incremento; workflow
completo, imagens e módulos especializados permanecem no
backlog.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
form.post('/management/opportunity-management-install');
}}
>
<label>
<input
type="checkbox"
checked={form.data.confirm}
onChange={(e) =>
form.setData('confirm', e.target.checked)
}
/>{' '}
Confirmo a aplicação deste plano
</label>
<button
className={button}
disabled={
installed ||
!ready ||
!form.data.confirm ||
form.processing
}
>
Aplicar plano de gestão
</button>
{!ready && (
<p>
Aplicação indisponível até comprovar QA,
backup/restauro, compatibilidade e reversão da versão
atual.
</p>
)}
{Object.values(form.errors).map((e) => (
<p role="alert" key={e}>
{e}
</p>
))}
</form>
</Management>
);
}
+4 -1
View File
@@ -34,6 +34,9 @@ export const states: Record<string, string> = {
draft: 'Rascunho',
published: 'Publicado',
archived: 'Arquivado',
returned: 'Devolvida',
approved: 'Aprovada internamente',
suspended: 'Suspensa',
};
export function Management({
@@ -57,7 +60,7 @@ export function Management({
>
<Link href="/management/interests">Pedidos de interesse</Link>
<Link href="/management/sectors">Sectores</Link>
<Link href="/management/opportunities">Oportunidades</Link>
<Link href="/management/opportunities">Gestão de Oportunidades</Link>
<Link href="/management/opportunity-origin">
Revisão da origem das oportunidades
</Link>
+10
View File
@@ -22,6 +22,7 @@ use App\Http\Controllers\InvestmentStructureController;
use App\Http\Controllers\InvestmentStructureRelationController;
use App\Http\Controllers\InvestmentTerritoryController;
use App\Http\Controllers\InvestorWorkspaceController;
use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalServicesController;
@@ -104,6 +105,15 @@ Route::middleware(['auth', 'verified', 'can:manage-portal', AiEnabled::class])->
Route::get('/api/v1/investment/territory-options', [PublicGeographyController::class, 'options'])->middleware('throttle:api');
Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management')->name('management.')->group(function () {
Route::get('opportunity-management-install', [OpportunityManagementController::class, 'installation']);
Route::post('opportunity-management-install', [OpportunityManagementController::class, 'applyInstallation']);
Route::get('opportunities', [OpportunityManagementController::class, 'index']);
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::get('opportunities/{id}/view', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::post('opportunities/drafts', [OpportunityManagementController::class, 'save']);
Route::put('opportunities/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
Route::post('opportunities/{id}/lifecycle', [OpportunityManagementController::class, 'lifecycle'])->whereNumber('id');
Route::get('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'show'])->whereNumber('id');
Route::post('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'save'])->whereNumber('id');
Route::get('territory-options', [InvestmentTerritoryController::class, 'options'])->middleware('throttle:api');
+13 -10
View File
@@ -9,6 +9,7 @@ use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\OpportunityManagement;
use Database\Seeders\InvestmentAreaSeeder;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -74,9 +75,9 @@ class InvestmentTerritoryTest extends TestCase
$this->assertCount(2, $service->context($opportunity->id)['primary_territory_path']);
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/opportunities', ['sector_id' => $sector->id, 'reference' => 'SYNTHETIC-CREATE', 'slug' => 'synthetic-create', 'title' => 'Synthetic create',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
$this->assertDatabaseHas('opportunities', ['reference' => 'SYNTHETIC-CREATE', 'primary_territory_id' => $district, 'territory_version' => 1]);
$created = $this->actingAs($manager)->postJson('/management/opportunities/drafts', ['sector_id' => $sector->id, 'creation_key' => (string) Str::uuid(), 'reason' => 'Cadastro territorial de rascunho apenas em QA', 'title' => 'Synthetic create',
'estimated_investment_currency' => 'USD', 'province_id' => $province, 'district_id' => $district])->assertCreated()->json();
$this->assertDatabaseHas('opportunities', ['id' => $created['id'], 'primary_territory_id' => $district]);
$this->post('/management/clusters', ['code' => 'GEO-CREATE', 'name' => 'Synthetic cluster', 'sector' => 'Synthetic', 'status' => 'planned', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
$this->assertDatabaseHas('economic_clusters', ['code' => 'GEO-CREATE', 'primary_territory_id' => $district]);
$manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
@@ -283,11 +284,12 @@ class InvestmentTerritoryTest extends TestCase
$province = $this->location('province', $country);
$sector = Sector::create(['name' => 'Synthetic edit', 'slug' => 'synthetic-edit', 'is_active' => true]);
$record = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit', 'status' => 'draft']);
$this->actingAs($manager)->put('/management/opportunities/'.$record->id, [
$this->actingAs($manager)->putJson('/management/opportunities/'.$record->id.'/draft', [
'sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
'province_id' => $province, 'territory_version' => 0,
])->assertRedirect();
'etag' => app(OpportunityManagement::class)->etag($record->fresh()), 'reason' => 'Atualização territorial do rascunho em QA',
])->assertOk();
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'primary_territory_id' => $province]);
$this->assertSame($province, $record->fresh()->primaryTerritory->id);
$this->get('/management/opportunities/'.$record->id.'/edit')->assertOk();
@@ -299,9 +301,10 @@ class InvestmentTerritoryTest extends TestCase
$payload = ['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Must not persist',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
'province_id' => $province, 'district_id' => $district, 'territory_version' => 1];
$this->putJson('/management/opportunities/'.$record->id, $payload)->assertUnprocessable()->assertJsonValidationErrors('district_id');
$etag = app(OpportunityManagement::class)->etag($record->fresh());
$this->putJson('/management/opportunities/'.$record->id.'/draft', [...$payload, 'etag' => $etag, 'reason' => 'Validação de hierarquia no rascunho QA'])->assertUnprocessable()->assertJsonValidationErrors('district_id');
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'title' => 'Synthetic edit', 'primary_territory_id' => $province]);
$this->putJson('/management/opportunities/'.$record->id, [...$payload, 'district_id' => null, 'territory_version' => 0])->assertConflict();
$this->putJson('/management/opportunities/'.$record->id.'/draft', [...$payload, 'district_id' => null, 'etag' => str_repeat('0', 64), 'reason' => 'Teste de conflito de edição territorial QA'])->assertConflict();
}
public function test_database_rejects_a_cycle_wrong_parent_and_duplicate_source_code(): void
@@ -348,10 +351,10 @@ class InvestmentTerritoryTest extends TestCase
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->from('/management/opportunities/create')
->post('/management/opportunities', [])->assertSessionHasErrors('reference');
->post('/management/opportunities', [])->assertSessionHasErrors('status');
$this->withCookie(config('session.cookie'), session()->getId())->withCredentials();
$this->getJson('/management/territory-options?type=province')->assertOk()->assertSessionHasErrors('reference');
$this->get('/management/opportunities/create')->assertInertia(fn (AssertableInertia $page) => $page->has('errors.reference'));
$this->getJson('/management/territory-options?type=province')->assertOk()->assertSessionHasErrors('status');
$this->get('/management/opportunities/create')->assertInertia(fn (AssertableInertia $page) => $page->has('errors.status'));
}
public function test_investment_areas_are_idempotent_and_preserve_legacy_sectors(): void
@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Services\OpportunityManagement;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class OpportunityManagementTest extends TestCase
{
use RefreshDatabase;
public function test_installation_requires_management_permission_and_current_proofs(): void
{
$this->actingAs(User::factory()->create())->getJson('/management/opportunity-management-install')->assertForbidden();
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$migration = require database_path('migrations/2026_10_09_020000_extend_opportunity_management.php');
$migration->down();
$preview = $this->actingAs($manager)->getJson('/management/opportunity-management-install')->assertOk()->assertJsonPath('installed', false)->assertJsonPath('ready', false)->json();
$this->postJson('/management/opportunity-management-install', ['hash' => $preview['hash'], 'confirm' => true])->assertUnprocessable();
$this->assertFalse(app(OpportunityManagement::class)->installed());
}
public function test_incomplete_draft_recovers_and_soft_delete_restore_preserves_identity(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$payload = ['creation_key' => (string) Str::uuid(), 'reason' => 'Criação de rascunho incompleto em QA'];
$result = $this->actingAs($manager)->postJson('/management/opportunities/drafts', $payload)->assertCreated()->json();
$id = $result['id'];
$this->assertDatabaseHas('opportunities', ['id' => $id, 'title' => null, 'sector_id' => null, 'estimated_investment_currency' => null, 'status' => 'draft']);
$this->postJson('/management/opportunities/drafts', $payload)->assertCreated()->assertJsonPath('id', $id);
$this->assertSame(1, Opportunity::count());
$this->postJson('/management/opportunities/drafts', [...$payload, 'title' => 'Different data'])->assertUnprocessable();
$this->get('/management/opportunities/'.$id.'/edit')->assertOk();
$update = $this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $result['etag'], 'title' => 'QA incomplete saved title', 'reason' => 'Edição de identificação incompleta QA'])->assertOk()->json();
$this->putJson('/management/opportunities/'.$id.'/draft', ['etag' => $result['etag'], 'title' => 'Lost update', 'reason' => 'Tentativa concorrente para teste QA'])->assertConflict()->assertJsonValidationErrors('etag');
$this->assertDatabaseHas('opportunities', ['id' => $id, 'title' => 'QA incomplete saved title']);
$request = ['etag' => $update['etag'], 'action' => 'delete', 'reason' => 'Exclusão lógica autorizada apenas em QA', 'confirm' => true];
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'confirm' => false])->assertUnprocessable();
$this->postJson('/management/opportunities/'.$id.'/lifecycle', $request)->assertOk();
$this->assertSoftDeleted('opportunities', ['id' => $id]);
$this->get('/management/opportunities/'.$id.'/edit')->assertOk();
$record = Opportunity::withTrashed()->findOrFail($id);
$this->postJson('/management/opportunities/'.$id.'/lifecycle', [...$request, 'action' => 'restore', 'etag' => app(OpportunityManagement::class)->etag($record)])->assertOk();
$this->assertDatabaseHas('opportunities', ['id' => $id, 'status' => 'draft', 'deleted_at' => null]);
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-management')->count());
$this->actingAs(User::factory()->create())->get('/management/opportunities')->assertForbidden();
$this->postJson('/management/opportunities/drafts', $payload)->assertForbidden();
}
public function test_closed_version_edits_do_not_change_public_values_classification_or_relations(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$sector = Sector::create(['name' => 'QA sector', 'slug' => 'qa-management', 'is_active' => true]);
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'INV-MZ-QA-001', 'title' => 'Published QA original', 'promoter_type' => 'privado', 'slug' => 'qa-management', 'status' => 'published', 'published_at' => now()->subMinute()]);
$project = Project::factory()->create();
DB::table('opportunity_projects')->insert(['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
DB::table('audit_events')->insert(['source_system' => 'opportunity-origin-review', 'entity_type' => 'opportunity_origin', 'action' => 'origin_classification_decided', 'actor_user_id' => $manager->id, 'after_data' => json_encode(['opportunity_id' => $opportunity->id, 'classification' => 'real', 'reason' => 'QA classification retained'])]);
$service = app(OpportunityManagement::class);
$update = $this->actingAs($manager)->putJson('/management/opportunities/'.$opportunity->id.'/draft', ['etag' => $service->etag($opportunity->fresh()), 'promoter_type' => 'privado', 'title' => 'QA new unpublished version', 'reason' => 'Nova versão em rascunho para teste QA'])->assertOk()->json();
$this->assertSame('Published QA original', $opportunity->fresh()->title);
$this->assertSame('QA new unpublished version', json_decode($opportunity->fresh()->management_draft, true)['title']);
$this->getJson('/api/v1/opportunities/qa-management')->assertOk()->assertJsonMissingPath('data.management_draft')->assertJsonPath('data.title', 'Published QA original');
$this->postJson('/management/opportunities/'.$opportunity->id.'/lifecycle', ['etag' => $update['etag'], 'action' => 'delete', 'reason' => 'Arquivo explícito de versão submetida QA', 'confirm' => true])->assertOk();
$this->assertDatabaseHas('opportunities', ['id' => $opportunity->id, 'status' => 'archived', 'deleted_at' => null]);
$this->assertTrue((bool) $opportunity->fresh()->management_closed_once);
$this->assertNull($opportunity->fresh()->first_submitted_at);
$this->assertDatabaseHas('opportunity_projects', ['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
$this->assertSame(1, DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count());
$this->putJson('/management/opportunities/'.$opportunity->id, ['status' => 'published'])->assertUnprocessable();
$this->assertSame(0, Opportunity::published()->count());
$this->postJson('/management/opportunities/'.$opportunity->id.'/lifecycle', ['etag' => $service->etag($opportunity->fresh()), 'action' => 'restore', 'reason' => 'Reabertura autorizada com revisão somente QA', 'confirm' => true])->assertOk();
$this->assertSame('draft', $opportunity->fresh()->status);
$this->assertSame(0, Opportunity::published()->count());
try {
DB::transaction(fn () => DB::table('opportunities')->where('id', $opportunity->id)->delete());
$this->fail('Physical deletion after a closed version was accepted');
} catch (QueryException $error) {
$this->assertStringContainsString('Physical deletion forbidden', $error->getMessage());
}
$this->assertDatabaseHas('opportunity_projects', ['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
}
}
+6 -10
View File
@@ -6,6 +6,7 @@ use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
@@ -113,24 +114,19 @@ class PortalManagementTest extends TestCase
$this->get('/management/sectors/'.$sector->id.'/edit')->assertOk();
}
public function test_opportunities_can_be_published_and_withdrawn_without_losing_money_precision(): void
public function test_draft_money_precision_is_preserved_without_editorial_publication_bypass(): void
{
$this->actingAs($this->manager());
$data = ['sector_id' => $this->sector()->id, 'reference' => 'INV-TEST', 'title' => 'Solar',
'slug' => 'solar', 'status' => 'draft', 'is_featured' => true, 'maturity_level' => 4,
'estimated_investment_amount' => '1234567890123456.78', 'estimated_investment_currency' => 'MZN'];
$this->post('/management/opportunities', $data)->assertRedirect('/management/opportunities');
$this->postJson('/management/opportunities/drafts', [...$data, 'creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho financeiro incompleto em QA'])->assertCreated();
$opportunity = Opportunity::firstOrFail();
$this->getJson('/api/v1/opportunities/solar')->assertNotFound();
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertRedirect();
$this->getJson('/api/v1/opportunities/solar')->assertOk()
->assertJsonPath('data.estimated_investment_amount', '1234567890123456.78')
->assertJsonPath('data.maturity_label', 'Projecto estruturado');
$this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 1);
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'archived'])->assertRedirect();
$this->getJson('/api/v1/opportunities/solar')->assertNotFound();
$this->assertSame('1234567890123456.78', $opportunity->estimated_investment_amount);
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'status' => 'published'])->assertSessionHasErrors('status');
$this->getJson('/api/v1/home')->assertJsonPath('data.stats.opportunities', 0);
$this->put('/management/opportunities/'.$opportunity->id, [...$data, 'estimated_investment_amount' => '-1'])->assertSessionHasErrors('estimated_investment_amount');
$this->putJson('/management/opportunities/'.$opportunity->id.'/draft', [...$data, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'reason' => 'Correção financeira testada apenas em QA', 'estimated_investment_amount' => '-1'])->assertUnprocessable()->assertJsonValidationErrors('estimated_investment_amount');
}
public function test_review_tracks_the_authenticated_reviewer_and_rejects_invalid_states(): void
@@ -0,0 +1,15 @@
<?php
// Protected active reader only; no application kernel or migrations.
require __DIR__.'/investmoz-v2-territory-audit.php';
$dir = dirname(__DIR__).'/storage/app/private/investmoz-v2';
$pdo->exec('BEGIN TRANSACTION READ ONLY');
$identity = $pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC);
$installed = (bool) $pdo->query("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='opportunities' AND column_name='management_version')")->fetchColumn();
$migrationApplied = (int) $pdo->query("SELECT count(*) FROM migrations WHERE migration='2026_10_09_020000_extend_opportunity_management'")->fetchColumn();
$classifications = $pdo->query("SELECT DISTINCT ON (after_data->>'opportunity_id') after_data->>'opportunity_id' AS id,after_data->>'classification' AS classification FROM audit_events WHERE source_system='opportunity-origin-review' ORDER BY after_data->>'opportunity_id',occurred_at DESC,id DESC")->fetchAll(PDO::FETCH_ASSOC);
$auditCount = (int) $pdo->query("SELECT count(*) FROM audit_events WHERE source_system IN ('opportunity-management','opportunity-management-install')")->fetchColumn();
$pdo->exec('ROLLBACK');
$result = ['captured_at' => gmdate('c'), 'identity' => $identity, 'installed' => $installed, 'migration_applied' => $migrationApplied, 'classifications' => $classifications, 'management_events' => $auditCount, 'active_writes' => 0];
file_put_contents($dir.'/opportunity-management-active-read-only.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($result).PHP_EOL;
+25
View File
@@ -0,0 +1,25 @@
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('node:fs'),assert=require('node:assert/strict');
const base='http://127.0.0.1:18095',dir='storage/app/private/investmoz-v2/';
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
const errors=[];
async function session(viewport){const context=await browser.newContext({viewport});await context.route('**/*',r=>r.request().url().startsWith(base)||r.request().url().startsWith('data:')?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');await page.getByRole('button',{name:'Log in',exact:true}).click();await page.waitForURL('**/dashboard');return {context,page};}
const desktop=await session({width:1280,height:900}),page=desktop.page;
await page.getByRole('link',{name:'Gestão de Oportunidades',exact:true}).click();await page.getByRole('link',{name:'Nova oportunidade',exact:true}).click();
await page.getByLabel('Motivo da alteração').fill('Criação incompleta autorizada somente em QA');
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.waitForURL(/\/management\/opportunities\/\d+\/edit$/);
const id=Number(page.url().match(/(\d+)\/edit$/)[1]);assert(id>6);
await page.getByLabel('Título oficial').fill('QA browser opportunity recovered from server');await page.getByLabel('Categoria',{exact:true}).selectOption('investment_project');
await page.getByLabel('Investimento estimado').fill('-1');await page.getByLabel('Motivo da alteração').fill('Edição financeira inválida para verificar erros QA');
await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('The estimated investment amount field format is invalid.').waitFor().catch(async()=>{await page.locator('[role="alert"]').first().waitFor();});
assert.equal(await page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
await page.getByLabel('Investimento estimado').fill('1000.25');await page.getByLabel('Moeda',{exact:true}).selectOption('MZN');await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.getByText('Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.',{exact:true}).waitFor();
await page.screenshot({path:dir+'opportunity-management-desktop.png',fullPage:true});await desktop.context.close();
const mobile=await session({width:390,height:844});await mobile.page.goto(base+`/management/opportunities/${id}/edit`);
await mobile.page.evaluate(()=>localStorage.clear());await mobile.page.reload();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');assert.equal(await mobile.page.getByLabel('Investimento estimado').inputValue(),'1000.25');
await mobile.page.screenshot({path:dir+'opportunity-management-mobile.png',fullPage:true});assert(await mobile.page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
await mobile.page.getByLabel('Motivo da alteração').fill('Exclusão lógica do rascunho de demonstração QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a exclusão/}).check();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).click();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).waitFor();
await mobile.page.getByLabel('Motivo da alteração').fill('Restauração autorizada do rascunho somente QA');await mobile.page.getByRole('checkbox',{name:/Confirmo a restauração/}).check();await mobile.page.getByRole('button',{name:'Restaurar',exact:true}).click();await mobile.page.getByRole('button',{name:'Excluir rascunho',exact:true}).waitFor();assert.equal(await mobile.page.getByLabel('Título oficial').inputValue(),'QA browser opportunity recovered from server');
assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,field_errors:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-management-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();
})().catch(e=>{console.error(e);process.exit(1)});
+92
View File
@@ -0,0 +1,92 @@
<?php
use App\Models\User;
use App\Services\OpportunityManagementInstallation;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Vite;
$root = dirname(__DIR__);
$dir = $root.'/storage/app/private/investmoz-v2';
$backup = json_decode(file_get_contents($dir.'/opportunity-management-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$target = $backup['restored_target'];
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
throw new RuntimeException('QA target required');
}
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18095', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'opportunity_management_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
throw new RuntimeException('Refusing non-QA target');
}
config(['portal.managers' => ['opportunity-manager@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$sessions = $dir.'/qa-opportunity-management-sessions';
if (! is_dir($sessions)) {
mkdir($sessions, 0700, true);
}
config(['session.files' => $sessions]);
Vite::useHotFile($root.'/storage/framework/qa-opportunity-management-hot-absent');
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'prepare') {
$migration = require $root.'/database/migrations/'.OpportunityManagementInstallation::MIGRATION.'.php';
$migration->up();
$oldColumns = array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1'));
$oldColumns = array_diff($oldColumns, ['management_version', 'opportunity_category', 'management_draft', 'management_creation_key', 'first_submitted_at', 'management_closed_once']);
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldColumns));
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM opportunities) t ORDER BY row_to_json(t)::text');
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $backup['table_hashes']['"public"."opportunities"']) {
throw new RuntimeException('Existing opportunity values changed');
}
$migration->down();
foreach ($backup['table_hashes'] as $table => $hash) {
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$table.' t ORDER BY row_to_json(t)::text');
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $hash) {
throw new RuntimeException('Restore/rollback comparison failed: '.$table);
}
}
Artisan::call('migrate', ['--path' => 'database/migrations/'.OpportunityManagementInstallation::MIGRATION.'.php', '--force' => true]);
$user = User::updateOrCreate(['email' => 'opportunity-manager@example.invalid'], ['name' => 'QA opportunity manager', 'password' => Hash::make('Opportunity-QA-2026!')]);
$user->forceFill(['email_verified_at' => now()])->save();
$proof = ['target' => $target, 'code_hash' => app(OpportunityManagementInstallation::class)->fingerprint(), 'compatibility_verified' => true, 'reversal_verified' => true, 'tables_compared' => count($backup['table_hashes']), 'active_writes' => 0];
file_put_contents($dir.'/opportunity-management-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
exit;
}
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
$columns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1')), ['management_version', 'opportunity_category', 'management_draft', 'management_creation_key', 'first_submitted_at', 'management_closed_once']);
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $columns));
$checks = [];
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
$key = '"public"."'.$table.'"';
$query = $table === 'opportunities' ? '(SELECT '.$projection.' FROM opportunities WHERE management_creation_key IS NULL)' : ($table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-management')" : $key);
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$query.' t ORDER BY row_to_json(t)::text');
$checks[$table] = hash('sha256', implode("\n", array_column($rows, 'row'))) === $backup['table_hashes'][$key];
if (! $checks[$table]) {
throw new RuntimeException('Baseline data changed: '.$table);
}
}
$browser = json_decode(file_get_contents($dir.'/opportunity-management-browser.json'), true, 512, JSON_THROW_ON_ERROR);
$draft = DB::table('opportunities')->where('id', $browser['id'])->first();
if ($draft->status !== 'draft' || $draft->deleted_at !== null) {
throw new RuntimeException('Browser cycle not restored');
}
$proof = ['target' => $target, 'code_hash' => app(OpportunityManagementInstallation::class)->fingerprint(), 'preserved' => $checks, 'browser_draft_id' => $draft->id, 'browser_draft_restored' => true, 'origin_classifications' => DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count(), 'active_writes' => 0];
file_put_contents($dir.'/opportunity-management-preservation.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
exit;
}
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
$asset = realpath($root.'/public'.$path);
$public = realpath($root.'/public');
if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset)) {
return false;
}
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
$app->handleRequest(Request::capture());