From 7ecfe3d543ab7971425a987bcd33afc5ea217a36 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 9 Oct 2026 08:16:51 +0200 Subject: [PATCH] feat: integrate opportunity draft management with audited lifecycle --- .../OpportunityManagementController.php | 94 ++++ .../PortalManagementController.php | 4 + app/Models/Opportunity.php | 9 + app/Services/OpportunityManagement.php | 166 ++++++ .../OpportunityManagementInstallation.php | 54 ++ ...9_020000_extend_opportunity_management.php | 28 + docs/investmoz/v2/backlog-mestre.md | 10 + .../v2/gestao-oportunidades-dashboard.md | 56 ++ resources/js/pages/dashboard.tsx | 6 +- .../js/pages/management/opportunities.tsx | 203 ++++++++ .../js/pages/management/opportunity-draft.tsx | 488 ++++++++++++++++++ .../pages/management/opportunity-install.tsx | 84 +++ resources/js/pages/management/shared.tsx | 5 +- routes/web.php | 10 + tests/Feature/InvestmentTerritoryTest.php | 23 +- tests/Feature/OpportunityManagementTest.php | 93 ++++ tests/Feature/PortalManagementTest.php | 16 +- .../opportunity-management-active-preview.php | 15 + tools/opportunity-management-browser.cjs | 25 + tools/opportunity-management-qa.php | 92 ++++ 20 files changed, 1459 insertions(+), 22 deletions(-) create mode 100644 app/Http/Controllers/OpportunityManagementController.php create mode 100644 app/Services/OpportunityManagement.php create mode 100644 app/Services/OpportunityManagementInstallation.php create mode 100644 database/migrations/2026_10_09_020000_extend_opportunity_management.php create mode 100644 docs/investmoz/v2/gestao-oportunidades-dashboard.md create mode 100644 resources/js/pages/management/opportunities.tsx create mode 100644 resources/js/pages/management/opportunity-draft.tsx create mode 100644 resources/js/pages/management/opportunity-install.tsx create mode 100644 tests/Feature/OpportunityManagementTest.php create mode 100644 tools/opportunity-management-active-preview.php create mode 100644 tools/opportunity-management-browser.cjs create mode 100644 tools/opportunity-management-qa.php diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php new file mode 100644 index 00000000..22f97b5b --- /dev/null +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -0,0 +1,94 @@ +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.']); + } +} diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php index f4f00ad9..a1e23b21 100644 --- a/app/Http/Controllers/PortalManagementController.php +++ b/app/Http/Controllers/PortalManagementController.php @@ -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' ? [ diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index d7552634..828bcc77 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -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', diff --git a/app/Services/OpportunityManagement.php b/app/Services/OpportunityManagement.php new file mode 100644 index 00000000..caf007e2 --- /dev/null +++ b/app/Services/OpportunityManagement.php @@ -0,0 +1,166 @@ +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 $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 $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 $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)]); + } +} diff --git a/app/Services/OpportunityManagementInstallation.php b/app/Services/OpportunityManagementInstallation.php new file mode 100644 index 00000000..ca73ad9f --- /dev/null +++ b/app/Services/OpportunityManagementInstallation.php @@ -0,0 +1,54 @@ + hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR)); + } + + /** @return array */ + 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)]); + }); + } +} diff --git a/database/migrations/2026_10_09_020000_extend_opportunity_management.php b/database/migrations/2026_10_09_020000_extend_opportunity_management.php new file mode 100644 index 00000000..fe3c4e91 --- /dev/null +++ b/database/migrations/2026_10_09_020000_extend_opportunity_management.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md index abe3ced4..fe3cc4ca 100644 --- a/docs/investmoz/v2/backlog-mestre.md +++ b/docs/investmoz/v2/backlog-mestre.md @@ -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. diff --git a/docs/investmoz/v2/gestao-oportunidades-dashboard.md b/docs/investmoz/v2/gestao-oportunidades-dashboard.md new file mode 100644 index 00000000..14679bc4 --- /dev/null +++ b/docs/investmoz/v2/gestao-oportunidades-dashboard.md @@ -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. diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 6ee1ac68..6249bdc9 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -52,7 +52,11 @@ export default function Dashboard({ summary }: { summary: Summary | null }) {
-

Catálogo

+

+ + Gestão de Oportunidades + +

{[ { diff --git a/resources/js/pages/management/opportunities.tsx b/resources/js/pages/management/opportunities.tsx new file mode 100644 index 00000000..5641fa28 --- /dev/null +++ b/resources/js/pages/management/opportunities.tsx @@ -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; + filters: { + q?: string; + status?: string; + category?: string; + deleted?: boolean; + sector_id?: string; + geography_scope?: string; + }; + counts: Record; + 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 ( + + + Nova oportunidade + +

+ {geographyReviewCount} registos com âmbito por indicar; + referências existentes permanecem aceites. +

+
+ {[ + 'draft', + 'submitted', + 'returned', + 'approved', + 'published', + 'suspended', + 'archived', + ].map((s) => ( + + {states[s] ?? s}: {counts[s] ?? 0} + + ))} +
+
{ + e.preventDefault(); + form.get('/management/opportunities'); + }} + > + form.setData('q', e.target.value)} + /> + + + + + + +
+
+ {records.data.map((r) => ( +
+

+ {r.title ?? 'Rascunho sem título'} +

+

+ ID {r.id} · {r.reference} ·{' '} + {r.opportunity_category ?? 'Categoria por indicar'}{' '} + ·{' '} + {r.deleted_at + ? 'Eliminado logicamente' + : (states[r.status] ?? r.status)} +

+

Atualizado: {r.updated_at}

+
+ + Ver + + + {r.deleted_at || r.status === 'archived' + ? 'Restaurar' + : r.status === 'draft' + ? 'Continuar preenchimento' + : 'Editar nova versão'} + +
+
+ ))} +
+ {!records.data.length &&

Nenhum registo encontrado.

} + +
+ ); +} diff --git a/resources/js/pages/management/opportunity-draft.tsx b/resources/js/pages/management/opportunity-draft.tsx new file mode 100644 index 00000000..ae4907c3 --- /dev/null +++ b/resources/js/pages/management/opportunity-draft.tsx @@ -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; +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, + creation_key: crypto.randomUUID(), + }); + const [busy, setBusy] = useState(false), + [errors, setErrors] = useState>({}), + [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 ( + + Voltar à gestão +

+ Classificação de origem: {classification}. Esta classificação + não cria associações territoriais. +

+ {closedVersion && ( +

+ 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. +

+ )} +
+ {message} + {errors.etag && ( +

+ {errors.etag}{' '} + + Abrir versão atual + +

+ )} +
+
{ + e.preventDefault(); + void send('save'); + }} + > +
+
+

+ Hierarquia do cadastro territorial existente. + Alterar esta seleção não cria relações com + projetos/clusters. +

+ {( + [ + 'province_id', + 'district_id', + 'admin_post_id', + 'locality_id', + ] as const + ).map((key, index) => ( + { + 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 ?? '', + })); + }} + /> + ))} +
+ + + + {Object.entries({ + title: 'Título oficial', + summary: 'Resumo', + description: 'Descrição', + promoter_name: 'Promotor', + }).map(([key, label]) => ( + + {key === 'description' || key === 'summary' ? ( +