feat(clusters): persist versioned competitiveness and approved public analysis

This commit is contained in:
2026-10-29 13:29:08 +02:00
parent 3f7082a3e6
commit 4875c4beb9
13 changed files with 826 additions and 141 deletions
@@ -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')
+129
View File
@@ -0,0 +1,129 @@
<?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['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;
}
}
@@ -1842,4 +1842,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
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.
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.
@@ -7400,4 +7400,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
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.
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.
@@ -3022,4 +3022,9 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
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.
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.
@@ -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>
);
}
+4
View File
@@ -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)}>
+401 -137
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { portalRequest, PortalApiError } from '@/lib/portal-api';
import {
assessmentReady,
diagnosticReady,
@@ -23,18 +24,99 @@ const labels = {
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(() =>
newCompetitivenessDraft(clusterId, sector),
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,
@@ -73,165 +155,347 @@ export default function Competitiveness({
pontuação automática nem média entre forças.
</p>
<p>
Rascunho nesta página: transfira o ficheiro antes de sair. Não é
gravado no servidor nem publicado. A classificação representa a
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>
<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.'}
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>
{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
<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}
rows={3}
maxLength={5000}
value={
draft.forces[force][
field as keyof typeof labels
]
}
value={draft.forces[force].pressure}
onChange={(e) =>
change(
force,
field as keyof typeof labels,
'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>
))}
</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.',
{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,
),
);
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>
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}
onClick={() => {
setDraft(pending);
setPending(null);
setMessage('Análise recuperada para edição.');
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);
}
}}
>
Confirmar recuperação
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}
onClick={() => {
setPending(null);
setMessage(
'Recuperação cancelada; campos actuais preservados.',
);
}}
disabled={
dirty ||
!server.approved ||
reason.trim().length < 10
}
onClick={() => command('withdrawn', { reason })}
>
Cancelar
Retirar aprovação pública
</button>
</div>
)}
{message && <p role="status">{message}</p>}
{dirty && (
<p>
Guarde primeiro as alterações ou recupere a versão
pretendida antes de decidir a aprovação.
</p>
)}
</details>
</fieldset>
</section>
);
}
+9 -1
View File
@@ -7,6 +7,7 @@ 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 }) {
@@ -76,6 +77,7 @@ export default function Editor({
memberships,
chainLinks,
gaps,
competitiveness,
}: {
structure: Structure;
record: StructureRecord | null;
@@ -84,6 +86,7 @@ export default function Editor({
chains: Chain[];
chainLinks: ChainLink[];
gaps: Gap[];
competitiveness?: CompetitivenessState | null;
memberships: {
id: string;
project_id: string;
@@ -299,7 +302,12 @@ export default function Editor({
</form>
{isCluster && record && (
<>
<Competitiveness key={record.id} clusterId={record.id} sector={record.sector ?? ''} />
<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
+2
View File
@@ -254,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,162 @@
<?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_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));
}
}