Add audited opportunity origin review and QA separation

This commit is contained in:
2026-10-09 02:46:36 +02:00
parent 6c7a9c9442
commit 344545430b
18 changed files with 1266 additions and 1 deletions
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityOriginReview;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityOriginReviewController extends Controller
{
public function index(OpportunityOriginReview $review): Response
{
return Inertia::render('management/opportunity-origin', ['records' => Opportunity::whereIn('reference', ['INV-MZ-AGR-001', 'INV-MZ-ENE-002', 'INV-MZ-TUR-003', 'INV-MZ-INF-004', 'INV-MZ-BLUE-005', 'INV-MZ-FIN-006'])->orderBy('id')->pluck('id')->map(fn ($id) => $review->state($id))->all(), 'ready' => $review->ready()]);
}
public function preview(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000']]);
return response()->json($review->preview($data['id'], $data['classification'], $data['reason']));
}
public function apply(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000'], 'snapshot_hash' => ['required', 'string', 'size:64'], 'confirm' => ['required', 'accepted']]);
return response()->json($review->apply($data['id'], $data['classification'], $data['reason'], $data['snapshot_hash'], $request->user()->id));
}
}
+2 -1
View File
@@ -58,6 +58,7 @@ class Opportunity extends Model
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now());
return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now())
->whereRaw("COALESCE((SELECT origin.after_data->>'classification' FROM audit_events origin WHERE origin.source_system='opportunity-origin-review' AND origin.after_data->>'opportunity_id'=opportunities.id::text ORDER BY origin.occurred_at DESC,origin.id DESC LIMIT 1),'undetermined') <> 'example'");
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
class OpportunityOriginReview
{
public function fingerprint(): string
{
return hash('sha256', implode('', array_map(fn ($file) => hash_file('sha256', base_path($file)), ['app/Services/OpportunityOriginReview.php', 'app/Models/Opportunity.php', 'app/Http/Controllers/OpportunityOriginReviewController.php', 'resources/js/pages/management/opportunity-origin.tsx'])));
}
/** @return array<string,mixed> */
public function state(int $id): array
{
$record = Opportunity::findOrFail($id);
$events = DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->get();
$history = $events->map(function ($event) {
$data = json_decode($event->after_data, true);
return ['id' => $event->id, 'classification' => $data['classification'], 'reason' => $data['reason'], 'author' => $event->actor_user_id, 'date' => $event->occurred_at];
})->all();
return ['id' => $id, 'reference' => $record->reference, 'title' => $record->title, 'description' => $record->description, 'classification' => $history[0]['classification'] ?? 'undetermined', 'history' => $history,
'snapshot_hash' => hash('sha256', json_encode([$record->getRawOriginal(), $history[0]['id'] ?? null], JSON_THROW_ON_ERROR))];
}
/** @return array<string,mixed> */
public function preview(int $id, string $classification, string $reason): array
{
abort_unless(in_array($classification, ['real', 'example', 'undetermined'], true) && trim($reason) !== '', 422, 'Classificação e fundamento são necessários.');
return ['current' => $this->state($id), 'classification' => $classification, 'reason' => trim($reason), 'effect' => $classification === 'example' ? 'Separar do catálogo público, API, pesquisa e mapa; consulta autenticada de demonstração preservada.' : 'Elegível para os filtros públicos editoriais existentes; indeterminado não comprova realidade.', 'relationships_changed' => false, 'ready' => $this->ready()];
}
public function ready(): bool
{
$path = config('opportunity_origin.proof_path', storage_path('app/private/investmoz-v2/opportunity-origin-proof.json'));
if (! is_file($path)) {
return false;
}
$proof = json_decode(file_get_contents($path) ?: '{}', true);
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
return ($proof['restore_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['code_hash'] ?? '') === $this->fingerprint()
&& ($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);
}
/** @return array<string,mixed> */
public function apply(int $id, string $classification, string $reason, string $snapshotHash, int $actor): array
{
$preview = $this->preview($id, $classification, $reason);
abort_unless($preview['ready'], 422, 'Prova atual de backup/restauro e reversão necessária.');
if (app()->environment('testing')) {
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base isolada.');
}
return DB::transaction(function () use ($id, $classification, $reason, $snapshotHash, $actor) {
DB::select('SELECT pg_advisory_xact_lock(91026, ?)', [$id]);
DB::table('opportunities')->where('id', $id)->lockForUpdate()->first();
$current = $this->state($id);
if ($current['classification'] === $classification && ($current['history'][0]['reason'] ?? null) === trim($reason)) {
return ['replayed' => true, 'changed' => 0];
}
abort_unless(hash_equals($current['snapshot_hash'], $snapshotHash), 409, 'Registo/decisão alterado; reveja a pré-visualização.');
DB::table('audit_events')->insert(['source_system' => 'opportunity-origin-review', 'entity_type' => 'opportunity_origin', 'action' => 'origin_classification_decided', 'actor_user_id' => $actor, 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'),
'before_data' => json_encode(['classification' => $current['classification']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['opportunity_id' => $id, 'classification' => $classification, 'reason' => trim($reason), 'reference' => $current['reference'], 'relationships_changed' => false], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'changed' => 1];
});
}
}
@@ -66,3 +66,11 @@ Foi corrigida uma fragilidade de interação: o mesmo botão mudava de aplicar p
Os três testes afetados passaram, com 30 asserções, incluindo recusa de reversão sem motivo/confirmação e conservação do motivo no histórico. TypeScript, formatação/lint, PHPStan e build passaram. As pendências de rastreabilidade, jurídicas específicas, cobertura documental, ClamD e primeiro pedido real permanecem separadas. **Não houve reaplicação automática na base ativa.**
O navegador QA confirmou pré-visualização, aplicação, recuperação e reversão após preencher motivo e confirmação; o botão de reversão estava inicialmente desativado. Sem erros de JavaScript. Leitura ativa final confirmou todas as guardas atuais do novo plano e apenas os dois eventos históricos, sendo o último a reversão.
## Confirmação da aplicação do novo plano
Leitura `READ ONLY` em 09/10/2026 às 02:18:17 locais: o novo plano foi aplicado às **02:16:24**, pela conta **13**. Evento `traceability_applied`, ID `62ca4e39-93b3-4de1-b575-b15bc026d2e4`, corresponde ao hash `ec11b9e400b7b127fbc8d42a2afe5e4d2c8a3919216a5d7439fb38b0751c57c6`, com 112 entradas e 13 alterações. Não há reversão deste novo plano nesta leitura.
As 13 referências persistidas correspondem exatamente à proposta e ao evento de auditoria; versões passaram de 3 para 4. Validações e tipos das associações foram preservados. A comparação dos estados existentes nas guardas confirma preservação, incluindo as 14 zonas `validated` e os seis estados legais `pending` preexistentes. **Estado atual: novo incremento aplicado na base ativa pelo fluxo autenticado.** Histórico da aplicação/reversão anterior permanece intacto.
Evidência privada: `territory-new-plan-confirmation.json`, com verificação por associação. Esta confirmação não escreveu na base ativa nem repetiu a aplicação. Pendências documentais específicas, cobertura do inventário, ClamD e primeiro pedido real mantêm os seus estados independentes.
+26
View File
@@ -171,3 +171,29 @@ Leitura `READ ONLY` em 09/10/2026 às 02:10:09 locais: auditoria confirma aplica
### Novo plano após reversão — 9 de outubro
O motivo humano não consta do evento original e não foi inventado. Corrigida a possibilidade de reversão imediata pelo botão que mudava após aplicação: exige agora motivo e confirmação expressa, conservados na auditoria; planos revertidos ficam desativados. Novo plano preparado com as versões 3 atuais das 13 associações, mantendo 112 entradas e validações existentes. Backup/restauro atualizado: 288 tabelas e cinco ficheiros em `desk_restore_20261009_001345_test:55441`; aplicação/idempotência/reversão verificadas na cópia. Três testes afetados/30 asserções, TypeScript, lint, PHPStan e build passaram. [Pré-visualização e prova nova](ativacao-rastreabilidade-territorial.md): válida até 10/10 às 02:13:54 locais. **Não reaplicado na base ativa; ação autenticada permanece por executar.**
### Aplicação do novo plano confirmada
Leitura `READ ONLY` às 02:18:17 locais de 09/10/2026: **V2-08/V2-13 aplicado na base ativa**, pelo utilizador 13 às 02:16:24, evento `62ca4e39-93b3-4de1-b575-b15bc026d2e4`. As 13 alterações correspondem à proposta e auditoria, com versões 4; 112 entradas registadas. Validações e tipos de associação, 14 zonas validadas e seis estados legais pendentes preexistentes preservados. Sem reversão do novo plano nesta leitura. Evidência privada: `territory-new-plan-confirmation.json`. Nenhuma escrita durante a confirmação; restantes pendências independentes não foram concluídas por esta aplicação.
### Associações solicitadas pelo proprietário — estado global
Comparação `READ ONLY` de todas as linhas do plano original: **28/28 associações projeto–território e 13/13 associações cluster–projeto presentes na base ativa**, sem pares ausentes. Evidência por associação: `owner-associations-confirmation.json`. As referências V2 complementam relações já aplicadas; não há necessidade de nova fonte nem de duplicar estas associações. A comunicação anterior sobre «nenhuma nova localização nesta aplicação» fica esclarecida pelo estado global acima. Correspondências das seis oportunidades genéricas e conflitos de identidade dos componentes preparados continuam questões concretas de ligação entre registos, não de credibilidade das fontes oficiais.
### Correspondências das seis oportunidades — revisão individual
[Ficha por ID, título, descrição, opções e trechos](oportunidades-correspondencias.md). Foram pesquisadas as extrações disponíveis de 35 documentos e 13 433 parágrafos do mestre relido, incluindo exemplos de cadastro/código. Não foi recuperada identificação inequívoca que ligue os seis registos existentes às âncoras/clusters; citações de âmbito/atividade são apresentadas como opções, sem inferência de identidade. Isto não reabre a aceitação oficial e não declara revisão integral da cobertura documental.
Plano único privado `opportunity-association-plan.json`: zero inserções e zero eliminações enquanto os pares permanecerem indeterminados. QA `desk_restore_20261009_001345_test:55441` confirmou os seis registos e relações existentes, sem aplicar opções nem criar duplicados; evidência `opportunity-association-qa.json`. Os pares decididos integrarão o mesmo plano, com preparação QA, deduplicação, backup/restauro, reversão e aplicação autenticada. As 28 localizações e 13 relações já aplicadas são preservadas; nenhuma escrita ativa nesta revisão.
### Origem e realidade das seis oportunidades
[Evidência, perguntas ao responsável e plano de separação](origem-oportunidades-e-separacao.md). Os seis registos coincidem nos oito campos comparados com `OpportunitySeeder.php`; o seeder define publicação, mas não comprova realidade do investimento. A cadeia atual de `DatabaseSeeder` não chama esse seeder. Auditoria disponível: nove edições posteriores, sem prova de execução/criação histórica nem designação do responsável. Real/exemplo/indeterminado e pertença/serviço permanecem decisões individuais, não inferências pelo setor ou pela origem de código.
Plano condicional de exemplos: classificação explícita pela auditoria existente, rótulo e consulta autenticada separada do catálogo/API/mapa públicos, preservando IDs e relações/interações/histórico. **Implementado e validado em QA; nenhuma decisão aplicada na base ativa**. Nenhum contacto externo enviado. As fontes oficiais já aceites não foram revalidadas. Evidência privada: `opportunity-origin-confirmation.json`.
### V2-06/V2-08/V2-13 — revisão autenticada da origem
[Relatório e procedimento](revisao-origem-oportunidades.md). Página `/management/opportunity-origin`: seis itens, decisões real/exemplo/indeterminado, fundamento obrigatório, autor/data e pré-visualização antes de confirmação individual. Nenhuma classificação inferida por ausência de prova ou coincidência com seeder; associações a projetos/clusters permanecem separadas.
**QA:** 58 testes/917 asserções passaram; percurso no navegador com separação/reversão, API/mapa e consulta autorizada. Backup/restauro de 288 tabelas/cinco ficheiros; comparação de dez tabelas após ensaio demonstrou preservação. **Ativo:** leitura com zero decisões de classificação; sem escritas nesta entrega. Prova preparada válida até 10/10/2026 02:38:49 locais, aplicação exclusivamente autenticada após pré-visualização. Cobertura documental, questões jurídicas, ClamD e primeiro pedido real mantêm estados próprios.
@@ -0,0 +1,116 @@
# Oportunidades — correspondências e plano único
Leitura protegida: 2026-10-09T00:32:17+00:00, investemoz:5432. Consulta das extrações de 35 documentos e 13433 parágrafos do mestre relido; não é revisão integral dos ficheiros do inventário. A aceitação das fontes oficiais não foi reaberta.
**Resultado:** zero ligações inequívocas recuperadas para as seis oportunidades existentes. As opções abaixo são alternativas para decisão, não correspondências confirmadas. O trecho documenta o projeto/cluster candidato; não identifica automaticamente a oportunidade como seu componente.
As referências de programação e os exemplos de código descrevem registos e funções. Não são reinterpretados como identidade institucional, promotor ou relação oficial. As correspondências continuam pendências específicas, independentes da cobertura do inventário, questões jurídicas, ClamD e primeiro pedido real.
## Origem e perguntas ao responsável
Ver [a conferência do seeder, a auditoria disponível e o plano condicional de separação](origem-oportunidades-e-separacao.md). Os registos coincidem com dados de código; isto não comprova investimentos reais nem autoriza classificá-los automaticamente como exemplos. Antes de decidir os pares, esclarecer projeto/polo dos IDs 1–3, localização/corredor do ID 4, integração em A12 ou independência do ID 5 e pertença versus serviço financeiro do ID 6. Nenhum contacto foi enviado e nenhuma decisão foi inventada.
## ID 1 — Unidade de processamento agrícola em Nampula
Referência: `INV-MZ-AGR-001`. Descrição atual: 120 empregos directos e substituição de importações.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **A03 — Programa de Desenvolvimento Integrado do Corredor de Nacala**, ID `10a6e456-b287-4cf7-9ce6-ccd4e28a0684`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `39f5973c-f4cd-49d0-aeea-1c0c71783550`, campo `description`. Trecho: «Programa integrado que combina porto, ferrovia, indústria, agricultura, energia, água e serviços para transformar o Norte num corredor produtivo e exportador.». **Não prova a relação com a oportunidade 1.**
- **CLU-01 — Agro-indústria e segurança alimentar**, ID `2b37d84a-0b71-4b87-b577-6f550954c908`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `ca6a1427-0e2b-406e-94bd-da23630d5ec4`, campo `anchors`. Trecho: «A06, A14 e A15; arroz, regadio, processamento, armazenamento, logística e comercialização.». **Não prova a relação com a oportunidade 1.**
**Dúvida concreta:** Qual é a atividade/cultura e o código do componente desta unidade? Integra expressamente o Corredor de Nacala ou apenas o âmbito de processamento do CLU-01? A06 refere a cadeia do arroz no Centro, não identifica esta unidade.
**Identificação no acervo revisto:** Reformulacao do portal do investidor 1.docx, parágrafo extraído 320: «INV-MZ-AGR-001]»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 869: «INV-MZ-AGR-001", "title": "Unidade de processamento agrícola", "sector": "Agronegócio", "province": "Nampula", "maturity": "Projecto estruturado", "estimatedInvestment": { "amount": 2500000, "currency": "USD" }, "actions": ["Ver ficha", "Ma»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 993: «Unidade de processamento agrícola em Nampula', 'province' => 'Nampula', 'district' => 'Nampula', 'amount' => 2500000, 'modality' => 'Capital ou joint venture', 'maturity' => 4], ['sector' => 'Energia e Mineração', 'title' => 'Par». Ocorrência de exemplo/código não constitui ligação a uma âncora.
## ID 2 — Mini-rede solar para polo produtivo
Referência: `INV-MZ-ENE-002`. Descrição atual: Acesso a energia para 40 unidades produtivas.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **A08 — Zona Económica Especial de Mocuba**, ID `1169d03c-fe38-47c5-a54c-286a3f126ce4`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `81d775e5-5dff-4fa2-98b1-72affb0a1082`, campo `description`. Trecho: «Ecossistema industrial e agro-logístico para transformar a Zambézia num pólo de processamento, inovação, energia e exportação.». **Não prova a relação com a oportunidade 2.**
- **CLU-02 — Energia e industrialização**, ID `e19235e0-0a36-4e65-8a61-07f1d52f07d1`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `3cd11616-9099-477c-bf3e-09cddca74053`, campo `gaps`. Trecho: «Subestações, geração complementar, manutenção, equipamentos, utilities, competências e contratos de compra.». **Não prova a relação com a oportunidade 2.**
**Dúvida concreta:** Qual é o polo produtivo e o código da mini-rede que abastece as 40 unidades? A fonte não identifica se faz parte de Mocuba, de outro parque ou de um projeto independente; geração solar não é identidade com A10 hidroelétrica.
**Identificação no acervo revisto:** Reformulacao do portal do investidor 1.docx, parágrafo extraído 3091: «INV-MZ-ENE-002',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 3092: «Mini-rede solar para polo produtivo',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 3101: «inv-mz-ene-002',». Ocorrência de exemplo/código não constitui ligação a uma âncora.
## ID 3 — Eco-lodge costeiro com integração comunitária
Referência: `INV-MZ-TUR-003`. Descrição atual: Emprego local e conservação da faixa costeira.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **A13 — Vilanculos – Região Integrada de Turismo**, ID `819b8981-55e3-471a-b95d-7e9047c3dcdc`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `bb15487f-1617-49e6-86b7-d617812b658b`, campo `description`. Trecho: «Destino turístico costeiro integrado que combina hotelaria, acessibilidade, energia, água, formação, economia azul e serviços digitais.». **Não prova a relação com a oportunidade 3.**
- **CLU-04 — Turismo costeiro e economia azul**, ID `379c7d07-c767-4ab5-80db-3211f29eaf9e`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `5f0ff662-dc74-41de-adcd-de784a595321`, campo `gaps`. Trecho: «Alojamento, formação, mobilidade, água, energia, serviços digitais, conservação e experiências locais.». **Não prova a relação com a oportunidade 3.**
**Dúvida concreta:** Qual é o nome/local exato do empreendimento e o componente documental? Está integrado em Vilanculos–Inhassoro–Bazaruto ou é um empreendimento costeiro independente? Inhambane e eco-lodge não demonstram essa integração.
**Identificação no acervo revisto:** Reformulacao do portal do investidor 1.docx, parágrafo extraído 4334: «INV-MZ-TUR-003',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 4335: «Eco-lodge costeiro com integração comunitária',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 9284: «INV-MZ-TUR-003',». Ocorrência de exemplo/código não constitui ligação a uma âncora.
## ID 4 — Centro logístico provincial
Referência: `INV-MZ-INF-004`. Descrição atual: Redução de custos logísticos regionais.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **A02 — Linha Ferroviária Norte–Sul**, ID `573bf0d6-8ed0-4167-9942-50e0775bf109`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `1125f069-61e2-4071-99f3-dd103caf3156`, campo `description`. Trecho: «Ligação ferroviária contínua entre Norte, Centro e Sul para reduzir custos logísticos, integrar mercados e reforçar Moçambique como plataforma regional.». **Não prova a relação com a oportunidade 4.**
- **A03 — Programa de Desenvolvimento Integrado do Corredor de Nacala**, ID `10a6e456-b287-4cf7-9ce6-ccd4e28a0684`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `39f5973c-f4cd-49d0-aeea-1c0c71783550`, campo `description`. Trecho: «Programa integrado que combina porto, ferrovia, indústria, agricultura, energia, água e serviços para transformar o Norte num corredor produtivo e exportador.». **Não prova a relação com a oportunidade 4.**
- **A11 — Corredor Ferroviário e Portuário de Macuse–Moatize**, ID `9c2398a0-e3ae-4225-b198-2eec9674a37f`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `72c13e83-e8dd-465d-99a9-eb0ccaa4fd0b`, campo `description`. Trecho: «Corredor ferroviário e porto de águas profundas para ligar Tete à costa, diversificar rotas e aumentar a capacidade exportadora.». **Não prova a relação com a oportunidade 4.**
- **CLU-03 — Logística, corredores e exportação**, ID `6c7da2d1-acd0-42aa-af39-9397fae9f557`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `ec9494a2-22cc-423e-8a9e-efa1e05ac2b4`, campo `gaps`. Trecho: «Última milha, intermodalidade, terminais, plataformas logísticas, digitalização, fronteiras e carga contratual.». **Não prova a relação com a oportunidade 4.**
**Dúvida concreta:** Qual é o terminal/plataforma, o corredor servido e o código contratual ou documental? A redução de custos logísticos não determina se integra Norte–Sul, Nacala, Macuse–Moatize ou nenhum deles; Sofala não decide a ligação.
**Identificação no acervo revisto:** Reformulacao do portal do investidor 1.docx, parágrafo extraído 993: «Centro logístico provincial', 'province' => 'Sofala', 'district' => 'Beira', 'amount' => 8500000, 'modality' => 'Concessão ou PPP', 'maturity' => 4], ]; foreach ($items as $item) { $sector = Sector::where('name', $i»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 9306: «INV-MZ-INF-004',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 9307: «Centro logístico provincial',». Ocorrência de exemplo/código não constitui ligação a uma âncora.
## ID 5 — Processamento de pescado e cadeia fria
Referência: `INV-MZ-BLUE-005`. Descrição atual: Redução de perdas pós-captura na pesca artesanal.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **A12 — Programa de Conectividade e Resiliência Costeira (Compacto II)**, ID `59f9d973-4974-4d54-8f25-2af5188801df`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6, dossier `a023d6b2-1fa2-4fe7-9709-f93ae1f10e99`, campo `description`. Trecho: «Programa integrado para melhorar conectividade rural, investimento agrícola e resiliência de comunidades e ecossistemas costeiros da Zambézia.». **Não prova a relação com a oportunidade 5.**
- **CLU-04 — Turismo costeiro e economia azul**, ID `379c7d07-c767-4ab5-80db-3211f29eaf9e`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `5f0ff662-dc74-41de-adcd-de784a595321`, campo `anchors`. Trecho: «A04 e A13; Crusse Jamali, Vilanculos, Bazaruto, hotelaria, marinas e aviação.». **Não prova a relação com a oportunidade 5.**
**Dúvida concreta:** Qual é a unidade de pescado e o componente do programa ao qual pertence? A12 identifica resiliência costeira, mas não esta unidade de processamento. CLU-04 identifica turismo/marinas, não demonstra cadeia fria pesqueira; pode não haver âncora/cluster correspondente no cadastro atual.
**Identificação no acervo revisto:** Reformulacao do portal do investidor 1.docx, parágrafo extraído 9325: «INV-MZ-BLUE-005',»; Reformulacao do portal do investidor 1.docx, parágrafo extraído 9326: «Processamento de pescado e cadeia fria',»; Reformulacao do portal do investidor v2.docx, parágrafo extraído 9475: «INV-MZ-BLUE-005',». Ocorrência de exemplo/código não constitui ligação a uma âncora.
## ID 6 — Plataforma de crédito para PME agrícolas
Referência: `INV-MZ-FIN-006`. Descrição atual: Acesso a financiamento para 300+ pequenas empresas.
Relações já cadastradas: 0 projetos, 0 clusters.
### Opções documentadas, sem associação automática
- **CLU-08 — Financiamento do investimento**, ID `37fd560d-74ad-416d-8ad4-15db3a4739ba`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `ed2ea4e4-fca7-4cc8-bb8c-f99a1dfda73e`, campo `anchors`. Trecho: «Bancos, DFI, fundos climáticos, garantias, venture capital, crédito sectorial e assistência técnica.». **Não prova a relação com a oportunidade 6.**
- **CLU-07 — Tecnologia e serviços digitais**, ID `7f224d3a-3c82-42b1-bd18-ad9ab8afac1e`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `523d2aa1-4c7d-4a84-97fc-220f4e845fd7`, campo `anchors`. Trecho: «Fintech, govtech, centros de dados, incubação, formação e serviços empresariais.». **Não prova a relação com a oportunidade 6.**
- **CLU-01 — Agro-indústria e segurança alimentar**, ID `2b37d84a-0b71-4b87-b577-6f550954c908`. Fonte: upgrade do portal de investimentos v4 programacao.docx — 4.5.2, dossier `ca6a1427-0e2b-406e-94bd-da23630d5ec4`, campo `gaps`. Trecho: «Irrigação, sementes, mecanização, frio, silos, embalagem, certificação, financiamento e compradores.». **Não prova a relação com a oportunidade 6.**
**Dúvida concreta:** Qual é o operador/instrumento e a relação assumida: financiamento transversal CLU-08, serviço fintech CLU-07 ou financiamento da cadeia CLU-01? Qual documento identifica esta plataforma e as 300+ empresas? Não atribuir todos os clusters automaticamente.
**Identificação no acervo revisto:** Não foi encontrada menção exata da referência/título/descrição nas extrações revistas. Não implica invalidade da fonte ou do registo.
## Plano único de novas ligações
1. Preservar todas as relações existentes, incluindo as 28 associações territoriais e 13 relações cluster–projeto já conferidas.
2. Decidir os pares dos seis registos acima e o âmbito da relação, podendo optar por nenhuma correspondência. Não exigir outra fonte para revalidar origem oficial aceite.
3. Adicionar ao mesmo plano apenas pares identificados explicitamente, com trecho ou decisão de correspondência do proprietário; verificar os IDs atuais e excluir os pares já cadastrados.
4. Preparar os pares decididos na base QA isolada, testar idempotência e os filtros/fichas/mapa, sem converter contexto em implantação.
5. Atualizar backup/restauro, verificar reversão das inserções específicas e apresentar uma única pré-visualização autenticada antes da aplicação ativa.
**Plano atual: zero inserções e zero eliminações.** Não existe um lote inequívoco para aplicar em QA ou na base ativa. A prova QA confirma que não houve associação automática nem alteração das relações existentes. Não reutilizar o botão de rastreabilidade das 13 referências como se aplicasse estas novas correspondências.
Matriz e plano completos privados: `opportunity-association-plan.json`. As opções não vão para `opportunity_projects`/`opportunity_clusters` enquanto permanecerem indeterminadas.
@@ -0,0 +1,45 @@
# Origem dos seis registos e separação de exemplos
9 de outubro de 2026. Complementa [as correspondências por oportunidade](oportunidades-correspondencias.md). Não decide pares por setor, localização ou proximidade, nem reabre a aceitação de fontes oficiais identificadas.
## O que a evidência permite concluir
Os seis IDs/referências constam, com os mesmos títulos, descrições, províncias, valores, maturidade, financiamento e o texto «Promotor em validação», de `database/seeders/OpportunitySeeder.php`. O ficheiro usa `updateOrCreate` pela referência, define `status=published` e atribui a mesma execução a `published_at`. O código entrou no commit inicial `250ac35`.
Isto confirma a existência e a correspondência dos registos com os valores do seeder. **Não comprova a existência real dos investimentos, a origem oficial de cada oportunidade ou qual pessoa executou a criação/importação histórica.** A data de criação coincidente dos seis registos é compatível com lote, mas não prova a execução específica do seeder. Também não autoriza concluir, sem decisão do responsável, que todos são fictícios.
O `DatabaseSeeder` atual não chama `OpportunitySeeder`; chama `ProductionReferenceSeeder` e, em local/testing, outros fixtures. Portanto, a criação destes seis registos não se explica pela cadeia atual do seeder geral. A auditoria disponível deve ser lida junto com o histórico de implantação, sem inventar uma autoria ou uma execução. Prova privada: `opportunity-origin-confirmation.json`.
A consulta protegida encontrou nove eventos `updated`, de `portal-management`, pela conta 2 em 7 de outubro. Esses eventos documentam edição posterior, não criação nem classificação como investimento real. A conta editora não foi automaticamente nomeada responsável pelas oportunidades. A comparação dos oito campos por registo passou nos seis IDs; nenhuma escrita ativa foi realizada.
Os textos já extraídos do acervo contêm menções a parte destes registos em exemplos e listagens de código. Esses exemplos fundamentam a origem de conceção do cadastro; não demonstram que uma oportunidade real pertence a uma âncora. Os seis mantêm o estado ativo existente nesta investigação: nenhuma classificação, publicação, descrição ou associação foi alterada.
## Perguntas a entregar ao responsável
Para **cada ID 1–6**, confirmar: investimento real, exemplo/demonstração ou origem ainda desconhecida; nome do promotor e identificador do investimento/componente, se disponíveis; documento ou decisão de cadastro que identifica o registo; responsável e data da declaração. Não pedir certificação adicional da credibilidade de uma fonte oficial já aceite.
| ID | Referência e título | Esclarecimento necessário antes de associar |
| --- | --- | --- |
| 1 | INV-MZ-AGR-001 — Unidade de processamento agrícola em Nampula | A que projeto/polo concreto pertence? Qual a atividade/cultura, unidade e código do componente? |
| 2 | INV-MZ-ENE-002 — Mini-rede solar para polo produtivo | Qual é o polo abastecido, o operador e a mini-rede concreta? Como se identifica o investimento das 40 unidades? |
| 3 | INV-MZ-TUR-003 — Eco-lodge costeiro com integração comunitária | Qual é o empreendimento e a sua pertença concreta a um projeto/polo? Não decidir apenas por Inhambane ou pela atividade turística. |
| 4 | INV-MZ-INF-004 — Centro logístico provincial | Qual a localização efetiva, instalação e corredor servido? Serve um corredor ou vários? |
| 5 | INV-MZ-BLUE-005 — Processamento de pescado e cadeia fria | Integra expressamente A12 ou é uma iniciativa independente? Qual é a unidade/componente que o demonstra? |
| 6 | INV-MZ-FIN-006 — Plataforma de crédito para PME agrícolas | Pertence a um cluster ou presta apenas serviço financeiro aos seus participantes? Qual o instrumento, operador e relação assumida? |
As alternativas do relatório de correspondências são apenas hipóteses de esclarecimento. Uma relação de serviço financeiro não deve ser gravada como pertença a cluster no pivot atual; exige representação explícita do papel sem alterar a identidade dos participantes.
Nenhuma mensagem foi enviada ao responsável. O questionário está preparado para entrega pelo proprietário; identidade e canal de contacto não foram presumidos.
## Plano único de classificação e eventual separação
1. Registar as respostas por ID e preservar a evidência e a autoria da decisão. Não classificar todos automaticamente por coincidirem com um seeder.
2. Para registos confirmados como reais, preservar publicação/IDs/histórico e preencher apenas as correspondências explicitamente decididas. Não aplicar opções do relatório como associações.
3. Para registos confirmados como exemplos, adicionar uma classificação explícita de conteúdo à entidade existente (`real`, `example`, `undetermined`, a reconciliar em QA com o modelo), mantendo separado o estado editorial. Não criar cópias paralelas nem reutilizar `validation_status` jurídico para classificar exemplos.
4. Apresentar rótulo **«Exemplo de demonstração — não constitui oportunidade real»**, separar do catálogo e métricas reais e permitir consulta em área de demonstração autorizada. Abranger pesquisa, API, mapa, filtros e fichas, incluindo URLs antigas; esconder apenas da pesquisa sem corrigir acesso direto seria insuficiente.
5. Preservar IDs, referências, ficheiros, mensagens, manifestações de interesse, favoritos, notificações, tarefas e relações existentes. Não eliminar, reseedar, substituir títulos ou apagar eventos. Solicitações históricas não se convertem em exemplos; identificar o contexto original e encaminhar a conferência humana.
6. Preparar uma única pré-visualização por ID: classificação anterior/proposta, fundamento, efeito na divulgação e nas métricas, dependências e valores para reversão. Implementar somente em QA antes da aplicação autenticada.
7. Testar catálogo misto, filtros/mapa/API, acesso direto, permissões da demonstração, idempotência, histórico e recuperação/reversão. Não aplicar um lote sem classificação individual decidida.
8. Antes de alteração ativa, atualizar backup/restauro, compatibilidade e ensaio de reversão; executar pelo fluxo autenticado existente. A reversão restaura classificação/divulgação anteriores sem apagar a decisão ou o histórico.
**Estado atualizado:** origem de código constatada; realidade/classificação e pares pendentes do responsável. A revisão autenticada e separação condicional foram implementadas e testadas em QA, sem aplicação ativa. A classificação utiliza eventos da auditoria existente, mantendo o estado editorial e dispensando nova coluna/tabela. Ver [implementação, provas e pré-visualização](revisao-origem-oportunidades.md). Questões jurídicas específicas, cobertura do inventário, ClamD e primeiro pedido real continuam independentes.
@@ -94,3 +94,11 @@ Diferença de cobertura do inventário documental, questões jurídicas específ
[Plano concreto de aplicação autenticada](ativacao-rastreabilidade-territorial.md): 112 entradas de fonte/fundamento para os mesmos 46 registos; 13 referências de relações cluster–projeto propostas, sem alterar validações ou criar ligações por proximidade. Os 28 vínculos territoriais existentes, as 11 referências oficiais/legais, as 14 fichas validadas e o pacote INE aceite foram reutilizados. Pendências de rastreabilidade preservam a aceitação existente.
Backup/restauro de 288 tabelas e cinco ficheiros verificado; aplicação e reversão testadas na cópia isolada. 56 testes PHP/884 asserções passaram e o navegador comprovou o clique V2, resposta HTTP, recuperação, fonte por associação na ficha e reversão. Nenhuma proposta deste incremento foi aplicada à base ativa. A pré-visualização autenticada mostra campos atuais/propostos, fonte, motivo e efeito antes da ação do responsável.
## Conferência de todas as associações do plano do proprietário
9 de outubro de 2026. A comparação por identificador do projeto, território e papel confirma que **as 28 associações territoriais expressas no plano original já estão cadastradas na base ativa**. As 13 relações cluster–projeto expressas nesse plano também existem. Nenhuma destas 41 associações está ausente; não é necessária nova fonte nem nova aplicação para duplicá-las. A última aplicação V2 acrescentou referências às 13 relações existentes; não criou as 28 localizações, que já estavam aplicadas anteriormente.
A resposta anterior que dizia apenas «não foram acrescentadas localizações nesta aplicação» descrevia o incremento V2, mas era incompleta quanto ao estado global. As localizações solicitadas naquele plano estão presentes, com os papéis `primary`, `secondary` e `impact` originais. Prova privada, por associação: `owner-associations-confirmation.json`, obtida exclusivamente por leitura.
As seis oportunidades genéricas existentes não contêm pares explícitos de correspondência com âncoras/clusters no plano examinado. Identificar esse par é uma questão de correspondência entre registos, não de revalidação da fonte oficial. Os 158 componentes preparados no plano original não são 158 oportunidades já cadastradas: 86 têm código explícito e 72 apresentam conflito entre código e legenda. Criar esses registos ou resolver os conflitos é trabalho distinto de aplicar novamente as 41 associações já existentes. Não se convertem automaticamente os componentes em ligações às seis oportunidades genéricas.
@@ -0,0 +1,37 @@
# Revisão da origem das seis oportunidades
9 de outubro de 2026. Complementa [a investigação de origem](origem-oportunidades-e-separacao.md) e [as correspondências individuais](oportunidades-correspondencias.md).
## Evidência encontrada
Os seis IDs 1–6 coincidem nos oito campos comparados com `database/seeders/OpportunitySeeder.php`, introduzido no commit `250ac35`. O histórico consultado não identificou chamada desse seeder na cadeia de `DatabaseSeeder`. A coincidência demonstra correspondência com a definição de código, mas não comprova execução histórica do seeder nem classifica os investimentos como reais ou fictícios.
A auditoria ativa contém nove edições posteriores pela conta 2, em 7 de outubro, sem evento de criação recuperado nesta pesquisa. A pesquisa de tabelas com nome relacionado com importação/seed e dos recibos privados consultados não encontrou prova adicional de importação. Este resultado é limitado ao histórico e artefactos consultados; ausência de prova não determina classificação.
Nas extrações de 35 documentos e nos 13 433 parágrafos do mestre relido, os registos 1–5 aparecem em exemplos/listagens de código. Não foi recuperada menção exata ao sexto nem correspondência inequívoca com âncoras/clusters. A aceitação das fontes oficiais existentes permanece intacta. A classificação e a associação territorial são decisões distintas.
## Revisão autenticada preparada
Endereço: **http://127.0.0.1:8000/management/opportunity-origin**, sujeito à autenticação e permissão de gestão existentes.
Cada cartão apresenta ID, referência, título, descrição, classificação e histórico. O responsável escolhe **oportunidade real**, **exemplo demonstrativo** ou **origem ainda indeterminada**, indica fundamento e executa **Pré-visualizar decisão**. A pré-visualização mostra o estado anterior, proposta e efeito. Só depois de marcar **Confirmo esta decisão de classificação** fica disponível **Aplicar decisão de classificação**, com prova válida de backup/restauro/reversão.
Sem decisão registada, o estado é indeterminado; isso não modifica a publicação editorial existente. A aplicação acrescenta um evento ao mecanismo de auditoria existente, com autor autenticado, data e fundamento. Não altera associações, IDs, descrições ou estados jurídicos. A classificação utiliza esses eventos, sem nova tabela ou migration. Pedidos repetidos são idempotentes; alterações concorrentes exigem nova pré-visualização.
Uma decisão de exemplo separa o item do catálogo público, pesquisa, API, mapa e acesso direto público. A gestão conserva consulta e rótulo explícito de demonstração. A reversão é uma nova decisão explícita, preservando ambas as decisões e as interações originais. Esta revisão não cria relações projeto/cluster nem converte pedidos históricos em exemplos.
## QA e preservação
- Testes de regressão: **58 testes, 917 asserções, todos passaram**, exclusivamente em QA isolada.
- Navegador: pré-visualização sem aplicação; confirmação explícita; separação de exemplo na API/mapa; consulta autenticada; reposição; nenhum erro JavaScript observado.
- Restauro atualizado: **288 tabelas e cinco ficheiros** verificados na cópia `desk_restore_20261009_003841_test:55441`.
- Após o ensaio, hashes de dez tabelas comparados com o backup: oportunidades, duas relações de oportunidade, favoritos, pedidos de interesse, recibos, mensagens, tarefas, localizações de projetos e relações cluster/projeto preservados. Os dois eventos de decisão do ensaio permanecem apenas em QA.
- O teste funcional inclui relações e favorito de fixture, conferindo preservação após separação e reversão. Fixtures não foram inseridas na base ativa.
Provas privadas: `opportunity-origin-confirmation.json`, `opportunity-origin-backup-proof.json`, `opportunity-origin-browser.json`, `opportunity-origin-reversal-evidence.json` e `opportunity-origin-proof.json`, em `storage/app/private/investmoz-v2`. Não publicar backup, configuração ou chave. A prova de ativação corresponde à versão ensaiada e expira em **10/10/2026 às 02:38:49 locais**; após isso é necessário atualizar backup/restauro antes de aplicar decisões.
## Estado ativo e pendências
Na leitura protegida desta entrega há **zero decisões de classificação** na base ativa. Nenhum dos seis foi classificado, retirado do catálogo ou associado automaticamente. A página está preparada para revisão individual pelo responsável, começando pela pré-visualização. Não foi enviado contacto externo.
Continuam separadas: decisões sobre realidade dos investimentos e correspondências ambíguas; diferença de cobertura do inventário; questões jurídicas específicas; ClamD real; fontes institucionais pendentes específicas; primeiro pedido real no Balcão. Estes testes não concluem nenhuma dessas pendências.
@@ -0,0 +1,233 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { Management } from './shared';
type RecordState = {
id: number;
reference: string;
title: string;
description: string;
classification: string;
snapshot_hash: string;
history: {
id: string;
classification: string;
reason: string;
author: number;
date: string;
}[];
};
const labels: Record<string, string> = {
real: 'Oportunidade real',
example: 'Exemplo demonstrativo',
undetermined: 'Origem ainda indeterminada',
};
type Preview = {
current: RecordState;
classification: string;
reason: string;
effect: string;
ready: boolean;
};
function Review({ record, ready }: { record: RecordState; ready: boolean }) {
const [classification, setClassification] = useState(record.classification);
const [reason, setReason] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const [error, setError] = useState('');
const [processing, setProcessing] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [result, setResult] = useState('');
async function request(apply: boolean) {
setProcessing(true);
setError('');
try {
const xsrf = document.cookie
.split('; ')
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
?.split('=')
.slice(1)
.join('=');
const response = await fetch(
'/management/opportunity-origin/' +
(apply ? 'apply' : 'preview'),
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(xsrf ?? ''),
},
body: JSON.stringify({
id: record.id,
classification,
reason,
snapshot_hash: preview?.current.snapshot_hash,
confirm: confirmed,
}),
},
);
const data = await response.json();
if (!response.ok)
throw new Error(
Object.values(data.errors ?? {})
.flat()
.join(' ') ||
data.message ||
'Operação recusada.',
);
if (apply) {
setPreview(null);
setResult(
'Decisão registada. Classificação separada das associações; histórico preservado.',
);
router.reload({ only: ['records', 'ready'] });
} else {
setPreview(data);
setConfirmed(false);
}
} catch (exception) {
setError(
exception instanceof Error
? exception.message
: 'Falha de comunicação; dados preservados.',
);
} finally {
setProcessing(false);
}
}
return (
<article className="space-y-3 rounded border p-4">
<h2 className="text-lg font-bold">
ID {record.id} — {record.title}
</h2>
<p>{record.reference}</p>
<p>{record.description}</p>
<p>
Classificação atual: {labels[record.classification]}.{' '}
{record.classification === 'example' &&
'Exemplo de demonstração — não constitui oportunidade real. Consulta autenticada preservada.'}
</p>
<label className="block">
Classificação
<select
aria-label="Classificação"
className="block rounded border p-2"
value={classification}
onChange={(event) => {
setClassification(event.target.value);
setPreview(null);
}}
>
{Object.entries(labels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label className="block">
Fundamento da decisão
<textarea
aria-label="Fundamento da decisão"
className="block w-full rounded border p-2"
value={reason}
maxLength={2000}
onChange={(event) => {
setReason(event.target.value);
setPreview(null);
}}
/>
</label>
<button
className="rounded border p-2"
disabled={processing || !reason.trim()}
onClick={() => request(false)}
>
Pré-visualizar decisão
</button>
{preview && (
<section
className="space-y-2 rounded border p-3"
aria-label="Pré-visualização da classificação"
>
<p>
{labels[preview.current.classification]} →{' '}
{labels[preview.classification]}
</p>
<p>{preview.reason}</p>
<p>{preview.effect}</p>
<p>
IDs, relações, interações e histórico preservados. Não
associa projetos/clusters nem valida informação
jurídica.
</p>
<label className="block">
<input
type="checkbox"
checked={confirmed}
onChange={(event) =>
setConfirmed(event.target.checked)
}
/>{' '}
Confirmo esta decisão de classificação
</label>
<button
className="rounded border p-2"
disabled={
processing || !confirmed || !ready || !preview.ready
}
onClick={() => request(true)}
>
Aplicar decisão de classificação
</button>
</section>
)}
{!ready && (
<p>
Aplicação indisponível até prova atual de backup/restauro e
reversão; a revisão e pré-visualização continuam
disponíveis.
</p>
)}
{error && <p role="alert">{error}</p>}
{result && <p role="status">{result}</p>}
<h3 className="font-bold">Histórico da classificação</h3>
{record.history.length ? (
record.history.map((event) => (
<p key={event.id}>
{labels[event.classification]} · {event.reason} · conta{' '}
{event.author} · {event.date}
</p>
))
) : (
<p>
Sem decisão registada; ausência de prova não significa
exemplo nem oportunidade real.
</p>
)}
</article>
);
}
export default function OpportunityOrigin({
records,
ready,
}: {
records: RecordState[];
ready: boolean;
}) {
return (
<Management title="Revisão da origem das oportunidades">
<p>
O seeder documenta valores de código; não prova a realidade do
investimento. A classificação é independente de publicação,
situação jurídica e associações territoriais. Exemplos decididos
ficam separados do catálogo público e disponíveis aqui para
consulta autenticada.
</p>
{records.map((record) => (
<Review key={record.id} record={record} ready={ready} />
))}
</Management>
);
}
+3
View File
@@ -58,6 +58,9 @@ 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/opportunity-origin">
Revisão da origem das oportunidades
</Link>
<Link href="/management/history">Histórico</Link>
<Link href="/management/crm/leads">CRM</Link>
<Link href="/management/crm/investors">Investidores</Link>
+4
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\OpportunityOriginReviewController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalServicesController;
use App\Http\Controllers\PublicBusinessOpportunityController;
@@ -121,6 +122,9 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
});
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('relationship-repair', [InvestmentRelationshipRepairController::class, 'preview'])->name('relationship-repair.preview');
Route::get('opportunity-origin', [OpportunityOriginReviewController::class, 'index'])->name('opportunity-origin.index');
Route::post('opportunity-origin/preview', [OpportunityOriginReviewController::class, 'preview'])->name('opportunity-origin.preview');
Route::post('opportunity-origin/apply', [OpportunityOriginReviewController::class, 'apply'])->name('opportunity-origin.apply');
Route::post('relationship-repair', [InvestmentRelationshipRepairController::class, 'apply'])->name('relationship-repair.apply');
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
@@ -0,0 +1,95 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityOriginReview;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class OpportunityOriginReviewTest extends TestCase
{
use RefreshDatabase;
private string $directory;
protected function setUp(): void
{
parent::setUp();
$this->directory = storage_path('framework/testing/origin-'.Str::uuid());
mkdir($this->directory, 0700, true);
file_put_contents($this->directory.'/dump', 'QA-only backup fixture');
config(['opportunity_origin.proof_path' => $this->directory.'/proof.json']);
file_put_contents($this->directory.'/proof.json', json_encode(['restore_verified' => true, 'reversal_verified' => true, 'code_hash' => app(OpportunityOriginReview::class)->fingerprint(), 'verified_at' => gmdate('c'), 'target' => ['database' => config('database.connections.pgsql.database'), 'port' => 55441], 'dump_path' => $this->directory.'/dump', 'sha256' => hash_file('sha256', $this->directory.'/dump')]));
}
protected function tearDown(): void
{
foreach (glob($this->directory.'/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->directory);
parent::tearDown();
}
private function fixture(): array
{
$sector = Sector::create(['name' => 'QA sector', 'slug' => 'qa-origin', 'is_active' => true]);
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'INV-MZ-AGR-001', 'title' => 'Synthetic QA opportunity', 'description' => 'Synthetic QA fixture only', 'slug' => 'qa-origin', 'status' => 'published', 'published_at' => now()->subMinute()]);
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$project = Project::factory()->create();
DB::table('opportunity_projects')->insert(['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
DB::table('opportunity_favourites')->insert(['user_id' => $manager->id, 'opportunity_id' => $opportunity->id]);
return compact('opportunity', 'manager', 'project');
}
public function test_preview_has_no_decision_and_example_separation_preserves_records_and_interactions(): void
{
$f = $this->fixture();
$before = $f['opportunity']->fresh()->getRawOriginal();
$payload = ['id' => $f['opportunity']->id, 'classification' => 'example', 'reason' => 'Responsible QA declaration: synthetic fixture'];
$preview = $this->actingAs($f['manager'])->postJson('/management/opportunity-origin/preview', $payload)->assertOk()->assertJsonPath('relationships_changed', false)->json();
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count());
$this->assertSame('undetermined', app(OpportunityOriginReview::class)->state($f['opportunity']->id)['classification']);
$apply = [...$payload, 'snapshot_hash' => $preview['current']['snapshot_hash'], 'confirm' => true];
$this->postJson('/management/opportunity-origin/apply', $apply)->assertOk()->assertJsonPath('changed', 1);
$this->postJson('/management/opportunity-origin/apply', $apply)->assertOk()->assertJsonPath('replayed', true);
$this->assertSame([], app(InvestmentCatalogue::class)->query([])->pluck('id')->all());
$this->get('/oportunidades/qa-origin')->assertNotFound();
$this->getJson('/api/v1/opportunities/qa-origin')->assertNotFound();
$this->assertSame(0, app(InvestmentCatalogue::class)->map([])['matched_count']);
$this->get('/management/opportunity-origin')->assertOk();
$this->assertSame($before, $f['opportunity']->fresh()->getRawOriginal());
$this->assertDatabaseHas('opportunity_projects', ['opportunity_id' => $f['opportunity']->id, 'project_id' => $f['project']->id]);
$this->assertDatabaseHas('opportunity_favourites', ['opportunity_id' => $f['opportunity']->id, 'user_id' => $f['manager']->id]);
$state = app(OpportunityOriginReview::class)->state($f['opportunity']->id);
$this->assertSame($f['manager']->id, $state['history'][0]['author']);
$this->assertSame($payload['reason'], $state['history'][0]['reason']);
$this->postJson('/management/opportunity-origin/apply', ['id' => $f['opportunity']->id, 'classification' => 'undetermined', 'reason' => 'QA restore previous classification', 'snapshot_hash' => $state['snapshot_hash'], 'confirm' => true])->assertOk();
$this->assertTrue(Opportunity::published()->whereKey($f['opportunity']->id)->exists());
$this->assertCount(2, app(OpportunityOriginReview::class)->state($f['opportunity']->id)['history']);
}
public function test_permissions_foundation_confirmation_and_conflicts_are_required(): void
{
$f = $this->fixture();
$payload = ['id' => $f['opportunity']->id, 'classification' => 'real', 'reason' => 'QA declaration'];
$this->actingAs(User::factory()->create())->postJson('/management/opportunity-origin/preview', $payload)->assertForbidden();
$this->actingAs($f['manager'])->postJson('/management/opportunity-origin/preview', [...$payload, 'reason' => ''])->assertUnprocessable();
$state = app(OpportunityOriginReview::class)->state($f['opportunity']->id);
$this->postJson('/management/opportunity-origin/apply', [...$payload, 'snapshot_hash' => $state['snapshot_hash']])->assertUnprocessable();
$f['opportunity']->update(['description' => 'QA correction']);
$this->postJson('/management/opportunity-origin/apply', [...$payload, 'snapshot_hash' => $state['snapshot_hash'], 'confirm' => true])->assertConflict();
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count());
unlink($this->directory.'/proof.json');
$this->postJson('/management/opportunity-origin/preview', $payload)->assertOk()->assertJsonPath('ready', false);
}
}
@@ -0,0 +1,42 @@
<?php
// Read-only verification in the restored QA database; never connect to the active target.
$directory = dirname(__DIR__).'/storage/app/private/investmoz-v2';
$proof = json_decode(file_get_contents($directory.'/territory-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$plan = json_decode(file_get_contents($directory.'/opportunity-association-plan.json'), true, 512, JSON_THROW_ON_ERROR);
$target = $proof['restored_target'];
if (! $proof['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
throw new RuntimeException('Isolated restored QA required.');
}
$pdo = new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$target['database'], 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$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);
if ($identity['database'] !== $target['database'] || (int) $identity['port'] !== 55441 || $identity['read_only'] !== 'on' || $plan['associations'] !== [] || $plan['deletions'] !== []) {
throw new RuntimeException('QA identity or review-only plan mismatch.');
}
$checks = [];
foreach ($plan['records'] as $record) {
$statement = $pdo->prepare('SELECT reference,title,description FROM opportunities WHERE id=?');
$statement->execute([$record['id']]);
$current = $statement->fetch(PDO::FETCH_ASSOC);
if (! $current || $current['reference'] !== $record['reference'] || $current['title'] !== $record['title'] || $current['description'] !== $record['description']) {
throw new RuntimeException('QA record identity differs.');
}
foreach (['opportunity_projects' => 'project_id', 'opportunity_clusters' => 'cluster_id'] as $table => $column) {
$statement = $pdo->prepare('SELECT '.$column.' FROM '.$table.' WHERE opportunity_id=? ORDER BY '.$column);
$statement->execute([$record['id']]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
$expected = $table === 'opportunity_projects' ? $record['existing_projects'] : $record['existing_clusters'];
if ($rows !== $expected) {
throw new RuntimeException('Existing relationships differ.');
}
}
if ($record['unequivocal_links'] !== [] || array_filter($record['options'], fn ($option) => $option['relation_proven'])) {
throw new RuntimeException('An option was promoted without a decision.');
}
$checks[] = ['id' => $record['id'], 'identity_matches' => true, 'existing_links_preserved' => true, 'no_option_applied' => true];
}
$pdo->exec('ROLLBACK');
$evidence = ['recorded_at' => gmdate('c'), 'identity' => $identity, 'checks' => $checks, 'plan_has_no_writes' => true, 'active_writes' => 0];
file_put_contents($directory.'/opportunity-association-qa.json', json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($evidence, JSON_THROW_ON_ERROR).PHP_EOL;
@@ -0,0 +1,259 @@
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const dir = path.join(root, 'storage/app/private/investmoz-v2');
const read = (file) =>
JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
const audit = read(path.join(dir, 'territory-audit.json'));
if (audit.identity.read_only !== 'on') throw Error('Read-only audit required');
const sources = read(
path.join(
root,
'storage/app/private/anchor-project-reconciliation/source-texts.json',
),
);
const master = read(path.join(dir, 'master-paragraphs-reopened.json'));
const norm = (text) =>
text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, ' ');
const units = sources
.flatMap((s) => [
...s.paragraphs.map((text, i) => ({
source: path.win32.basename(s.path),
locator: `parágrafo extraído ${i + 1}`,
text,
})),
...s.tables.flatMap((table, i) =>
table.map((cells, j) => ({
source: path.win32.basename(s.path),
locator: `tabela ${i + 1}, linha ${j + 1}`,
text: cells.join(' | '),
})),
),
])
.concat(
master.map((r) => ({
source: 'Documento mestre relido',
locator: `w:p ${r.p}`,
text: r.text,
})),
);
const decisions = {
1: {
options: [
['A03', 'description'],
['CLU-01', 'anchors'],
],
question:
'Qual é a atividade/cultura e o código do componente desta unidade? Integra expressamente o Corredor de Nacala ou apenas o âmbito de processamento do CLU-01? A06 refere a cadeia do arroz no Centro, não identifica esta unidade.',
},
2: {
options: [
['A08', 'description'],
['CLU-02', 'gaps'],
],
question:
'Qual é o polo produtivo e o código da mini-rede que abastece as 40 unidades? A fonte não identifica se faz parte de Mocuba, de outro parque ou de um projeto independente; geração solar não é identidade com A10 hidroelétrica.',
},
3: {
options: [
['A13', 'description'],
['CLU-04', 'gaps'],
],
question:
'Qual é o nome/local exato do empreendimento e o componente documental? Está integrado em Vilanculos–Inhassoro–Bazaruto ou é um empreendimento costeiro independente? Inhambane e eco-lodge não demonstram essa integração.',
},
4: {
options: [
['A02', 'description'],
['A03', 'description'],
['A11', 'description'],
['CLU-03', 'gaps'],
],
question:
'Qual é o terminal/plataforma, o corredor servido e o código contratual ou documental? A redução de custos logísticos não determina se integra Norte–Sul, Nacala, Macuse–Moatize ou nenhum deles; Sofala não decide a ligação.',
},
5: {
options: [
['A12', 'description'],
['CLU-04', 'anchors'],
],
question:
'Qual é a unidade de pescado e o componente do programa ao qual pertence? A12 identifica resiliência costeira, mas não esta unidade de processamento. CLU-04 identifica turismo/marinas, não demonstra cadeia fria pesqueira; pode não haver âncora/cluster correspondente no cadastro atual.',
},
6: {
options: [
['CLU-08', 'anchors'],
['CLU-07', 'anchors'],
['CLU-01', 'gaps'],
],
question:
'Qual é o operador/instrumento e a relação assumida: financiamento transversal CLU-08, serviço fintech CLU-07 ou financiamento da cadeia CLU-01? Qual documento identifica esta plataforma e as 300+ empresas? Não atribuir todos os clusters automaticamente.',
},
};
const records = audit.records
.filter((r) => r.kind === 'opportunities')
.map((entity) => {
const opportunity = entity.record;
const decision = decisions[opportunity.id];
if (!decision)
throw Error('New opportunity requires individual review');
const hits = units.filter((u) =>
[
opportunity.reference,
opportunity.title,
opportunity.description,
].some((text) => norm(u.text).includes(norm(text))),
);
const options = decision.options.map(([code, field]) => {
const dossier = audit.dossiers.find(
(d) => JSON.parse(d.content).code === code,
);
if (!dossier) throw Error('Missing dossier ' + code);
const content = JSON.parse(dossier.content);
return {
code,
target_id: dossier.project_id || dossier.cluster_id,
target_type: dossier.project_id ? 'project' : 'cluster',
title: dossier.title,
source_reference: dossier.source,
dossier_id: dossier.id,
quote: content[field],
field,
relation_proven: false,
status: 'option_for_decision_only',
};
});
return {
id: opportunity.id,
reference: opportunity.reference,
title: opportunity.title,
description: opportunity.description,
existing_projects: entity.relations.projects,
existing_clusters: entity.relations.clusters,
source_mentions: hits.map((hit) => {
const indices = [
opportunity.reference,
opportunity.title,
opportunity.description,
]
.map((text) =>
hit.text.toLowerCase().indexOf(text.toLowerCase()),
)
.filter((index) => index >= 0);
const start = indices.length ? Math.min(...indices) : 0;
return {
source: hit.source,
locator: hit.locator,
quote: hit.text.slice(start, start + 240),
};
}),
options,
question: decision.question,
unequivocal_links: [],
status: 'decision_required',
};
});
const plan = {
schema: 1,
captured_at: audit.captured_at,
target: {
database: audit.identity.database,
port: Number(audit.identity.port),
},
coverage: {
extracted_documents: sources.length,
master_paragraphs: master.length,
text_units: units.length,
full_inventory_review: false,
},
existing_links_preserved: true,
records,
associations: [],
deletions: [],
status: 'decision_required',
reason: 'No explicit identification links these six opportunity records to an anchor/cluster in the reviewed extracts. Options are not applied associations.',
};
fs.writeFileSync(
path.join(dir, 'opportunity-association-plan.json'),
JSON.stringify(plan, null, 2),
);
const lines = [
'# Oportunidades — correspondências e plano único',
'',
`Leitura protegida: ${audit.captured_at}, ${plan.target.database}:${plan.target.port}. Consulta das extrações de ${sources.length} documentos e ${master.length} parágrafos do mestre relido; não é revisão integral dos ficheiros do inventário. A aceitação das fontes oficiais não foi reaberta.`,
'',
'**Resultado:** zero ligações inequívocas recuperadas para as seis oportunidades existentes. As opções abaixo são alternativas para decisão, não correspondências confirmadas. O trecho documenta o projeto/cluster candidato; não identifica automaticamente a oportunidade como seu componente.',
'',
'As referências de programação e os exemplos de código descrevem registos e funções. Não são reinterpretados como identidade institucional, promotor ou relação oficial. As correspondências continuam pendências específicas, independentes da cobertura do inventário, questões jurídicas, ClamD e primeiro pedido real.',
];
lines.push(
'',
'## Origem e perguntas ao responsável',
'',
'Ver [a conferência do seeder, a auditoria disponível e o plano condicional de separação](origem-oportunidades-e-separacao.md). Os registos coincidem com dados de código; isto não comprova investimentos reais nem autoriza classificá-los automaticamente como exemplos. Antes de decidir os pares, esclarecer projeto/polo dos IDs 1–3, localização/corredor do ID 4, integração em A12 ou independência do ID 5 e pertença versus serviço financeiro do ID 6. Nenhum contacto foi enviado e nenhuma decisão foi inventada.',
);
for (const r of records) {
lines.push(
'',
`## ID ${r.id} — ${r.title}`,
'',
`Referência: \`${r.reference}\`. Descrição atual: ${r.description}`,
'',
`Relações já cadastradas: ${r.existing_projects.length} projetos, ${r.existing_clusters.length} clusters.`,
'',
'### Opções documentadas, sem associação automática',
'',
);
for (const option of r.options)
lines.push(
`- **${option.code} — ${option.title}**, ID \`${option.target_id}\`. Fonte: ${option.source_reference}, dossier \`${option.dossier_id}\`, campo \`${option.field}\`. Trecho: «${option.quote}». **Não prova a relação com a oportunidade ${r.id}.**`,
);
lines.push(
'',
`**Dúvida concreta:** ${r.question}`,
'',
`**Identificação no acervo revisto:** ${
r.source_mentions.length
? r.source_mentions
.slice(0, 3)
.map(
(hit) =>
`${hit.source}, ${hit.locator}: «${hit.quote.replace(/\s+/g, ' ')}»`,
)
.join('; ') +
'. Ocorrência de exemplo/código não constitui ligação a uma âncora.'
: 'Não foi encontrada menção exata da referência/título/descrição nas extrações revistas. Não implica invalidade da fonte ou do registo.'
}`,
);
}
lines.push(
'',
'## Plano único de novas ligações',
'',
'1. Preservar todas as relações existentes, incluindo as 28 associações territoriais e 13 relações cluster–projeto já conferidas.',
'2. Decidir os pares dos seis registos acima e o âmbito da relação, podendo optar por nenhuma correspondência. Não exigir outra fonte para revalidar origem oficial aceite.',
'3. Adicionar ao mesmo plano apenas pares identificados explicitamente, com trecho ou decisão de correspondência do proprietário; verificar os IDs atuais e excluir os pares já cadastrados.',
'4. Preparar os pares decididos na base QA isolada, testar idempotência e os filtros/fichas/mapa, sem converter contexto em implantação.',
'5. Atualizar backup/restauro, verificar reversão das inserções específicas e apresentar uma única pré-visualização autenticada antes da aplicação ativa.',
'',
'**Plano atual: zero inserções e zero eliminações.** Não existe um lote inequívoco para aplicar em QA ou na base ativa. A prova QA confirma que não houve associação automática nem alteração das relações existentes. Não reutilizar o botão de rastreabilidade das 13 referências como se aplicasse estas novas correspondências.',
'',
'Matriz e plano completos privados: `opportunity-association-plan.json`. As opções não vão para `opportunity_projects`/`opportunity_clusters` enquanto permanecerem indeterminadas.',
);
fs.writeFileSync(
path.join(root, 'docs/investmoz/v2/oportunidades-correspondencias.md'),
lines.join('\n') + '\n',
);
console.log(
JSON.stringify({
records: records.length,
associations: 0,
sources: sources.length,
text_units: units.length,
decision_required: records.length,
}),
);
+36
View File
@@ -0,0 +1,36 @@
<?php
// Reuse the protected reader; no seeder, Laravel kernel or write to the active database.
require __DIR__.'/investmoz-v2-territory-audit.php';
$directory = dirname(__DIR__).'/storage/app/private/investmoz-v2';
$audit = json_decode(file_get_contents($directory.'/territory-audit.json'), true, 512, JSON_THROW_ON_ERROR);
$source = file_get_contents(dirname(__DIR__).'/database/seeders/OpportunitySeeder.php');
preg_match_all("/'reference' => '([^']+)', 'title' => '([^']+)', 'province' => '([^']+)', 'maturity' => ([0-9]+), 'amount' => ([0-9]+), 'financing' => '([^']+)', 'impact' => '([^']+)'/", $source, $matches, PREG_SET_ORDER);
if (count($matches) !== 6) {
throw new RuntimeException('Seeder format changed; individual review required.');
}
$comparisons = [];
foreach ($audit['records'] as $entity) {
if ($entity['kind'] !== 'opportunities') {
continue;
}
$record = $entity['record'];
$seed = array_values(array_filter($matches, fn ($match) => $match[1] === $record['reference']))[0] ?? null;
if (! $seed) {
throw new RuntimeException('Opportunity not covered by reviewed seeder.');
}
$fields = ['reference' => $seed[1], 'title' => $seed[2], 'province' => $seed[3], 'maturity_level' => (int) $seed[4], 'estimated_investment_amount' => $seed[5], 'financing_modality' => $seed[6], 'description' => $seed[7], 'promoter_name' => 'Promotor em validação'];
$checks = [];
foreach ($fields as $field => $value) {
$checks[$field] = $field === 'estimated_investment_amount' ? (float) $record[$field] === (float) $value : (string) $record[$field] === (string) $value;
}
$comparisons[] = ['id' => $record['id'], 'reference' => $record['reference'], 'title' => $record['title'], 'created_at' => $record['created_at'], 'status' => $record['status'], 'checks' => $checks, 'code_origin' => 'database/seeders/OpportunitySeeder.php', 'real_world_status' => 'not_established_by_seeder', 'classification_decision' => 'pending_responsible'];
}
$pdo->exec('BEGIN TRANSACTION READ ONLY');
$events = $pdo->query("SELECT action,source_system,actor_user_id,occurred_at,after_data->>'reference' AS reference,after_data->>'opportunity_id' AS opportunity_id FROM audit_events WHERE entity_type IN ('opportunity','opportunities') OR after_data->>'reference' IN ('INV-MZ-AGR-001','INV-MZ-ENE-002','INV-MZ-TUR-003','INV-MZ-INF-004','INV-MZ-BLUE-005','INV-MZ-FIN-006') ORDER BY occurred_at,id")->fetchAll(PDO::FETCH_ASSOC);
$importTables = $pdo->query("SELECT table_schema,table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') AND (table_name ILIKE '%import%' OR table_name ILIKE '%seed%') ORDER BY table_schema,table_name")->fetchAll(PDO::FETCH_ASSOC);
$classificationEvents = (int) $pdo->query("SELECT count(*) FROM audit_events WHERE source_system='opportunity-origin-review'")->fetchColumn();
$pdo->exec('ROLLBACK');
$proof = ['captured_at' => $audit['captured_at'], 'identity' => $audit['identity'], 'seeder_sha256' => hash('sha256', $source), 'comparisons' => $comparisons, 'available_opportunity_audit_events' => $events, 'import_registry_tables_found' => $importTables, 'active_classification_events' => $classificationEvents, 'creation_execution_proven' => false, 'official_origin_revalidation' => false, 'active_writes' => 0];
file_put_contents($directory.'/opportunity-origin-confirmation.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['matching_records' => count($comparisons), 'matched_fields' => count(array_filter($comparisons, fn ($record) => ! in_array(false, $record['checks'], true))), 'available_audit_events' => count($events), 'real_world_status' => 'requires_responsible_confirmation', 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
+170
View File
@@ -0,0 +1,170 @@
const {
chromium,
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const dir = 'storage/app/private/investmoz-v2/';
const backup = JSON.parse(
fs.readFileSync(dir + 'opportunity-origin-backup-proof.json'),
);
const audit = JSON.parse(fs.readFileSync(dir + 'territory-audit.json'));
const record = audit.records.find(
(r) => r.kind === 'opportunities' && r.id === 1,
).record;
const base = 'http://127.0.0.1:18094';
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath:
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
});
try {
const page = await browser.newPage();
await page.route('**/*', (route) =>
route.request().url().startsWith(base)
? route.continue()
: route.abort(),
);
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const response = await page.goto(base + '/login');
assert.equal(
response.headers()['x-investmoz-qa-target'],
backup.restored_target.database + ':55441',
);
await page
.locator('input[name=email]')
.fill('origin-browser@example.invalid');
await page.locator('input[name=password]').fill('Origin-QA-2026!');
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await page.waitForURL((url) => url.pathname !== '/login');
await page.goto(base + '/management/opportunity-origin');
await page
.getByRole('heading', {
name: 'ID 1 — ' + record.title,
exact: true,
})
.waitFor();
assert.equal(await page.locator('article').count(), 6);
const article = page.locator('article').first();
const statusBeforePreview = (
await page.request.get(
base + '/api/v1/opportunities/' + record.slug,
)
).status();
await article
.getByLabel('Classificação', { exact: true })
.selectOption('example');
await article
.getByLabel('Fundamento da decisão')
.fill(
'Declaração sintética QA: separar exemplo, sem classificar o registo ativo.',
);
await article
.getByRole('button', { name: 'Pré-visualizar decisão' })
.click();
await article
.getByRole('region', { name: 'Pré-visualização da classificação' })
.waitFor();
const apply = article.getByRole('button', {
name: 'Aplicar decisão de classificação',
});
assert.equal(await apply.isEnabled(), false);
assert.equal(
(
await page.request.get(
base + '/api/v1/opportunities/' + record.slug,
)
).status(),
statusBeforePreview,
);
await page.screenshot({ path: dir + 'opportunity-origin-preview.png' });
await article
.getByLabel('Confirmo esta decisão de classificação')
.check();
await apply.click();
await article.getByRole('status').waitFor();
await page.reload();
await page
.getByText(
'Exemplo de demonstração — não constitui oportunidade real.',
{ exact: false },
)
.waitFor();
assert.equal(
(
await page.request.get(
base + '/api/v1/opportunities/' + record.slug,
)
).status(),
404,
);
const listing = await page.request.get(
base + '/api/v1/opportunities/search',
);
assert.equal(listing.status(), 200);
const map = await page.request.get(
base + '/api/v1/investment/map?opportunity_id=1',
);
assert.equal(map.status(), 200);
assert.equal((await map.json()).matched_count, 0);
await article
.getByLabel('Classificação', { exact: true })
.selectOption('undetermined');
await article
.getByLabel('Fundamento da decisão')
.fill(
'QA: restituir classificação indeterminada, preservando auditoria.',
);
await article
.getByRole('button', { name: 'Pré-visualizar decisão' })
.click();
await article
.getByRole('region', { name: 'Pré-visualização da classificação' })
.waitFor();
await article
.getByLabel('Confirmo esta decisão de classificação')
.check();
await article
.getByRole('button', { name: 'Aplicar decisão de classificação' })
.click();
await article.getByRole('status').waitFor();
await page.reload();
await page
.getByRole('heading', {
name: 'ID 1 — ' + record.title,
exact: true,
})
.waitFor();
assert.equal(
(
await page.request.get(
base + '/api/v1/opportunities/' + record.slug,
)
).status(),
200,
);
assert.deepEqual(errors, []);
const evidence = {
recorded_at: new Date().toISOString(),
target: backup.restored_target,
preview_has_no_application: true,
explicit_confirmation: true,
example_separated_from_api_map: true,
authenticated_consultation: true,
restored: true,
errors,
active_writes: 0,
};
fs.writeFileSync(
dir + 'opportunity-origin-browser.json',
JSON.stringify(evidence, null, 2),
);
console.log(JSON.stringify(evidence));
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+73
View File
@@ -0,0 +1,73 @@
<?php
use App\Models\User;
use App\Services\OpportunityOriginReview;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Vite;
$root = dirname(__DIR__);
$directory = $root.'/storage/app/private/investmoz-v2';
$proof = json_decode(file_get_contents($directory.'/opportunity-origin-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$target = $proof['restored_target'];
if (! $proof['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
throw new RuntimeException('Isolated restored QA required.');
}
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18094', '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' => 'origin_review_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();
$c = config('database.connections.pgsql');
if (config('database.default') !== 'pgsql' || $c['host'] !== '127.0.0.1' || (int) $c['port'] !== 55441 || $c['database'] !== $target['database'] || ! empty($c['url'])) {
throw new RuntimeException('Refusing non-QA configuration.');
}
$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 database.');
}
$qa = $directory.'/qa-origin-review';
if (! is_dir($qa.'/sessions')) {
mkdir($qa.'/sessions', 0700, true);
}
config(['session.files' => $qa.'/sessions', 'portal.managers' => ['origin-browser@example.invalid'], 'opportunity_origin.proof_path' => $qa.'/proof.json', 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
Vite::useHotFile($root.'/storage/framework/origin-qa-absent-hot');
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'seed') {
$user = User::updateOrCreate(['email' => 'origin-browser@example.invalid'], ['name' => 'Origin review QA', 'password' => Hash::make('Origin-QA-2026!')]);
$user->forceFill(['email_verified_at' => now()])->save();
file_put_contents($qa.'/proof.json', json_encode([...$proof, 'target' => $target, 'reversal_verified' => true, 'fixture_only' => true, 'code_hash' => app(OpportunityOriginReview::class)->fingerprint()], JSON_THROW_ON_ERROR));
echo json_encode(['target' => $target, 'qa_user' => $user->id, 'code_hash' => app(OpportunityOriginReview::class)->fingerprint(), 'active_writes' => 0]).PHP_EOL;
exit;
}
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
$hashes = [];
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects'] as $table) {
$qualified = '"public"."'.$table.'"';
$rows = DB::select('SELECT row_to_json(t)::text AS row_json FROM '.$qualified.' t ORDER BY row_to_json(t)::text');
$hashes[$qualified] = hash('sha256', implode("\n", array_map(fn ($row) => $row->row_json, $rows)));
if ($hashes[$qualified] !== $proof['table_hashes'][$qualified]) {
throw new RuntimeException('Record/interaction preservation failed: '.$table);
}
}
$events = DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->orderBy('occurred_at')->get();
$state = app(OpportunityOriginReview::class)->state(1);
if ($state['classification'] !== 'undetermined' || $events->count() !== 2) {
throw new RuntimeException('QA classification cycle incomplete.');
}
$evidence = ['verified_at' => gmdate('c'), 'target' => $target, 'hashes' => $hashes, 'classification_restored' => $state['classification'] === 'undetermined', 'decision_events' => $events->count(), 'code_hash' => app(OpportunityOriginReview::class)->fingerprint(), 'active_writes' => 0];
file_put_contents($directory.'/opportunity-origin-reversal-evidence.json', json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($evidence, JSON_THROW_ON_ERROR).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());