Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
979f3f850e | ||
|
|
b81f477bf2 | ||
|
|
568a488ae7 | ||
|
|
d96f9c6bf5 | ||
|
|
4875c4beb9 | ||
|
|
3f7082a3e6 | ||
|
|
23ecccfaad | ||
|
|
332c16ebb5 |
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Services\ClusterCompetitiveness;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ClusterCompetitivenessController extends Controller
|
||||
{
|
||||
public function show(EconomicCluster $cluster, ClusterCompetitiveness $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->management($cluster->id))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function change(Request $request, EconomicCluster $cluster, string $action, ClusterCompetitiveness $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['expected_version' => 'required|integer|min:0',
|
||||
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
|
||||
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
|
||||
'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
|
||||
|
||||
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
|
||||
->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,7 @@ class InvestmentPortfolioController extends Controller
|
||||
|
||||
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
|
||||
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ class InvestmentStructureController extends Controller
|
||||
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
|
||||
->orderBy('origins.code')->orderBy('destinations.code')
|
||||
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
|
||||
'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null,
|
||||
'viewerId' => $request->user()->id,
|
||||
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
|
||||
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ClusterCompetitiveness
|
||||
{
|
||||
public const FORCES = ['rivalry', 'entrants', 'substitutes', 'suppliers', 'buyers'];
|
||||
|
||||
public const FIELDS = ['evidence', 'source', 'period', 'rationale', 'limitations', 'opportunity'];
|
||||
|
||||
private const SOURCE = 'cluster-competitiveness';
|
||||
|
||||
private function events(string $cluster): Builder
|
||||
{
|
||||
return DB::table('audit_events')->where('source_system', self::SOURCE)
|
||||
->where('entity_type', 'economic_clusters')->where('entity_id', $cluster);
|
||||
}
|
||||
|
||||
private function decode(?object $event): array
|
||||
{
|
||||
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
|
||||
: ['version' => 0, 'draft' => null, 'approved' => null];
|
||||
}
|
||||
|
||||
public function current(string $cluster): array
|
||||
{
|
||||
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
|
||||
}
|
||||
|
||||
public function management(string $cluster): array
|
||||
{
|
||||
$state = $this->current($cluster);
|
||||
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
|
||||
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
|
||||
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function validateDraft(array $draft, string $cluster): array
|
||||
{
|
||||
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
|
||||
'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000',
|
||||
'forces' => 'required|array:'.implode(',', self::FORCES)];
|
||||
foreach (self::FORCES as $force) {
|
||||
$rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS);
|
||||
$rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high';
|
||||
foreach (self::FIELDS as $field) {
|
||||
$rules["forces.$force.$field"] = 'present|nullable|string|max:5000';
|
||||
}
|
||||
}
|
||||
$clean = Validator::make($draft, $rules)->validate();
|
||||
$clean['version'] = 1;
|
||||
$clean['sector'] = trim($clean['sector'] ?? '');
|
||||
$clean['region'] = trim($clean['region'] ?? '');
|
||||
foreach (self::FORCES as $force) {
|
||||
foreach (['pressure', ...self::FIELDS] as $field) {
|
||||
$clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function assertComplete(?array $draft): void
|
||||
{
|
||||
$missing = ! $draft || ! $draft['sector'] || ! $draft['region'];
|
||||
foreach (self::FORCES as $force) {
|
||||
$row = $draft['forces'][$force] ?? [];
|
||||
$missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true);
|
||||
foreach (self::FIELDS as $field) {
|
||||
$missing = $missing || trim($row[$field] ?? '') === '';
|
||||
}
|
||||
}
|
||||
if ($missing) {
|
||||
throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */
|
||||
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
|
||||
{
|
||||
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
|
||||
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
|
||||
$before = $this->current($cluster->id);
|
||||
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
|
||||
$state = $before;
|
||||
if ($action === 'saved') {
|
||||
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
|
||||
} elseif ($action === 'restored') {
|
||||
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
|
||||
abort_unless($event, 404);
|
||||
$state['draft'] = $this->decode($event)['draft'];
|
||||
} elseif ($action === 'approved') {
|
||||
$this->assertComplete($state['draft']);
|
||||
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor,
|
||||
'version' => $expected + 1, 'evidence' => $input['reason']];
|
||||
} elseif ($action === 'withdrawn') {
|
||||
$state['approved'] = null;
|
||||
} else {
|
||||
throw new \LogicException('Unsupported competitiveness action');
|
||||
}
|
||||
$state['version']++;
|
||||
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
|
||||
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
|
||||
'action' => $action, 'data_classification' => 'restricted',
|
||||
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
|
||||
'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null,
|
||||
'reason' => Crypt::encryptString($input['reason'] ?? ''),
|
||||
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $this->management($cluster->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function publicData(string $cluster): ?array
|
||||
{
|
||||
$approved = $this->current($cluster)['approved'];
|
||||
|
||||
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
|
||||
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
|
||||
}
|
||||
}
|
||||
@@ -1819,3 +1819,48 @@ Incluídos: consulta provincial nacional/corredores e detalhe territorial; pesqu
|
||||
Conferidos o commit servido, APIs filtradas e assets instalados. Reutilizadas as provas QA aceites; a bateria obrigatória do pipeline não reabre a sua aceitação. Mantém-se pendente a conferência visual autenticada, incluindo navegação, ida/volta a recursos e Gurué no mesmo dossier. A recusa relatada em Turismo ocorreu após um botão; a falha concreta do CTA de cadastro foi corrigida, mas não se declara reproduzida e resolvida a acção original sem a sessão real. A escolha do formulário fiscal concreto ainda precisa de ser esclarecida; os diplomas IVA mostrados não são formulários preenchíveis.
|
||||
|
||||
Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios QA anteriores conservam o estado histórico antes da instalação. As 435 observações de Gurué não são 435 empresas; as listas são internas, a cobertura nacional é parcial e a comparação sustenta hipóteses. L8 permanece parcial. Actualizados V2-01/V2-07/V2-10 na matriz e manual existentes.
|
||||
|
||||
|
||||
## 28/10/2026 — preparação de formulários fiscais, inventário antes das provas
|
||||
|
||||
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
|
||||
|
||||
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
|
||||
|
||||
|
||||
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
|
||||
|
||||
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
|
||||
|
||||
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
|
||||
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
|
||||
|
||||
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
|
||||
|
||||
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
|
||||
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
|
||||
|
||||
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
|
||||
|
||||
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
|
||||
### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso
|
||||
|
||||
Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos.
|
||||
|
||||
Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior.
|
||||
### ACT-01 — prova específica antes da instalação, 29/10/2026
|
||||
|
||||
Sete testes Feature/93 asserções passaram na base restaurada competitiveness_persistence_20261029_test, porta 55441: gravação e leitura HTTP entre pedidos, retorno pela edição da gestão, histórico cifrado, validação de campos/cluster/forças, normalização do formato recuperável, conflitos sem sobrescrita, aprovação fixa, rascunhos posteriores sem divulgação, recuperação histórica como nova versão, retirada e exclusão do conteúdo não aprovado na consulta pública. Utilizadores sem permissão e pedidos anónimos foram recusados na gestão. O teste público percorreu a ficha publicada e confirmou 404 da ficha não publicada. Zero escritas na base activa. Esta prova não afirma conferência visual autenticada nem correcção dos filtros provinciais, que permanecem abertos. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json. Compilação QA, tipos e quatro testes frontend dirigidos passaram; instalação ainda pendente.
|
||||
### 29/10/2026 — ACT-01, gravação/recuperação e consulta aprovada instaladas: b81f477b
|
||||
|
||||
Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa para retorno. Gestão → Clusters → Editar → Competitividade agora recupera o rascunho do servidor e permite gravar, consultar a versão actual, confirmar recuperação e recuperar uma versão histórica como nova gravação. Versões cifradas e imutáveis na auditoria existente, com bloqueio transaccional do cluster e controlo de versão; conflitos conservam a versão guardada e os campos do editor. Reutilizada a permissão existente de gestão, sem novos papéis ou aprovadores. Rascunhos incompletos podem ser guardados; a aprovação exige âmbito e fundamentos das cinco forças.
|
||||
|
||||
**Percurso da gestão:** preencher → Guardar rascunho no servidor → voltar à edição para recuperar → consultar histórico quando necessário. Para comparar com outra gravação, usar Consultar versão actual do servidor e confirmar antes de substituir os campos; transferir primeiro edições que se queira conservar. Recuperar o histórico cria nova versão e mantém o histórico e a aprovação pública existente. A aprovação ou retirada é expressa, com evidência/motivo registado. A ficha pública de um cluster publicado mostra exclusivamente a análise aprovada; novas gravações privadas não alteram essa versão. Uma ficha não publicada continua indisponível ao público. Não houve aprovação nem divulgação automática de análises.
|
||||
|
||||
**Prova específica:** sete testes Feature/93 asserções na base restaurada e isolada competitiveness_persistence_20261029_test, porta55441. Abrange gravação/recuperação HTTP, retorno na gestão, validação e formato, histórico cifrado, conflito, acesso recusado, aprovação fixa, rascunho posterior privado, recuperação, retirada e consulta pública/404 de ficha não publicada. Os 1.180 testes frontend não são apresentados como prova substituta de persistência. Pipeline: tipos, compilação, lifecycle de schema1/3106, Unit59/320 e frontend1180 sem falhas. Dez ficheiros de código/testes do instalado conferidos por SHA-256 com os da QA; C132 no destino passou.
|
||||
|
||||
**Preservação e limites:** backup fresco/restauro isolado176; comparação posterior de176 tabelas sem alterações de domínio, apenas diferenças operacionais de cache/sessões; 369 ficheiros/configuração idênticos. Ligação real teste_investidor_app conferida em leitura, com privilégios existentes de SELECT/INSERT em auditoria e bloqueio do cluster; endpoint de gestão anónimo HTTP401. Nenhuma escrita sintética na base activa, migração, importação, mudança de permissões ou nova publicação. A conferência visual autenticada no instalado permanece pendente; não foi simulada uma decisão real de conteúdo no activo. ACT-01 tem agora persistência e consulta aprovada implementadas/instaladas, mantendo parcial a cobertura factual por sector/região. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json.
|
||||
|
||||
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
|
||||
|
||||
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"checks": {
|
||||
"feature_tests": 7,
|
||||
"isolated_build": "passed",
|
||||
"feature_assertions": 93,
|
||||
"frontend_tests": 4,
|
||||
"types": "passed"
|
||||
},
|
||||
"tested_candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
|
||||
"schema_changes": 0,
|
||||
"active_writes": 0,
|
||||
"source_sha256": {
|
||||
"tests/Feature/ClusterCompetitivenessTest.php": "3c4b27fbc2c00210bd6be78057d88d03f54676c82907fafcbe57843ea7d726cb",
|
||||
"app/Http/Controllers/InvestmentPortfolioController.php": "0793d06bf94b679b301f4aedbd54d73184ed7b27d3100036dfc81f4621c101d6",
|
||||
"resources/js/pages/portfolio/show.tsx": "6562798defc1546466b1ed3c66e0d51b1c6c1b08f03c8d5acc915877792ce7da",
|
||||
"routes/web.php": "76501452ad4ce187ce234127ef160a5a458f629d583d9ccb3c67c1c9d4a1a561",
|
||||
"resources/js/components/cluster-competitiveness-public.tsx": "85ab2f59de4c63faf6ea699855bbcf0c9efaea0ff9c39421d5640286e9df5910",
|
||||
"app/Http/Controllers/ClusterCompetitivenessController.php": "12ab13860da1acda02dd77f38d0ddd0d1b8fca5856f7fa8823e9e6193143368e",
|
||||
"app/Services/ClusterCompetitiveness.php": "24cae918c14a84470b7fc2e1485eacd9c4e2306a54928ca9de6a56576f5227df",
|
||||
"resources/js/pages/structures/editor.tsx": "c6d50445de3c1f18b266afe5898cd31198fc5946606770ac74a5f01e6e67b712",
|
||||
"resources/js/pages/structures/competitiveness.tsx": "8c4253cef8450b47245a918712bb81fe0ff2a333f54726a38239264a398f4fde",
|
||||
"app/Http/Controllers/InvestmentStructureController.php": "b0d11991240fa5f6fa0d56a4c66b63f68665d0687b3772b4f8ca632460499930"
|
||||
},
|
||||
"baseline": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"provincial_filters": "separate_and_open_not_changed_or_verified",
|
||||
"remaining": [
|
||||
"authenticated visual conference of the installed management workflow",
|
||||
"factual sector and regional analysis and explicit content approvals",
|
||||
"provincial filter issue remains separate and open",
|
||||
"remaining planned ACT activities"
|
||||
],
|
||||
"status": "installed_specific_persistence_QA_and_preservation_verified_visual_pending",
|
||||
"proof": {
|
||||
"candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
|
||||
"status": "passed",
|
||||
"database": "competitiveness_persistence_20261029_test",
|
||||
"port": 55441,
|
||||
"snapshot_restored": true,
|
||||
"active_database_writes": 0,
|
||||
"schema_changes": 0,
|
||||
"log": "/var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/qa-568a488ae76c631714547614f52ca6be7a121e2d.log",
|
||||
"summary": "PHPUnit 12.5.34 by Sebastian Bergmann and contributors.\n\nRuntime: PHP 8.4.27\nConfiguration: /var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/568a488ae76c631714547614f52ca6be7a121e2d-1793273608890561258/phpunit.xml\n\n....... 7 / 7 (100%)\n\nTime: 00:04.973, Memory: 58.50 MB\n\n\u001b[30;42mOK (7 tests, 93 assertions)\u001b[0m\n"
|
||||
},
|
||||
"installed_candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"backup": {
|
||||
"stage": "/var/backups/investmoz-competitiveness-persistence-20261029/backup",
|
||||
"created_at": "2026-10-29T11:32:49Z",
|
||||
"target": {
|
||||
"database": "teste_investidor",
|
||||
"port": 5432
|
||||
},
|
||||
"tables": 176,
|
||||
"files_verified": 369,
|
||||
"dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"files_sha256": "bb615875347f7b27da593f148af973fc825555524a261f8f68c50cd4ce26962c",
|
||||
"restore_verified": false
|
||||
},
|
||||
"restore": {
|
||||
"restore_verified": true,
|
||||
"tables_verified": 176,
|
||||
"container_network": "none",
|
||||
"database": "competitiveness_persistence_restore_20261029_test",
|
||||
"port": 55441,
|
||||
"source_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"active_database_writes": 0
|
||||
},
|
||||
"code": {
|
||||
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"source_files_verified": 10,
|
||||
"diagnostics_C132": {
|
||||
"result": "passed",
|
||||
"case": "C132",
|
||||
"source_explicit_constant_OFF": true,
|
||||
"effective_configuration_OFF": true,
|
||||
"configuration_cached": false,
|
||||
"database_connections_used": 0,
|
||||
"active_writes": 0
|
||||
},
|
||||
"active_writes": 0
|
||||
},
|
||||
"runtime": {
|
||||
"identity": {
|
||||
"database": "teste_investidor",
|
||||
"role": "teste_investidor_app",
|
||||
"port": 5432,
|
||||
"audit_read": true,
|
||||
"audit_append": true,
|
||||
"cluster_lock": true
|
||||
},
|
||||
"management_read_from_database": true,
|
||||
"server_version": 0,
|
||||
"history_items": 0,
|
||||
"approved_projection_available": false,
|
||||
"actor_impersonation": false,
|
||||
"active_domain_writes": 0,
|
||||
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"anonymous_management_status": 401,
|
||||
"persistence_actions_compiled": true,
|
||||
"specific_write_recovery_and_public_projection_QA": "7 tests / 93 assertions; no synthetic writes on active database",
|
||||
"provincial_filters": "separate and open; not changed or confirmed here",
|
||||
"authenticated_visual_conference": "pending"
|
||||
},
|
||||
"preservation": {
|
||||
"navigation_asset_and_shared_props_verified": true,
|
||||
"at": "2026-10-29T11:42:15.258836+00:00",
|
||||
"served_commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"backup_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"prior_public_tables_checked": 176,
|
||||
"prior_domain_rows_preserved": true,
|
||||
"new_migration_registry_rows": 0,
|
||||
"operational_delta": {
|
||||
"cache": {
|
||||
"before": {
|
||||
"rows": 119,
|
||||
"checksum": "5ddea5ecf35f8dc04d275b189821b59a"
|
||||
},
|
||||
"after": {
|
||||
"rows": 119,
|
||||
"checksum": "8336a458d425e14c66b20c3da9aa01de"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"before": {
|
||||
"rows": 10,
|
||||
"checksum": "aa65128c0bafc9eab8419174754327d7"
|
||||
},
|
||||
"after": {
|
||||
"rows": 12,
|
||||
"checksum": "41f44e385b8a31b0af1f22e1a29680ce"
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent_files_and_configuration_identical": 369,
|
||||
"private_catalogue": {
|
||||
"observations": 361464,
|
||||
"distinct_administrative_observations": 214299,
|
||||
"preserved_repetitions": 147165,
|
||||
"unmapped_territory": 205608
|
||||
},
|
||||
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
|
||||
"anonymous_query_status": 401,
|
||||
"public_revision_confirmed": true,
|
||||
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
|
||||
"unique_company_count": null,
|
||||
"publication": false,
|
||||
"national_coverage": "partial",
|
||||
"investment_conclusion": "hypothesis,not proved demand or viability",
|
||||
"L8": "unchanged,partial"
|
||||
},
|
||||
"pipeline": {
|
||||
"unit_tests": 59,
|
||||
"summaries": [
|
||||
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
|
||||
"✓ built in 33.96s",
|
||||
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
|
||||
"# tests 1180",
|
||||
"# pass 1180",
|
||||
"# fail 0"
|
||||
],
|
||||
"schema_assertions": 3106,
|
||||
"frontend_tests": 1180,
|
||||
"result": {
|
||||
"status": "installed",
|
||||
"commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"release": "/var/www/investmoz-homologacao/releases/b81f477bf2385227ed3099868e80b1e3c7bcb46b-5eed771e",
|
||||
"previous_release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"requested_commits": [
|
||||
"b81f477bf2385227ed3099868e80b1e3c7bcb46b"
|
||||
],
|
||||
"finished_at": "2026-10-29T11:39:35.927983+00:00"
|
||||
},
|
||||
"frontend_failures": 0,
|
||||
"unit_assertions": 320
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"activity": "ACT-20261029-01",
|
||||
"status": "partial_increment_installed_preservation_verified_visual_pending",
|
||||
"baseline": "332c16ebb555e8d6b244fa519bc777d89c1a8b8f",
|
||||
"branch": "codex/competitiveness-sequential-20261029",
|
||||
"target": "https://teste-investidor.tempo.co.mz/",
|
||||
"checks": {
|
||||
"diff": "passed",
|
||||
"types": "passed",
|
||||
"diagnostics_C132": "passed_in_original_workspace",
|
||||
"frontend_tests": 4,
|
||||
"isolated_qa_build": "passed"
|
||||
},
|
||||
"active_database_writes": 0,
|
||||
"schema_changes": false,
|
||||
"permission_changes": false,
|
||||
"coverage": [
|
||||
"five qualitative forces in existing cluster management",
|
||||
"sector and regional scope",
|
||||
"evidence, source, period, rationale, limitations and investment opportunity",
|
||||
"explicit uncertainty",
|
||||
"JSON download and confirmed same-cluster recovery"
|
||||
],
|
||||
"remaining": [
|
||||
"authenticated visual verification",
|
||||
"server persistence and canonical territorial scope",
|
||||
"factual sector and regional data",
|
||||
"public consultation"
|
||||
],
|
||||
"source_sha256": {
|
||||
"resources/js/lib/cluster-competitiveness.ts": "1ed496f51f020da9f0c02ab0aa324b58a9dd17fa34a10d8c1a90d9915800fecb",
|
||||
"resources/js/pages/structures/competitiveness.tsx": "55338c144cefae4f91cef765b8e5e8b28ceb6c76375d46943c3c3430aeddc68e",
|
||||
"resources/js/pages/structures/editor.tsx": "080a90ab8607f05ec493e560ed8280a6f7eec743ed1954f0057a0ce6caa09208",
|
||||
"tests/frontend/cluster-competitiveness.test.mjs": "f48a9ed872f49ad900424daab4689cb0eeeb31cc93ebf1c608408c0a8c2b435b"
|
||||
},
|
||||
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"backup": {
|
||||
"stage": "/var/backups/investmoz-competitiveness-delivery-20261029/backup",
|
||||
"created_at": "2026-10-29T11:04:46Z",
|
||||
"target": {
|
||||
"database": "teste_investidor",
|
||||
"port": 5432
|
||||
},
|
||||
"tables": 176,
|
||||
"files_verified": 369,
|
||||
"dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
|
||||
"files_sha256": "8e0022b73cd0074e36f8efef0b7c803a386e24e6e4cf85f567757ff67e311369",
|
||||
"restore_verified": false
|
||||
},
|
||||
"restore": {
|
||||
"restore_verified": true,
|
||||
"tables_verified": 176,
|
||||
"container_network": "none",
|
||||
"database": "competitiveness_delivery_20261029_test",
|
||||
"port": 55441,
|
||||
"source_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
|
||||
"active_database_writes": 0
|
||||
},
|
||||
"preservation": {
|
||||
"navigation_asset_and_shared_props_verified": true,
|
||||
"at": "2026-10-29T11:10:44.303271+00:00",
|
||||
"served_commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"backup_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
|
||||
"prior_public_tables_checked": 176,
|
||||
"prior_domain_rows_preserved": true,
|
||||
"new_migration_registry_rows": 0,
|
||||
"operational_delta": {
|
||||
"cache": {
|
||||
"before": {
|
||||
"rows": 119,
|
||||
"checksum": "5f3c36637fbf1e9dc4700066d4c45530"
|
||||
},
|
||||
"after": {
|
||||
"rows": 119,
|
||||
"checksum": "b1c9949cfacc34906c406e27e95cbd9b"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"before": {
|
||||
"rows": 37,
|
||||
"checksum": "b0e409c4c77c58b48252d9847c3c61ef"
|
||||
},
|
||||
"after": {
|
||||
"rows": 38,
|
||||
"checksum": "f4d94db55488bc0bcf6f727f222473c1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent_files_and_configuration_identical": 369,
|
||||
"private_catalogue": {
|
||||
"observations": 361464,
|
||||
"distinct_administrative_observations": 214299,
|
||||
"preserved_repetitions": 147165,
|
||||
"unmapped_territory": 205608
|
||||
},
|
||||
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
|
||||
"anonymous_query_status": 401,
|
||||
"public_revision_confirmed": true,
|
||||
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
|
||||
"unique_company_count": null,
|
||||
"publication": false,
|
||||
"national_coverage": "partial",
|
||||
"investment_conclusion": "hypothesis,not proved demand or viability",
|
||||
"L8": "unchanged,partial"
|
||||
},
|
||||
"installed_code": {
|
||||
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"source_files_verified": 4,
|
||||
"diagnostics_C132": {
|
||||
"result": "passed",
|
||||
"case": "C132",
|
||||
"source_explicit_constant_OFF": true,
|
||||
"effective_configuration_OFF": true,
|
||||
"configuration_cached": false,
|
||||
"database_connections_used": 0,
|
||||
"active_writes": 0
|
||||
},
|
||||
"active_writes": 0
|
||||
},
|
||||
"pipeline": {
|
||||
"unit_tests": 59,
|
||||
"frontend_failures": 0,
|
||||
"schema_assertions": 3106,
|
||||
"checks": [
|
||||
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
|
||||
"✓ built in 34.50s",
|
||||
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
|
||||
"# tests 1180",
|
||||
"# pass 1180",
|
||||
"# fail 0"
|
||||
],
|
||||
"frontend_tests": 1180,
|
||||
"result": {
|
||||
"status": "installed",
|
||||
"commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
|
||||
"previous_release": "/var/www/investmoz-homologacao/releases/332c16ebb555e8d6b244fa519bc777d89c1a8b8f-f0dfc218",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"requested_commits": [
|
||||
"23ecccfaad1053f55bf0591fd242cb5fa3406c38"
|
||||
],
|
||||
"finished_at": "2026-10-29T11:10:02.884033+00:00"
|
||||
},
|
||||
"unit_assertions": 320
|
||||
}
|
||||
}
|
||||
@@ -7377,3 +7377,48 @@ Incluídos: consulta provincial nacional/corredores e detalhe territorial; pesqu
|
||||
Conferidos o commit servido, APIs filtradas e assets instalados. Reutilizadas as provas QA aceites; a bateria obrigatória do pipeline não reabre a sua aceitação. Mantém-se pendente a conferência visual autenticada, incluindo navegação, ida/volta a recursos e Gurué no mesmo dossier. A recusa relatada em Turismo ocorreu após um botão; a falha concreta do CTA de cadastro foi corrigida, mas não se declara reproduzida e resolvida a acção original sem a sessão real. A escolha do formulário fiscal concreto ainda precisa de ser esclarecida; os diplomas IVA mostrados não são formulários preenchíveis.
|
||||
|
||||
Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios QA anteriores conservam o estado histórico antes da instalação. As 435 observações de Gurué não são 435 empresas; as listas são internas, a cobertura nacional é parcial e a comparação sustenta hipóteses. L8 permanece parcial. Actualizados V2-01/V2-07/V2-10 na matriz e manual existentes.
|
||||
|
||||
|
||||
## 28/10/2026 — preparação de formulários fiscais, inventário antes das provas
|
||||
|
||||
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
|
||||
|
||||
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
|
||||
|
||||
|
||||
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
|
||||
|
||||
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
|
||||
|
||||
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
|
||||
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
|
||||
|
||||
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
|
||||
|
||||
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
|
||||
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
|
||||
|
||||
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
|
||||
|
||||
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
|
||||
### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso
|
||||
|
||||
Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos.
|
||||
|
||||
Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior.
|
||||
### ACT-01 — prova específica antes da instalação, 29/10/2026
|
||||
|
||||
Sete testes Feature/93 asserções passaram na base restaurada competitiveness_persistence_20261029_test, porta 55441: gravação e leitura HTTP entre pedidos, retorno pela edição da gestão, histórico cifrado, validação de campos/cluster/forças, normalização do formato recuperável, conflitos sem sobrescrita, aprovação fixa, rascunhos posteriores sem divulgação, recuperação histórica como nova versão, retirada e exclusão do conteúdo não aprovado na consulta pública. Utilizadores sem permissão e pedidos anónimos foram recusados na gestão. O teste público percorreu a ficha publicada e confirmou 404 da ficha não publicada. Zero escritas na base activa. Esta prova não afirma conferência visual autenticada nem correcção dos filtros provinciais, que permanecem abertos. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json. Compilação QA, tipos e quatro testes frontend dirigidos passaram; instalação ainda pendente.
|
||||
### 29/10/2026 — ACT-01, gravação/recuperação e consulta aprovada instaladas: b81f477b
|
||||
|
||||
Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa para retorno. Gestão → Clusters → Editar → Competitividade agora recupera o rascunho do servidor e permite gravar, consultar a versão actual, confirmar recuperação e recuperar uma versão histórica como nova gravação. Versões cifradas e imutáveis na auditoria existente, com bloqueio transaccional do cluster e controlo de versão; conflitos conservam a versão guardada e os campos do editor. Reutilizada a permissão existente de gestão, sem novos papéis ou aprovadores. Rascunhos incompletos podem ser guardados; a aprovação exige âmbito e fundamentos das cinco forças.
|
||||
|
||||
**Percurso da gestão:** preencher → Guardar rascunho no servidor → voltar à edição para recuperar → consultar histórico quando necessário. Para comparar com outra gravação, usar Consultar versão actual do servidor e confirmar antes de substituir os campos; transferir primeiro edições que se queira conservar. Recuperar o histórico cria nova versão e mantém o histórico e a aprovação pública existente. A aprovação ou retirada é expressa, com evidência/motivo registado. A ficha pública de um cluster publicado mostra exclusivamente a análise aprovada; novas gravações privadas não alteram essa versão. Uma ficha não publicada continua indisponível ao público. Não houve aprovação nem divulgação automática de análises.
|
||||
|
||||
**Prova específica:** sete testes Feature/93 asserções na base restaurada e isolada competitiveness_persistence_20261029_test, porta55441. Abrange gravação/recuperação HTTP, retorno na gestão, validação e formato, histórico cifrado, conflito, acesso recusado, aprovação fixa, rascunho posterior privado, recuperação, retirada e consulta pública/404 de ficha não publicada. Os 1.180 testes frontend não são apresentados como prova substituta de persistência. Pipeline: tipos, compilação, lifecycle de schema1/3106, Unit59/320 e frontend1180 sem falhas. Dez ficheiros de código/testes do instalado conferidos por SHA-256 com os da QA; C132 no destino passou.
|
||||
|
||||
**Preservação e limites:** backup fresco/restauro isolado176; comparação posterior de176 tabelas sem alterações de domínio, apenas diferenças operacionais de cache/sessões; 369 ficheiros/configuração idênticos. Ligação real teste_investidor_app conferida em leitura, com privilégios existentes de SELECT/INSERT em auditoria e bloqueio do cluster; endpoint de gestão anónimo HTTP401. Nenhuma escrita sintética na base activa, migração, importação, mudança de permissões ou nova publicação. A conferência visual autenticada no instalado permanece pendente; não foi simulada uma decisão real de conteúdo no activo. ACT-01 tem agora persistência e consulta aprovada implementadas/instaladas, mantendo parcial a cobertura factual por sector/região. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json.
|
||||
|
||||
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
|
||||
|
||||
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
|
||||
@@ -3006,3 +3006,41 @@ AUD-SIM-INT01 through AUD-SIM-INT08 are recorded in the same matrix and manual.
|
||||
Actualização QA de 20/10/2026: os mesmos oito itens foram reconciliados no manual e nas duas tabelas existentes. Data com mensagem junto ao campo; inventário dos parâmetros efectivos (zero regras fiscais QA); controlos matemáticos independentes; aviso de recálculo; quatro relatórios reais do exportador com 6/6/6/5 páginas; consulta fiscal com erro/nova tentativa e traduções comuns verificados. O formulário personalizado já não exige contexto fiscal que não envia ao cálculo. Restam continuidade autenticada entre dois dossiers/nova sessão e histórico, mudança de parâmetros, controlos delimitados de cash-flow/plano, impressão nativa Edge/comparação pela interface e mensagens dinâmicas/falhas dirigidas de gravação/exportação. Estimativa restante **6–10 h**, distinta das 18–30 h iniciais. Sem main, servidor remoto, importação ou activação fiscal. Exemplos PDF são renderizações do DOCX; não certificam impressão do Edge. Ver o saldo detalhado AUD-SIM-INT01–08 no mesmo manual.
|
||||
|
||||
Continuação delimitada: dois dossiers e nova entrada conferidos no navegador; histórico agora visível no percurso de pequeno negócio, hashes preservados; Abrir cálculo encaminha para a etapa financeira; repetição de gravação após 503 corrigida e conferida; controlos independentes de tesouraria/plano passaram. CSV e Word descarregados pela interface coincidem nos valores comuns e apresentam a receita completa do crédito. Seis páginas do DOCX conferidas; PDF nativo ainda aguarda gravação pelo utilizador. QA tem zero regras com visibilidade completa, mas existem três originais fiscais versionados e análises/referências reutilizáveis; não se concluiu ausência na homologação sem autenticação SSH. Versões sintéticas testadas apenas em memória, sem cadastro/activação. Encontrada e corrigida perda dos pressupostos da tesouraria: snapshot no JSON existente, recuperação por dossier e relatório, sem migration; testes HTTP/frontend passaram, falta conferência visual deste novo delta. Saldo **2–4 h** após acessos/ficheiro, detalhado no mesmo manual; integração do motor fiscal permanece separada e não foi implementada. Sem main ou intervenção remota.
|
||||
|
||||
|
||||
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
|
||||
|
||||
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
|
||||
|
||||
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
|
||||
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
|
||||
|
||||
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
|
||||
|
||||
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
|
||||
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
|
||||
|
||||
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
|
||||
|
||||
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
|
||||
### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso
|
||||
|
||||
Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos.
|
||||
|
||||
Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior.
|
||||
### ACT-01 — prova específica antes da instalação, 29/10/2026
|
||||
|
||||
Sete testes Feature/93 asserções passaram na base restaurada competitiveness_persistence_20261029_test, porta 55441: gravação e leitura HTTP entre pedidos, retorno pela edição da gestão, histórico cifrado, validação de campos/cluster/forças, normalização do formato recuperável, conflitos sem sobrescrita, aprovação fixa, rascunhos posteriores sem divulgação, recuperação histórica como nova versão, retirada e exclusão do conteúdo não aprovado na consulta pública. Utilizadores sem permissão e pedidos anónimos foram recusados na gestão. O teste público percorreu a ficha publicada e confirmou 404 da ficha não publicada. Zero escritas na base activa. Esta prova não afirma conferência visual autenticada nem correcção dos filtros provinciais, que permanecem abertos. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json. Compilação QA, tipos e quatro testes frontend dirigidos passaram; instalação ainda pendente.
|
||||
### 29/10/2026 — ACT-01, gravação/recuperação e consulta aprovada instaladas: b81f477b
|
||||
|
||||
Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa para retorno. Gestão → Clusters → Editar → Competitividade agora recupera o rascunho do servidor e permite gravar, consultar a versão actual, confirmar recuperação e recuperar uma versão histórica como nova gravação. Versões cifradas e imutáveis na auditoria existente, com bloqueio transaccional do cluster e controlo de versão; conflitos conservam a versão guardada e os campos do editor. Reutilizada a permissão existente de gestão, sem novos papéis ou aprovadores. Rascunhos incompletos podem ser guardados; a aprovação exige âmbito e fundamentos das cinco forças.
|
||||
|
||||
**Percurso da gestão:** preencher → Guardar rascunho no servidor → voltar à edição para recuperar → consultar histórico quando necessário. Para comparar com outra gravação, usar Consultar versão actual do servidor e confirmar antes de substituir os campos; transferir primeiro edições que se queira conservar. Recuperar o histórico cria nova versão e mantém o histórico e a aprovação pública existente. A aprovação ou retirada é expressa, com evidência/motivo registado. A ficha pública de um cluster publicado mostra exclusivamente a análise aprovada; novas gravações privadas não alteram essa versão. Uma ficha não publicada continua indisponível ao público. Não houve aprovação nem divulgação automática de análises.
|
||||
|
||||
**Prova específica:** sete testes Feature/93 asserções na base restaurada e isolada competitiveness_persistence_20261029_test, porta55441. Abrange gravação/recuperação HTTP, retorno na gestão, validação e formato, histórico cifrado, conflito, acesso recusado, aprovação fixa, rascunho posterior privado, recuperação, retirada e consulta pública/404 de ficha não publicada. Os 1.180 testes frontend não são apresentados como prova substituta de persistência. Pipeline: tipos, compilação, lifecycle de schema1/3106, Unit59/320 e frontend1180 sem falhas. Dez ficheiros de código/testes do instalado conferidos por SHA-256 com os da QA; C132 no destino passou.
|
||||
|
||||
**Preservação e limites:** backup fresco/restauro isolado176; comparação posterior de176 tabelas sem alterações de domínio, apenas diferenças operacionais de cache/sessões; 369 ficheiros/configuração idênticos. Ligação real teste_investidor_app conferida em leitura, com privilégios existentes de SELECT/INSERT em auditoria e bloqueio do cluster; endpoint de gestão anónimo HTTP401. Nenhuma escrita sintética na base activa, migração, importação, mudança de permissões ou nova publicação. A conferência visual autenticada no instalado permanece pendente; não foi simulada uma decisão real de conteúdo no activo. ACT-01 tem agora persistência e consulta aprovada implementadas/instaladas, mantendo parcial a cobertura factual por sector/região. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json.
|
||||
|
||||
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
|
||||
|
||||
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
|
||||
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
|
||||
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
|
||||
|
||||
export type ApprovedCompetitiveness = {
|
||||
draft: CompetitivenessDraft;
|
||||
version: number;
|
||||
at: string;
|
||||
};
|
||||
const pressures = {
|
||||
low: 'Baixa',
|
||||
medium: 'Média',
|
||||
high: 'Alta',
|
||||
'': 'Indeterminada',
|
||||
};
|
||||
|
||||
export default function ClusterCompetitivenessPublic({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ApprovedCompetitiveness;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Competitividade aprovada"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Competitividade — cinco forças de Porter
|
||||
</h2>
|
||||
<p>
|
||||
{analysis.draft.sector} · {analysis.draft.region} · Versão
|
||||
aprovada {analysis.version} · {analysis.at}
|
||||
</p>
|
||||
<p>
|
||||
Apreciação qualitativa fundamentada nas fontes e períodos
|
||||
indicados. Não representa garantia de procura ou viabilidade.
|
||||
</p>
|
||||
{forces.map((force) => {
|
||||
const row = analysis.draft.forces[force];
|
||||
return (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary>
|
||||
{forceLabels[force]} · Pressão{' '}
|
||||
{pressures[row.pressure]}
|
||||
</summary>
|
||||
<dl className="space-y-2">
|
||||
{(
|
||||
[
|
||||
['evidence', 'Evidências'],
|
||||
['source', 'Fonte e referência'],
|
||||
['period', 'Período'],
|
||||
['rationale', 'Justificação'],
|
||||
['limitations', 'Limitações'],
|
||||
[
|
||||
'opportunity',
|
||||
'Oportunidade e fundamento',
|
||||
],
|
||||
] as const
|
||||
).map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{row[field]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export const forceLabels = {
|
||||
rivalry: 'Rivalidade entre concorrentes',
|
||||
entrants: 'Ameaça de novos entrantes',
|
||||
substitutes: 'Ameaça de produtos ou serviços substitutos',
|
||||
suppliers: 'Poder negocial dos fornecedores',
|
||||
buyers: 'Poder negocial dos clientes',
|
||||
} as const;
|
||||
|
||||
export type Force = keyof typeof forceLabels;
|
||||
export type Assessment = {
|
||||
pressure: '' | 'low' | 'medium' | 'high';
|
||||
evidence: string;
|
||||
source: string;
|
||||
period: string;
|
||||
rationale: string;
|
||||
limitations: string;
|
||||
opportunity: string;
|
||||
};
|
||||
export type CompetitivenessDraft = {
|
||||
version: 1;
|
||||
clusterId: string;
|
||||
sector: string;
|
||||
region: string;
|
||||
forces: Record<Force, Assessment>;
|
||||
};
|
||||
|
||||
export const forces = Object.keys(forceLabels) as Force[];
|
||||
const fields = [
|
||||
'evidence',
|
||||
'source',
|
||||
'period',
|
||||
'rationale',
|
||||
'limitations',
|
||||
'opportunity',
|
||||
] as const;
|
||||
|
||||
export function newCompetitivenessDraft(
|
||||
clusterId: string,
|
||||
sector: string,
|
||||
): CompetitivenessDraft {
|
||||
return {
|
||||
version: 1,
|
||||
clusterId,
|
||||
sector,
|
||||
region: '',
|
||||
forces: Object.fromEntries(
|
||||
forces.map((force) => [
|
||||
force,
|
||||
{
|
||||
pressure: '',
|
||||
evidence: '',
|
||||
source: '',
|
||||
period: '',
|
||||
rationale: '',
|
||||
limitations: '',
|
||||
opportunity: '',
|
||||
},
|
||||
]),
|
||||
) as Record<Force, Assessment>,
|
||||
};
|
||||
}
|
||||
|
||||
// A pressure is an author's qualitative assessment, never inferred from missing data.
|
||||
export function assessmentReady(row: Assessment): boolean {
|
||||
return (
|
||||
['low', 'medium', 'high'].includes(row.pressure) &&
|
||||
fields.every((field) => row[field].trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function diagnosticReady(draft: CompetitivenessDraft): boolean {
|
||||
return (
|
||||
Boolean(draft.sector.trim() && draft.region.trim()) &&
|
||||
forces.every((force) => assessmentReady(draft.forces[force]))
|
||||
);
|
||||
}
|
||||
|
||||
export function readCompetitivenessDraft(
|
||||
raw: string,
|
||||
clusterId: string,
|
||||
): CompetitivenessDraft {
|
||||
if (raw.length > 250_000)
|
||||
throw Error('O ficheiro excede o limite de 250 KB.');
|
||||
const input = JSON.parse(raw);
|
||||
if (!input || input.version !== 1 || input.clusterId !== clusterId) {
|
||||
throw Error(
|
||||
'O ficheiro não corresponde a este cluster ou tem uma versão incompatível.',
|
||||
);
|
||||
}
|
||||
const text = (value: unknown, max: number): string => {
|
||||
if (typeof value !== 'string' || value.length > max)
|
||||
throw Error('O ficheiro contém campos inválidos.');
|
||||
return value;
|
||||
};
|
||||
const draft = newCompetitivenessDraft(clusterId, text(input.sector, 200));
|
||||
draft.region = text(input.region, 1000);
|
||||
for (const force of forces) {
|
||||
const row = input.forces?.[force];
|
||||
if (!row || !['', 'low', 'medium', 'high'].includes(row.pressure)) {
|
||||
throw Error('O ficheiro não contém as cinco forças válidas.');
|
||||
}
|
||||
draft.forces[force].pressure = row.pressure;
|
||||
for (const field of fields)
|
||||
draft.forces[force][field] = text(row[field], 5000);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { modeloAFields, modeloAReference } from './iva-modelo-a.ts';
|
||||
|
||||
export type FiscalFormKind = 'iva' | 'irpc';
|
||||
export const fiscalFormVersion = 'investmoz-fiscal-preparation/1';
|
||||
export const fiscalForms = {
|
||||
iva: { title: 'IVA — Modelo A', source: modeloAReference.file, hash: modeloAReference.sha256, coverage: 'Campos 01–27 do exemplar, para preenchimento manual. A edição e as divergências já documentadas continuam identificadas.' },
|
||||
irpc: { title: 'IRPC — Modelo 22', source: 'IRPC M-22- DECLARACAO DE RENDIMENTOS.pdf', hash: '4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88', coverage: 'Preparação parcial: resultado, lucro, matéria colectável e liquidação nos campos abaixo. Os restantes ajustamentos e anexos do exemplar não são preenchidos aqui. L8 permanece parcial.' },
|
||||
} as const;
|
||||
const irpcFields = [
|
||||
['200', 'Resultado do exercício'], ['259', 'Prejuízo fiscal'], ['260', 'Lucro tributável'],
|
||||
['269', 'Prejuízo fiscal — regime geral'], ['270', 'Lucro tributável — regime geral'],
|
||||
['276', 'Prejuízos fiscais deduzidos'], ['277', 'Benefícios fiscais a deduzir'], ['278', 'Matéria colectável — regime geral'],
|
||||
['300', 'Imposto à taxa normal'], ['301', 'Imposto à taxa reduzida'], ['302', 'Colecta total'],
|
||||
['303', 'Dupla tributação económica'], ['304', 'Dupla tributação internacional'], ['305', 'Benefícios fiscais'],
|
||||
['306', 'Pagamento especial por conta'], ['308', 'Total das deduções'], ['309', 'IRPC liquidado'],
|
||||
['310', 'Retenções na fonte'], ['311', 'Pagamentos por conta'], ['312', 'IRPC a pagar antes de juros'],
|
||||
['313', 'IRPC a recuperar antes de juros'], ['314', 'Juros compensatórios'], ['315', 'IRPC a pagar'], ['316', 'IRPC a recuperar'],
|
||||
] as const;
|
||||
export function fiscalFormFields(kind: FiscalFormKind) {
|
||||
return kind === 'iva'
|
||||
? modeloAFields.map(f => ({ code: f.field, label: f.purpose, guidance: f.basis }))
|
||||
: irpcFields.map(([code, label]) => ({ code, label, guidance: Number(code) < 269 ? 'Quadro 10 do exemplar.' : Number(code) < 300 ? 'Quadro 11, regime geral. Outros regimes não incluídos nesta preparação.' : 'Quadro 12 do exemplar. Preenchimento manual, sem cálculo ou validação automática da liquidação.' }));
|
||||
}
|
||||
export type FiscalPreparation = {
|
||||
version: typeof fiscalFormVersion;
|
||||
kind: FiscalFormKind;
|
||||
sourceHash: string;
|
||||
name: string;
|
||||
nuit: string;
|
||||
period: string;
|
||||
notes: string;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
export function newFiscalPreparation(kind: FiscalFormKind): FiscalPreparation {
|
||||
return { version: fiscalFormVersion, kind, sourceHash: fiscalForms[kind].hash, name: '', nuit: '', period: '', notes: '', values: {} };
|
||||
}
|
||||
export function readFiscalPreparation(raw: string): FiscalPreparation {
|
||||
if (raw.length > 100_000) throw new Error('O ficheiro excede o tamanho de uma preparação.');
|
||||
let p: unknown;
|
||||
try { p = JSON.parse(raw); } catch { throw new Error('Não foi possível ler este ficheiro de preparação. Os campos actuais foram preservados.'); }
|
||||
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('Ficheiro de preparação inválido.');
|
||||
const v = p as Record<string, unknown>;
|
||||
if (v.version !== fiscalFormVersion || (v.kind !== 'iva' && v.kind !== 'irpc') || v.sourceHash !== fiscalForms[v.kind].hash) throw new Error('Versão ou exemplar diferente. A preparação actual foi preservada.');
|
||||
for (const key of ['name', 'nuit', 'period', 'notes']) {
|
||||
if (typeof v[key] !== 'string' || v[key].length > 5000) throw new Error('Identificação ou notas inválidas.');
|
||||
}
|
||||
if (!v.values || typeof v.values !== 'object' || Array.isArray(v.values)) throw new Error('Campos inválidos.');
|
||||
const allowed = new Set(fiscalFormFields(v.kind).map(f => f.code));
|
||||
const values: Record<string, string> = {};
|
||||
for (const [code, value] of Object.entries(v.values)) {
|
||||
if (!allowed.has(code) || typeof value !== 'string' || value.length > 100) throw new Error('Campo não reconhecido. A preparação actual foi preservada.');
|
||||
values[code] = value;
|
||||
}
|
||||
return { ...newFiscalPreparation(v.kind), name: v.name as string, nuit: v.nuit as string, period: v.period as string, notes: v.notes as string, values };
|
||||
}
|
||||
export function fiscalPreparationIssues(p: FiscalPreparation): string[] {
|
||||
const issues: string[] = [];
|
||||
if (!p.name.trim()) issues.push('Indique o nome ou designação social.');
|
||||
if (!/^\d{9}$/.test(p.nuit)) issues.push('Indique o NUIT com 9 algarismos.');
|
||||
if (!(p.kind === 'iva' ? /^\d{4}-(0[1-9]|1[0-2])$/ : /^\d{4}$/).test(p.period)) issues.push(p.kind === 'iva' ? 'Indique ano e mês do período.' : 'Indique o ano do exercício.');
|
||||
for (const f of fiscalFormFields(p.kind)) {
|
||||
const value = p.values[f.code] ?? '';
|
||||
if (value !== '' && !/^-?\d{1,24}(?:[.,]\d{1,2})?$/.test(value)) issues.push(`Campo ${f.code}: use um montante sem separador de milhares, com até duas casas decimais.`);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PortalMenu } from '@/layouts/portal-navigation';
|
||||
import { fiscalForms, fiscalFormFields, fiscalPreparationIssues, newFiscalPreparation, readFiscalPreparation, type FiscalFormKind, type FiscalPreparation } from '@/lib/fiscal-form-preparation';
|
||||
|
||||
const input = 'mt-1 block w-full rounded border border-slate-400 bg-white p-3 text-slate-900';
|
||||
const button = 'rounded border border-emerald-800 px-4 py-3 font-semibold text-emerald-900 disabled:opacity-50';
|
||||
export default function FiscalForms({ initialTax = 'iva' }: { initialTax?: FiscalFormKind }) {
|
||||
const [kind, setKind] = useState<FiscalFormKind>(initialTax);
|
||||
const [drafts, setDrafts] = useState({ iva: newFiscalPreparation('iva'), irpc: newFiscalPreparation('irpc') });
|
||||
const [step, setStep] = useState(0);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [pending, setPending] = useState<FiscalPreparation | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const p = drafts[kind], source = fiscalForms[kind], fields = fiscalFormFields(kind);
|
||||
const issues = fiscalPreparationIssues(p);
|
||||
useEffect(() => {
|
||||
setKind(initialTax);
|
||||
}, [initialTax]);
|
||||
useEffect(() => {
|
||||
const warn = (e: BeforeUnloadEvent) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } };
|
||||
window.addEventListener('beforeunload', warn);
|
||||
const unsubscribe = router.on('before', e => {
|
||||
if (dirty && !window.confirm('Os campos estão apenas nesta página. Guarde o ficheiro para continuar depois. Pretende sair?')) e.preventDefault();
|
||||
});
|
||||
return () => { window.removeEventListener('beforeunload', warn); unsubscribe(); };
|
||||
}, [dirty]);
|
||||
function patch(update: Partial<FiscalPreparation>) {
|
||||
setDrafts(d => ({ ...d, [kind]: { ...d[kind], ...update } }));
|
||||
setDirty(true); setNotice('');
|
||||
}
|
||||
function download() {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(p)], { type: 'application/json' }));
|
||||
const a = document.createElement('a'); a.href = url; a.download = `investmoz-preparacao-${kind}.json`; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setNotice('Ficheiro de preparação gerado. Guarde-o para recuperar os campos. Não foi enviado ao portal nem à AT.');
|
||||
// Other tax preparation may still contain unsaved edits.
|
||||
}
|
||||
return <div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<Head title="Preencher formulário fiscal" />
|
||||
<div className="print:hidden"><PortalMenu /></div>
|
||||
<main className="mx-auto max-w-5xl space-y-6 px-5 py-8">
|
||||
<div className="print:hidden"><Link href="/documentos" className="underline">Documentos e Formulários</Link></div>
|
||||
<h1 className="text-3xl font-bold">Preencher formulário fiscal</h1>
|
||||
<p>Prepare os valores de IVA ou IRPC, confira e guarde um ficheiro para continuar depois. Os campos vazios significam informação em falta.</p>
|
||||
<p className="rounded border border-amber-600 bg-amber-50 p-3">Preparação para conferência, sem submissão à Autoridade Tributária. Os valores são introduzidos por si; não há apuramento automático nem confirmação de elegibilidade de deduções.</p>
|
||||
<label className="block max-w-lg print:hidden">Formulário
|
||||
<select className={input} value={kind} onChange={e => { setKind(e.target.value as FiscalFormKind); setStep(0); setNotice(''); setPending(null); }}>
|
||||
<option value="iva">IVA — Modelo A</option><option value="irpc">IRPC — Modelo 22</option>
|
||||
</select>
|
||||
</label>
|
||||
<h2 className="text-xl font-bold">{source.title}</h2>
|
||||
<p>{source.coverage}</p>
|
||||
<nav aria-label="Etapas de preenchimento" className="flex flex-wrap gap-2 print:hidden">
|
||||
{['Identificação e período', 'Preencher valores', 'Conferir e guardar'].map((label, n) => <button type="button" key={label} className={button} aria-current={step === n ? 'step' : undefined} onClick={() => setStep(n)}>{n + 1}. {label}</button>)}
|
||||
</nav>
|
||||
<form onSubmit={e => e.preventDefault()} className="space-y-5">
|
||||
<fieldset className={`${step !== 0 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}>
|
||||
<legend className="font-bold">Identificação e período</legend>
|
||||
<label className="block">Nome ou designação social<input className={input} autoComplete="organization" maxLength={5000} value={p.name} onChange={e => patch({ name: e.target.value })} /></label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>NUIT<input className={input} inputMode="numeric" maxLength={9} value={p.nuit} onChange={e => patch({ nuit: e.target.value })} /></label>
|
||||
<label>{kind === 'iva' ? 'Período — ano e mês' : 'Exercício — ano'}<input className={input} type={kind === 'iva' ? 'month' : 'text'} inputMode={kind === 'irpc' ? 'numeric' : undefined} placeholder={kind === 'iva' ? 'AAAA-MM' : 'AAAA'} maxLength={7} value={p.period} onChange={e => patch({ period: e.target.value })} /></label>
|
||||
</div>
|
||||
<p className="text-sm">Dados mantidos apenas nesta página até descarregar o ficheiro. Não há gravação automática. Guarde antes de sair ou actualizar.</p>
|
||||
</fieldset>
|
||||
<fieldset className={`${step !== 1 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}>
|
||||
<legend className="font-bold">Valores em meticais (MZN)</legend>
|
||||
<p>Sem separador de milhares; vírgula ou ponto para os cêntimos. Não preencha zero quando desconhece o valor.</p>
|
||||
{kind === 'irpc' && <p>Crédito económico (303) e internacional (304) são distintos. Não utilize a mesma parcela nos dois campos. Esta preparação manual não verifica as parcelas; conserve o suporte para conferência.</p>}
|
||||
<div className="grid gap-5 sm:grid-cols-2">{fields.map(f => <div key={f.code} className="break-inside-avoid">
|
||||
<label className="block">{f.code} — {f.label}<input className={input} inputMode="decimal" maxLength={100} value={p.values[f.code] ?? ''} onChange={e => patch({ values: { ...p.values, [f.code]: e.target.value } })} /></label>
|
||||
<details className="mt-1 text-sm print:hidden"><summary className="cursor-pointer underline">Orientação do campo</summary><p>{f.guidance}</p></details>
|
||||
</div>)}</div>
|
||||
</fieldset>
|
||||
<section className={`${step !== 2 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`} aria-label="Conferir e guardar">
|
||||
<h3 className="font-bold">Conferência da preparação</h3>
|
||||
<p>{fields.filter(f => (p.values[f.code] ?? '') !== '').length} de {fields.length} campos com valor. Campos vazios não foram convertidos em zero.</p>
|
||||
{issues.length > 0 ? <div><p>Por conferir — pode guardar a preparação incompleta:</p><ul className="list-disc pl-6">{issues.map(i => <li key={i}>{i}</li>)}</ul></div> : <p>Formato dos campos conferido. Os montantes e a aplicação fiscal ainda devem ser conferidos.</p>}
|
||||
<dl className="grid gap-3 sm:grid-cols-2 print:hidden">{fields.filter(f => (p.values[f.code] ?? '') !== '').map(f => <div key={f.code}><dt>{f.code} — {f.label}</dt><dd className="font-semibold">{p.values[f.code]} MZN</dd></div>)}</dl>
|
||||
<label className="block">Notas e referências dos valores<textarea className={input} rows={4} maxLength={5000} value={p.notes} onChange={e => patch({ notes: e.target.value })} /></label>
|
||||
<div className="flex flex-wrap gap-3 print:hidden"><button type="button" className={button} onClick={download}>Guardar ficheiro para continuar</button><button type="button" className={button} onClick={() => window.print()}>Imprimir preparação</button></div>
|
||||
<p className="text-sm">O ficheiro contém os dados introduzidos. Guarde-o num local privado. Não fica associado automaticamente ao dossier.</p>
|
||||
</section>
|
||||
<div className="flex justify-between gap-3 print:hidden"><button type="button" className={button} disabled={step === 0} onClick={() => setStep(step - 1)}>Anterior</button><button type="button" className={button} disabled={step === 2} onClick={() => setStep(step + 1)}>Continuar</button></div>
|
||||
</form>
|
||||
<section className="space-y-3 rounded border p-4 print:hidden" aria-label="Recuperar preparação">
|
||||
<label className="block">Recuperar ficheiro guardado<input className={input} type="file" accept=".json,application/json" onChange={async e => {
|
||||
const file = e.target.files?.[0]; e.target.value = ''; if (!file) return;
|
||||
try { if (file.size > 100_000) throw new Error('O ficheiro é demasiado grande.'); setPending(readFiscalPreparation(await file.text())); setNotice('Ficheiro lido. Confirme a recuperação abaixo.'); }
|
||||
catch (error) { setPending(null); setNotice(error instanceof Error ? error.message : 'Não foi possível ler o ficheiro. Os campos actuais foram preservados.'); }
|
||||
}} /></label>
|
||||
{pending && <div className="space-y-2"><p>Recuperar {fiscalForms[pending.kind].title}, período {pending.period || 'por indicar'}? Substitui apenas os campos desse formulário nesta página.</p><button type="button" className={button} onClick={() => { setDrafts(d => ({ ...d, [pending.kind]: pending })); setKind(pending.kind); setStep(2); setPending(null); setDirty(true); setNotice('Preparação recuperada.'); }}>Confirmar recuperação</button> <button type="button" className={button} onClick={() => setPending(null)}>Cancelar</button></div>}
|
||||
</section>
|
||||
{notice && <p role="status" className="rounded border p-3">{notice}</p>}
|
||||
<details className="rounded border p-4"><summary className="cursor-pointer font-semibold">Exemplar e cobertura</summary><p>{source.source}</p><p className="break-all text-sm">SHA256: {source.hash}</p><p>{source.coverage}</p><p>Os cálculos e correspondências já disponíveis no simulador permanecem separados desta introdução manual. Não se executam fórmulas nem se transportam valores entre edições automaticamente.</p></details>
|
||||
<div className="flex flex-wrap gap-4 print:hidden"><Link href="/simulador-fiscal" className="underline">Consultar cálculos e preparação M20/M22 no simulador</Link><Link href="/legislacao" className="underline">Consultar legislação</Link></div>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -538,6 +538,15 @@ export default function Services(p: Props) {
|
||||
opportunities={p.opportunities}
|
||||
/>
|
||||
)}
|
||||
{p.section === 'documents' && <section aria-label="Preencher formulários fiscais" className="space-y-3 rounded-xl border bg-white p-5">
|
||||
<h2 className="text-xl font-bold">Que formulário pretende preencher?</h2>
|
||||
<p>Abra os campos do formulário fiscal. A legislação e os guias de consulta ficam abaixo.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link className={button} href="/formularios/fiscais?imposto=iva">Preencher IVA — Modelo A</Link>
|
||||
<Link className={button} href="/formularios/fiscais?imposto=irpc">Preparar IRPC — Modelo 22</Link>
|
||||
</div>
|
||||
<p className="text-sm">Preparação manual, com gravação em ficheiro. Cobertura parcial de IRPC; sem submissão à AT.</p>
|
||||
</section>}
|
||||
{['procedures', 'documents', 'legislation'].includes(
|
||||
p.section,
|
||||
) && <JourneyDocumentResources stages={p.stages} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Management } from '../management/shared';
|
||||
import { Layout, Field, control, button, labels, kind } from './shared';
|
||||
import type { Dossier } from './shared';
|
||||
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
|
||||
import ClusterCompetitivenessPublic, { type ApprovedCompetitiveness } from '@/components/cluster-competitiveness-public';
|
||||
export default function Show(props: Props) {
|
||||
return (
|
||||
<Editor
|
||||
@@ -21,6 +22,7 @@ export default function Show(props: Props) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
competitiveness?: ApprovedCompetitiveness | null;
|
||||
complements?: ComplementOption[];
|
||||
geography: StructureGeography;
|
||||
existingSubmissions?: { id: string; version: number; updated_at: string }[];
|
||||
@@ -54,6 +56,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Editor({
|
||||
competitiveness,
|
||||
complements = [],
|
||||
record,
|
||||
related,
|
||||
@@ -94,6 +97,7 @@ function Editor({
|
||||
{kind(record)} · {record.content.code}
|
||||
</p>
|
||||
<p className="text-sm">Fonte: {record.source}</p>
|
||||
{competitiveness && <ClusterCompetitivenessPublic analysis={competitiveness} />}
|
||||
{!management && (
|
||||
<div className="space-y-2 rounded-xl border p-4">
|
||||
<Link className="underline" href={portfolioPreparationHref(record.id, pageUrl)}>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useState } from 'react';
|
||||
import { portalRequest, PortalApiError } from '@/lib/portal-api';
|
||||
import {
|
||||
assessmentReady,
|
||||
diagnosticReady,
|
||||
forceLabels,
|
||||
forces,
|
||||
newCompetitivenessDraft,
|
||||
readCompetitivenessDraft,
|
||||
} from '@/lib/cluster-competitiveness';
|
||||
import type {
|
||||
Assessment,
|
||||
CompetitivenessDraft,
|
||||
Force,
|
||||
} from '@/lib/cluster-competitiveness';
|
||||
import { button, control } from '../management/shared';
|
||||
|
||||
const labels = {
|
||||
evidence: 'Observações e evidências locais',
|
||||
source: 'Fonte e referência recuperável (documento, página ou endereço)',
|
||||
period: 'Período dos dados e data da consulta',
|
||||
rationale: 'Justificação da pressão atribuída',
|
||||
limitations: 'Limitações, cobertura e pressupostos',
|
||||
opportunity: 'Oportunidade de investimento e fundamento',
|
||||
} as const;
|
||||
|
||||
export type CompetitivenessState = {
|
||||
version: number;
|
||||
draft: CompetitivenessDraft | null;
|
||||
approved: {
|
||||
draft: CompetitivenessDraft;
|
||||
version: number;
|
||||
at: string;
|
||||
} | null;
|
||||
history: { version: number; action: string; at: string; actor: number }[];
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
saved: 'Gravação',
|
||||
approved: 'Aprovação pública',
|
||||
withdrawn: 'Retirada da aprovação',
|
||||
restored: 'Recuperação do histórico',
|
||||
};
|
||||
|
||||
export default function Competitiveness({
|
||||
clusterId,
|
||||
sector,
|
||||
initial,
|
||||
}: {
|
||||
clusterId: string;
|
||||
sector: string;
|
||||
initial?: CompetitivenessState | null;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(
|
||||
() => initial?.draft ?? newCompetitivenessDraft(clusterId, sector),
|
||||
);
|
||||
const [server, setServer] = useState<CompetitivenessState>(
|
||||
initial ?? { version: 0, draft: null, approved: null, history: [] },
|
||||
);
|
||||
const [serverPending, setServerPending] =
|
||||
useState<CompetitivenessState | null>(null);
|
||||
const [restoreVersion, setRestoreVersion] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const dirty =
|
||||
JSON.stringify(draft) !==
|
||||
JSON.stringify(
|
||||
server.draft ?? newCompetitivenessDraft(clusterId, sector),
|
||||
);
|
||||
const endpoint = `/management/clusters/${clusterId}/competitiveness`;
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PortalApiError) {
|
||||
if (error.status === 409)
|
||||
return 'Conflito de versão. Os campos actuais foram conservados. Transfira o rascunho e consulte a versão do servidor antes de reconciliar.';
|
||||
if ([401, 403, 419].includes(error.status))
|
||||
return 'Sessão ou permissão indisponível. Os campos foram conservados; transfira o rascunho antes de autenticar-se novamente.';
|
||||
if (error.status === 422)
|
||||
return (
|
||||
Object.values(error.errors).flat().join(' ') ||
|
||||
error.message
|
||||
);
|
||||
}
|
||||
return 'Não foi possível concluir a operação; os campos actuais foram conservados.';
|
||||
}
|
||||
function apply(state: CompetitivenessState) {
|
||||
setServer(state);
|
||||
setDraft(state.draft ?? newCompetitivenessDraft(clusterId, sector));
|
||||
setPending(null);
|
||||
setServerPending(null);
|
||||
setRestoreVersion(null);
|
||||
}
|
||||
async function command(
|
||||
action: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const state = await portalRequest<CompetitivenessState>(
|
||||
`${endpoint}/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected_version: server.version,
|
||||
...extra,
|
||||
}),
|
||||
},
|
||||
);
|
||||
apply(state);
|
||||
setMessage(
|
||||
`${actions[action]} concluída no servidor. Versão ${state.version}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function change(force: Force, field: keyof Assessment, value: string) {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
forces: {
|
||||
...previous.forces,
|
||||
[force]: { ...previous.forces[force], [field]: value },
|
||||
},
|
||||
}));
|
||||
}
|
||||
function download() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(draft, null, 2)], {
|
||||
type: 'application/json',
|
||||
}),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `competitividade-${clusterId}.json`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setMessage(
|
||||
'Ficheiro preparado para transferência. Conserve-o para recuperar a análise.',
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="max-w-3xl space-y-4 rounded border p-4"
|
||||
aria-label="Diagnóstico de competitividade"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Competitividade — cinco forças de Porter
|
||||
</h2>
|
||||
<p>
|
||||
Análise qualitativa por sector e região ligada a este cluster.
|
||||
Descreva as evidências e justifique cada pressão; não existe
|
||||
pontuação automática nem média entre forças.
|
||||
</p>
|
||||
<p>
|
||||
Guarde o rascunho para o recuperar no servidor. A publicação
|
||||
exige aprovação expressa; a classificação representa a
|
||||
apreciação do autor.
|
||||
</p>
|
||||
<p role="status">
|
||||
Versão no servidor: {server.version}.{' '}
|
||||
{dirty
|
||||
? 'Existem alterações por guardar.'
|
||||
: 'Campos correspondem à versão recuperada.'}{' '}
|
||||
{server.approved
|
||||
? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.`
|
||||
: 'Sem análise aprovada para consulta pública.'}
|
||||
</p>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<label className="block">
|
||||
Sector analisado
|
||||
<input
|
||||
className={control}
|
||||
maxLength={200}
|
||||
value={draft.sector}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, sector: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{draft.sector !== sector && (
|
||||
<p>
|
||||
O sector da análise difere do sector actual do cluster (
|
||||
{sector}). Confira o âmbito antes de reutilizar
|
||||
conclusões.
|
||||
</p>
|
||||
)}
|
||||
<label className="block">
|
||||
Região / província / distrito e abrangência
|
||||
<input
|
||||
className={control}
|
||||
maxLength={1000}
|
||||
value={draft.region}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, region: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p role="status">
|
||||
{diagnosticReady(draft)
|
||||
? 'Cinco forças preenchidas para revisão; fontes e conclusões requerem leitura do autor.'
|
||||
: 'Diagnóstico incompleto: indique o âmbito e complete as evidências das cinco forças.'}
|
||||
</p>
|
||||
{forces.map((force) => (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{forceLabels[force]} ·{' '}
|
||||
{assessmentReady(draft.forces[force])
|
||||
? 'Preenchida para revisão'
|
||||
: 'Indeterminada / incompleta'}
|
||||
</summary>
|
||||
<div className="space-y-3 pt-3">
|
||||
<label className="block">
|
||||
Pressão competitiva
|
||||
<select
|
||||
className={control}
|
||||
value={draft.forces[force].pressure}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
force,
|
||||
'pressure',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Indeterminada</option>
|
||||
<option value="low">Baixa</option>
|
||||
<option value="medium">Média</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</label>
|
||||
{Object.entries(labels).map(([field, label]) => (
|
||||
<label key={field} className="block">
|
||||
{label}
|
||||
<textarea
|
||||
className={control}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
value={
|
||||
draft.forces[force][
|
||||
field as keyof typeof labels
|
||||
]
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
force,
|
||||
field as keyof typeof labels,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<button type="button" className={button} onClick={download}>
|
||||
Transferir análise / rascunho
|
||||
</button>
|
||||
<label className="block">
|
||||
Recuperar ficheiro deste cluster
|
||||
<input
|
||||
className={control}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setPending(null);
|
||||
try {
|
||||
if (file.size > 250_000)
|
||||
throw Error(
|
||||
'O ficheiro excede o limite de 250 KB.',
|
||||
);
|
||||
setPending(
|
||||
readCompetitivenessDraft(
|
||||
await file.text(),
|
||||
clusterId,
|
||||
),
|
||||
);
|
||||
setMessage(
|
||||
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível ler o ficheiro.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{pending && (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<p>
|
||||
Recuperar análise de{' '}
|
||||
{pending.sector || 'sector não indicado'} em{' '}
|
||||
{pending.region || 'região não indicada'}? Os campos
|
||||
actuais serão substituídos. Transfira primeiro o
|
||||
rascunho actual se o quiser conservar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setDraft(pending);
|
||||
setPending(null);
|
||||
setMessage('Análise recuperada para edição.');
|
||||
}}
|
||||
>
|
||||
Confirmar recuperação
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setPending(null);
|
||||
setMessage(
|
||||
'Recuperação cancelada; campos actuais preservados.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!dirty}
|
||||
onClick={() => command('saved', { draft })}
|
||||
>
|
||||
Guardar rascunho no servidor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setServerPending(
|
||||
await portalRequest<CompetitivenessState>(
|
||||
endpoint,
|
||||
),
|
||||
);
|
||||
setMessage(
|
||||
'Versão do servidor lida. Confirme antes de substituir os campos.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Consultar versão actual do servidor
|
||||
</button>
|
||||
</div>
|
||||
{serverPending && (
|
||||
<div className="rounded border p-3">
|
||||
<p>
|
||||
Versão {serverPending.version}:{' '}
|
||||
{serverPending.draft?.sector ||
|
||||
'Sector não indicado'}{' '}
|
||||
/{' '}
|
||||
{serverPending.draft?.region ||
|
||||
'Região não indicada'}
|
||||
. Recuperar substitui os campos actuais. Transfira
|
||||
primeiro alterações que queira conservar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
apply(serverPending);
|
||||
setMessage(
|
||||
'Versão actual recuperada do servidor.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Confirmar recuperação do servidor
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setServerPending(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Histórico no servidor (últimas 50 versões)
|
||||
</summary>
|
||||
{!server.history.length && (
|
||||
<p>Ainda não existem gravações.</p>
|
||||
)}
|
||||
{server.history.map((entry) => (
|
||||
<p key={entry.version}>
|
||||
Versão {entry.version} ·{' '}
|
||||
{actions[entry.action] ?? entry.action} · {entry.at}{' '}
|
||||
· Utilizador {entry.actor}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => setRestoreVersion(entry.version)}
|
||||
>
|
||||
Recuperar como rascunho
|
||||
</button>
|
||||
</p>
|
||||
))}
|
||||
{restoreVersion !== null && (
|
||||
<div>
|
||||
<p>
|
||||
Recuperar a versão {restoreVersion} cria uma
|
||||
nova versão no servidor e substitui os campos
|
||||
actuais. A versão pública aprovada mantém-se.
|
||||
Transfira primeiro alterações por guardar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
command('restored', {
|
||||
revision: restoreVersion,
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirmar recuperação do histórico
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setRestoreVersion(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<details className="rounded border p-3">
|
||||
<summary>Aprovação para consulta pública</summary>
|
||||
<p>
|
||||
Aprovar torna a versão guardada consultável na ficha
|
||||
pública deste cluster, quando essa ficha estiver
|
||||
publicada. Guardar um rascunho não o publica. A
|
||||
evidência de aprovação e as identidades do histórico
|
||||
ficam na gestão.
|
||||
</p>
|
||||
<label className="block">
|
||||
Evidência / motivo da aprovação ou retirada
|
||||
<textarea
|
||||
className={control}
|
||||
maxLength={3000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
dirty ||
|
||||
!diagnosticReady(draft) ||
|
||||
server.version === 0 ||
|
||||
reason.trim().length < 10
|
||||
}
|
||||
onClick={() => command('approved', { reason })}
|
||||
>
|
||||
Aprovar versão guardada para consulta pública
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
dirty ||
|
||||
!server.approved ||
|
||||
reason.trim().length < 10
|
||||
}
|
||||
onClick={() => command('withdrawn', { reason })}
|
||||
>
|
||||
Retirar aprovação pública
|
||||
</button>
|
||||
{dirty && (
|
||||
<p>
|
||||
Guarde primeiro as alterações ou recupere a versão
|
||||
pretendida antes de decidir a aprovação.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
</fieldset>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Management, Field, button, control } from '../management/shared';
|
||||
import { titles, statuses, stages, roles } from './types';
|
||||
import type { Structure, StructureRecord, Chain } from './types';
|
||||
import ValueNetwork from './value-network';
|
||||
import Competitiveness from './competitiveness';
|
||||
import type { CompetitivenessState } from './competitiveness';
|
||||
import type { ChainLink, Gap } from './value-network';
|
||||
|
||||
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
|
||||
@@ -75,6 +77,7 @@ export default function Editor({
|
||||
memberships,
|
||||
chainLinks,
|
||||
gaps,
|
||||
competitiveness,
|
||||
}: {
|
||||
structure: Structure;
|
||||
record: StructureRecord | null;
|
||||
@@ -83,6 +86,7 @@ export default function Editor({
|
||||
chains: Chain[];
|
||||
chainLinks: ChainLink[];
|
||||
gaps: Gap[];
|
||||
competitiveness?: CompetitivenessState | null;
|
||||
memberships: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -298,6 +302,12 @@ export default function Editor({
|
||||
</form>
|
||||
{isCluster && record && (
|
||||
<>
|
||||
<Competitiveness
|
||||
key={record.id}
|
||||
clusterId={record.id}
|
||||
sector={record.sector ?? ''}
|
||||
initial={competitiveness}
|
||||
/>
|
||||
<section className="max-w-3xl space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Projectos associados
|
||||
|
||||
@@ -50,6 +50,11 @@ use Laravel\Fortify\Features;
|
||||
// Route::inertia('/', 'welcome')->name('home');
|
||||
Route::get('/', [PortalServicesController::class, 'home'])->name('home');
|
||||
Route::inertia('/contextos-de-investimento', 'portal/investment-contexts')->name('portal.investment-contexts');
|
||||
Route::get('/formularios/fiscais', function (\Illuminate\Http\Request $request) {
|
||||
$input = $request->validate(['imposto' => ['nullable', 'in:iva,irpc']]);
|
||||
|
||||
return Inertia::render('portal/fiscal-forms', ['initialTax' => $input['imposto'] ?? 'iva']);
|
||||
})->name('portal.fiscal-forms');
|
||||
Route::inertia('/politica-de-privacidade', 'portal/privacy-policy')->name('legal.privacy');
|
||||
Route::inertia('/termos-de-uso', 'portal/terms-of-use')->name('legal.terms');
|
||||
foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao' => 'legislation', 'documentos' => 'documents',
|
||||
@@ -249,6 +254,8 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\EnsureScopedPortalAd
|
||||
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
|
||||
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
|
||||
Route::get('clusters/{cluster}/competitiveness', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
|
||||
Route::post('clusters/{cluster}/competitiveness/{action}', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored'])->name('clusters.competitiveness.change');
|
||||
Route::post('clusters/{cluster}/chain-links', [ClusterValueChainController::class, 'link'])->name('clusters.chain-links.store');
|
||||
Route::delete('clusters/{cluster}/chain-links/{link}', [ClusterValueChainController::class, 'removeLink'])->name('clusters.chain-links.delete');
|
||||
Route::post('clusters/{cluster}/gaps', [ClusterValueChainController::class, 'gap'])->name('clusters.gaps.store');
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\User;
|
||||
use App\Services\ClusterCompetitiveness;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ClusterCompetitivenessTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
private function manager(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email], 'portal.profile_access_enabled' => false]);
|
||||
$this->actingAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function cluster(): EconomicCluster
|
||||
{
|
||||
return EconomicCluster::create(['code' => 'COMP-'.bin2hex(random_bytes(6)), 'name' => 'Cluster de teste de persistência', 'sector' => 'Energia', 'status' => 'planned']);
|
||||
}
|
||||
|
||||
private function draft(EconomicCluster $cluster): array
|
||||
{
|
||||
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'Distrito de demonstração', 'forces' => []];
|
||||
foreach (ClusterCompetitiveness::FORCES as $force) {
|
||||
$draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'Observação de teste, não publicável como facto real',
|
||||
'source' => 'Fonte de demonstração p. 2', 'period' => '2025', 'rationale' => 'Apreciação de teste',
|
||||
'limitations' => 'Dados sintéticos', 'opportunity' => 'Hipótese de demonstração'];
|
||||
}
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
private function url(EconomicCluster $cluster): string
|
||||
{
|
||||
return '/management/clusters/'.$cluster->id.'/competitiveness';
|
||||
}
|
||||
|
||||
public function test_save_recovers_from_database_across_requests_and_records_encrypted_history(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
|
||||
->assertOk()->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonPath('approved', null);
|
||||
$this->getJson($this->url($cluster))->assertOk()->assertHeader('Cache-Control', 'no-store, private')
|
||||
->assertJsonPath('draft', $draft)->assertJsonPath('history.0.actor', $actor->id)->assertJsonPath('history.0.action', 'saved');
|
||||
$this->get('/management/clusters/'.$cluster->id.'/edit')->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('structures/editor')->where('competitiveness.version', 1)->where('competitiveness.draft.region', $draft['region']));
|
||||
$event = DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->first();
|
||||
$this->assertStringNotContainsString($draft['forces']['rivalry']['evidence'], $event->after_data);
|
||||
$this->assertSame('restricted', $event->data_classification);
|
||||
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
}
|
||||
|
||||
public function test_validation_conflict_and_server_error_do_not_overwrite_the_saved_version(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$changed = $draft;
|
||||
$changed['region'] = 'Outra região';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $changed])->assertConflict();
|
||||
$changed['forces']['buyers']['pressure'] = 'zero';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
|
||||
$changed = $draft;
|
||||
$changed['clusterId'] = $this->cluster()->id;
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonCount(1, 'history');
|
||||
}
|
||||
|
||||
public function test_approval_pins_content_and_later_drafts_and_restoration_do_not_publish(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$url = $this->url($cluster);
|
||||
$this->postJson($url.'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$this->postJson($url.'/approved', ['expected_version' => 1, 'reason' => 'Aprovação sintética em QA'])->assertOk()->assertJsonPath('version', 2);
|
||||
$changed = $draft;
|
||||
$changed['region'] = 'Rascunho privado posterior';
|
||||
$this->postJson($url.'/saved', ['expected_version' => 2, 'draft' => $changed])->assertOk();
|
||||
$public = app(ClusterCompetitiveness::class)->publicData($cluster->id);
|
||||
$this->assertSame($draft, $public['draft']);
|
||||
$this->assertSame(['draft', 'version', 'at'], array_keys($public));
|
||||
$this->postJson($url.'/restored', ['expected_version' => 3, 'revision' => 1])->assertOk()
|
||||
->assertJsonPath('version', 4)->assertJsonPath('draft', $draft)->assertJsonPath('approved.version', 2);
|
||||
$this->assertSame($public, app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
$this->postJson($url.'/withdrawn', ['expected_version' => 4, 'reason' => 'Retirada sintética em QA'])->assertOk()->assertJsonCount(5, 'history');
|
||||
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
$this->getJson($url)->assertJsonPath('draft', $draft);
|
||||
}
|
||||
|
||||
public function test_incomplete_drafts_are_saved_but_not_approved_and_unknown_revisions_do_not_write(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$draft['forces']['buyers']['source'] = '';
|
||||
$draft['forces']['entrants']['pressure'] = '';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$this->postJson($this->url($cluster).'/approved', ['expected_version' => 1, 'reason' => 'Pedido de aprovação QA'])->assertUnprocessable();
|
||||
$this->postJson($this->url($cluster).'/restored', ['expected_version' => 1, 'revision' => 99])->assertNotFound();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft.forces.buyers.source', '');
|
||||
}
|
||||
|
||||
public function test_saved_data_keeps_the_portable_file_contract_and_rejects_missing_forces(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$draft['version'] = '1';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
|
||||
->assertOk()->assertJsonPath('draft.version', 1);
|
||||
unset($draft['forces']['suppliers']);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $draft])->assertUnprocessable();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1);
|
||||
}
|
||||
|
||||
public function test_anonymous_and_unauthorised_users_cannot_read_history_or_mutate(): void
|
||||
{
|
||||
$cluster = $this->cluster();
|
||||
$url = $this->url($cluster);
|
||||
$this->getJson($url)->assertUnauthorized();
|
||||
$this->postJson($url.'/saved', [])->assertUnauthorized();
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->getJson($url)->assertForbidden();
|
||||
foreach (['saved', 'approved', 'withdrawn', 'restored'] as $action) {
|
||||
$this->postJson($url.'/'.$action, [])->assertForbidden();
|
||||
}
|
||||
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->count());
|
||||
}
|
||||
|
||||
public function test_public_dossier_returns_only_the_approved_projection_and_unpublished_dossiers_remain_hidden(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$dossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'Ficha sintética QA', 'source' => 'Fonte sintética QA',
|
||||
'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner',
|
||||
'approval_evidence' => 'Aprovação sintética só na base QA', 'published_at' => now()]);
|
||||
$service = app(ClusterCompetitiveness::class);
|
||||
$service->change($cluster, $actor->id, 0, 'saved', ['draft' => $draft]);
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
|
||||
$service->change($cluster, $actor->id, 1, 'approved', ['reason' => 'Aprovação privada de teste']);
|
||||
$draft['region'] = 'CONTEÚDO PRIVADO NÃO APROVADO';
|
||||
$service->change($cluster, $actor->id, 2, 'saved', ['draft' => $draft]);
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('competitiveness.version', 2)->where('competitiveness.draft.region', 'Distrito de demonstração')
|
||||
->missing('competitiveness.actor')->missing('competitiveness.evidence')->missing('competitiveness.history'));
|
||||
$dossier->update(['publication_status' => 'draft']);
|
||||
$this->get('/carteira/'.$dossier->id)->assertNotFound();
|
||||
$dossier->update(['publication_status' => 'published']);
|
||||
$service->change($cluster, $actor->id, 3, 'withdrawn', ['reason' => 'Retirada privada de teste']);
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FiscalFormEntryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
\Tests\Support\ProactiveInvestmentFixture::guard();
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
public function test_both_fiscal_forms_are_public_and_do_not_redirect_to_legal_documents(): void
|
||||
{
|
||||
config(['portal.homologation_only' => false]);
|
||||
foreach (['iva', 'irpc'] as $tax) {
|
||||
$this->get('/formularios/fiscais?imposto='.$tax)
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page->component('portal/fiscal-forms')->where('initialTax', $tax));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assessmentReady,
|
||||
diagnosticReady,
|
||||
forces,
|
||||
newCompetitivenessDraft,
|
||||
readCompetitivenessDraft,
|
||||
} from '../../resources/js/lib/cluster-competitiveness.ts';
|
||||
|
||||
function complete() {
|
||||
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
|
||||
draft.region = 'Distrito de demonstração';
|
||||
for (const force of forces)
|
||||
Object.assign(draft.forces[force], {
|
||||
pressure: 'medium',
|
||||
evidence: 'Observação de demonstração',
|
||||
source: 'Fonte de demonstração, p. 2',
|
||||
period: '2025',
|
||||
rationale: 'Pressão fundamentada na observação',
|
||||
limitations: 'Amostra parcial',
|
||||
opportunity: 'Investigar capacidade de armazenamento',
|
||||
});
|
||||
return draft;
|
||||
}
|
||||
|
||||
test('ausência de dados permanece indeterminada nas cinco forças independentes', () => {
|
||||
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
|
||||
assert.equal(forces.length, 5);
|
||||
assert.equal(diagnosticReady(draft), false);
|
||||
draft.forces.buyers.evidence = 'Observação';
|
||||
assert.equal(draft.forces.suppliers.evidence, '');
|
||||
assert.ok(forces.every((force) => draft.forces[force].pressure === ''));
|
||||
});
|
||||
|
||||
test('classificação exige todos os fundamentos; âmbito é necessário para o diagnóstico', () => {
|
||||
const draft = complete();
|
||||
assert.equal(diagnosticReady(draft), true);
|
||||
for (const field of [
|
||||
'evidence',
|
||||
'source',
|
||||
'period',
|
||||
'rationale',
|
||||
'limitations',
|
||||
'opportunity',
|
||||
]) {
|
||||
const row = { ...draft.forces.buyers, [field]: ' ' };
|
||||
assert.equal(assessmentReady(row), false, field);
|
||||
}
|
||||
draft.region = '';
|
||||
assert.equal(diagnosticReady(draft), false);
|
||||
});
|
||||
|
||||
test('ficheiro conserva rascunhos, dados e incertezas sem modificar o original', () => {
|
||||
const draft = complete();
|
||||
draft.forces.entrants.pressure = '';
|
||||
const restored = readCompetitivenessDraft(
|
||||
JSON.stringify(draft),
|
||||
'cluster-a',
|
||||
);
|
||||
assert.deepEqual(restored, draft);
|
||||
restored.forces.buyers.source = 'Outra fonte';
|
||||
assert.notEqual(restored.forces.buyers.source, draft.forces.buyers.source);
|
||||
assert.equal(diagnosticReady(restored), false);
|
||||
});
|
||||
|
||||
test('recuperação rejeita outro cluster, versões, forças ausentes e campos inválidos', () => {
|
||||
assert.throws(() =>
|
||||
readCompetitivenessDraft(JSON.stringify(complete()), 'cluster-b'),
|
||||
);
|
||||
for (const mutate of [
|
||||
(d) => {
|
||||
d.version = 2;
|
||||
},
|
||||
(d) => {
|
||||
delete d.forces.buyers;
|
||||
},
|
||||
(d) => {
|
||||
d.forces.buyers.pressure = 'zero';
|
||||
},
|
||||
(d) => {
|
||||
d.forces.buyers.source = 42;
|
||||
},
|
||||
(d) => {
|
||||
d.forces.buyers.source = 'x'.repeat(5001);
|
||||
},
|
||||
(d) => {
|
||||
d.region = null;
|
||||
},
|
||||
]) {
|
||||
const draft = complete();
|
||||
mutate(draft);
|
||||
assert.throws(() =>
|
||||
readCompetitivenessDraft(JSON.stringify(draft), 'cluster-a'),
|
||||
);
|
||||
}
|
||||
assert.throws(() => readCompetitivenessDraft('null', 'cluster-a'));
|
||||
assert.throws(() =>
|
||||
readCompetitivenessDraft('x'.repeat(250001), 'cluster-a'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import ts from 'typescript';
|
||||
import * as forms from '../../resources/js/lib/fiscal-form-preparation.ts';
|
||||
const require=createRequire(import.meta.url);
|
||||
const source=fs.readFileSync(new URL('../../resources/js/pages/portal/fiscal-forms.tsx',import.meta.url),'utf8');
|
||||
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const module={exports:{}};
|
||||
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/layouts/portal-navigation'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
|
||||
test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation',()=>{
|
||||
const html=renderToStaticMarkup(React.createElement(module.exports.default));
|
||||
for(const label of ['Identificação e período','Nome ou designação social','NUIT','Período — ano e mês','Conferir e guardar','Guardar ficheiro para continuar','Recuperar ficheiro guardado','Confirmar recuperação']) {
|
||||
// Confirmation appears only after a compatible file is selected.
|
||||
if(label !== 'Confirmar recuperação') assert.ok(html.includes(label),label);
|
||||
}
|
||||
assert.equal((html.match(/inputMode="decimal"/g)??[]).length,27);
|
||||
assert.match(html,/sem submissão à Autoridade Tributária/);
|
||||
assert.match(html,/Não há gravação automática/);
|
||||
assert.doesNotMatch(html,/value="0"/);
|
||||
});
|
||||
test('IRPC entry opens the requested form directly and keeps its partial coverage explicit',()=>{
|
||||
const html=renderToStaticMarkup(React.createElement(module.exports.default,{initialTax:'irpc'}));
|
||||
assert.match(html,/IRPC — Modelo 22/);
|
||||
assert.match(html,/L8 permanece parcial/);
|
||||
assert.match(html,/303 — Dupla tributação económica/);
|
||||
assert.match(html,/304 — Dupla tributação internacional/);
|
||||
assert.match(html,/Não utilize a mesma parcela/);
|
||||
assert.equal((html.match(/inputMode="decimal"/g)??[]).length,24);
|
||||
assert.doesNotMatch(html,/01 — Base das operações/);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {newFiscalPreparation, readFiscalPreparation, fiscalPreparationIssues, fiscalFormFields} from '../../resources/js/lib/fiscal-form-preparation.ts';
|
||||
|
||||
test('IVA and IRPC preparations round trip deliberate entries and unknown amounts without zero defaults', () => {
|
||||
for (const kind of ['iva', 'irpc']) {
|
||||
const p = newFiscalPreparation(kind);
|
||||
p.name = 'Investidor de teste'; p.nuit = '123456789'; p.period = kind === 'iva' ? '2026-10' : '2026';
|
||||
const [first, second] = fiscalFormFields(kind);
|
||||
p.values[first.code] = '123456789012345678901234,01';
|
||||
p.values[second.code] = '';
|
||||
const restored = readFiscalPreparation(JSON.stringify(p));
|
||||
assert.deepEqual(restored, p);
|
||||
assert.deepEqual(fiscalPreparationIssues(restored), []);
|
||||
assert.equal(restored.values[second.code], '');
|
||||
assert.equal(Object.values(restored.values).includes('0'), false);
|
||||
}
|
||||
});
|
||||
test('foreign editions, unknown fields and malformed or oversized files cannot replace preparation', () => {
|
||||
const p = newFiscalPreparation('iva');
|
||||
for (const v of [{...p, sourceHash: 'different'}, {...p, values: {'999': '1'}}, {...p, values: []}, {...p, nuit: 123}, {...p, version: 'later'}]) assert.throws(() => readFiscalPreparation(JSON.stringify(v)));
|
||||
assert.throws(() => readFiscalPreparation('{broken'));
|
||||
assert.throws(() => readFiscalPreparation('x'.repeat(100001)));
|
||||
assert.equal(p.kind, 'iva'); assert.deepEqual(p.values, {});
|
||||
});
|
||||
test('invalid format is identified without rounding or discarding an incomplete preparation', () => {
|
||||
const p = newFiscalPreparation('irpc');
|
||||
p.values['303'] = '1.234'; p.values['304'] = '25,00';
|
||||
const restored = readFiscalPreparation(JSON.stringify(p));
|
||||
assert.equal(restored.values['303'], '1.234');
|
||||
assert.ok(fiscalPreparationIssues(restored).some(i => i.startsWith('Campo 303:')));
|
||||
assert.equal(fiscalFormFields('irpc').find(f => f.code === '303').label, 'Dupla tributação económica');
|
||||
assert.equal(fiscalFormFields('irpc').find(f => f.code === '304').label, 'Dupla tributação internacional');
|
||||
});
|
||||
Reference in New Issue
Block a user