Add audited opportunity origin review and QA separation
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user